@0dai-dev/cli 4.3.8 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -86,7 +86,7 @@ Global flags: `--target PATH`, `--version`, `--help`, `--json`, `--quiet`. See `
86
86
 
87
87
  ## What it does
88
88
 
89
- `0dai init` and `0dai sync` are activation-first. `init` can complete auth and plan activation in one run: browser/OAuth exchange code via `--auth-code`, Team/Pro activation code via `--code` or `--activation-code`, or the interactive OAuth/device-code prompt. After that it binds the project and sends only allowlisted project metadata (file names + package/build manifests) to the API and generates:
89
+ `0dai init` and `0dai sync` are activation-first. `init` can complete auth and plan activation in one run: browser/OAuth exchange code via `--auth-code`, Team/Pro activation code via `--code` or `--activation-code`, or the interactive OAuth/device-code prompt. After that it binds the project and sends only allowlisted project metadata (file names + hashes of package/build manifests — never their contents) to the API and generates:
90
90
 
91
91
  - `ai/` — manifests, personas, skills, playbooks, delegation policy
92
92
  - `.claude/` — settings, agents, hooks, rules
@@ -95,7 +95,7 @@ Global flags: `--target PATH`, `--version`, `--help`, `--json`, `--quiet`. See `
95
95
  - `.aider/` — config, agents
96
96
  - `AGENTS.md`, `.mcp.json`
97
97
 
98
- Your source code is never sent. Only file names and package/build manifests.
98
+ Your source code is never sent. Only file names and hashes of package/build manifests — never their contents.
99
99
 
100
100
  ## Privacy
101
101
 
@@ -105,7 +105,7 @@ Local activation and experience records stay on your machine: `ai/meta/telemetry
105
105
 
106
106
  Cursor and Copilot are editors. They help inside one coding session. 0dai writes a project layer that Claude Code, Codex, OpenCode, Gemini, and Aider can all read. The point is not another autocomplete box; it is one manifest, one set of agent roles, one task queue, and one health check for the repo.
107
107
 
108
- A fresh `0dai init` turns a repo into an AI-ready workspace. It records stack detection, safe project commands, agent preferences, MCP config, and delegation policy under `ai/`, then writes native files such as `AGENTS.md` and `.mcp.json`. Your source stays local; only file names and package/build manifests go to the API.
108
+ A fresh `0dai init` turns a repo into an AI-ready workspace. It records stack detection, safe project commands, agent preferences, MCP config, and delegation policy under `ai/`, then writes native files such as `AGENTS.md` and `.mcp.json`. Your source stays local; only file names and hashes of package/build manifests go to the API — never their contents.
109
109
 
110
110
  0dai is useful when work needs more than one model or more than one sitting. You can ask for a backlog item with `0dai run "add password reset tests"`, check the queue with `0dai swarm status`, and continue from the same project facts in another agent CLI. Cursor or Copilot can still be your editor; 0dai keeps the repo-level context and task state outside the editor.
111
111
 
@@ -113,7 +113,7 @@ The first five minutes are direct: run `0dai init`, run `0dai doctor`, then try
113
113
 
114
114
  ## Free-tier examples
115
115
 
116
- On the free tier you get ≈5 backlog tasks/day. Example: in one day you might ask 0dai to split and queue these tasks:
116
+ On the free tier you get 1 swarm task/day — an activation teaser (Pro lifts this to 50/day). Enough for one daily cleanup pass; pick a task to queue, for example:
117
117
 
118
118
  - Write missing tests for the login form.
119
119
  - Add empty-state copy to the dashboard.
@@ -121,7 +121,7 @@ On the free tier you get ≈5 backlog tasks/day. Example: in one day you might a
121
121
  - Draft a PR checklist from the files changed.
122
122
  - Summarize today's `ai/swarm/done` tasks for standup.
123
123
 
124
- Each task can become one or more agent prompts, so the exact count depends on task size. Treat the free tier as enough for a daily cleanup pass; Pro is for larger task queues, graph sync with edges, and session roaming across projects.
124
+ Each backlog item splits into one or more agent prompts. The free tier's 1 task/day suits a daily cleanup pass; Pro (50/day) is for larger task queues, graph sync with edges, and session roaming across projects.
125
125
 
126
126
  ## Links
127
127
 
package/bin/0dai.js CHANGED
@@ -201,7 +201,11 @@ const { cmdTrust } = require("../lib/commands/trust");
201
201
  function printHelp() {
202
202
  const counts = loadCanonicalCounts();
203
203
  console.log(`\n ${T}0dai${R} v${VERSION} — One config for ${counts.agent_clis_total} AI agent CLIs · ${mcpToolsLabel(counts)}\n`);
204
- console.log("First-run sequence (canonical):");
204
+ console.log("Try it now — no account, no network:");
205
+ console.log(" 0dai init --local --dry-run # preview configs for this repo, writes nothing");
206
+ console.log(" 0dai init --local # generate them on disk, still no account");
207
+ console.log("");
208
+ console.log("First-run sequence (canonical — full per-CLI configs + sync):");
205
209
  console.log(" npm install -g @0dai-dev/cli # install once, globally");
206
210
  console.log(" 0dai auth login # sign in (OAuth / device code)");
207
211
  console.log(" 0dai activate free # claim free-tier license");
@@ -209,7 +213,7 @@ function printHelp() {
209
213
  console.log(" 0dai doctor # verify health and drift");
210
214
  console.log("");
211
215
  console.log("Start (first 5 minutes):");
212
- console.log(" init Create ai/ layer + MCP [--local] [--dry-run] [--minimal]");
216
+ console.log(" init Create ai/ layer + MCP [--local] [--dry-run] [--minimal] [--template <stack>]");
213
217
  console.log(" doctor Check health, credentials, and drift [--drift] [--security]");
214
218
  console.log(" status Show maturity, swarm, and session state [--json]");
215
219
  console.log(" quickstart Run auth, init, doctor, and status checks in order");
@@ -17,11 +17,12 @@ function resolvePythonScript(target, name) {
17
17
  }
18
18
 
19
19
  function printExperienceHelp() {
20
- console.log("Usage: 0dai experience [list|stats|record-json|sync|warnings|dismiss]");
20
+ console.log("Usage: 0dai experience [list|stats|report|record-json|sync|warnings|dismiss]");
21
21
  console.log("");
22
22
  console.log("Commands:");
23
23
  console.log(" list Show recent local experience events");
24
24
  console.log(" stats Summarize local experience events");
25
+ console.log(" report What your agents learned about this repo over N days [--period 30d] [--days N]");
25
26
  console.log(" record-json Record one JSON payload through the repo experience helper");
26
27
  console.log(" sync Sync eligible events when the repo helper permits it");
27
28
  console.log(" warnings Show anti-pattern warnings");
@@ -61,6 +62,12 @@ function cmdExperience(target, sub, args) {
61
62
  if (periodIdx >= 0 && args[periodIdx + 1]) forwarded.push("--period", args[periodIdx + 1]);
62
63
  const byIdx = args.indexOf("--by");
63
64
  if (byIdx >= 0 && args[byIdx + 1]) forwarded.push("--by", args[byIdx + 1]);
65
+ } else if (command === "report") {
66
+ if (args.includes("--json")) forwarded.push("--json");
67
+ const periodIdx = args.indexOf("--period");
68
+ if (periodIdx >= 0 && args[periodIdx + 1]) forwarded.push("--period", args[periodIdx + 1]);
69
+ const daysIdx = args.indexOf("--days");
70
+ if (daysIdx >= 0 && args[daysIdx + 1]) forwarded.push("--days", args[daysIdx + 1]);
64
71
  } else if (command === "sync") {
65
72
  if (args.includes("--json")) forwarded.push("--json");
66
73
  const limitIdx = args.indexOf("--limit");
@@ -413,11 +413,31 @@ async function cmdProjectBind(target, args = []) {
413
413
  // files — { relPath: content } the offline path would write
414
414
  // agentTargets — [{ cli, files: [...] }] the per-CLI config paths a full
415
415
  // (authenticated) `0dai init` would manage for each detected CLI
416
- function buildLocalDryRunPreview(target) {
416
+ // --template <stack> (#3931): let the user DECLARE the stack so a fresh repo gets a
417
+ // stack-labeled starter ai/ layer without relying on detection — removes the
418
+ // "empty ai/ / stack: unknown" first-run barrier (TTFV). Canonical stacks mirror
419
+ // detectStackHint's vocabulary; common aliases are accepted.
420
+ const TEMPLATE_ALIASES = {
421
+ next: "nextjs", nextjs: "nextjs",
422
+ fastapi: "python-service", python: "python-service", "python-service": "python-service",
423
+ go: "go-service", golang: "go-service", "go-service": "go-service",
424
+ flutter: "flutter",
425
+ rn: "react-native", expo: "react-native", "react-native": "react-native",
426
+ express: "backend-api", node: "backend-api", "backend-api": "backend-api",
427
+ ml: "data-ml", "data-ml": "data-ml",
428
+ react: "frontend", vue: "frontend", frontend: "frontend",
429
+ };
430
+ const TEMPLATE_CHOICES = [...new Set(Object.values(TEMPLATE_ALIASES))].sort();
431
+ function resolveTemplateStack(raw) {
432
+ const key = String(raw || "").trim().toLowerCase();
433
+ return TEMPLATE_ALIASES[key] || "";
434
+ }
435
+
436
+ function buildLocalDryRunPreview(target, templateStack = "") {
417
437
  const metadata = collectMetadata(target);
418
438
  const { projectFiles, manifestContents, clis } = metadata;
419
439
  const projectName = inferProjectName(target, manifestContents);
420
- const stack = detectStackHint(projectFiles, manifestContents);
440
+ const stack = templateStack || detectStackHint(projectFiles, manifestContents);
421
441
 
422
442
  // Mirror the offline generator (lib/wizard.js stepGenerate) so the preview
423
443
  // matches what `0dai init --local` writes without auth — no fabricated bodies.
@@ -454,11 +474,11 @@ function buildLocalDryRunPreview(target) {
454
474
  // preview to stdout and returns. Writes nothing to disk and makes no network
455
475
  // calls. This is the only path that bypasses the auth/license preflight, and
456
476
  // only when BOTH --local AND --dry-run are set (see cmdInit).
457
- function cmdInitLocalDryRun(target) {
458
- const { projectName, stack, clis, files, agentTargets } = buildLocalDryRunPreview(target);
477
+ function cmdInitLocalDryRun(target, templateStack = "") {
478
+ const { projectName, stack, clis, files, agentTargets } = buildLocalDryRunPreview(target, templateStack);
459
479
  log(`local dry-run: no account or network needed`);
460
480
  console.log(` project: ${projectName}`);
461
- console.log(` stack: ${stack}`);
481
+ console.log(` stack: ${stack}${templateStack ? " (declared via --template)" : ""}`);
462
482
  console.log(` agent CLIs detected: ${clis.length ? clis.join(", ") : "none"}`);
463
483
  console.log("");
464
484
  const names = Object.keys(files);
@@ -487,12 +507,26 @@ async function cmdInit(target, args = []) {
487
507
  const noWizard = args.includes("--no-wizard");
488
508
  const localMode = args.includes("--local");
489
509
 
510
+ // --template <stack> (#3931): declare the stack for the offline/local scaffold so a
511
+ // fresh repo gets a stack-labeled starter instead of "stack: unknown". Validated
512
+ // against the canonical stack set (aliases accepted); applies to the --local paths.
513
+ const templateIdx = args.indexOf("--template");
514
+ let templateStack = "";
515
+ if (templateIdx >= 0) {
516
+ const raw = templateIdx + 1 < args.length ? args[templateIdx + 1] : "";
517
+ templateStack = resolveTemplateStack(raw);
518
+ if (!templateStack) {
519
+ log(`unknown --template '${raw || "(missing)"}'. Valid: ${TEMPLATE_CHOICES.join(", ")}`);
520
+ return;
521
+ }
522
+ }
523
+
490
524
  // Offline preview: `0dai init --local --dry-run` renders configs in memory,
491
525
  // prints them, and returns. Bypasses the auth/license/server preflight ONLY
492
526
  // when BOTH flags are present (issue #4475). Plain --dry-run (server plan)
493
527
  // and plain --local (wizard, writes) are unchanged.
494
528
  if (localMode && dryRun) {
495
- cmdInitLocalDryRun(target);
529
+ cmdInitLocalDryRun(target, templateStack);
496
530
  return;
497
531
  }
498
532
 
@@ -552,7 +586,7 @@ async function cmdInit(target, args = []) {
552
586
  }
553
587
  if (localMode) {
554
588
  const { runWizard } = require("../wizard");
555
- const result = await runWizard(target, { forceLocal: true });
589
+ const result = await runWizard(target, { forceLocal: true, stack: templateStack });
556
590
  if (result.completed) {
557
591
  ensureRuntimeGitignore(target);
558
592
  await runMcpBootstrap(target, args);
@@ -1154,6 +1188,8 @@ module.exports = {
1154
1188
  cmdSync,
1155
1189
  cmdProjectBind,
1156
1190
  buildLocalDryRunPreview,
1191
+ resolveTemplateStack,
1192
+ TEMPLATE_CHOICES,
1157
1193
  cmdInitLocalDryRun,
1158
1194
  buildLocalSyncPreview,
1159
1195
  runMcpBootstrap,
@@ -10,10 +10,13 @@ const {
10
10
  const { recordActivationFirstTask } = require("../utils/activation_telemetry");
11
11
 
12
12
  function printRunHelp() {
13
- console.log(`Usage: 0dai run <goal> [--dry-run] [--dry-cost] [--max-cost N] [--agent claude|codex|gemini] [--provider deepseek|gemini-direct|codex|claude-opus]`);
13
+ console.log(`Usage: 0dai run <goal> [--now] [--dry-run] [--dry-cost] [--max-cost N] [--agent claude|codex|gemini] [--provider deepseek|gemini-direct|codex|claude-opus]`);
14
14
  console.log(` Example: 0dai run "add dark mode to settings page"`);
15
- console.log(` Example: 0dai run "fix login bug" --dry-cost`);
15
+ console.log(` Example: 0dai run "fix login bug" --now # execute locally + print a scored receipt`);
16
+ console.log(` Example: 0dai run "refactor auth" --dry-cost`);
16
17
  console.log(` Example: 0dai run "refactor auth" --max-cost 0.50`);
18
+ console.log(` --now (alias --execute) runs the assigned agent CLI on this repo right now`);
19
+ console.log(` (it may modify files); without it, tasks are only queued for ' 0dai watch '.`);
17
20
  }
18
21
 
19
22
  function isRunHelpRequest(goal, args = []) {
@@ -28,6 +31,7 @@ async function cmdRun(goal, target, args = []) {
28
31
 
29
32
  const dryRun = args.includes("--dry-run");
30
33
  const dryCost = args.includes("--dry-cost");
34
+ const now = args.includes("--now") || args.includes("--execute");
31
35
  const agentIdx = args.indexOf("--agent");
32
36
  const agentOverride = agentIdx >= 0 ? args[agentIdx + 1] : null;
33
37
  const providerIdx = args.indexOf("--provider");
@@ -102,6 +106,7 @@ async function cmdRun(goal, target, args = []) {
102
106
  try { fs.mkdirSync(queueDir, { recursive: true }); } catch {}
103
107
 
104
108
  const created = [];
109
+ const createdEntries = [];
105
110
  for (const t of tasks) {
106
111
  const ts = Date.now();
107
112
  const rand = Math.random().toString(36).slice(2, 6);
@@ -125,14 +130,30 @@ async function cmdRun(goal, target, args = []) {
125
130
  try {
126
131
  fs.writeFileSync(path.join(queueDir, `${id}.json`), JSON.stringify(entry, null, 2));
127
132
  created.push(id);
133
+ createdEntries.push(entry);
128
134
  } catch (e) { log(`warn: could not write task ${id}: ${e.message}`); }
129
135
  }
130
136
 
131
137
  console.log(`\n ${T}✓${R} ${created.length} task${created.length === 1 ? "" : "s"} added to swarm queue`);
138
+
139
+ const identity = created.length > 0
140
+ ? shared.buildProjectIdentity(target, shared.collectMetadata(target))
141
+ : null;
142
+
143
+ if (now && createdEntries.length > 0) {
144
+ runNow(target, queueDir, createdEntries, {
145
+ goal,
146
+ maxCost,
147
+ costEstimateUsd: costEstimate ? costEstimate.totalUsd : null,
148
+ identity,
149
+ });
150
+ return;
151
+ }
152
+
132
153
  console.log(` ${D}Monitor: 0dai watch | Queue: 0dai swarm status${R}\n`);
154
+ console.log(` ${D}Tip: re-run with --now to execute locally and get a scored receipt.${R}\n`);
133
155
 
134
- if (created.length > 0) {
135
- const identity = shared.buildProjectIdentity(target, shared.collectMetadata(target));
156
+ if (identity) {
136
157
  recordActivationFirstTask(target, identity.project_id, {
137
158
  outcome: "task_queued",
138
159
  task_count: created.length,
@@ -140,6 +161,66 @@ async function cmdRun(goal, target, args = []) {
140
161
  }
141
162
  }
142
163
 
164
+ // Execute the just-queued tasks locally (synchronous), emit a scored receipt,
165
+ // move drained task files queue→done, and record the executed activation outcome.
166
+ function runNow(target, queueDir, entries, { goal, maxCost, costEstimateUsd, identity }) {
167
+ const { drainTasks } = require("../run/local_executor");
168
+ const { buildReceipt, writeReceipt, renderReceipt } = require("../run/scored_receipt");
169
+
170
+ console.log(
171
+ `\n ${T}Executing locally${R} ${D}(--now)${R} — running the assigned agent on this`
172
+ + ` repo now; it may modify files.\n`,
173
+ );
174
+
175
+ const results = drainTasks(entries, {
176
+ cwd: target,
177
+ budgetUsd: maxCost != null ? maxCost : undefined,
178
+ defaultAgent: entries[0] && entries[0].assigned_to,
179
+ });
180
+
181
+ const runId = `run-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
182
+ const receipt = buildReceipt({
183
+ runId,
184
+ goal,
185
+ results,
186
+ costEstimateUsd,
187
+ agent: entries[0] && entries[0].assigned_to,
188
+ });
189
+ const file = writeReceipt(target, receipt);
190
+ console.log(renderReceipt(receipt, { T, R, D }));
191
+ if (file) {
192
+ console.log(` ${D}receipt: ${path.relative(target, file)}${R}\n`);
193
+ } else {
194
+ log(`warn: could not persist run receipt to ai/meta/receipts/${runId}.json`);
195
+ }
196
+
197
+ // Move drained task files queue→done so `0dai watch` / `status` stay consistent.
198
+ const doneDir = path.join(target, "ai", "swarm", "done");
199
+ try { fs.mkdirSync(doneDir, { recursive: true }); } catch {}
200
+ for (const r of results) {
201
+ if (!r.taskId) continue;
202
+ const src = path.join(queueDir, `${r.taskId}.json`);
203
+ try {
204
+ const entry = JSON.parse(fs.readFileSync(src, "utf8"));
205
+ entry.status = r.status === "pass" ? "done"
206
+ : r.status === "noop" ? "noop"
207
+ : r.status === "skipped" ? "queued" : "failed";
208
+ entry.run_id = runId;
209
+ entry.result = { status: r.status, exit_code: r.exitCode, reason: r.reason, duration_ms: r.durationMs };
210
+ if (r.status === "skipped") continue; // leave only skipped tasks queued for a retry
211
+ fs.writeFileSync(path.join(doneDir, `${r.taskId}.json`), JSON.stringify(entry, null, 2));
212
+ fs.unlinkSync(src);
213
+ } catch { /* best-effort */ }
214
+ }
215
+
216
+ if (identity) {
217
+ recordActivationFirstTask(target, identity.project_id, {
218
+ outcome: receipt.status === "pass" ? "executed_pass" : "executed_fail",
219
+ task_count: entries.length,
220
+ });
221
+ }
222
+ }
223
+
143
224
  function printCostPreview(costEstimate) {
144
225
  console.log(`\n ${T}Cost preview${R} (estimate):\n`);
145
226
  costEstimate.tasks.forEach((row, index) => {
@@ -55,9 +55,14 @@ function collectSprintPayload(target, options = {}) {
55
55
  out.available = true;
56
56
  if (r.status !== 0) {
57
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");
58
+ const lastLine = (err.split("\n").slice(-1)[0] || "").replace(/^(fatal:|error:|failed to run git:)\s*/i, "");
59
+ if (/not a git repository/i.test(err)) {
60
+ out.reason = "not a git repository — run inside a repo";
61
+ } else if (/auth|logged in|token/i.test(err)) {
62
+ out.reason = "gh not authenticated — run: gh auth login";
63
+ } else {
64
+ out.reason = lastLine || "gh issue list failed";
65
+ }
61
66
  return out;
62
67
  }
63
68
  out.authenticated = true;
@@ -66,7 +71,10 @@ function collectSprintPayload(target, options = {}) {
66
71
  ? parsed.map((i) => ({ number: i.number, title: i.title || "" }))
67
72
  : [];
68
73
  } catch (e) {
69
- out.reason = String((e && e.message) || e).split("\n").slice(-1)[0] || "sprint probe failed";
74
+ const msg = (String((e && e.message) || e).split("\n").slice(-1)[0] || "").replace(/^(fatal:|error:)\s*/i, "");
75
+ out.reason = /not a git repository/i.test(msg)
76
+ ? "not a git repository — run inside a repo"
77
+ : (msg || "sprint probe failed");
70
78
  }
71
79
  return out;
72
80
  }
@@ -1082,6 +1082,73 @@ def cmd_sync(args: argparse.Namespace) -> int:
1082
1082
  return 0 if result.get("ok") or result.get("skipped") else 1
1083
1083
 
1084
1084
 
1085
+ def format_report(stats: dict, recent: list[dict]) -> str:
1086
+ """End-user 'what your agents learned about this repo over N days' summary (#3932)."""
1087
+ s = stats.get("summary") or {}
1088
+ period = s.get("period", "")
1089
+ event_count = int(s.get("event_count", 0))
1090
+ task_count = int(s.get("task_count", 0))
1091
+ sr = int(round((s.get("success_rate") or 0) * 100))
1092
+ lines = [f"What your agents learned — last {period}", ""]
1093
+ lines.append(
1094
+ f" {event_count} event(s) · {task_count} task(s) · "
1095
+ f"{int(s.get('success_count', 0))} ✅ / {int(s.get('failure_count', 0))} ❌ "
1096
+ f"({sr}% success) · ${float(s.get('total_cost', 0)):.2f} spent"
1097
+ )
1098
+ if not task_count:
1099
+ lines += ["", " No task experiences recorded yet — run agents on swarm tasks to build history."]
1100
+ return "\n".join(lines)
1101
+
1102
+ def _top(group_key: str, label: str) -> None:
1103
+ groups = stats.get(group_key) or {}
1104
+ ranked = sorted(groups.items(), key=lambda kv: -int(kv[1].get("count", 0)))[:5]
1105
+ if not ranked:
1106
+ return
1107
+ lines.append("")
1108
+ lines.append(f" {label}:")
1109
+ for name, m in ranked:
1110
+ lines.append(
1111
+ f" {str(name):<16} {int(m.get('count', 0)):>3} task(s) · "
1112
+ f"{int(round((m.get('success_rate') or 0) * 100))}% success"
1113
+ )
1114
+
1115
+ _top("by_task_type", "Top work")
1116
+ _top("by_agent", "By agent")
1117
+
1118
+ recs = s.get("recommendations") or []
1119
+ if recs:
1120
+ lines.append("")
1121
+ lines.append(" Learned:")
1122
+ for r in recs:
1123
+ lines.append(f" • {r}")
1124
+
1125
+ if recent:
1126
+ lines.append("")
1127
+ lines.append(" Recent:")
1128
+ for ev in recent[:5]:
1129
+ task = ev.get("task") or {}
1130
+ goal = sanitize_text(task.get("goal") or ev.get("event_type") or "", limit=40) or "—"
1131
+ result = str(task.get("result") or "")
1132
+ mark = "✅" if result in SUCCESS_RESULTS else ("❌" if result in FAIL_RESULTS else "•")
1133
+ ts = str(ev.get("timestamp") or "")[:10]
1134
+ lines.append(f" {ts} {mark} {goal}")
1135
+
1136
+ lines += ["", " Anti-pattern warnings: 0dai experience warnings"]
1137
+ return "\n".join(lines)
1138
+
1139
+
1140
+ def cmd_report(args: argparse.Namespace) -> int:
1141
+ target = _detect_target(args.target)
1142
+ period = f"{args.days}d" if getattr(args, "days", "") else args.period
1143
+ stats = compute_stats(target, period=period, by="all")
1144
+ recent = load_events(target, since=period, limit=8, include_archive=True)
1145
+ if args.json:
1146
+ print(json.dumps({"report": stats, "recent": recent[:8]}, indent=2, ensure_ascii=False))
1147
+ else:
1148
+ print(format_report(stats, recent))
1149
+ return 0
1150
+
1151
+
1085
1152
  def build_parser() -> argparse.ArgumentParser:
1086
1153
  parser = argparse.ArgumentParser(prog="0dai experience", description="Structured experience event pipeline.")
1087
1154
  sub = parser.add_subparsers(dest="command")
@@ -1103,6 +1170,13 @@ def build_parser() -> argparse.ArgumentParser:
1103
1170
  stats_parser.add_argument("--json", action="store_true")
1104
1171
  stats_parser.set_defaults(func=cmd_stats)
1105
1172
 
1173
+ report_parser = sub.add_parser("report")
1174
+ report_parser.add_argument("--target", default=".")
1175
+ report_parser.add_argument("--period", default="30d")
1176
+ report_parser.add_argument("--days", default="", help="shorthand for --period <N>d")
1177
+ report_parser.add_argument("--json", action="store_true")
1178
+ report_parser.set_defaults(func=cmd_report)
1179
+
1106
1180
  record_parser = sub.add_parser("record-json")
1107
1181
  record_parser.add_argument("--target", default=".")
1108
1182
  record_parser.add_argument("--payload", default="")
@@ -0,0 +1,223 @@
1
+ "use strict";
2
+
3
+ // Local synchronous task executor for `0dai run --now`.
4
+ //
5
+ // The keystone of activation: `0dai run` decomposes a goal and queues tasks,
6
+ // but with `--now` it ALSO drains those tasks to completion *locally* — by
7
+ // shelling out to the user's already-installed agent CLI (claude/codex/...) in
8
+ // a single-shot headless invocation. It deliberately does NOT touch the
9
+ // tmux/swarm-runner fleet (that path is for the multi-agent queue drain and is
10
+ // infrastructure the npm-only audience does not have). A fresh `npm i -g`
11
+ // user with `claude` on PATH gets a real completed task + a scored receipt.
12
+ //
13
+ // Invocation templates mirror scripts/spawn_subagent.sh — the authoritative
14
+ // pattern for how 0dai drives each agent CLI non-interactively.
15
+
16
+ const cp = require("child_process");
17
+
18
+ const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000; // 15 min per task
19
+ const OUTPUT_TAIL_CHARS = 2000;
20
+
21
+ // Build the headless single-shot command for an agent. Returns null for agents
22
+ // without a known non-interactive form (caller treats this as "skipped").
23
+ function buildInvocation(agent, prompt, opts = {}) {
24
+ const budget = Number.isFinite(opts.budgetUsd) ? opts.budgetUsd : null;
25
+ switch (agent) {
26
+ case "claude":
27
+ return {
28
+ bin: "claude",
29
+ args: [
30
+ "--print",
31
+ "--permission-mode", "auto",
32
+ ...(budget != null ? ["--max-budget-usd", String(budget)] : []),
33
+ prompt,
34
+ ],
35
+ };
36
+ case "codex":
37
+ return {
38
+ bin: "codex",
39
+ args: ["exec", "--skip-git-repo-check", "--full-auto", prompt],
40
+ };
41
+ case "gemini":
42
+ // gemini --approval-mode enum is default|auto_edit|yolo|plan ('auto' is
43
+ // rejected). yolo = auto-approve all tools (write-capable), the mode a
44
+ // headless task needs to actually make changes.
45
+ return { bin: "gemini", args: ["--approval-mode", "yolo", "-p", prompt] };
46
+ case "opencode":
47
+ return { bin: "opencode", args: ["run", prompt] };
48
+ case "qoder":
49
+ return {
50
+ bin: "qodercli",
51
+ args: ["-p", prompt, "--yolo", "--output-format", "json"],
52
+ };
53
+ default:
54
+ return null;
55
+ }
56
+ }
57
+
58
+ // Compose the instruction handed to the agent. Grounded in the decomposed task
59
+ // plus the original goal so the agent has the same context the queue entry has.
60
+ function buildPrompt(task) {
61
+ const ctx = (task && task.context) || {};
62
+ const lines = [];
63
+ if (ctx.goal) lines.push(`Goal: ${ctx.goal}`);
64
+ lines.push(`Task: ${task.title || "(untitled)"}`);
65
+ if (task.description) lines.push("", task.description);
66
+ lines.push(
67
+ "",
68
+ "Make the change in this repository now. Keep the diff focused on this task.",
69
+ "When finished, print a one-line summary of what you changed.",
70
+ );
71
+ return lines.join("\n");
72
+ }
73
+
74
+ // Default command runner — a thin wrapper over spawnSync so tests can inject a
75
+ // fake. Signature: runner(bin, args, { cwd, timeoutMs }) -> spawnSync-like result.
76
+ function defaultRunner(bin, args, opts = {}) {
77
+ return cp.spawnSync(bin, args, {
78
+ cwd: opts.cwd,
79
+ encoding: "utf8",
80
+ timeout: opts.timeoutMs || DEFAULT_TIMEOUT_MS,
81
+ maxBuffer: 32 * 1024 * 1024,
82
+ stdio: ["ignore", "pipe", "pipe"],
83
+ });
84
+ }
85
+
86
+ // Snapshot of the repo's uncommitted state, used to tell whether an agent run
87
+ // actually changed anything. Returns a comparable token, or null when we can't
88
+ // tell (no git / not a repo) — callers must treat null as "unknown", never as
89
+ // "no change". Injectable so tests stay hermetic.
90
+ function defaultChangeProbe(cwd) {
91
+ try {
92
+ const r = cp.spawnSync("git", ["status", "--porcelain"], {
93
+ cwd,
94
+ encoding: "utf8",
95
+ timeout: 5000,
96
+ stdio: ["ignore", "pipe", "ignore"],
97
+ });
98
+ if (!r || r.status !== 0) return null;
99
+ return r.stdout || "";
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ // Is `bin` resolvable on PATH? Uses the same injected runner so tests stay hermetic.
106
+ function agentInstalled(bin, runner, opts = {}) {
107
+ if (opts.skipInstallCheck) return true;
108
+ try {
109
+ const r = runner("sh", ["-c", `command -v ${bin} >/dev/null 2>&1`], {
110
+ cwd: opts.cwd,
111
+ timeoutMs: 5000,
112
+ });
113
+ return !!r && r.status === 0;
114
+ } catch {
115
+ return false;
116
+ }
117
+ }
118
+
119
+ function tail(text) {
120
+ if (!text) return "";
121
+ const s = String(text);
122
+ return s.length > OUTPUT_TAIL_CHARS ? s.slice(-OUTPUT_TAIL_CHARS) : s;
123
+ }
124
+
125
+ // Execute a single queued task locally. Pure except for the injected runner.
126
+ // Result: { taskId, agent, status: "pass"|"fail"|"skipped", exitCode, durationMs,
127
+ // reason, command, outputTail }.
128
+ function executeTask(task, opts = {}) {
129
+ const runner = opts.runner || defaultRunner;
130
+ const agent = task.assigned_to || opts.defaultAgent || "claude";
131
+ const taskId = task.id || null;
132
+
133
+ const invocation = buildInvocation(agent, buildPrompt(task), {
134
+ budgetUsd: opts.budgetUsd,
135
+ });
136
+ if (!invocation) {
137
+ return {
138
+ taskId, agent, status: "skipped", exitCode: null, durationMs: 0, changed: null,
139
+ reason: `no headless invocation known for agent '${agent}'`,
140
+ command: null, outputTail: "",
141
+ };
142
+ }
143
+
144
+ const command = `${invocation.bin} ${invocation.args.join(" ")}`;
145
+ if (!agentInstalled(invocation.bin, runner, { cwd: opts.cwd, skipInstallCheck: opts.skipInstallCheck })) {
146
+ return {
147
+ taskId, agent, status: "skipped", exitCode: null, durationMs: 0, changed: null,
148
+ reason: `'${invocation.bin}' not found on PATH — install it or run the task yourself`,
149
+ command, outputTail: "",
150
+ };
151
+ }
152
+
153
+ const changeProbe = opts.changeProbe || defaultChangeProbe;
154
+ const before = changeProbe(opts.cwd);
155
+
156
+ const started = Date.now();
157
+ let r;
158
+ try {
159
+ r = runner(invocation.bin, invocation.args, { cwd: opts.cwd, timeoutMs: opts.timeoutMs });
160
+ } catch (e) {
161
+ return {
162
+ taskId, agent, status: "fail", exitCode: null, durationMs: Date.now() - started,
163
+ changed: null, reason: `executor error: ${e.message}`, command, outputTail: "",
164
+ };
165
+ }
166
+ const durationMs = Date.now() - started;
167
+
168
+ const after = changeProbe(opts.cwd);
169
+ const changed = (before == null || after == null) ? null : (before !== after);
170
+
171
+ // Timeout / signal kill → fail. spawnSync sets .error on timeout and may set .signal.
172
+ if (r && (r.error || r.signal)) {
173
+ const why = r.error ? r.error.message : `killed by signal ${r.signal}`;
174
+ return {
175
+ taskId, agent, status: "fail", exitCode: r.status ?? null, durationMs, changed,
176
+ reason: `agent did not complete: ${why}`, command,
177
+ outputTail: tail(r.stderr || r.stdout),
178
+ };
179
+ }
180
+
181
+ // Exit 0 is necessary but NOT sufficient: an agent can exit 0 having changed
182
+ // nothing. When we can tell (git repo), a clean exit with no file change is a
183
+ // "noop", never a success — the receipt must not claim a task was done when
184
+ // the repo is untouched. When we can't tell (changed === null) fall back to
185
+ // the exit code.
186
+ const exitCode = r ? r.status : null;
187
+ let status;
188
+ let reason;
189
+ if (exitCode !== 0) {
190
+ status = "fail";
191
+ reason = `agent exited ${exitCode}${changed === true ? " (repo changed)" : ""}`;
192
+ } else if (changed === false) {
193
+ status = "noop";
194
+ reason = "agent exited 0 but made no file changes";
195
+ } else {
196
+ status = "pass";
197
+ reason = changed === true ? "agent exited 0; repo changed" : "agent exited 0";
198
+ }
199
+ return {
200
+ taskId, agent, status, exitCode, durationMs, changed, reason,
201
+ command, outputTail: tail((r && (r.stdout || r.stderr)) || ""),
202
+ };
203
+ }
204
+
205
+ // Drain a list of queued task entries sequentially (synchronous, local).
206
+ function drainTasks(tasks, opts = {}) {
207
+ const results = [];
208
+ for (const task of tasks) {
209
+ results.push(executeTask(task, opts));
210
+ }
211
+ return results;
212
+ }
213
+
214
+ module.exports = {
215
+ buildInvocation,
216
+ buildPrompt,
217
+ executeTask,
218
+ drainTasks,
219
+ agentInstalled,
220
+ defaultRunner,
221
+ defaultChangeProbe,
222
+ DEFAULT_TIMEOUT_MS,
223
+ };
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+
3
+ // Scored run-receipt — the canonical artifact a completed `0dai run --now`
4
+ // produces: a verdict about what the run actually did, persisted as JSON and
5
+ // printed inline. Honest by construction — a clean exit that changed nothing is
6
+ // a "noop", not a pass — so the number is a fair summary, not a success
7
+ // rubber-stamp. (Caveat: change detection needs a git repo; outside one the
8
+ // outcome falls back to the agent's exit code.) Distinct from
9
+ // `lib/commands/receipt.js`, which renders a per-session OG *image* to
10
+ // ~/.0dai/receipts/; this writes a structured per-run JSON to
11
+ // ai/meta/receipts/<run-id>.json.
12
+
13
+ const fs = require("fs");
14
+ const path = require("path");
15
+
16
+ const SCHEMA = "0dai.run-receipt/1";
17
+
18
+ function receiptsDir(target) {
19
+ return path.join(target, "ai", "meta", "receipts");
20
+ }
21
+
22
+ function receiptPath(target, runId) {
23
+ return path.join(receiptsDir(target), `${runId}.json`);
24
+ }
25
+
26
+ // Completion score, 0-100. Transparent and honest: the share of *executed*
27
+ // tasks that passed. "noop" (ran clean but changed nothing) and "fail" both
28
+ // count in the denominator but not as passes, so a run that touched nothing
29
+ // can never score 100. Skipped tasks (agent not installed / unsupported) are
30
+ // excluded entirely and reported separately, so the number never silently
31
+ // rewards a run where nothing actually ran.
32
+ function scoreRun(results) {
33
+ const passed = results.filter((r) => r.status === "pass").length;
34
+ const failed = results.filter((r) => r.status === "fail").length;
35
+ const noop = results.filter((r) => r.status === "noop").length;
36
+ const executed = passed + failed + noop;
37
+ return executed > 0 ? Math.round((100 * passed) / executed) : 0;
38
+ }
39
+
40
+ // Overall verdict for the run. Only an all-pass run is "pass"; a run with no
41
+ // passes at all is "fail"; anything mixed (some pass + any fail/noop/skip) is
42
+ // "partial". An empty run is "fail" (nothing succeeded).
43
+ function overallStatus(results) {
44
+ if (!results.length) return "fail";
45
+ const passed = results.filter((r) => r.status === "pass").length;
46
+ const failed = results.filter((r) => r.status === "fail").length;
47
+ const noop = results.filter((r) => r.status === "noop").length;
48
+ const skipped = results.filter((r) => r.status === "skipped").length;
49
+ if (passed > 0 && failed === 0 && noop === 0 && skipped === 0) return "pass";
50
+ if (passed === 0) return "fail";
51
+ return "partial";
52
+ }
53
+
54
+ function buildReceipt({ runId, goal, results, costEstimateUsd = null, agent = null }) {
55
+ const passed = results.filter((r) => r.status === "pass").length;
56
+ const failed = results.filter((r) => r.status === "fail").length;
57
+ const noop = results.filter((r) => r.status === "noop").length;
58
+ const skipped = results.filter((r) => r.status === "skipped").length;
59
+ return {
60
+ schema: SCHEMA,
61
+ run_id: runId,
62
+ goal: goal || "",
63
+ created_at: new Date().toISOString(),
64
+ agent: agent || (results[0] && results[0].agent) || null,
65
+ status: overallStatus(results),
66
+ score: scoreRun(results),
67
+ passed,
68
+ failed,
69
+ noop,
70
+ skipped,
71
+ total: results.length,
72
+ cost_estimate_usd: costEstimateUsd,
73
+ tasks: results.map((r) => ({
74
+ id: r.taskId,
75
+ agent: r.agent,
76
+ status: r.status,
77
+ exit_code: r.exitCode,
78
+ duration_ms: r.durationMs,
79
+ changed: r.changed ?? null,
80
+ reason: r.reason,
81
+ })),
82
+ };
83
+ }
84
+
85
+ // Persist the receipt. Best-effort: returns the path on success, null on failure.
86
+ function writeReceipt(target, receipt) {
87
+ try {
88
+ fs.mkdirSync(receiptsDir(target), { recursive: true });
89
+ const file = receiptPath(target, receipt.run_id);
90
+ fs.writeFileSync(file, JSON.stringify(receipt, null, 2));
91
+ return file;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ // Human-readable inline summary printed at the end of a `--now` run.
98
+ function renderReceipt(receipt, palette = {}) {
99
+ const T = palette.T || "";
100
+ const R = palette.R || "";
101
+ const D = palette.D || "";
102
+ const mark = receipt.status === "pass" ? "✓" : receipt.status === "fail" ? "✗" : "◐";
103
+ const lines = [];
104
+ lines.push("");
105
+ lines.push(` ${T}Run receipt${R} ${D}${receipt.run_id}${R}`);
106
+ lines.push(
107
+ ` ${mark} ${receipt.status.toUpperCase()}`
108
+ + ` score ${receipt.score}/100`
109
+ + ` (${receipt.passed} passed, ${receipt.failed} failed`
110
+ + `${receipt.noop ? `, ${receipt.noop} noop` : ""}`
111
+ + `${receipt.skipped ? `, ${receipt.skipped} skipped` : ""})`,
112
+ );
113
+ for (const t of receipt.tasks) {
114
+ const tm = t.status === "pass" ? "✓"
115
+ : t.status === "fail" ? "✗"
116
+ : t.status === "noop" ? "∅" : "–";
117
+ const dur = t.duration_ms != null ? ` ${Math.round(t.duration_ms / 1000)}s` : "";
118
+ lines.push(` ${tm} ${D}${t.id || ""}${R} ${t.reason}${dur}`);
119
+ }
120
+ if (receipt.cost_estimate_usd != null) {
121
+ lines.push(` ${D}est. cost ~$${receipt.cost_estimate_usd.toFixed(2)}${R}`);
122
+ }
123
+ lines.push("");
124
+ return lines.join("\n");
125
+ }
126
+
127
+ module.exports = {
128
+ SCHEMA,
129
+ receiptsDir,
130
+ receiptPath,
131
+ scoreRun,
132
+ overallStatus,
133
+ buildReceipt,
134
+ writeReceipt,
135
+ renderReceipt,
136
+ };
@@ -314,17 +314,34 @@ function inferProjectName(target, manifestContents) {
314
314
  }
315
315
 
316
316
  function detectStackHint(projectFiles, manifestContents) {
317
+ // Config-file signals first (strongest).
317
318
  if (manifestContents["next.config.js"] || manifestContents["next.config.mjs"] || manifestContents["next.config.ts"]) return "nextjs";
319
+ // Flutter: a pubspec.yaml that pulls the flutter SDK (a plain Dart pubspec has no flutter dep).
320
+ if (manifestContents["pubspec.yaml"] && /(^|\n)\s*flutter\s*:|sdk:\s*flutter/i.test(manifestContents["pubspec.yaml"])) return "flutter";
321
+
322
+ // Node deps fallback (#4493): when config files are absent, infer the stack from
323
+ // package.json dependencies so an account-free `init --local` still gets a real stack
324
+ // instead of "unknown". Order matters — react-native ships react, so it must win first.
318
325
  if (manifestContents["package.json"]) {
319
326
  try {
320
327
  const pkg = JSON.parse(manifestContents["package.json"]);
321
328
  const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
329
+ if (deps["react-native"] || deps.expo) return "react-native";
322
330
  if (deps.next) return "nextjs";
323
- if (deps.react || deps.vue || deps.svelte) return "frontend";
331
+ if (deps.react || deps.vue || deps.svelte || deps["@angular/core"]) return "frontend";
332
+ if (deps.express || deps.fastify || deps.koa || deps["@nestjs/core"] || deps["@hapi/hapi"]) return "backend-api";
324
333
  } catch {}
325
334
  }
335
+
326
336
  if (manifestContents["go.mod"]) return "go-service";
327
- if (manifestContents["pyproject.toml"] || manifestContents["requirements.txt"]) return "python-service";
337
+
338
+ // Python: a data/ML signal wins over the generic python service hint.
339
+ if (manifestContents["pyproject.toml"] || manifestContents["requirements.txt"]) {
340
+ const pyText = `${manifestContents["requirements.txt"] || ""}\n${manifestContents["pyproject.toml"] || ""}`;
341
+ if (/\b(pandas|numpy|scikit-learn|sklearn|torch|tensorflow|keras|jupyter|transformers)\b/i.test(pyText)) return "data-ml";
342
+ return "python-service";
343
+ }
344
+
328
345
  if (projectFiles.some((name) => name.startsWith("apps/") || name.startsWith("packages/"))) return "monorepo";
329
346
  return "unknown";
330
347
  }
package/lib/wizard.js CHANGED
@@ -149,6 +149,41 @@ async function stepDetect(rl, target) {
149
149
  return detected;
150
150
  }
151
151
 
152
+ // Best-effort offline command detection for the account-free scaffold (#4493 follow-up).
153
+ // Surfaces real, runnable commands so the generated CLAUDE.md/AGENTS.md is useful without
154
+ // an account. Only emits commands actually present (package.json scripts / Makefile) or
155
+ // safe stack conventions — never invents project-specific commands.
156
+ function detectProjectCommands(target, stack = []) {
157
+ const cmds = [];
158
+ const seen = new Set();
159
+ const add = (cmd) => { if (cmd && !seen.has(cmd)) { seen.add(cmd); cmds.push(cmd); } };
160
+ try {
161
+ const pkgPath = path.join(target, "package.json");
162
+ if (fs.existsSync(pkgPath)) {
163
+ const scripts = JSON.parse(fs.readFileSync(pkgPath, "utf8")).scripts || {};
164
+ for (const name of ["dev", "start", "build", "test", "lint", "typecheck"]) {
165
+ if (scripts[name]) add(`npm run ${name}`);
166
+ }
167
+ }
168
+ } catch {}
169
+ try {
170
+ const mkPath = path.join(target, "Makefile");
171
+ if (fs.existsSync(mkPath)) {
172
+ const targets = fs.readFileSync(mkPath, "utf8").split("\n")
173
+ .map((l) => (l.match(/^([a-zA-Z][\w-]*):/) || [])[1]).filter(Boolean);
174
+ for (const t of ["build", "test", "lint", "run", "dev"]) {
175
+ if (targets.includes(t)) add(`make ${t}`);
176
+ }
177
+ }
178
+ } catch {}
179
+ if (!cmds.length) {
180
+ const s = stack[0] || "";
181
+ if (s === "go-service") { add("go build ./..."); add("go test ./..."); }
182
+ else if (s === "python-service" || s === "fastapi" || s === "data-ml") { add("pytest"); add("ruff check ."); }
183
+ }
184
+ return cmds;
185
+ }
186
+
152
187
  function stepGenerate(target, agent, stack) {
153
188
  console.log("");
154
189
  console.log(" Generating AI agent configs...");
@@ -184,9 +219,15 @@ function stepGenerate(target, agent, stack) {
184
219
  );
185
220
  ensureLiveManifestDefaults(target);
186
221
 
187
- // CLAUDE.md
188
- const claudeMd = `# ${discovery.project_name}\n\nStack: ${stack.join(", ") || "unknown"}\nGenerated by 0dai wizard.\n\n## Commands\n\nSee ai/manifest/ for full configuration.\n`;
189
- const agentsMd = `# Agent Configuration\n\nProject: ${discovery.project_name}\nStack: ${stack.join(", ") || "unknown"}\n\nSee ai/manifest/ for configuration details.\n`;
222
+ // CLAUDE.md / AGENTS.md — enriched offline scaffold (#4493 follow-up): real detected
223
+ // commands + an honest pointer to the account-gated full config, instead of a stub.
224
+ const stackLabel = stack.join(", ") || "unknown";
225
+ const commands = detectProjectCommands(target, stack);
226
+ const cmdBlock = commands.length
227
+ ? commands.map((c) => `- \`${c}\``).join("\n")
228
+ : "_No build/test commands detected. Add them to package.json scripts or a Makefile, then re-run._";
229
+ const claudeMd = `# ${discovery.project_name}\n\nStack: ${stackLabel}\nGenerated offline by \`0dai init --local\`.\n\n## Commands\n\n${cmdBlock}\n\n## Project memory\n\nDurable context (manifest, decisions, roadmap) lives in \`ai/\`. Run \`0dai sync\` with a free account to generate the full per-CLI config set tailored to ${stackLabel}.\n`;
230
+ const agentsMd = `# Agent Configuration\n\nProject: ${discovery.project_name}\nStack: ${stackLabel}\n\n## Commands\n\n${cmdBlock}\n\nProject context lives in \`ai/\`. Run \`0dai sync\` (free account) for the full per-CLI configuration.\n`;
190
231
  writeFiles(target, {
191
232
  "CLAUDE.md": claudeMd,
192
233
  "AGENTS.md": agentsMd,
@@ -237,8 +278,9 @@ function stepNext(mode) {
237
278
  async function runWizard(target, options = {}) {
238
279
  const forceLocal = Boolean(options.forceLocal);
239
280
  if (!isInteractive()) {
240
- // Non-interactive: use defaults silently
241
- stepGenerate(target, "all", []);
281
+ // Non-interactive: use defaults silently. A declared --template stack (#3931)
282
+ // skips detection so the scaffold is stack-labeled instead of "unknown".
283
+ stepGenerate(target, "all", options.stack ? [options.stack] : []);
242
284
  return { completed: true, interactive: false };
243
285
  }
244
286
 
@@ -265,7 +307,8 @@ async function runWizard(target, options = {}) {
265
307
  return { completed: false, interactive: true, cloudRequested: true, agent, mode };
266
308
  }
267
309
 
268
- const stack = await stepDetect(rl, target);
310
+ // A declared --template stack (#3931) skips interactive detection.
311
+ const stack = options.stack ? [options.stack] : await stepDetect(rl, target);
269
312
  if (aborted) return { completed: false };
270
313
 
271
314
  stepGenerate(target, agent, stack);
@@ -317,5 +360,6 @@ module.exports = {
317
360
  needsWizard,
318
361
  isInteractive,
319
362
  stepGenerate,
363
+ detectProjectCommands,
320
364
  AGENTS,
321
365
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0dai-dev/cli",
3
- "version": "4.3.8",
3
+ "version": "4.4.0",
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"