@0dai-dev/cli 4.3.9 → 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
@@ -213,7 +213,7 @@ function printHelp() {
213
213
  console.log(" 0dai doctor # verify health and drift");
214
214
  console.log("");
215
215
  console.log("Start (first 5 minutes):");
216
- 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>]");
217
217
  console.log(" doctor Check health, credentials, and drift [--drift] [--security]");
218
218
  console.log(" status Show maturity, swarm, and session state [--json]");
219
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) => {
@@ -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
+ };
package/lib/wizard.js CHANGED
@@ -278,8 +278,9 @@ function stepNext(mode) {
278
278
  async function runWizard(target, options = {}) {
279
279
  const forceLocal = Boolean(options.forceLocal);
280
280
  if (!isInteractive()) {
281
- // Non-interactive: use defaults silently
282
- 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] : []);
283
284
  return { completed: true, interactive: false };
284
285
  }
285
286
 
@@ -306,7 +307,8 @@ async function runWizard(target, options = {}) {
306
307
  return { completed: false, interactive: true, cloudRequested: true, agent, mode };
307
308
  }
308
309
 
309
- 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);
310
312
  if (aborted) return { completed: false };
311
313
 
312
314
  stepGenerate(target, agent, stack);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0dai-dev/cli",
3
- "version": "4.3.9",
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"