@0dai-dev/cli 4.3.7 → 4.3.8

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/bin/0dai.js CHANGED
@@ -210,7 +210,7 @@ function printHelp() {
210
210
  console.log("");
211
211
  console.log("Start (first 5 minutes):");
212
212
  console.log(" init Create ai/ layer + MCP [--local] [--dry-run] [--minimal]");
213
- console.log(" doctor Check health, credentials, and drift [--drift]");
213
+ console.log(" doctor Check health, credentials, and drift [--drift] [--security]");
214
214
  console.log(" status Show maturity, swarm, and session state [--json]");
215
215
  console.log(" quickstart Run auth, init, doctor, and status checks in order");
216
216
  console.log(" detect Show detected stack");
@@ -341,6 +341,7 @@ function printInitHelp(commandName = "init") {
341
341
  console.log(" --local Generate the ai/ layer offline without signing in");
342
342
  console.log(" --minimal Generate the smallest starter layer");
343
343
  console.log(" --dry-run Preview init changes without writing them");
344
+ console.log(" (pair with --local to preview configs with no account or network)");
344
345
  console.log(" --no-wizard Skip the interactive first-run wizard");
345
346
  console.log(" --auth-code Exchange a browser/device auth code before init");
346
347
  console.log(" --code Redeem an activation/license code before init");
@@ -503,13 +504,15 @@ async function main() {
503
504
  case "detect": await cmdDetect(target); break;
504
505
  case "doctor": {
505
506
  const driftMode = args.includes("--drift");
507
+ const securityMode = args.includes("--security");
506
508
  // Go fast-path covers only the base read-only doctor (no --drift, no
507
- // network). Anything else falls through to the full Node implementation.
509
+ // --security, no network). Anything else falls through to the full Node
510
+ // implementation so the flag is not silently swallowed.
508
511
  const layerFreshness = collectLayerVersionFreshness(target);
509
- if (!driftMode && layerFreshness.status !== "stale") {
512
+ if (!driftMode && !securityMode && layerFreshness.status !== "stale") {
510
513
  tryGoHotPath("doctor", target, args.slice(1));
511
514
  }
512
- cmdDoctor(target, { drift: driftMode, json: args.includes("--json") });
515
+ cmdDoctor(target, { drift: driftMode, security: securityMode, json: args.includes("--json") });
513
516
  break;
514
517
  }
515
518
  case "drift": {
@@ -481,6 +481,197 @@ function printDriftReport(target, options = {}) {
481
481
  return payload;
482
482
  }
483
483
 
484
+ // --- security mode (0dai doctor --security) -------------------------------
485
+ //
486
+ // Static, code-derived disclosure of the data boundary so a reviewer can see,
487
+ // without reading source, what leaves the box and which routes need operator
488
+ // authZ. Three sections:
489
+ // 1. egress — network endpoints the CLI may call (reused verbatim from
490
+ // trust.js _buildColdPayload so there is ONE egress list).
491
+ // 2. local sinks — files the CLI writes locally (NOT egress) + the host-side
492
+ // secret vault dir, live-detected and labeled.
493
+ // 3. authZ — deny-by-default operator-gate summary, enforced server-side
494
+ // (web/src/lib/auth.ts requireOperator); described, not probed.
495
+ //
496
+ // This mode performs NO network calls and reads NO secret values — it only
497
+ // reports paths and presence. Run `0dai trust` for the live enforced scope.
498
+
499
+ // authZ summary mirrors web/src/lib/auth.ts. Tiers are described, not probed —
500
+ // the CLI cannot reach into the deployment's route guards at runtime.
501
+ const AUTHZ_SUMMARY = Object.freeze({
502
+ enforced_by: "web/src/lib/auth.ts (Next.js API routes)",
503
+ default: "deny",
504
+ note:
505
+ "Operator-state routes require authN + authZ. With ODAI_OPERATOR_EMAILS unset, "
506
+ + "every authenticated user is denied (403) — default-deny, not default-allow.",
507
+ tiers: Object.freeze([
508
+ {
509
+ level: "operator",
510
+ guard: "requireOperator",
511
+ rule: "authN + email in ODAI_OPERATOR_EMAILS allowlist; unset allowlist = deny all",
512
+ routes: "swarm/*, cli/[action], team",
513
+ },
514
+ {
515
+ level: "authenticated",
516
+ guard: "requireAuth",
517
+ rule: "any valid token (401 if absent/invalid)",
518
+ routes: "plugins",
519
+ },
520
+ {
521
+ level: "auth-forward",
522
+ guard: "forward",
523
+ rule: "web layer forwards any Authorization header to the Python API; downstream authZ enforced there (verify)",
524
+ routes: "admin/* (adminForwardAuth), events",
525
+ },
526
+ {
527
+ level: "open",
528
+ guard: "none",
529
+ rule: "intentionally unauthenticated public endpoints",
530
+ routes: "contact, public/*",
531
+ },
532
+ ]),
533
+ });
534
+
535
+ function _secretVaultProbe(home = process.env.HOME || process.env.USERPROFILE || "") {
536
+ // Host-side secret vault written by scripts/telegram_secrets.py and
537
+ // scripts/secrets_decrypt.sh — NOT by this CLI. We only report presence.
538
+ const dir = home ? path.join(home, ".config", "secrets") : "";
539
+ const present = Boolean(dir && fs.existsSync(dir));
540
+ return {
541
+ name: "~/.config/secrets",
542
+ path: dir,
543
+ present,
544
+ written_by_cli: false,
545
+ note: present
546
+ ? "present on this host; populated by server-side secret sync, not the CLI"
547
+ : "absent on this host; populated by server-side secret sync when configured (verify)",
548
+ };
549
+ }
550
+
551
+ function _mcpEgressEntry(target, env = process.env) {
552
+ // MCP cloud transport host — DEFAULT_MCP_HOST from lib/utils/mcp-auth.js,
553
+ // overridable via MCP_HOST / ODAI_MCP_HOST. The local 0dai + filesystem MCP
554
+ // servers run as on-box subprocesses (no egress); only the cloud server
555
+ // ("claude_ai_0dai") talks to the network. We also surface the configured
556
+ // server names from .mcp.json if present (no values, just names).
557
+ const host = env.MCP_HOST || env.ODAI_MCP_HOST || "https://mcp.0dai.dev";
558
+ let configured = [];
559
+ try {
560
+ configured = _readMcpConfigSummary(target).servers || [];
561
+ } catch { configured = []; }
562
+ return {
563
+ endpoint: `${host}/mcp`,
564
+ trigger: "MCP cloud server (claude_ai_0dai) when an agent CLI connects to it",
565
+ data: "MCP tool calls over an authenticated session; local 0dai + filesystem servers are on-box subprocesses (no egress)",
566
+ configured_servers: configured,
567
+ };
568
+ }
569
+
570
+ function collectSecurityPayload(target, options = {}) {
571
+ const home = options.home || process.env.HOME || process.env.USERPROFILE || "";
572
+ const env = options.env || process.env;
573
+ let egress;
574
+ try {
575
+ egress = require("./trust")._buildColdPayload(target).egress;
576
+ } catch {
577
+ egress = { status: "unavailable", note: "trust.js egress facts not loadable" };
578
+ }
579
+ // Augment the trust.js subset with the two surfaces the issue names that are
580
+ // not in that subset: the MCP cloud transport and the activation telemetry
581
+ // POST. Both are derived from real CLI code (mcp-auth.js, auth.js).
582
+ egress = JSON.parse(JSON.stringify(egress));
583
+ egress.note =
584
+ "Egress surfaces relevant to this disclosure (MCP, telemetry, on-command "
585
+ + "pushes). Not an exhaustive list of every /v1/* endpoint the CLI can reach.";
586
+ egress.mcp = _mcpEgressEntry(target, env);
587
+ if (Array.isArray(egress.on_explicit_command_only)) {
588
+ egress.on_explicit_command_only.push({
589
+ endpoint: "POST /v1/events (api.0dai.dev)",
590
+ trigger: "0dai activate free (best-effort, on successful activation)",
591
+ data: "activation event {event, timestamp, path, source}; no file contents, no secrets",
592
+ });
593
+ }
594
+ return {
595
+ target,
596
+ generated_at: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
597
+ note:
598
+ "Static, code-derived data boundary. This mode makes NO network calls and "
599
+ + "reads NO secret values. Run `0dai trust` for the live enforced scope.",
600
+ egress,
601
+ local_sinks: [
602
+ {
603
+ name: "ai/meta/telemetry/activation.jsonl",
604
+ kind: "local file write",
605
+ note: "activation funnel timing written to disk; a summary event is POSTed to /v1/events only on `0dai activate free` (see egress)",
606
+ },
607
+ {
608
+ name: "ai/meta/telemetry/*.jsonl",
609
+ kind: "local file write",
610
+ note: "MCP tool usage, operator-ack, and other local telemetry; written to disk, not auto-POSTed",
611
+ },
612
+ ],
613
+ secret_vault: _secretVaultProbe(home),
614
+ authz: AUTHZ_SUMMARY,
615
+ docs: "docs/data-boundary.md",
616
+ };
617
+ }
618
+
619
+ function printSecurityReport(target, options = {}) {
620
+ const payload = collectSecurityPayload(target, options);
621
+ const W = process.stdout.isTTY ? "\x1b[33m" : "";
622
+ const G = process.stdout.isTTY ? "\x1b[32m" : "";
623
+ const R2 = process.stdout.isTTY ? "\x1b[0m" : "";
624
+
625
+ console.log("\n 0dai doctor --security — data boundary");
626
+ console.log(` ${D}${payload.note}${R2}`);
627
+
628
+ console.log("\n egress (network):");
629
+ const eg = payload.egress || {};
630
+ if (Array.isArray(eg.unconditional)) {
631
+ console.log(" unconditional (every CLI run):");
632
+ for (const ep of eg.unconditional) {
633
+ console.log(` * ${ep.endpoint}`);
634
+ console.log(` ${D}trigger: ${ep.trigger}${R2}`);
635
+ }
636
+ }
637
+ if (eg.mcp) {
638
+ console.log(" MCP:");
639
+ console.log(` * ${eg.mcp.endpoint}`);
640
+ console.log(` ${D}trigger: ${eg.mcp.trigger}${R2}`);
641
+ if (Array.isArray(eg.mcp.configured_servers) && eg.mcp.configured_servers.length) {
642
+ console.log(` ${D}configured servers: ${eg.mcp.configured_servers.join(", ")}${R2}`);
643
+ }
644
+ }
645
+ if (Array.isArray(eg.on_explicit_command_only)) {
646
+ console.log(" on explicit command only:");
647
+ for (const ep of eg.on_explicit_command_only) {
648
+ console.log(` * ${ep.endpoint} ${D}(${ep.trigger})${R2}`);
649
+ console.log(` ${D}data: ${ep.data}${R2}`);
650
+ }
651
+ }
652
+ if (!eg.unconditional && !eg.on_explicit_command_only) {
653
+ console.log(` ${D}${eg.note || "egress facts unavailable"}${R2}`);
654
+ }
655
+
656
+ console.log("\n local sinks (no egress):");
657
+ for (const sink of payload.local_sinks) {
658
+ console.log(` * ${sink.name} ${D}(${sink.note})${R2}`);
659
+ }
660
+ const vault = payload.secret_vault;
661
+ const vaultMark = vault.present ? `${G}present${R2}` : `${D}absent${R2}`;
662
+ console.log(` ${vaultMark} ${vault.name} ${D}(${vault.note})${R2}`);
663
+
664
+ console.log("\n authZ (enforced server-side, deny-by-default):");
665
+ console.log(` ${W}default: ${payload.authz.default}${R2} ${D}— ${payload.authz.note}${R2}`);
666
+ for (const tier of payload.authz.tiers) {
667
+ console.log(` * ${tier.level.padEnd(13)} ${tier.guard} ${D}→ ${tier.routes}${R2}`);
668
+ console.log(` ${D}${tier.rule}${R2}`);
669
+ }
670
+
671
+ console.log(`\n ${D}Full doc: ${payload.docs} Live enforced scope: 0dai trust${R2}`);
672
+ return payload;
673
+ }
674
+
484
675
  function collectDoctorPayload(target, options = {}) {
485
676
  const payload = {
486
677
  binary: "node",
@@ -499,6 +690,14 @@ function collectDoctorPayload(target, options = {}) {
499
690
  function cmdDoctor(target, options = {}) {
500
691
  const ai = path.join(target, "ai");
501
692
  if (!fs.existsSync(ai)) { log("No 0dai config found. Run: 0dai init"); return; }
693
+ if (options.security) {
694
+ if (options.json) {
695
+ const payload = collectSecurityPayload(target, options.securityOptions || {});
696
+ console.log(JSON.stringify(payload, null, 2));
697
+ return payload;
698
+ }
699
+ return printSecurityReport(target, options.securityOptions || {});
700
+ }
502
701
  if (options.json) {
503
702
  const payload = collectDoctorPayload(target, {
504
703
  drift: options.drift,
@@ -794,4 +993,6 @@ module.exports = {
794
993
  collectGitHubIdentityPayload,
795
994
  collectMcpExposurePayload,
796
995
  formatMcpExposureLine,
996
+ collectSecurityPayload,
997
+ printSecurityReport,
797
998
  };
@@ -7,10 +7,11 @@ const {
7
7
  VERSION, SUPPORTED_CLIS,
8
8
  CONFIG_DIR, PROJECTS_FILE,
9
9
  apiCall, makeEnsureAuthenticated, ensureLicenseActivation, loadAuthState,
10
- collectMetadata, buildProjectIdentity, registerProject, projectIdFor, hashManifestFiles,
10
+ collectMetadata, inferProjectName, detectStackHint,
11
+ buildProjectIdentity, registerProject, projectIdFor, hashManifestFiles,
11
12
  loadProjectBinding, boundProjectIdentityFromBinding, validateProjectDisplayName,
12
13
  writeProjectBinding,
13
- writeFiles, missingLiveManifestDefaults, ensureLiveManifestDefaults,
14
+ writeFiles, missingLiveManifestDefaults, ensureLiveManifestDefaults, LIVE_MANIFEST_DEFAULTS,
14
15
  sendProjectHeartbeat, recordExperienceEvent,
15
16
  logFirstRunSuccess, checkVersion,
16
17
  } = shared;
@@ -403,12 +404,98 @@ async function cmdProjectBind(target, args = []) {
403
404
  return payload;
404
405
  }
405
406
 
407
+ // buildLocalDryRunPreview — pure, offline preview for `0dai init --local --dry-run`.
408
+ // Detects the stack and renders the configs the local (offline) init path
409
+ // produces, entirely IN MEMORY. Makes ZERO auth/license/server/network calls:
410
+ // it only reads the target dir via collectMetadata + the local detection helpers
411
+ // (inferProjectName / detectStackHint), all of which run on-disk with no egress.
412
+ // Returns { projectName, stack, clis, files, agentTargets } where:
413
+ // files — { relPath: content } the offline path would write
414
+ // agentTargets — [{ cli, files: [...] }] the per-CLI config paths a full
415
+ // (authenticated) `0dai init` would manage for each detected CLI
416
+ function buildLocalDryRunPreview(target) {
417
+ const metadata = collectMetadata(target);
418
+ const { projectFiles, manifestContents, clis } = metadata;
419
+ const projectName = inferProjectName(target, manifestContents);
420
+ const stack = detectStackHint(projectFiles, manifestContents);
421
+
422
+ // Mirror the offline generator (lib/wizard.js stepGenerate) so the preview
423
+ // matches what `0dai init --local` writes without auth — no fabricated bodies.
424
+ const claudeMd = `# ${projectName}\n\nStack: ${stack}\nGenerated by 0dai (local mode).\n\n## Commands\n\nSee ai/manifest/ for full configuration.\n`;
425
+ const agentsMd = `# Agent Configuration\n\nProject: ${projectName}\nStack: ${stack}\n\nSee ai/manifest/ for configuration details.\n`;
426
+ const discovery = {
427
+ project_name: projectName,
428
+ stack,
429
+ detected_stack: [stack],
430
+ selected_agents: clis.length ? clis : SUPPORTED_CLIS.map((c) => c.name),
431
+ local: true,
432
+ };
433
+ const files = {
434
+ "CLAUDE.md": claudeMd,
435
+ "AGENTS.md": agentsMd,
436
+ "ai/manifest/discovery.json": JSON.stringify(discovery, null, 2) + "\n",
437
+ "ai/manifest/project.yaml": `plan: free\nname: ${projectName}\nstack: ${stack}\n`,
438
+ // Live manifest scaffolds the offline path writes via ensureLiveManifestDefaults
439
+ // (lib/wizard.js stepGenerate). Use the canonical bodies, not fabricated ones.
440
+ ...LIVE_MANIFEST_DEFAULTS,
441
+ "ai/VERSION": `${VERSION}\n`,
442
+ };
443
+
444
+ // Per-CLI native config paths the full managed init would generate. We list
445
+ // paths (factual, from SUPPORTED_CLIS) rather than inventing offline bodies.
446
+ const agentTargets = SUPPORTED_CLIS
447
+ .filter((c) => Array.isArray(c.agentFiles) && c.agentFiles.length)
448
+ .map((c) => ({ cli: c.name, files: c.agentFiles }));
449
+
450
+ return { projectName, stack, clis, files, agentTargets };
451
+ }
452
+
453
+ // cmdInitLocalDryRun — offline `0dai init --local --dry-run`. Prints the
454
+ // preview to stdout and returns. Writes nothing to disk and makes no network
455
+ // calls. This is the only path that bypasses the auth/license preflight, and
456
+ // only when BOTH --local AND --dry-run are set (see cmdInit).
457
+ function cmdInitLocalDryRun(target) {
458
+ const { projectName, stack, clis, files, agentTargets } = buildLocalDryRunPreview(target);
459
+ log(`local dry-run: no account or network needed`);
460
+ console.log(` project: ${projectName}`);
461
+ console.log(` stack: ${stack}`);
462
+ console.log(` agent CLIs detected: ${clis.length ? clis.join(", ") : "none"}`);
463
+ console.log("");
464
+ const names = Object.keys(files);
465
+ log(`local dry-run: would write ${names.length} file(s) (nothing written):`);
466
+ for (const name of names) {
467
+ console.log(` ${D}+ ${name}${R}`);
468
+ }
469
+ console.log("");
470
+ log(`local dry-run: per-CLI native configs a full 0dai init would manage:`);
471
+ for (const target_ of agentTargets) {
472
+ console.log(` ${D}${target_.cli}: ${target_.files.join(", ")}${R}`);
473
+ }
474
+ console.log("");
475
+ for (const name of names) {
476
+ console.log(`${T}--- ${name} ---${R}`);
477
+ console.log(files[name].replace(/\n$/, ""));
478
+ console.log("");
479
+ }
480
+ console.log(` ${D}Sign in to write these for real: 0dai init --local${R}`);
481
+ return { projectName, stack, clis, files, agentTargets };
482
+ }
483
+
406
484
  async function cmdInit(target, args = []) {
407
485
  const dryRun = args.includes("--dry-run");
408
486
  const minimal = args.includes("--minimal");
409
487
  const noWizard = args.includes("--no-wizard");
410
488
  const localMode = args.includes("--local");
411
489
 
490
+ // Offline preview: `0dai init --local --dry-run` renders configs in memory,
491
+ // prints them, and returns. Bypasses the auth/license/server preflight ONLY
492
+ // when BOTH flags are present (issue #4475). Plain --dry-run (server plan)
493
+ // and plain --local (wizard, writes) are unchanged.
494
+ if (localMode && dryRun) {
495
+ cmdInitLocalDryRun(target);
496
+ return;
497
+ }
498
+
412
499
  if (fs.existsSync(path.join(target, "ai", "VERSION"))) {
413
500
  const v = fs.readFileSync(path.join(target, "ai", "VERSION"), "utf8").trim();
414
501
  log(`ai/ layer already exists (v${v}). Run '0dai sync' to update.`);
@@ -1066,6 +1153,8 @@ module.exports = {
1066
1153
  cmdInit,
1067
1154
  cmdSync,
1068
1155
  cmdProjectBind,
1156
+ buildLocalDryRunPreview,
1157
+ cmdInitLocalDryRun,
1069
1158
  buildLocalSyncPreview,
1070
1159
  runMcpBootstrap,
1071
1160
  bindProjectForCloud,
@@ -30,7 +30,7 @@ function findRepoScript(target, name) {
30
30
 
31
31
  function runLocal(target, args) {
32
32
  const script = findRepoScript(target, "play_book.py");
33
- if (!script) return { ok: false, error: "play_book.py not found locally" };
33
+ if (!script) return { ok: false, repoRequired: true, error: "play_book.py not found locally" };
34
34
  const res = spawnSync("python3", [script, "--root", target, ...args], {
35
35
  encoding: "utf8",
36
36
  stdio: ["ignore", "pipe", "pipe"],
@@ -116,7 +116,7 @@ function cmdFork(target, slug, args) {
116
116
  const author = argAfter(args, "--author");
117
117
  if (!newSlug || !author) { log("fork requires --as <new-slug> --author <handle>"); return; }
118
118
  const res = runLocal(target, ["fork", slug, "--as", newSlug, "--author", author]);
119
- if (!res.ok) { log(`fork failed: ${res.error}`); process.exit(1); }
119
+ if (!handleLocal(res, "fork")) return;
120
120
  console.log(typeof res.data === "string" ? res.data : JSON.stringify(res.data, null, 2));
121
121
  }
122
122
 
@@ -127,7 +127,7 @@ function cmdRun(target, slug, args) {
127
127
  if (args[i] === "--var" && args[i + 1]) { fwdArgs.push("--var", args[i + 1]); i++; }
128
128
  }
129
129
  const res = runLocal(target, fwdArgs);
130
- if (!res.ok) { log(`run failed: ${res.error}`); process.exit(1); }
130
+ if (!handleLocal(res, "run")) return;
131
131
  console.log(typeof res.data === "string" ? res.data : JSON.stringify(res.data, null, 2));
132
132
  }
133
133
 
@@ -136,7 +136,7 @@ function cmdRank(target, args) {
136
136
  const outcomes = argAfter(args, "--outcomes");
137
137
  if (outcomes) fwdArgs.push("--outcomes", outcomes);
138
138
  const res = runLocal(target, fwdArgs);
139
- if (!res.ok) { log(`rank failed: ${res.error}`); process.exit(1); }
139
+ if (!handleLocal(res, "rank")) return;
140
140
  console.log(typeof res.data === "string" ? res.data : JSON.stringify(res.data, null, 2));
141
141
  }
142
142
 
@@ -145,6 +145,22 @@ function argAfter(args, flag) {
145
145
  return i >= 0 && i + 1 < args.length ? args[i + 1] : null;
146
146
  }
147
147
 
148
+ // fork/run/rank drive the bundled play_book.py, which only ships inside a 0dai
149
+ // project checkout (it needs ai/plays/ on disk + PyYAML). A bare `npm install -g`
150
+ // has neither, so findRepoScript returns null. That is an expected environment
151
+ // limitation, not a crash — say so honestly and exit 0 instead of a misleading
152
+ // "X failed: play_book.py not found locally" + exit 1.
153
+ function handleLocal(res, verb) {
154
+ if (res.repoRequired) {
155
+ log(`'play ${verb}' runs locally against ai/plays/ and needs a 0dai project checkout (it is not bundled in the npm package).`);
156
+ log(`Run it from a cloned project — e.g. 'git clone https://github.com/iGeezmo/0dai && cd 0dai && 0dai play ${verb} …'.`);
157
+ log(`The server-backed 'play list / get / search' work anywhere.`);
158
+ return false;
159
+ }
160
+ if (!res.ok) { log(`${verb} failed: ${res.error}`); process.exit(1); }
161
+ return true;
162
+ }
163
+
148
164
  async function cmdPlay(target, sub, args) {
149
165
  const remaining = args.slice(2);
150
166
  if (!sub || sub === "help") {
@@ -26,6 +26,107 @@ function loadProjectBinding(target) {
26
26
  return shared.loadProjectBinding(target);
27
27
  }
28
28
 
29
+ // Open sprint:current issues are the authoritative work queue (SPRINT.md
30
+ // retired as SoT). Probe gh; degrade quietly when gh is absent or the token
31
+ // is not authenticated — never throw out of status.
32
+ function collectSprintPayload(target, options = {}) {
33
+ const out = { available: false, authenticated: false, issues: [], reason: "" };
34
+ try {
35
+ const r = spawnSync(
36
+ "gh",
37
+ [
38
+ "issue", "list",
39
+ "--label", "sprint:current",
40
+ "--state", "open",
41
+ "--json", "number,title",
42
+ "--limit", "20",
43
+ ],
44
+ {
45
+ cwd: target,
46
+ stdio: ["ignore", "pipe", "pipe"],
47
+ encoding: "utf8",
48
+ timeout: options.ghTimeout || 8000,
49
+ },
50
+ );
51
+ if (r.error && r.error.code === "ENOENT") {
52
+ out.reason = "gh CLI not installed";
53
+ return out;
54
+ }
55
+ out.available = true;
56
+ if (r.status !== 0) {
57
+ const err = String(r.stderr || "").trim();
58
+ out.reason = /auth|logged in|token/i.test(err)
59
+ ? "gh not authenticated — run: gh auth login"
60
+ : (err.split("\n").slice(-1)[0] || "gh issue list failed");
61
+ return out;
62
+ }
63
+ out.authenticated = true;
64
+ const parsed = JSON.parse(String(r.stdout || "[]").trim() || "[]");
65
+ out.issues = Array.isArray(parsed)
66
+ ? parsed.map((i) => ({ number: i.number, title: i.title || "" }))
67
+ : [];
68
+ } catch (e) {
69
+ out.reason = String((e && e.message) || e).split("\n").slice(-1)[0] || "sprint probe failed";
70
+ }
71
+ return out;
72
+ }
73
+
74
+ // Active flight = newest flight plan under ai/meta/reports/flight-F*.md, ranked
75
+ // by flight number (F17 > F6) then by the trailing date in the filename.
76
+ function findActiveFlight(target) {
77
+ const dir = path.join(target, "ai", "meta", "reports");
78
+ let names;
79
+ try {
80
+ names = fs.readdirSync(dir).filter((f) => /^flight-F\d+.*\.md$/.test(f));
81
+ } catch {
82
+ return null;
83
+ }
84
+ if (!names.length) return null;
85
+ const parse = (name) => {
86
+ const m = name.match(/^flight-F(\d+)/);
87
+ const dateM = name.match(/(\d{4}-\d{2}-\d{2})/);
88
+ return { name, num: m ? Number(m[1]) : -1, date: dateM ? dateM[1] : "" };
89
+ };
90
+ const newest = names
91
+ .map(parse)
92
+ .sort((a, b) => (b.num - a.num) || b.date.localeCompare(a.date))[0];
93
+ const file = path.join(dir, newest.name);
94
+ let id = `F${newest.num}`, title = "", flightStatus = "";
95
+ try {
96
+ const text = fs.readFileSync(file, "utf8");
97
+ const fm = text.match(/^---\n([\s\S]*?)\n---/);
98
+ const block = fm ? fm[1] : text;
99
+ const idM = block.match(/^id:\s*(.+)$/m);
100
+ const titleM = block.match(/^title:\s*(.+)$/m);
101
+ const statusM = block.match(/^status:\s*(.+)$/m);
102
+ if (idM) id = idM[1].trim().replace(/^["']|["']$/g, "");
103
+ if (titleM) title = titleM[1].trim().replace(/^["']|["']$/g, "");
104
+ if (statusM) flightStatus = statusM[1].trim().replace(/^["']|["']$/g, "");
105
+ } catch {}
106
+ return { id, title, status: flightStatus, file: path.relative(target, file) };
107
+ }
108
+
109
+ // Drift signal: roadmap.json is generated from ROADMAP.md. If the source is
110
+ // newer than the generated artifact, the JSON is stale and should be regenerated.
111
+ function collectRoadmapDrift(target) {
112
+ const out = { detected: false, reason: "" };
113
+ const jsonPath = path.join(target, "ai", "meta", "manifest", "roadmap.json");
114
+ const mdPath = path.join(target, "ROADMAP.md");
115
+ let jsonMtime, mdMtime;
116
+ try { jsonMtime = fs.statSync(jsonPath).mtimeMs; } catch { return out; }
117
+ try { mdMtime = fs.statSync(mdPath).mtimeMs; } catch { return out; }
118
+ // Prefer an explicit updated_at/generated_at in the JSON over file mtime when present.
119
+ const j = loadJson(jsonPath);
120
+ const stamp = j && (j.updated_at || j.generated_at);
121
+ const jsonTime = stamp ? Date.parse(stamp) : NaN;
122
+ const jsonEffective = Number.isFinite(jsonTime) ? jsonTime : jsonMtime;
123
+ if (mdMtime > jsonEffective) {
124
+ out.detected = true;
125
+ out.reason = "ROADMAP.md is newer than roadmap.json — run: python3 scripts/roadmap_to_json.py";
126
+ }
127
+ return out;
128
+ }
129
+
29
130
  function collectStatusPayload(target, options = {}) {
30
131
  const ai = path.join(target, "ai");
31
132
  const baseIdentity = shared.buildProjectIdentity(target, shared.collectMetadata(target));
@@ -139,6 +240,9 @@ function collectStatusPayload(target, options = {}) {
139
240
  drift: {
140
241
  detected: driftDetected,
141
242
  },
243
+ sprint: collectSprintPayload(target, options),
244
+ flight: findActiveFlight(target),
245
+ roadmap_drift: collectRoadmapDrift(target),
142
246
  mcp_exposure: collectMcpExposurePayload(target, options.mcpExposureOptions || {}),
143
247
  warnings: warningCount,
144
248
  activation_ttfv: getActivationDurationStats(target),
@@ -159,6 +263,33 @@ function cmdStatus(target, options = {}) {
159
263
  console.log(` swarm: ${payload.swarm.queued} queued, ${payload.swarm.active} active, ${payload.swarm.done} done`);
160
264
  }
161
265
 
266
+ const sprint = payload.sprint || {};
267
+ if (sprint.authenticated) {
268
+ if (sprint.issues.length) {
269
+ console.log(` sprint:current — ${sprint.issues.length} open:`);
270
+ for (const i of sprint.issues.slice(0, 8)) {
271
+ console.log(` #${i.number} ${i.title}`);
272
+ }
273
+ if (sprint.issues.length > 8) {
274
+ console.log(` …and ${sprint.issues.length - 8} more`);
275
+ }
276
+ } else {
277
+ console.log(` sprint:current — none open`);
278
+ }
279
+ } else if (sprint.reason) {
280
+ console.log(` sprint:current — ${D}unavailable (${sprint.reason})${R}`);
281
+ }
282
+
283
+ if (payload.flight) {
284
+ const ftitle = payload.flight.title ? ` ${payload.flight.title}` : "";
285
+ const fstatus = payload.flight.status ? ` [${payload.flight.status}]` : "";
286
+ console.log(` flight: ${payload.flight.id}${ftitle}${fstatus}`);
287
+ }
288
+
289
+ if (payload.roadmap_drift && payload.roadmap_drift.detected) {
290
+ console.log(` roadmap: ${D}${payload.roadmap_drift.reason}${R}`);
291
+ }
292
+
162
293
  if (payload.swarm.quota_state === "locked") {
163
294
  console.log(` swarm quota: ${D}locked (Free) — upgrade for ${payload.swarm.daily_limit} tasks/day${R}`);
164
295
  } else {
@@ -198,4 +329,10 @@ function cmdStatus(target, options = {}) {
198
329
  return payload;
199
330
  }
200
331
 
201
- module.exports = { cmdStatus, collectStatusPayload };
332
+ module.exports = {
333
+ cmdStatus,
334
+ collectStatusPayload,
335
+ collectSprintPayload,
336
+ findActiveFlight,
337
+ collectRoadmapDrift,
338
+ };
@@ -283,4 +283,4 @@ function cmdTrust(target, args) {
283
283
  process.exit(1);
284
284
  }
285
285
 
286
- module.exports = { cmdTrust, renderColdScope };
286
+ module.exports = { cmdTrust, renderColdScope, _buildColdPayload };
@@ -26,7 +26,10 @@ from collections import defaultdict
26
26
  from dataclasses import dataclass, field
27
27
  from typing import Any, Iterable
28
28
 
29
- from PIL import Image, ImageColor, ImageDraw, ImageFont
29
+ try: # Pillow is needed ONLY for `--png`; the text + `--json` paths never touch it.
30
+ from PIL import Image, ImageColor, ImageDraw, ImageFont
31
+ except ModuleNotFoundError: # npm-installed users may lack Pillow — degrade, don't crash at import.
32
+ Image = ImageColor = ImageDraw = ImageFont = None # type: ignore[assignment]
30
33
 
31
34
 
32
35
  SUPPORTED_MODES = ("edits", "failures", "authorship")
@@ -781,6 +784,12 @@ def _draw_wrapped_text(draw: ImageDraw.ImageDraw, text: str, box: tuple[float, f
781
784
 
782
785
 
783
786
  def render_png(payload: dict[str, Any], output_path: pathlib.Path | str) -> pathlib.Path:
787
+ if Image is None: # Pillow absent (e.g. a fresh `npm install -g` without `pip install pillow`).
788
+ raise SystemExit(
789
+ "0dai heatmap --png needs Pillow, which is not installed.\n"
790
+ "Install it (pip install pillow) for PNG export; the default text and --json "
791
+ "summaries work without it."
792
+ )
784
793
  width = int(payload.get("width") or DEFAULT_WIDTH)
785
794
  height = int(payload.get("height") or DEFAULT_HEIGHT)
786
795
  out = pathlib.Path(output_path).expanduser().resolve()
package/lib/shared.js CHANGED
@@ -492,6 +492,7 @@ module.exports = {
492
492
  sendProjectHeartbeat, recordExperienceEvent, logFirstRunSuccess,
493
493
  // Files
494
494
  mergeSettingsJson, writeFiles, missingLiveManifestDefaults, ensureLiveManifestDefaults,
495
+ LIVE_MANIFEST_DEFAULTS,
495
496
  findRepoScript, repoScriptCandidates, resolveBundledScript, resolvePythonScript,
496
497
  // Version
497
498
  checkVersion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0dai-dev/cli",
3
- "version": "4.3.7",
3
+ "version": "4.3.8",
4
4
  "description": "One config layer for seven AI coding agents \u2014 Claude Code, Codex, OpenCode, Gemini, Aider, Qoder, Cursor",
5
5
  "bin": {
6
6
  "0dai": "./bin/0dai.js"