@0dai-dev/cli 4.3.9 → 4.4.1
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 +5 -5
- package/bin/0dai.js +1 -1
- package/lib/commands/experience.js +8 -1
- package/lib/commands/init.js +43 -7
- package/lib/commands/run.js +85 -4
- package/lib/python/experience_pipeline.py +74 -0
- package/lib/run/local_executor.js +225 -0
- package/lib/run/scored_receipt.js +136 -0
- package/lib/wizard.js +27 -4
- package/package.json +1 -1
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
|
|
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
|
|
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");
|
package/lib/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/lib/commands/run.js
CHANGED
|
@@ -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" --
|
|
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 (
|
|
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,225 @@
|
|
|
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 version-fragile: live gemini 0.44.1
|
|
43
|
+
// hard-errors on 'yolo' (accepts only default|auto_edit|plan; #4514). Use
|
|
44
|
+
// 'auto_edit' — present wherever --approval-mode exists and write-capable
|
|
45
|
+
// for edit tools, so it's the cross-version-safe choice for a headless
|
|
46
|
+
// code task ('yolo' is not guaranteed on older builds, 'auto' is invalid).
|
|
47
|
+
return { bin: "gemini", args: ["--approval-mode", "auto_edit", "-p", prompt] };
|
|
48
|
+
case "opencode":
|
|
49
|
+
return { bin: "opencode", args: ["run", prompt] };
|
|
50
|
+
case "qoder":
|
|
51
|
+
return {
|
|
52
|
+
bin: "qodercli",
|
|
53
|
+
args: ["-p", prompt, "--yolo", "--output-format", "json"],
|
|
54
|
+
};
|
|
55
|
+
default:
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Compose the instruction handed to the agent. Grounded in the decomposed task
|
|
61
|
+
// plus the original goal so the agent has the same context the queue entry has.
|
|
62
|
+
function buildPrompt(task) {
|
|
63
|
+
const ctx = (task && task.context) || {};
|
|
64
|
+
const lines = [];
|
|
65
|
+
if (ctx.goal) lines.push(`Goal: ${ctx.goal}`);
|
|
66
|
+
lines.push(`Task: ${task.title || "(untitled)"}`);
|
|
67
|
+
if (task.description) lines.push("", task.description);
|
|
68
|
+
lines.push(
|
|
69
|
+
"",
|
|
70
|
+
"Make the change in this repository now. Keep the diff focused on this task.",
|
|
71
|
+
"When finished, print a one-line summary of what you changed.",
|
|
72
|
+
);
|
|
73
|
+
return lines.join("\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Default command runner — a thin wrapper over spawnSync so tests can inject a
|
|
77
|
+
// fake. Signature: runner(bin, args, { cwd, timeoutMs }) -> spawnSync-like result.
|
|
78
|
+
function defaultRunner(bin, args, opts = {}) {
|
|
79
|
+
return cp.spawnSync(bin, args, {
|
|
80
|
+
cwd: opts.cwd,
|
|
81
|
+
encoding: "utf8",
|
|
82
|
+
timeout: opts.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
83
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
84
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Snapshot of the repo's uncommitted state, used to tell whether an agent run
|
|
89
|
+
// actually changed anything. Returns a comparable token, or null when we can't
|
|
90
|
+
// tell (no git / not a repo) — callers must treat null as "unknown", never as
|
|
91
|
+
// "no change". Injectable so tests stay hermetic.
|
|
92
|
+
function defaultChangeProbe(cwd) {
|
|
93
|
+
try {
|
|
94
|
+
const r = cp.spawnSync("git", ["status", "--porcelain"], {
|
|
95
|
+
cwd,
|
|
96
|
+
encoding: "utf8",
|
|
97
|
+
timeout: 5000,
|
|
98
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
99
|
+
});
|
|
100
|
+
if (!r || r.status !== 0) return null;
|
|
101
|
+
return r.stdout || "";
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Is `bin` resolvable on PATH? Uses the same injected runner so tests stay hermetic.
|
|
108
|
+
function agentInstalled(bin, runner, opts = {}) {
|
|
109
|
+
if (opts.skipInstallCheck) return true;
|
|
110
|
+
try {
|
|
111
|
+
const r = runner("sh", ["-c", `command -v ${bin} >/dev/null 2>&1`], {
|
|
112
|
+
cwd: opts.cwd,
|
|
113
|
+
timeoutMs: 5000,
|
|
114
|
+
});
|
|
115
|
+
return !!r && r.status === 0;
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function tail(text) {
|
|
122
|
+
if (!text) return "";
|
|
123
|
+
const s = String(text);
|
|
124
|
+
return s.length > OUTPUT_TAIL_CHARS ? s.slice(-OUTPUT_TAIL_CHARS) : s;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Execute a single queued task locally. Pure except for the injected runner.
|
|
128
|
+
// Result: { taskId, agent, status: "pass"|"fail"|"skipped", exitCode, durationMs,
|
|
129
|
+
// reason, command, outputTail }.
|
|
130
|
+
function executeTask(task, opts = {}) {
|
|
131
|
+
const runner = opts.runner || defaultRunner;
|
|
132
|
+
const agent = task.assigned_to || opts.defaultAgent || "claude";
|
|
133
|
+
const taskId = task.id || null;
|
|
134
|
+
|
|
135
|
+
const invocation = buildInvocation(agent, buildPrompt(task), {
|
|
136
|
+
budgetUsd: opts.budgetUsd,
|
|
137
|
+
});
|
|
138
|
+
if (!invocation) {
|
|
139
|
+
return {
|
|
140
|
+
taskId, agent, status: "skipped", exitCode: null, durationMs: 0, changed: null,
|
|
141
|
+
reason: `no headless invocation known for agent '${agent}'`,
|
|
142
|
+
command: null, outputTail: "",
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const command = `${invocation.bin} ${invocation.args.join(" ")}`;
|
|
147
|
+
if (!agentInstalled(invocation.bin, runner, { cwd: opts.cwd, skipInstallCheck: opts.skipInstallCheck })) {
|
|
148
|
+
return {
|
|
149
|
+
taskId, agent, status: "skipped", exitCode: null, durationMs: 0, changed: null,
|
|
150
|
+
reason: `'${invocation.bin}' not found on PATH — install it or run the task yourself`,
|
|
151
|
+
command, outputTail: "",
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const changeProbe = opts.changeProbe || defaultChangeProbe;
|
|
156
|
+
const before = changeProbe(opts.cwd);
|
|
157
|
+
|
|
158
|
+
const started = Date.now();
|
|
159
|
+
let r;
|
|
160
|
+
try {
|
|
161
|
+
r = runner(invocation.bin, invocation.args, { cwd: opts.cwd, timeoutMs: opts.timeoutMs });
|
|
162
|
+
} catch (e) {
|
|
163
|
+
return {
|
|
164
|
+
taskId, agent, status: "fail", exitCode: null, durationMs: Date.now() - started,
|
|
165
|
+
changed: null, reason: `executor error: ${e.message}`, command, outputTail: "",
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const durationMs = Date.now() - started;
|
|
169
|
+
|
|
170
|
+
const after = changeProbe(opts.cwd);
|
|
171
|
+
const changed = (before == null || after == null) ? null : (before !== after);
|
|
172
|
+
|
|
173
|
+
// Timeout / signal kill → fail. spawnSync sets .error on timeout and may set .signal.
|
|
174
|
+
if (r && (r.error || r.signal)) {
|
|
175
|
+
const why = r.error ? r.error.message : `killed by signal ${r.signal}`;
|
|
176
|
+
return {
|
|
177
|
+
taskId, agent, status: "fail", exitCode: r.status ?? null, durationMs, changed,
|
|
178
|
+
reason: `agent did not complete: ${why}`, command,
|
|
179
|
+
outputTail: tail(r.stderr || r.stdout),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Exit 0 is necessary but NOT sufficient: an agent can exit 0 having changed
|
|
184
|
+
// nothing. When we can tell (git repo), a clean exit with no file change is a
|
|
185
|
+
// "noop", never a success — the receipt must not claim a task was done when
|
|
186
|
+
// the repo is untouched. When we can't tell (changed === null) fall back to
|
|
187
|
+
// the exit code.
|
|
188
|
+
const exitCode = r ? r.status : null;
|
|
189
|
+
let status;
|
|
190
|
+
let reason;
|
|
191
|
+
if (exitCode !== 0) {
|
|
192
|
+
status = "fail";
|
|
193
|
+
reason = `agent exited ${exitCode}${changed === true ? " (repo changed)" : ""}`;
|
|
194
|
+
} else if (changed === false) {
|
|
195
|
+
status = "noop";
|
|
196
|
+
reason = "agent exited 0 but made no file changes";
|
|
197
|
+
} else {
|
|
198
|
+
status = "pass";
|
|
199
|
+
reason = changed === true ? "agent exited 0; repo changed" : "agent exited 0";
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
taskId, agent, status, exitCode, durationMs, changed, reason,
|
|
203
|
+
command, outputTail: tail((r && (r.stdout || r.stderr)) || ""),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Drain a list of queued task entries sequentially (synchronous, local).
|
|
208
|
+
function drainTasks(tasks, opts = {}) {
|
|
209
|
+
const results = [];
|
|
210
|
+
for (const task of tasks) {
|
|
211
|
+
results.push(executeTask(task, opts));
|
|
212
|
+
}
|
|
213
|
+
return results;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = {
|
|
217
|
+
buildInvocation,
|
|
218
|
+
buildPrompt,
|
|
219
|
+
executeTask,
|
|
220
|
+
drainTasks,
|
|
221
|
+
agentInstalled,
|
|
222
|
+
defaultRunner,
|
|
223
|
+
defaultChangeProbe,
|
|
224
|
+
DEFAULT_TIMEOUT_MS,
|
|
225
|
+
};
|
|
@@ -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
|
@@ -9,9 +9,24 @@
|
|
|
9
9
|
const fs = require("fs");
|
|
10
10
|
const path = require("path");
|
|
11
11
|
const readline = require("readline");
|
|
12
|
-
const { writeFiles, ensureLiveManifestDefaults } = require("./shared");
|
|
12
|
+
const { writeFiles, ensureLiveManifestDefaults, collectMetadata, detectStackHint } = require("./shared");
|
|
13
13
|
const { loadCanonicalCounts, mcpToolsLabel } = require("./utils/canonical-counts");
|
|
14
14
|
|
|
15
|
+
// Offline stack resolution for the non-interactive path. Mirrors the
|
|
16
|
+
// `init --local --dry-run` preview (which uses detectStackHint) so the actual
|
|
17
|
+
// scaffold gets the SAME real stack instead of "unknown" (#4515). Returns
|
|
18
|
+
// [stack] for a confident detection, or [] when genuinely unknown (a generic
|
|
19
|
+
// library with no framework / language marker — honest, not a wrong guess).
|
|
20
|
+
function detectLocalStack(target) {
|
|
21
|
+
try {
|
|
22
|
+
const meta = collectMetadata(target);
|
|
23
|
+
const hint = detectStackHint(meta.projectFiles, meta.manifestContents);
|
|
24
|
+
return hint && hint !== "unknown" ? [hint] : [];
|
|
25
|
+
} catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
15
30
|
// ---------------------------------------------------------------------------
|
|
16
31
|
// Helpers
|
|
17
32
|
// ---------------------------------------------------------------------------
|
|
@@ -278,8 +293,14 @@ function stepNext(mode) {
|
|
|
278
293
|
async function runWizard(target, options = {}) {
|
|
279
294
|
const forceLocal = Boolean(options.forceLocal);
|
|
280
295
|
if (!isInteractive()) {
|
|
281
|
-
// Non-interactive:
|
|
282
|
-
|
|
296
|
+
// Non-interactive (CI / piped / headless `init --local`): a declared
|
|
297
|
+
// --template stack (#3931) wins; otherwise resolve the stack OFFLINE via
|
|
298
|
+
// detectStackHint (#4515) so the scaffold is labeled with the real stack
|
|
299
|
+
// (python-service, go-service, …) instead of "unknown". Previously this
|
|
300
|
+
// passed [] and never detected — the dry-run promised a stack the actual
|
|
301
|
+
// init didn't deliver.
|
|
302
|
+
const stack = options.stack ? [options.stack] : detectLocalStack(target);
|
|
303
|
+
stepGenerate(target, "all", stack);
|
|
283
304
|
return { completed: true, interactive: false };
|
|
284
305
|
}
|
|
285
306
|
|
|
@@ -306,7 +327,8 @@ async function runWizard(target, options = {}) {
|
|
|
306
327
|
return { completed: false, interactive: true, cloudRequested: true, agent, mode };
|
|
307
328
|
}
|
|
308
329
|
|
|
309
|
-
|
|
330
|
+
// A declared --template stack (#3931) skips interactive detection.
|
|
331
|
+
const stack = options.stack ? [options.stack] : await stepDetect(rl, target);
|
|
310
332
|
if (aborted) return { completed: false };
|
|
311
333
|
|
|
312
334
|
stepGenerate(target, agent, stack);
|
|
@@ -358,6 +380,7 @@ module.exports = {
|
|
|
358
380
|
needsWizard,
|
|
359
381
|
isInteractive,
|
|
360
382
|
stepGenerate,
|
|
383
|
+
detectLocalStack,
|
|
361
384
|
detectProjectCommands,
|
|
362
385
|
AGENTS,
|
|
363
386
|
};
|
package/package.json
CHANGED