@nanopm/cli 0.1.11 → 0.1.13

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/dist/index.js CHANGED
@@ -67,7 +67,7 @@ var Constants = {
67
67
  opportunity_next_action: ["investigate", "explore_solutions"],
68
68
  opportunity_priority: ["low", "medium", "high"],
69
69
  opportunity_status: ["proposed", "confirmed", "set_aside", "replaced"],
70
- notification_kind: ["review", "stalled"],
70
+ notification_kind: ["review", "stalled", "build_question", "build_pr"],
71
71
  problem_status: ["candidate", "confirmed", "addressed", "dropped"],
72
72
  provenance: ["observed", "stated", "inferred"],
73
73
  source_kind: ["postgres_watch", "posthog_watch"],
@@ -391,7 +391,7 @@ var SkillManifestSchema = z2.object({
391
391
  /**
392
392
  * Run only when the founder asks for it on one item, by a click or a command, never by Nano on
393
393
  * its own: the autonomy dial, which governs what Nano does unasked, does not cap it
394
- * (`capCapabilities`). A build is the one today (docs/⏳nanopm-75-build-it.md, settled 2).
394
+ * (`capCapabilities`). A build is the one today (docs/⏳nanopm-76-build-it.md, settled 2).
395
395
  */
396
396
  asked: z2.boolean().default(false),
397
397
  /** The first user message sent to the harness, unless the job params provide one. */
@@ -673,6 +673,18 @@ function formatStreamEvent(event) {
673
673
  return "\u2298 cancel requested";
674
674
  case "error":
675
675
  return `\u2717 ${String(p.code ?? "error")}: ${String(p.message ?? "")}`;
676
+ // A build (docs/⏳nanopm-76-build-it.md): its plan, each step with its commit, a pause, the pull request.
677
+ case "plan":
678
+ return ["The plan:", ...(Array.isArray(p.steps) ? p.steps : []).map((s, i) => ` ${i + 1}. ${String(s)}`)].join("\n");
679
+ case "step":
680
+ return `\u2713 ${String(p.step ?? "")}. ${String(p.says ?? "")}${p.commit ? ` \xB7 ${String(p.commit)}` : ""}`;
681
+ case "paused": {
682
+ const at = new Date(String(p.resets_at ?? ""));
683
+ const when = Number.isNaN(at.getTime()) ? "when it resets" : `at ${at.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
684
+ return `\u25CC paused: your Claude limit is reached. Nano carries on by itself ${when}.`;
685
+ }
686
+ case "pr":
687
+ return `\u2387 draft pull request #${String(p.number ?? "")} \xB7 ${String(p.url ?? "")}`;
676
688
  default:
677
689
  return null;
678
690
  }
@@ -2177,12 +2189,14 @@ function boardOf(all, solutions) {
2177
2189
  walk(tree);
2178
2190
  const cards2 = [];
2179
2191
  for (const [leafId, list] of byLeaf) {
2180
- const standing = list.filter((s) => s.status === "proposed");
2181
- const pick3 = standing.find((s) => s.horizon) ?? null;
2192
+ const standing = list.filter((s) => s.status === "proposed" || s.status === "building");
2193
+ const placed = list.find((s) => s.horizon && s.status !== "rejected") ?? null;
2194
+ const shipped = list.filter((s) => s.status === "done").at(-1);
2195
+ const pick3 = standing.find((s) => s.status === "building") ?? shipped ?? placed;
2182
2196
  const leaf = nodes.get(leafId);
2183
2197
  const branch = leaf ? branchOf(tree, leaf.id) : null;
2184
- if (!pick3 || !leaf || !branch) continue;
2185
- cards2.push({ leaf, branch, pick: pick3, others: standing.filter((s) => s.id !== pick3.id), horizon: pick3.horizon, place: pick3.place ?? 1 });
2198
+ if (!pick3 || !placed || !leaf || !branch) continue;
2199
+ cards2.push({ leaf, branch, pick: pick3, others: standing.filter((s) => s.id !== pick3.id), horizon: placed.horizon, place: placed.place ?? 1 });
2186
2200
  }
2187
2201
  const col = (h) => HORIZONS.indexOf(h);
2188
2202
  cards2.sort((a, b) => col(a.horizon) - col(b.horizon) || a.place - b.place || a.pick.created_at.localeCompare(b.pick.created_at));
@@ -3673,7 +3687,7 @@ function buildSandboxSettings(home) {
3673
3687
  }
3674
3688
  return { permissions: { deny, defaultMode: "default" } };
3675
3689
  }
3676
- function sandboxSettingsFor(home, grantedTools, network = []) {
3690
+ function sandboxSettingsFor(home, grantedTools, network = [], answerFile) {
3677
3691
  const settings = buildSandboxSettings(home);
3678
3692
  const granted = new Set(grantedTools);
3679
3693
  settings.permissions.deny = settings.permissions.deny.filter((rule) => !granted.has(rule));
@@ -3685,8 +3699,14 @@ function sandboxSettingsFor(home, grantedTools, network = []) {
3685
3699
  network: { allowedDomains: network.filter((h) => !/(^|\.)github(usercontent)?\.com$/.test(h)) }
3686
3700
  };
3687
3701
  }
3702
+ if (answerFile) settings.hooks = { PreToolUse: [{ matcher: "mcp__nanopm__ask_user", hooks: [{ type: "command", command: askHook(answerFile) }] }] };
3688
3703
  return settings;
3689
3704
  }
3705
+ function askHook(answerFile) {
3706
+ const quote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
3707
+ const decision = (d) => quote(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: d } }));
3708
+ return `if [ -f ${quote(answerFile)} ]; then mv ${quote(answerFile)} ${quote(`${answerFile}.taken`)}; printf '%s' ${decision("allow")}; else printf '%s' ${decision("defer")}; fi`;
3709
+ }
3690
3710
 
3691
3711
  // ../daemon/src/adapters/claude-code/stream.ts
3692
3712
  var RESULT_FAILURES = {
@@ -3704,6 +3724,8 @@ var StreamParser = class {
3704
3724
  model = "unknown";
3705
3725
  toolCalls = /* @__PURE__ */ new Map();
3706
3726
  sawResult = false;
3727
+ /** When the Claude window opens again, from the last rejected `rate_limit_event`. */
3728
+ resetsAt;
3707
3729
  // The read size, counted here because the stream is the one place every call passes.
3708
3730
  contextPeak = 0;
3709
3731
  readCalls = {};
@@ -3714,6 +3736,9 @@ var StreamParser = class {
3714
3736
  get currentSessionId() {
3715
3737
  return this.sessionId;
3716
3738
  }
3739
+ get limitResetsAt() {
3740
+ return this.resetsAt;
3741
+ }
3717
3742
  /** Feed one NDJSON line; get zero or more events. Non-JSON lines are ignored. */
3718
3743
  parseLine(line) {
3719
3744
  const trimmed = line.trim();
@@ -3765,10 +3790,18 @@ var StreamParser = class {
3765
3790
  }
3766
3791
  return events;
3767
3792
  }
3793
+ case "rate_limit_event": {
3794
+ const info = obj.rate_limit_info;
3795
+ if (info?.status === "rejected" && typeof info.resetsAt === "number") this.resetsAt = info.resetsAt;
3796
+ return [];
3797
+ }
3768
3798
  case "result": {
3769
3799
  this.sawResult = true;
3770
3800
  this.sessionId = obj.session_id ?? this.sessionId;
3771
3801
  const usage = this.usageFrom(obj);
3802
+ if (obj.stop_reason === "tool_deferred" && obj.deferred_tool_use?.name) {
3803
+ return [{ type: "deferred", sessionId: this.sessionId ?? "", tool: obj.deferred_tool_use.name, input: obj.deferred_tool_use.input, usage }];
3804
+ }
3772
3805
  if (obj.subtype === "success" && !obj.is_error) {
3773
3806
  if (this.structuredOutput && obj.structured_output === void 0) {
3774
3807
  return [{ type: "failed", sessionId: this.sessionId, code: "harness_error", message: "Claude omitted the required structured output." }];
@@ -3777,7 +3810,7 @@ var StreamParser = class {
3777
3810
  }
3778
3811
  const message = obj.result || `claude ended with ${obj.subtype ?? "an unknown result"}`;
3779
3812
  const code = isRateLimit(message) ? "rate_limit" : RESULT_FAILURES[obj.subtype ?? ""] ?? "harness_error";
3780
- return [{ type: "failed", sessionId: this.sessionId, code, message }];
3813
+ return [{ type: "failed", sessionId: this.sessionId, code, message, ...code === "rate_limit" && this.resetsAt ? { resetsAt: this.resetsAt } : {} }];
3781
3814
  }
3782
3815
  default:
3783
3816
  return [];
@@ -3993,7 +4026,7 @@ var ClaudeCodeAdapter = class {
3993
4026
  await writeFile2(mcpConfigPath, JSON.stringify({ mcpServers }), { mode: 384 });
3994
4027
  const settingsPath = join3(spec2.runDir, "settings.json");
3995
4028
  const flags = claudeCodeToolFlags(spec2.capabilities, { workspace: spec2.cwd, harnessWeb: spec2.harnessWeb, without: spec2.withoutTools });
3996
- await writeFile2(settingsPath, JSON.stringify(sandboxSettingsFor(spec2.env.HOME ?? "", flags.tools, spec2.network)), { mode: 384 });
4029
+ await writeFile2(settingsPath, JSON.stringify(sandboxSettingsFor(spec2.env.HOME ?? "", flags.tools, spec2.network, spec2.answerFile)), { mode: 384 });
3997
4030
  const child = spawn(this.claudePath, this.buildArgs(spec2, mcpConfigPath, settingsPath), {
3998
4031
  cwd: spec2.cwd,
3999
4032
  env: spec2.env,
@@ -4024,7 +4057,8 @@ var ClaudeCodeAdapter = class {
4024
4057
  const { code } = await exit;
4025
4058
  if (!parser.finished) {
4026
4059
  const failure2 = classifyExit(stderr, aborted);
4027
- yield { type: "failed", sessionId: parser.currentSessionId, ...failure2, message: appendExit(failure2.message, code) };
4060
+ const resetsAt = failure2.code === "rate_limit" ? parser.limitResetsAt : void 0;
4061
+ yield { type: "failed", sessionId: parser.currentSessionId, ...failure2, message: appendExit(failure2.message, code), ...resetsAt ? { resetsAt } : {} };
4028
4062
  }
4029
4063
  } finally {
4030
4064
  spec2.signal.removeEventListener("abort", onAbort);
@@ -4052,7 +4086,7 @@ function parsePrintResult(stdout) {
4052
4086
  import { mkdir as mkdir6, readdir as readdir3, readFile as readFile9, rm as rm5, writeFile as writeFile6 } from "fs/promises";
4053
4087
  import { openSync, closeSync, unlinkSync, writeFileSync, readFileSync as readFileSyncNode, mkdirSync } from "fs";
4054
4088
  import { hostname as hostname2 } from "os";
4055
- import { join as join13 } from "path";
4089
+ import { join as join14 } from "path";
4056
4090
  import { setTimeout as sleep } from "timers/promises";
4057
4091
 
4058
4092
  // ../daemon/src/awake.ts
@@ -4111,11 +4145,14 @@ async function latestVersion(fetchImpl = fetch) {
4111
4145
  return null;
4112
4146
  }
4113
4147
  }
4148
+ function installArgs(version, home = homedir2()) {
4149
+ return ["install", "-g", "--prefix", appDir(home), `${PACKAGE}@${version}`, "--prefer-online", "--no-fund", "--no-audit", "--loglevel=error"];
4150
+ }
4114
4151
  async function installVersion(version, home = homedir2()) {
4115
4152
  if (!RELEASE.test(version)) return { ok: false, why: `not a version: ${version}` };
4116
4153
  const runtime = join4(root(home), "runtime", "bin");
4117
4154
  try {
4118
- await execFileAsync2(join4(runtime, "npm"), ["install", "-g", "--prefix", appDir(home), `${PACKAGE}@${version}`, "--no-fund", "--no-audit", "--loglevel=error"], {
4155
+ await execFileAsync2(join4(runtime, "npm"), installArgs(version, home), {
4119
4156
  timeout: 5 * 6e4,
4120
4157
  env: { ...process.env, PATH: `${runtime}:${process.env.PATH ?? ""}`, NPM_CONFIG_UPDATE_NOTIFIER: "false" }
4121
4158
  });
@@ -4137,6 +4174,7 @@ function shouldUpdate(s) {
4137
4174
  }
4138
4175
 
4139
4176
  // ../daemon/src/mcp.ts
4177
+ import { join as join5 } from "path";
4140
4178
  function memoryMcpCommand(env = process.env) {
4141
4179
  const override = env.NANOPM_MCP_COMMAND;
4142
4180
  if (override) {
@@ -4163,13 +4201,17 @@ function memoryMcpServer(job, auth, askTimeoutMs, workspace) {
4163
4201
  NANOPM_PROJECT_ID: job.project_id,
4164
4202
  NANOPM_USER_ID: auth.userId,
4165
4203
  NANOPM_ACTOR: job.skill,
4166
- ...workspace ? { NANOPM_WORKSPACE: workspace } : {},
4204
+ // A build: its questions come back through the answer file the hook hands over (buildAnswerFile).
4205
+ ...workspace ? { NANOPM_WORKSPACE: workspace, NANOPM_ANSWER_FILE: buildAnswerFile(job.id) } : {},
4167
4206
  NANOPM_ASK_TIMEOUT_MS: String(askTimeoutMs),
4168
4207
  PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
4169
4208
  HOME: process.env.HOME ?? ""
4170
4209
  }
4171
4210
  };
4172
4211
  }
4212
+ function buildAnswerFile(jobId) {
4213
+ return join5(nanopmHome(), "run", jobId, "answer");
4214
+ }
4173
4215
 
4174
4216
  // ../connectors/src/postgres.ts
4175
4217
  import postgres from "postgres";
@@ -5286,7 +5328,7 @@ import { promisify as promisify4 } from "util";
5286
5328
  // ../daemon/src/repo.ts
5287
5329
  import { execFile as execFile3 } from "child_process";
5288
5330
  import { readFile as readFile2, rm as rm3, writeFile as writeFile4 } from "fs/promises";
5289
- import { join as join5 } from "path";
5331
+ import { join as join6 } from "path";
5290
5332
 
5291
5333
  // ../shared/src/build.ts
5292
5334
  var BUILD_SKILL = "build";
@@ -5296,11 +5338,26 @@ var BUILD_STEP_MAX = 120;
5296
5338
  var BUILD_RESUMES_MAX = 3;
5297
5339
  var BUILD_TRAILER = "Co-Authored-By: NanoPM <noreply@nanopm.app>";
5298
5340
  var BUILD_COLUMNS = "id, ref, title, status, actor, agent_spec, spec";
5341
+ var answered = (q) => typeof q?.answer === "string" && Boolean(q.answer.trim()) && typeof q.ask === "string";
5342
+ function openQuestions(spec2) {
5343
+ return (spec2?.questions ?? []).filter((q) => !answered(q)).length;
5344
+ }
5345
+ function specForAgent(agentSpec, spec2) {
5346
+ const said = (spec2?.questions ?? []).filter(answered);
5347
+ if (!said.length) return agentSpec;
5348
+ return `${agentSpec.trimEnd()}
5349
+
5350
+ ## The founder's answers
5351
+
5352
+ ${said.map((q) => `- ${q.ask.trim()}
5353
+ ${q.answer.trim()}`).join("\n")}
5354
+ `;
5355
+ }
5299
5356
  function buildRefusal(solution, opts = {}) {
5300
5357
  if (solution.actor === "founder") return `${solution.ref} is yours to do, not Nano's to build.`;
5301
5358
  if (!["proposed", "accepted", "building"].includes(solution.status)) return `${solution.ref} is ${solution.status}: only a solution on the roadmap can be built.`;
5302
5359
  if (!solution.agent_spec) return `${solution.ref} has no agent spec yet. Write its specs first: nanopm spec ${solution.ref} --write`;
5303
- const open = solution.spec?.questions?.length ?? 0;
5360
+ const open = openQuestions(solution.spec);
5304
5361
  if (open && !opts.anyway) {
5305
5362
  return `${solution.ref} still has ${open} question${open === 1 ? "" : "s"} for you. Answer ${open === 1 ? "it" : "them"} in its spec, or build anyway: nanopm build ${solution.ref} --anyway`;
5306
5363
  }
@@ -5340,7 +5397,7 @@ async function queueBuild(client, ask7) {
5340
5397
  if (readError) throw new Error(`could not read the solution: ${pgMessage(readError)}`);
5341
5398
  if (!data) throw new Error(`No solution ${ask7.ref} on the Roadmap.`);
5342
5399
  const solution = data;
5343
- const refused = buildRefusal(solution, { anyway: ask7.anyway });
5400
+ const refused = buildRefusal(solution, { anyway: ask7.anyway || Boolean(ask7.resumeOf) });
5344
5401
  if (refused) throw new Error(refused);
5345
5402
  const { data: job, error } = await client.from("jobs").insert({
5346
5403
  project_id: ask7.projectId,
@@ -5348,12 +5405,55 @@ async function queueBuild(client, ask7) {
5348
5405
  requested_by: ask7.userId,
5349
5406
  trigger: "user",
5350
5407
  target_daemon_id: ask7.daemonId,
5351
- params: { solution_id: solution.id, repo_path: ask7.repoPath, ...ask7.anyway ? { anyway: true } : {} }
5408
+ // `was`: where the solution goes back on the board if the build stops (`unstart_solution`).
5409
+ // `origin`: the run that made the copy of the code, which every continuation works in.
5410
+ params: { solution_id: solution.id, repo_path: ask7.repoPath, was: ask7.resumeOf?.was ?? solution.status, ...ask7.resumeOf ? { origin: ask7.resumeOf.origin } : {}, ...ask7.anyway ? { anyway: true } : {} },
5411
+ // Continuing a stopped build takes up its worktree, its history and its session.
5412
+ ...ask7.resumeOf ? { resume_of: ask7.resumeOf.id } : {}
5352
5413
  }).select("id").single();
5353
5414
  if (error?.code === "23505") throw new Error("A build of this product is already running. One at a time: `nanopm status` says where it is.");
5354
5415
  if (error || !job) throw new Error(`could not queue the build: ${error ? pgMessage(error) : "no row"}`);
5355
5416
  return { jobId: job.id, solution };
5356
5417
  }
5418
+ var DECIDE_FOR_ME = "Decide yourself: pick what you judge best for the users, then say what you decided and why under *Decisions I made*.";
5419
+ var isDecideForMe = (answer) => /^\s*decide(\s+for\s+me)?\s*\.?\s*$/i.test(answer);
5420
+ function answerForAgent(answer) {
5421
+ return isDecideForMe(answer) ? DECIDE_FOR_ME : answer;
5422
+ }
5423
+ var BUILD_HISTORY_KINDS = ["worktree", "plan", "step", "question", "answer", "paused", "pr"];
5424
+ function trustedPlace(p, at) {
5425
+ const [branch, base, fork] = [p.branch, p.base, p.fork].map((v) => typeof v === "string" ? v : "");
5426
+ if (!/^nano\/[a-z0-9][a-z0-9-]*$/.test(branch) || !/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(base) || !/^[0-9a-f]{7,40}$/.test(fork)) return null;
5427
+ return { dir: buildDir(at.home, at.jobId), branch, base, fork };
5428
+ }
5429
+ function buildHistory(events, at) {
5430
+ let place = null;
5431
+ let plan = null;
5432
+ const done = /* @__PURE__ */ new Set();
5433
+ let last = null;
5434
+ const answers = /* @__PURE__ */ new Map();
5435
+ for (const e of events) {
5436
+ if (e.kind === "worktree") place = trustedPlace(e.payload, at);
5437
+ else if (e.kind === "plan" && Array.isArray(e.payload.steps)) plan = e.payload.steps;
5438
+ else if (e.kind === "step" && typeof e.payload.step === "number") done.add(e.payload.step - 1);
5439
+ else if (e.kind === "answer") answers.set(e.seq, String(e.payload.answer ?? ""));
5440
+ if (e.kind === "question" || e.kind === "paused") last = e;
5441
+ else if (e.kind === "plan" || e.kind === "step") last = null;
5442
+ }
5443
+ if (!place) return { place, plan, done, resume: null };
5444
+ if (last?.kind === "question" && answers.has(last.seq) && last.payload.about === "usage") return { place, plan, done, resume: { why: "limit" } };
5445
+ if (last?.kind === "question" && answers.has(last.seq)) return { place, plan, done, resume: { why: "answer", answer: answers.get(last.seq) } };
5446
+ if (last?.kind === "paused") return { place, plan, done, resume: { why: "limit" } };
5447
+ return { place, plan, done, resume: { why: "lost" } };
5448
+ }
5449
+ function wasOf(params) {
5450
+ return params?.was === "proposed" ? "proposed" : "accepted";
5451
+ }
5452
+ async function loadBuildHistory(client, jobIds, at) {
5453
+ const { data, error } = await client.from("job_events").select("kind, seq, payload").in("job_id", [...jobIds]).in("kind", [...BUILD_HISTORY_KINDS]).order("id");
5454
+ if (error) throw new Error(`could not read the build's history: ${error.message}`);
5455
+ return buildHistory((data ?? []).map((e) => ({ kind: e.kind, seq: e.seq, payload: e.payload ?? {} })), at);
5456
+ }
5357
5457
 
5358
5458
  // ../daemon/src/repo.ts
5359
5459
  import { promisify as promisify3 } from "util";
@@ -5369,10 +5469,10 @@ var LOCKED = {
5369
5469
  };
5370
5470
  async function exec(cmd, repo, args, timeoutMs = TIMEOUT_MS) {
5371
5471
  if (typeof repo === "string") return execFileAsync3(cmd, cmd === "git" ? ["-C", repo, ...args] : args, { cwd: repo, maxBuffer: MAX_BUFFER, timeout: timeoutMs });
5372
- await writeFile4(join5(repo.dir, ".git"), `gitdir: ${repo.admin}
5472
+ await writeFile4(join6(repo.dir, ".git"), `gitdir: ${repo.admin}
5373
5473
  `);
5374
- await writeFile4(join5(repo.admin, "commondir"), repo.commondir);
5375
- await rm3(join5(repo.admin, "config.worktree"), { force: true });
5474
+ await writeFile4(join6(repo.admin, "commondir"), repo.commondir);
5475
+ await rm3(join6(repo.admin, "config.worktree"), { force: true });
5376
5476
  return execFileAsync3(cmd, args, { cwd: repo.dir, env: { ...process.env, GIT_DIR: repo.admin, GIT_WORK_TREE: repo.dir, ...LOCKED }, maxBuffer: MAX_BUFFER, timeout: timeoutMs });
5377
5477
  }
5378
5478
  async function git(repo, args, timeoutMs = TIMEOUT_MS) {
@@ -5441,7 +5541,7 @@ async function addWorktree(clone, dir, branch) {
5441
5541
  const onto = await baseRef(clone, base);
5442
5542
  await git(clone, ["worktree", "add", "-b", branch, dir, onto]);
5443
5543
  const admin = await git(dir, ["rev-parse", "--absolute-git-dir"]);
5444
- const worktree = { dir, admin, commondir: await readFile2(join5(admin, "commondir"), "utf8") };
5544
+ const worktree = { dir, admin, commondir: await readFile2(join6(admin, "commondir"), "utf8") };
5445
5545
  return { base, fork: await git(worktree, ["rev-parse", "HEAD"]), worktree };
5446
5546
  }
5447
5547
  async function removeWorktree(clone, dir) {
@@ -5476,6 +5576,13 @@ async function ghReady(cwd) {
5476
5576
  return /ENOENT/.test(errorMessage(err)) ? "the GitHub CLI is not installed on this machine: brew install gh, then gh auth login" : "the GitHub CLI is not signed in on this machine: run gh auth login in your terminal";
5477
5577
  }
5478
5578
  }
5579
+ async function pullRequestMerged(cwd, number) {
5580
+ try {
5581
+ return await gh(cwd, ["pr", "view", String(number), "--json", "state", "--jq", ".state"]) === "MERGED";
5582
+ } catch {
5583
+ return false;
5584
+ }
5585
+ }
5479
5586
  async function openPullRequest(cwd, pr) {
5480
5587
  try {
5481
5588
  const out = await gh(cwd, ["pr", "create", "--draft", "--head", pr.branch, "--base", pr.base, "--title", pr.title, "--body", pr.body]);
@@ -5550,46 +5657,27 @@ async function reviewDeploys(client, daemonId, now = /* @__PURE__ */ new Date())
5550
5657
  return results;
5551
5658
  }
5552
5659
 
5553
- // ../daemon/src/review-preflight.ts
5554
- async function reviewWithoutData(client, job) {
5555
- if (job.skill !== "review" || !job.project_id) return null;
5556
- if (job.trigger === "user") return null;
5557
- const { data: connections } = await client.from("connections").select("id, status").eq("project_id", job.project_id).is("removed_at", null);
5558
- const usable = (connections ?? []).filter((c) => c.status === "ok");
5559
- if (usable.length) return null;
5560
- const attached = (connections ?? []).length;
5561
- const summary = attached ? `No review this time: ${attached} data source${attached === 1 ? " is" : "s are"} attached but none is working. Run \`nanopm connect --list\` to see what is wrong.` : "No review this time: nothing is connected, so there was nothing to read. Run `nanopm connect` to attach your database.";
5562
- const { error } = await client.from("reviews").insert({
5563
- project_id: job.project_id,
5564
- summary,
5565
- confirms: [],
5566
- contradicts: [],
5567
- job_id: job.id
5568
- });
5569
- if (error) return { status: "failed", error: { code: "harness_error", message: `could not record that there was nothing to read: ${error.message}` } };
5570
- await client.from("activity_log").insert({
5571
- project_id: job.project_id,
5572
- job_id: job.id,
5573
- actor: "daemon",
5574
- step: "review.skipped",
5575
- summary: attached ? "no working data source" : "nothing connected",
5576
- outcome: "ok"
5577
- });
5578
- return { status: "succeeded", result: summary };
5579
- }
5660
+ // ../daemon/src/build-job.ts
5661
+ import { existsSync as existsSync5 } from "fs";
5662
+ import { mkdir as mkdir5, readdir as readdir2, readFile as readFile8, writeFile as writeFile5 } from "fs/promises";
5663
+ import { homedir as homedir4 } from "os";
5664
+ import { basename, join as join13 } from "path";
5665
+
5666
+ // ../daemon/src/problem-mapping-harness.ts
5667
+ import { randomUUID as randomUUID2 } from "crypto";
5580
5668
 
5581
5669
  // ../daemon/src/skills.ts
5582
5670
  import { existsSync as existsSync2, readdirSync } from "fs";
5583
5671
  import { readFile as readFile3 } from "fs/promises";
5584
- import { dirname as dirname2, join as join6, resolve as resolve2 } from "path";
5672
+ import { dirname as dirname2, join as join7, resolve as resolve2 } from "path";
5585
5673
  function findSkillsDir(env = process.env) {
5586
5674
  if (env.NANOPM_SKILLS_DIR) return env.NANOPM_SKILLS_DIR;
5587
5675
  const candidates = [
5588
- join6(process.cwd(), "skills"),
5676
+ join7(process.cwd(), "skills"),
5589
5677
  ...process.argv[1] ? [resolve2(dirname2(process.argv[1]), "..", "skills")] : []
5590
5678
  ];
5591
5679
  for (const dir of candidates) {
5592
- if (existsSync2(join6(dir, "onboard", "skill.json"))) return dir;
5680
+ if (existsSync2(join7(dir, "onboard", "skill.json"))) return dir;
5593
5681
  }
5594
5682
  throw new Error(`Cannot find the skills directory (tried ${candidates.join(", ")}). Set NANOPM_SKILLS_DIR.`);
5595
5683
  }
@@ -5599,9 +5687,9 @@ function skillDirs(skillsDir, env = process.env) {
5599
5687
  }
5600
5688
  async function loadSkill(name, skillsDir = findSkillsDir()) {
5601
5689
  if (!/^[a-z][a-z0-9-]*$/.test(name)) throw new Error(`Invalid skill name "${name}"`);
5602
- const dir = skillDirs(skillsDir).map((d) => join6(d, name)).find((d) => existsSync2(join6(d, "skill.json")));
5690
+ const dir = skillDirs(skillsDir).map((d) => join7(d, name)).find((d) => existsSync2(join7(d, "skill.json")));
5603
5691
  if (!dir) throw new Error(`Unknown skill "${name}"`);
5604
- const raw = JSON.parse(await readFile3(join6(dir, "skill.json"), "utf8"));
5692
+ const raw = JSON.parse(await readFile3(join7(dir, "skill.json"), "utf8"));
5605
5693
  let manifest;
5606
5694
  try {
5607
5695
  manifest = parseSkillManifest(raw);
@@ -5610,7 +5698,7 @@ async function loadSkill(name, skillsDir = findSkillsDir()) {
5610
5698
  }
5611
5699
  const tainted = taintedByTheWeb(manifest);
5612
5700
  if (tainted) throw new Error(`the "${name}" skill cannot be loaded: ${tainted}`);
5613
- const body = await readFile3(join6(dir, "SKILL.md"), "utf8");
5701
+ const body = await readFile3(join7(dir, "SKILL.md"), "utf8");
5614
5702
  return { manifest, body, dir };
5615
5703
  }
5616
5704
  var NOT_WITH_READ_WEB = ["write_memory", "problems", "propose", "review", "write_repo", "run_commands"];
@@ -5645,7 +5733,7 @@ function renderPrompt(template, params) {
5645
5733
  var NEEDS_THE_REQUESTERS_CLONE = ["write_repo", "run_commands"];
5646
5734
  var NEEDS_THE_REQUESTERS_IDENTITY = /* @__PURE__ */ new Set(["want", "start"]);
5647
5735
  async function clonelessSkills(skillsDir = findSkillsDir()) {
5648
- const names = readdirSync(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync2(join6(skillsDir, d.name, "skill.json"))).map((d) => d.name);
5736
+ const names = readdirSync(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync2(join7(skillsDir, d.name, "skill.json"))).map((d) => d.name);
5649
5737
  const out = [];
5650
5738
  for (const name of names) {
5651
5739
  try {
@@ -5659,7 +5747,6 @@ async function clonelessSkills(skillsDir = findSkillsDir()) {
5659
5747
  }
5660
5748
 
5661
5749
  // ../daemon/src/problem-mapping-harness.ts
5662
- import { randomUUID as randomUUID2 } from "crypto";
5663
5750
  function mappingHarnessRunner(options) {
5664
5751
  return async (input) => {
5665
5752
  const skill = await loadSkill(`problem-${input.role}`);
@@ -5975,7 +6062,7 @@ var UNANSWERED = /structured[ _]output|returned no result/i;
5975
6062
 
5976
6063
  // ../daemon/src/site.ts
5977
6064
  import { readFile as readFile4 } from "fs/promises";
5978
- import { join as join7 } from "path";
6065
+ import { join as join8 } from "path";
5979
6066
  var SITE_PATHS = ["/", "/about", "/pricing"];
5980
6067
  var PAGE_CHARS = 6e3;
5981
6068
  var SITE_TIMEOUT_MS = 2e4;
@@ -6098,7 +6185,7 @@ function siteFromSearch(name, hits) {
6098
6185
  }
6099
6186
  async function read(repoRoot, name) {
6100
6187
  try {
6101
- const text2 = await readFile4(join7(repoRoot, name), "utf8");
6188
+ const text2 = await readFile4(join8(repoRoot, name), "utf8");
6102
6189
  return text2.length > 2e5 ? null : text2;
6103
6190
  } catch {
6104
6191
  return null;
@@ -6153,7 +6240,7 @@ async function fromReadme(repoRoot) {
6153
6240
 
6154
6241
  // ../daemon/src/repo-docs.ts
6155
6242
  import { readFile as readFile5, readdir, stat } from "fs/promises";
6156
- import { join as join8, relative } from "path";
6243
+ import { join as join9, relative } from "path";
6157
6244
  var WRITTEN = /(persona|audience|customer|user-research|research\/|icp|segment)/i;
6158
6245
  var ABOUT_PEOPLE = /(persona|audience|customer|user-research|research\/|interview|feedback|survey|verbatim|voice|icp|segment)/i;
6159
6246
  var DOC = /\.(md|mdx|txt)$/i;
@@ -6171,7 +6258,7 @@ async function readDocs(cwd, opts = {}) {
6171
6258
  return;
6172
6259
  }
6173
6260
  for (const entry of entries) {
6174
- const full = join8(dir, entry.name);
6261
+ const full = join9(dir, entry.name);
6175
6262
  if (SKIP.test(`/${relative(cwd, full)}`)) continue;
6176
6263
  if (entry.isDirectory()) await walk(full, depth + 1);
6177
6264
  else if (DOC.test(entry.name) && (!opts.onlyAboutPeople || ABOUT_PEOPLE.test(relative(cwd, full)))) found.push(full);
@@ -6658,8 +6745,8 @@ function rank(candidates, readings, verdict, faults) {
6658
6745
  const failed2 = v?.failed ?? [];
6659
6746
  const fatal = failed2.filter((t) => MUST_PASS_TESTS.includes(t.test));
6660
6747
  const gates = faults.get(candidate.lands) ?? [];
6661
- const answered = /* @__PURE__ */ new Set([...v?.passed ?? [], ...failed2.map((t) => t.test)]);
6662
- const unanswered = MUST_PASS_TESTS.filter((t) => !answered.has(t));
6748
+ const answered2 = /* @__PURE__ */ new Set([...v?.passed ?? [], ...failed2.map((t) => t.test)]);
6749
+ const unanswered = MUST_PASS_TESTS.filter((t) => !answered2.has(t));
6663
6750
  const reservations = [
6664
6751
  ...fatal.map((t) => `${t.test} \u2014 ${t.because}`),
6665
6752
  ...gates,
@@ -7599,7 +7686,7 @@ function gateProposal(input) {
7599
7686
  for (let hops = 0; n && n.status === "replaced" && n.replaced_by && hops < 50; hops += 1) n = byId.get(n.replaced_by);
7600
7687
  return n && n.status !== "replaced" ? n : void 0;
7601
7688
  };
7602
- const answered = (r) => r.status === "confirmed" || r.author_kind === "user";
7689
+ const answered2 = (r) => r.status === "confirmed" || r.author_kind === "user";
7603
7690
  const under = scopeRow ? new Set(descendants(rows2, scopeRow).map((r) => r.id)) : null;
7604
7691
  const needOf = (r) => {
7605
7692
  let n = r;
@@ -7718,13 +7805,13 @@ function gateProposal(input) {
7718
7805
  keptIds.add(target.id);
7719
7806
  continue;
7720
7807
  }
7721
- if (input.hook && !answered(target) && target.provenance === "observed" && draft.provenance === "inferred") {
7808
+ if (input.hook && !answered2(target) && target.provenance === "observed" && draft.provenance === "inferred") {
7722
7809
  placed.set(node.key, { kind: "existing", row: target, branch: home });
7723
7810
  keptIds.add(target.id);
7724
7811
  kept.push(`"${target.label}"`);
7725
7812
  continue;
7726
7813
  }
7727
- if (answered(target)) {
7814
+ if (answered2(target)) {
7728
7815
  home.nodes.push(outgoing(node.key, draft, outside, parent, nextRank(parent), { next_of: target.id }));
7729
7816
  proposals += 1;
7730
7817
  placed.set(node.key, { kind: "existing", row: target, branch: home });
@@ -7751,7 +7838,7 @@ function gateProposal(input) {
7751
7838
  if (input.resumeOf && r.job_id === input.resumeOf) return false;
7752
7839
  if (within && needOf(r).id !== within) return false;
7753
7840
  if (input.hook && r.provenance === "observed") return false;
7754
- return !descendants(rows2, r).some((d) => d.id !== r.id && d.status !== "dropped" && (answered(d) || keptIds.has(d.id) || input.hook && d.provenance === "observed"));
7841
+ return !descendants(rows2, r).some((d) => d.id !== r.id && d.status !== "dropped" && (answered2(d) || keptIds.has(d.id) || input.hook && d.provenance === "observed"));
7755
7842
  });
7756
7843
  }
7757
7844
  return { branches: [...branches.values()].filter((b) => b.nodes.length), cut, kept, proposals, sweepable };
@@ -7986,7 +8073,7 @@ ${body}
7986
8073
  // ../daemon/src/crew.ts
7987
8074
  import { randomUUID as randomUUID8 } from "crypto";
7988
8075
  import { mkdir as mkdir2 } from "fs/promises";
7989
- import { join as join9 } from "path";
8076
+ import { join as join10 } from "path";
7990
8077
  var READ_ONLY = {
7991
8078
  read_repo: /^(Read|Glob|Grep)$/,
7992
8079
  read_data: /(^|__)(list_connections|describe_source|query)$/
@@ -8001,7 +8088,7 @@ async function attempt6({ adapter, spec: spec2, onEvent, skill: skillName, name,
8001
8088
  if (skill.manifest.capabilities.some((c) => !holds.includes(c))) throw new Error(`The ${name} may not hold tools.`);
8002
8089
  const refused = grant.find((c) => !READ_ONLY[c] || !skill.manifest.capabilities.includes(c));
8003
8090
  if (refused) throw new Error(`The ${name} may not hold ${refused}.`);
8004
- const runDir = dir ? join9(spec2.runDir, dir) : spec2.runDir;
8091
+ const runDir = dir ? join10(spec2.runDir, dir) : spec2.runDir;
8005
8092
  if (dir) await mkdir2(runDir, { recursive: true, mode: 448 });
8006
8093
  let result;
8007
8094
  let usage = null;
@@ -9281,8 +9368,8 @@ async function flashBranch(ctx, w, n, places) {
9281
9368
  }
9282
9369
  return out;
9283
9370
  }
9284
- function answersFor(answered, leaf) {
9285
- return answered.find((a) => a.ref.trim().toUpperCase() === leaf.ref.toUpperCase())?.solutions ?? [];
9371
+ function answersFor(answered2, leaf) {
9372
+ return answered2.find((a) => a.ref.trim().toUpperCase() === leaf.ref.toUpperCase())?.solutions ?? [];
9286
9373
  }
9287
9374
  async function deepDive(ctx, leaf, place, reads) {
9288
9375
  const whole = deepMaterial(ctx.known, leaf);
@@ -10061,7 +10148,7 @@ import { execFile as execFile6 } from "child_process";
10061
10148
  import { randomUUID as randomUUID11 } from "crypto";
10062
10149
  import { existsSync as existsSync3 } from "fs";
10063
10150
  import { mkdir as mkdir3, readFile as readFile6 } from "fs/promises";
10064
- import { join as join10 } from "path";
10151
+ import { join as join11 } from "path";
10065
10152
  import { promisify as promisify6 } from "util";
10066
10153
  var execFileAsync6 = promisify6(execFile6);
10067
10154
  var DEFINITIONS_TIMEOUT_MS = 9e4;
@@ -10097,7 +10184,7 @@ function pickLines(raw) {
10097
10184
  }
10098
10185
  async function killRecorded(runDir) {
10099
10186
  try {
10100
- const pid = Number((await readFile6(join10(runDir, "harness.pid"), "utf8")).trim());
10187
+ const pid = Number((await readFile6(join11(runDir, "harness.pid"), "utf8")).trim());
10101
10188
  if (pid > 1) process.kill(pid, "SIGKILL");
10102
10189
  } catch {
10103
10190
  }
@@ -10117,7 +10204,7 @@ async function readDefinitions({ adapter, spec: spec2, publish: publish5, journa
10117
10204
  if (!hasRepo) return give({}, "No repository to read: the card offers the common definitions.", "ok");
10118
10205
  try {
10119
10206
  const skill = await loadSkill("define-objective");
10120
- const runDir = join10(spec2.runDir, "definitions");
10207
+ const runDir = join11(spec2.runDir, "definitions");
10121
10208
  await mkdir3(runDir, { recursive: true, mode: 448 });
10122
10209
  const schema = definitionsOutputSchema();
10123
10210
  const [lines, listed] = await Promise.all([countingLines(spec2.cwd), listTrackedFiles(spec2.cwd)]);
@@ -10179,7 +10266,7 @@ ${renderTrackedFiles(listed.files, void 0, listed.wiki)}
10179
10266
  if (!result) throw new Error("no answer");
10180
10267
  const parsed = DefinitionsAnswer.safeParse(JSON.parse(result));
10181
10268
  if (!parsed.success) throw new Error("an answer in the wrong shape");
10182
- const checked = checkProposals(parsed.data, (file) => existsSync3(join10(spec2.cwd, file)));
10269
+ const checked = checkProposals(parsed.data, (file) => existsSync3(join11(spec2.cwd, file)));
10183
10270
  const definitions2 = lever ? checked[lever] ? { [lever]: checked[lever] } : {} : checked;
10184
10271
  const kept = Object.values(definitions2).reduce((n, list) => n + (list?.length ?? 0), 0);
10185
10272
  const read6 = Object.values(definitions2).flat().filter((d) => d?.found).length;
@@ -10453,8 +10540,8 @@ function localDate(timezone, at) {
10453
10540
  // ../daemon/src/run-job.ts
10454
10541
  import { randomUUID as randomUUID12 } from "crypto";
10455
10542
  import { existsSync as existsSync4 } from "fs";
10456
- import { mkdir as mkdir5, readFile as readFile8, rm as rm4 } from "fs/promises";
10457
- import { homedir as homedir4, tmpdir } from "os";
10543
+ import { mkdir as mkdir4, readFile as readFile7, rm as rm4 } from "fs/promises";
10544
+ import { homedir as homedir3, tmpdir } from "os";
10458
10545
  import { join as join12, resolve as resolve4 } from "path";
10459
10546
 
10460
10547
  // ../daemon/src/events.ts
@@ -10535,168 +10622,6 @@ function lighten(value) {
10535
10622
  return value ?? null;
10536
10623
  }
10537
10624
 
10538
- // ../daemon/src/build-job.ts
10539
- import { mkdir as mkdir4, readdir as readdir2, readFile as readFile7, writeFile as writeFile5 } from "fs/promises";
10540
- import { homedir as homedir3 } from "os";
10541
- import { join as join11 } from "path";
10542
- var PLAN_TOOL = "mcp__nanopm__build_plan";
10543
- var STEP_TOOL = "mcp__nanopm__build_step";
10544
- var CLAUDE_CODE_MIN = "2.1.283";
10545
- async function prepareBuild(client, job, clone, adapter) {
10546
- const params = job.params ?? {};
10547
- const dir = buildDir(homedir3(), job.id);
10548
- let made = false;
10549
- try {
10550
- const [solution, machine] = await Promise.all([loadSolution(client, job.project_id, String(params.solution_id ?? "")), machineRefusal(adapter)]);
10551
- const refused = buildRefusal(solution, { anyway: params.anyway === true }) ?? machine;
10552
- if (refused) return { ok: false, outcome: failed(refused) };
10553
- const branch = buildBranch(solution.ref, solution.title);
10554
- await mkdir4(join11(dir, ".."), { recursive: true, mode: 448 });
10555
- const { base, fork, worktree } = await addWorktree(clone, dir, branch);
10556
- made = true;
10557
- await handOverTheSpec(worktree, solution.agent_spec);
10558
- await startSolution(client, job.project_id, solution.ref, job.id);
10559
- return { ok: true, build: { solution, clone, dir, worktree, branch, base, fork, network: buildNetwork(await readdir2(dir)) } };
10560
- } catch (err) {
10561
- if (made) await removeWorktree(clone, dir);
10562
- return { ok: false, outcome: failed(errorMessage(err)) };
10563
- }
10564
- }
10565
- async function runBuildJob(input) {
10566
- const { client, job, adapter, spec: spec2, events, log, verifyCommand: verifyCommand2 } = input;
10567
- const { solution, clone, dir, worktree, branch, base, fork } = input.build;
10568
- await log("build.start", `building ${solution.ref} on ${branch}, in a copy of your code`);
10569
- await events.write("text", { text: `Working on ${branch}, in a copy of your code. Your own checkout is not touched.` });
10570
- let plan = null;
10571
- const done = /* @__PURE__ */ new Set();
10572
- let usage;
10573
- let said = "";
10574
- const stop2 = async (message, opts = { keep: true }) => {
10575
- await unstartSolution(client, job.project_id, solution.ref, solution.status).catch(() => void 0);
10576
- if (opts.keep) await commitAll(worktree, `${solution.ref}: unfinished \u2014 ${solution.title}`).catch(() => null);
10577
- else await removeWorktree(clone, dir);
10578
- return { ...failed(opts.keep ? `${message}. What was done is on ${branch}, in ${dir}.` : message), usage };
10579
- };
10580
- const session2 = async (prompt, resume2) => {
10581
- let end;
10582
- for await (const event of adapter.run({ ...spec2, prompt, resumeSessionId: resume2 ? spec2.sessionId : void 0 })) {
10583
- await input.onEvent(event);
10584
- if (event.type === "tool_use" && event.tool === PLAN_TOOL && !plan) {
10585
- plan = stepsOf(event.input);
10586
- if (plan) await events.write("plan", { steps: plan });
10587
- } else if (event.type === "tool_result" && event.tool === STEP_TOOL && !event.isError) {
10588
- const { step, summary } = event.input ?? {};
10589
- const index = Number(step) - 1;
10590
- if (Number.isInteger(index) && index >= 0 && !done.has(index)) {
10591
- const says = plan?.[index] ?? `step ${index + 1}`;
10592
- const commit = await commitAll(worktree, `${solution.ref}: ${says}`, summary);
10593
- done.add(index);
10594
- await events.write("step", { step: index + 1, says, commit: commit?.sha ?? null, files: commit?.files ?? 0 });
10595
- }
10596
- } else if (event.type === "finished") {
10597
- usage = sumUsage(usage, event.usage);
10598
- said = event.result;
10599
- end = { status: "succeeded" };
10600
- } else if (event.type === "failed") {
10601
- end = { status: "failed", error: { code: event.code, message: event.message } };
10602
- }
10603
- }
10604
- return end?.status === "succeeded" ? null : end ?? failed("the agent produced no result");
10605
- };
10606
- const check = verifyCommand2 ? `
10607
-
10608
- The project's own check is \`${verifyCommand2}\`. Run it before your last build_step, fix what it reports, and give its result under *How I checked it*.` : "";
10609
- let ended = await session2(spec2.prompt + check, false);
10610
- for (let resumes = 0; !ended && !(plan && !stepsLeft(plan, done).length); resumes += 1) {
10611
- if (resumes >= BUILD_RESUMES_MAX) {
10612
- const left = stepsLeft(plan, done).length;
10613
- return stop2(plan ? `Nano stopped with ${left} step${left === 1 ? "" : "s"} of its plan left` : "Nano stopped without saying its plan");
10614
- }
10615
- ended = await session2(carryOn(plan, done), true);
10616
- }
10617
- if (ended) return stop2(ended.error?.message ?? "the build stopped");
10618
- await commitAll(worktree, `${solution.ref}: ${solution.title}`);
10619
- if (!await commitsSinceFork(worktree, fork)) return stop2("the build finished without changing anything", { keep: false });
10620
- const rebased = await rebaseOnto(worktree, base);
10621
- if (!rebased.ok) return stop2(`${base} moved while Nano was building, and the same files changed on both sides: ${rebased.files.join(", ") || "see git status"}`);
10622
- const [pushed, noPr, page] = await Promise.all([pushBranch(worktree, branch), ghReady(worktree), solutionPage(client, job.project_id, solution.ref)]);
10623
- if (!pushed.pushed) return stop2(`the push failed: ${pushed.reason}`);
10624
- const pr = noPr ? null : await openPullRequest(worktree, { branch, base, title: `${solution.ref}: ${solution.title}`, body: prBody(said, page) });
10625
- if (pr && !pr.ok) return { ...failed(`the branch ${branch} is pushed, but GitHub refused the pull request: ${pr.reason}${pushed.reviewUrl ? `. Open it yourself: ${pushed.reviewUrl}` : ""}`), usage };
10626
- if (pr) {
10627
- await events.write("pr", { number: pr.number, url: pr.url });
10628
- await log("build.pr", `draft pull request #${pr.number} for ${solution.ref}`);
10629
- }
10630
- await removeWorktree(clone, dir);
10631
- return {
10632
- status: "succeeded",
10633
- result: [
10634
- said.trim(),
10635
- "",
10636
- pr ? `Draft pull request #${pr.number} \xB7 ${pr.url}` : `Pushed ${branch}. No pull request: ${noPr}.${pushed.reviewUrl ? ` ${pushed.reviewUrl}` : ""}`
10637
- ].filter(Boolean).join("\n"),
10638
- data: { branch, session: spec2.sessionId, ...pr ? { pr: { number: pr.number, url: pr.url } } : { review_url: pushed.reviewUrl ?? null } },
10639
- usage
10640
- };
10641
- }
10642
- var failed = (message) => ({ status: "failed", error: { code: "harness_error", message } });
10643
- async function loadSolution(client, projectId, id) {
10644
- const { data, error } = await client.from("solutions").select(BUILD_COLUMNS).eq("id", id).eq("project_id", projectId).is("superseded_by", null).maybeSingle();
10645
- if (error) throw new Error(`could not read the solution: ${error.message}`);
10646
- if (!data) throw new Error("that solution is not on this project any more");
10647
- return data;
10648
- }
10649
- async function machineRefusal(adapter) {
10650
- const found = await adapter.detect();
10651
- const version = /\d+\.\d+\.\d+/.exec(found.version ?? "")?.[0];
10652
- if (version && !newer(CLAUDE_CODE_MIN, version)) return null;
10653
- return `a build needs Claude Code ${CLAUDE_CODE_MIN} or later on this machine${found.version ? ` (it has ${found.version})` : ""}: run claude update`;
10654
- }
10655
- async function handOverTheSpec(worktree, agentSpec) {
10656
- await mkdir4(join11(worktree.dir, ".nanopm"), { recursive: true });
10657
- await writeFile5(join11(worktree.dir, ".nanopm", "SPEC.md"), agentSpec);
10658
- const exclude = join11(await commonGitDir(worktree), "info", "exclude");
10659
- const current = await readFile7(exclude, "utf8").catch(() => "");
10660
- if (!current.split("\n").includes(".nanopm/")) {
10661
- await mkdir4(join11(exclude, ".."), { recursive: true });
10662
- await writeFile5(exclude, `${current}${current && !current.endsWith("\n") ? "\n" : ""}.nanopm/
10663
- `);
10664
- }
10665
- }
10666
- function stepsOf(input) {
10667
- const steps = input?.steps;
10668
- return Array.isArray(steps) && steps.length && steps.every((s) => typeof s === "string") ? steps : null;
10669
- }
10670
- function carryOn(plan, done) {
10671
- if (!plan) return "You stopped before saying your plan. Call build_plan with it now, then build it step by step, calling build_step after each.";
10672
- const left = stepsLeft(plan, done);
10673
- return [
10674
- `Steps ${left.map((i) => i + 1).join(", ")} of your plan are not done yet:`,
10675
- ...left.map((i) => `${i + 1}. ${plan[i]}`),
10676
- "",
10677
- "Check git log and git status to see where you are, then carry on: finish each step, run its checks, and call build_step after it. Do not run anything in the background."
10678
- ].join("\n");
10679
- }
10680
- async function solutionPage(client, projectId, ref) {
10681
- const { data } = await client.from("projects").select("slug").eq("id", projectId).maybeSingle();
10682
- return data?.slug ? `${webUrl()}/p/${data.slug}/roadmap/${ref}` : null;
10683
- }
10684
- function prBody(said, page) {
10685
- return [
10686
- said.trim(),
10687
- page ? `
10688
- The solution and its spec: ${page}` : "",
10689
- "\n\u2014 Built by NanoPM with Claude Code. A draft: review before merging."
10690
- ].filter(Boolean).join("\n");
10691
- }
10692
- async function startSolution(client, projectId, ref, jobId) {
10693
- const { error } = await client.rpc("start_solution", { p_project_id: projectId, p_ref: ref, p_job_id: jobId });
10694
- if (error) throw new Error(pgMessage(error));
10695
- }
10696
- async function unstartSolution(client, projectId, ref, was) {
10697
- await client.rpc("unstart_solution", { p_project_id: projectId, p_ref: ref, p_to: was === "proposed" ? "proposed" : "accepted" });
10698
- }
10699
-
10700
10625
  // ../daemon/src/run-job.ts
10701
10626
  import { hostname } from "os";
10702
10627
 
@@ -10731,11 +10656,13 @@ async function runJob(job, ctx) {
10731
10656
  };
10732
10657
  }
10733
10658
  const durationMs = Date.now() - startedAt;
10659
+ const over = outcome.status !== "waiting";
10734
10660
  await events.status(outcome.status, outcome.error ? { error: outcome.error } : crewRun(job) && outcome.result ? { text: outcome.result } : {});
10735
10661
  await withRetry(
10736
10662
  () => ctx.client.from("jobs").update({
10737
10663
  status: outcome.status,
10738
- finished_at: (/* @__PURE__ */ new Date()).toISOString(),
10664
+ // A parked job is not over: it waits, with no heartbeat, for the founder or the limit.
10665
+ finished_at: over ? (/* @__PURE__ */ new Date()).toISOString() : null,
10739
10666
  // A mapping run's root row belongs to SQL: checkpoints, receipt and text land there
10740
10667
  // with the publication. A resumed job's own row is free to carry the text.
10741
10668
  ...job.skill === "problems" && !job.resume_of ? {} : { result: outcome.result !== void 0 ? { text: outcome.result, ...outcome.data } : null },
@@ -10748,7 +10675,7 @@ async function runJob(job, ctx) {
10748
10675
  ctx,
10749
10676
  job,
10750
10677
  "job.end",
10751
- outcome.status === "succeeded" ? `finished ${job.skill}` : outcome.status === "cancelled" ? `${job.skill} cancelled by the user` : `${job.skill} failed: ${outcome.error?.message}`,
10678
+ outcome.status === "succeeded" ? `finished ${job.skill}` : outcome.status === "waiting" ? `${job.skill} waits, with nothing running` : outcome.status === "cancelled" ? `${job.skill} cancelled by the user` : `${job.skill} failed: ${outcome.error?.message}`,
10752
10679
  {
10753
10680
  usage: {
10754
10681
  tokens: usageTokens(outcome.usage),
@@ -10757,15 +10684,15 @@ async function runJob(job, ctx) {
10757
10684
  ...outcome.usage?.contextPeakTokens ? { context_peak: outcome.usage.contextPeakTokens } : {},
10758
10685
  ...outcome.usage?.reads ? { files_read: outcome.usage.reads.files, calls: outcome.usage.reads.calls } : {}
10759
10686
  },
10760
- outcome: outcome.status === "succeeded" ? "ok" : "error"
10687
+ outcome: outcome.status === "succeeded" || !over ? "ok" : "error"
10761
10688
  }
10762
10689
  );
10763
10690
  return outcome;
10764
10691
  }
10765
10692
  async function execute(job, skill, ctx, events) {
10766
10693
  const runDir = join12(nanopmHome(), "run", job.id);
10767
- await mkdir5(runDir, { recursive: true, mode: 448 });
10768
- const resumeSessionId = job.resume_of ? await sessionOf(ctx.client, job.resume_of) : void 0;
10694
+ await mkdir4(runDir, { recursive: true, mode: 448 });
10695
+ const resumeSessionId = job.resume_of ? await sessionOf(ctx.client, job.resume_of) : job.harness_session_id ?? void 0;
10769
10696
  const sessionId = resumeSessionId ?? randomUUID12();
10770
10697
  await ctx.client.from("jobs").update({ harness_session_id: sessionId }).eq("id", job.id);
10771
10698
  const adapter = ctx.adapter ?? new ClaudeCodeAdapter();
@@ -10815,7 +10742,7 @@ async function execute(job, skill, ctx, events) {
10815
10742
  if (quickRun2) {
10816
10743
  const cwd2 = scratchDir(job.id);
10817
10744
  try {
10818
- await mkdir5(cwd2, { recursive: true, mode: 448 });
10745
+ await mkdir4(cwd2, { recursive: true, mode: 448 });
10819
10746
  const caps = skill.manifest.capabilities;
10820
10747
  return await quickRun2({
10821
10748
  client: ctx.client,
@@ -10894,6 +10821,7 @@ async function execute(job, skill, ctx, events) {
10894
10821
  capabilities,
10895
10822
  harnessWeb,
10896
10823
  network: build2?.network,
10824
+ answerFile: build2 ? buildAnswerFile(job.id) : void 0,
10897
10825
  withoutTools: skill.manifest.withoutTools,
10898
10826
  // The workspace reaches the memory server only for a job that changes files; that is
10899
10827
  // what lets the build's tools refuse everywhere else.
@@ -11075,7 +11003,7 @@ function harnessWebFor(searchProvider, machineCanSearch) {
11075
11003
  }
11076
11004
  async function machineWebSearch() {
11077
11005
  try {
11078
- const parsed = JSON.parse(await readFile8(join12(nanopmHome(), "daemon.json"), "utf8"));
11006
+ const parsed = JSON.parse(await readFile7(join12(nanopmHome(), "daemon.json"), "utf8"));
11079
11007
  return parsed.webSearch === true;
11080
11008
  } catch {
11081
11009
  return false;
@@ -11126,12 +11054,12 @@ async function resolveCwd(ctx, job, params, fallback, mustBeTheClone = false) {
11126
11054
  const requested = params.repo_path;
11127
11055
  if (typeof requested !== "string" || !requested) {
11128
11056
  if (mustBeTheClone) refuse2("the job named no repository");
11129
- await mkdir5(fallback, { recursive: true, mode: 448 });
11057
+ await mkdir4(fallback, { recursive: true, mode: 448 });
11130
11058
  return fallback;
11131
11059
  }
11132
11060
  if (!job.project_id) {
11133
11061
  if (mustBeTheClone) refuse2("the job belongs to no project");
11134
- await mkdir5(fallback, { recursive: true, mode: 448 });
11062
+ await mkdir4(fallback, { recursive: true, mode: 448 });
11135
11063
  return fallback;
11136
11064
  }
11137
11065
  const { data: clone } = await ctx.client.from("clones").select("repo_path").eq("project_id", job.project_id).eq("daemon_id", ctx.daemonId).maybeSingle();
@@ -11139,12 +11067,12 @@ async function resolveCwd(ctx, job, params, fallback, mustBeTheClone = false) {
11139
11067
  if (!allowed || resolve4(allowed) !== resolve4(requested)) {
11140
11068
  if (mustBeTheClone) refuse2(`${requested} is not the clone recorded for this project on this machine. Run any \`nanopm\` command inside your clone here to record it`);
11141
11069
  console.error(`job ${job.id}: repo_path ${requested} is not this machine's clone; running in a scratch directory instead`);
11142
- await mkdir5(fallback, { recursive: true, mode: 448 });
11070
+ await mkdir4(fallback, { recursive: true, mode: 448 });
11143
11071
  return fallback;
11144
11072
  }
11145
11073
  if (existsSync4(allowed)) return resolve4(allowed);
11146
11074
  if (mustBeTheClone) refuse2(`${allowed} is not on this machine any more`);
11147
- await mkdir5(fallback, { recursive: true, mode: 448 });
11075
+ await mkdir4(fallback, { recursive: true, mode: 448 });
11148
11076
  return fallback;
11149
11077
  }
11150
11078
  function asData8(label, body) {
@@ -11153,7 +11081,7 @@ ${body}
11153
11081
  </${label}>`;
11154
11082
  }
11155
11083
  function harnessEnv(timeoutMs, source = process.env) {
11156
- const home = source.HOME ?? homedir4();
11084
+ const home = source.HOME ?? homedir3();
11157
11085
  const env = {
11158
11086
  HOME: home,
11159
11087
  PATH: [source.PATH ?? "/usr/local/bin:/usr/bin:/bin", join12(home, ".local", "bin")].join(":"),
@@ -11187,6 +11115,309 @@ function usageTokens(usage) {
11187
11115
  return usage.inputTokens + (usage.cacheWriteTokens ?? 0) + usage.outputTokens;
11188
11116
  }
11189
11117
 
11118
+ // ../daemon/src/build-job.ts
11119
+ var PLAN_TOOL = "mcp__nanopm__build_plan";
11120
+ var ASK_TOOL = "mcp__nanopm__ask_user";
11121
+ var STEP_TOOL = "mcp__nanopm__build_step";
11122
+ var CLAUDE_CODE_MIN = "2.1.283";
11123
+ async function prepareBuild(client, job, clone, adapter) {
11124
+ const params = job.params ?? {};
11125
+ const dir = buildDir(homedir4(), job.id);
11126
+ let made = false;
11127
+ try {
11128
+ const [solution, machine, history] = await Promise.all([
11129
+ loadSolution(client, job.project_id, String(params.solution_id ?? "")),
11130
+ machineRefusal(adapter),
11131
+ loadHistory(client, job)
11132
+ ]);
11133
+ const refused = buildRefusal(solution, { anyway: params.anyway === true || Boolean(history.place) }) ?? machine;
11134
+ if (refused) return { ok: false, outcome: failed(refused) };
11135
+ const network = async (at) => buildNetwork(await readdir2(at));
11136
+ if (history.place) {
11137
+ const { place } = history;
11138
+ if (!existsSync5(place.dir)) return { ok: false, outcome: failed(`this build's copy of the code is gone (${place.dir}): build it again`) };
11139
+ const worktree2 = { dir: place.dir, admin: join13(await commonGitDir(clone), "worktrees", basename(place.dir)), commondir: "../..\n" };
11140
+ await startSolution(client, job.project_id, solution.ref, job.id);
11141
+ return { ok: true, build: { solution, clone, ...place, worktree: worktree2, network: await network(place.dir), history } };
11142
+ }
11143
+ const branch = buildBranch(solution.ref, solution.title);
11144
+ await mkdir5(join13(dir, ".."), { recursive: true, mode: 448 });
11145
+ const { base, fork, worktree } = await addWorktree(clone, dir, branch);
11146
+ made = true;
11147
+ await appendJobEvent(client, job.id, "worktree", { dir, branch, base, fork }, "nanopm");
11148
+ await handOverTheSpec(worktree, specForAgent(solution.agent_spec, solution.spec));
11149
+ await startSolution(client, job.project_id, solution.ref, job.id);
11150
+ return { ok: true, build: { solution, clone, dir, worktree, branch, base, fork, network: await network(dir), history } };
11151
+ } catch (err) {
11152
+ if (made) await removeWorktree(clone, dir);
11153
+ return { ok: false, outcome: failed(errorMessage(err)) };
11154
+ }
11155
+ }
11156
+ async function runBuildJob(input) {
11157
+ const { client, job, adapter, spec: spec2, events, log, verifyCommand: verifyCommand2 } = input;
11158
+ const { solution, clone, dir, worktree, branch, base, fork, history } = input.build;
11159
+ const was = wasOf(job.params);
11160
+ let plan = history.plan;
11161
+ const done = new Set(history.done);
11162
+ let usage;
11163
+ let said = "";
11164
+ const stop2 = async (message, opts = { keep: true }) => {
11165
+ await unstartSolution(client, job.project_id, solution.ref, was).catch(() => void 0);
11166
+ if (opts.keep) await commitAll(worktree, `${solution.ref}: unfinished \u2014 ${solution.title}`).catch(() => null);
11167
+ else await removeWorktree(clone, dir);
11168
+ return { ...failed(opts.keep ? `${message}. What was done is on ${branch}, in ${dir}.` : message), usage };
11169
+ };
11170
+ const park = async (why) => {
11171
+ if ("ask" in why) {
11172
+ await events.write("question", { ...why.ask, ...plan ? {} : { about: "plan" } });
11173
+ await log("build.ask", `${solution.ref} waits on the founder`);
11174
+ } else if (why.resetsAt) {
11175
+ await events.write("paused", { why: "limit", resets_at: new Date(why.resetsAt * 1e3).toISOString() });
11176
+ await log("build.pause", `${solution.ref} paused until the Claude limit resets`);
11177
+ } else {
11178
+ await events.write("question", { question: "Your Claude usage is used up. Get more in Claude, or wait for it to come back, then answer to carry on.", options: ["Carry on"], about: "usage" });
11179
+ }
11180
+ return { status: "waiting", usage };
11181
+ };
11182
+ const session2 = async (prompt, resume3) => {
11183
+ let end = { stop: failed("the agent produced no result") };
11184
+ for await (const event of adapter.run({ ...spec2, prompt, resumeSessionId: resume3 ? spec2.sessionId : void 0 })) {
11185
+ await input.onEvent(event);
11186
+ if (event.type === "tool_use" && event.tool === PLAN_TOOL && !plan) {
11187
+ plan = stepsOf(event.input);
11188
+ if (plan) await events.write("plan", { steps: plan });
11189
+ } else if (event.type === "tool_result" && event.tool === STEP_TOOL && !event.isError) {
11190
+ const { step, summary } = event.input ?? {};
11191
+ const index = Number(step) - 1;
11192
+ if (Number.isInteger(index) && index >= 0 && !done.has(index)) {
11193
+ const says = plan?.[index] ?? `step ${index + 1}`;
11194
+ const commit = await commitAll(worktree, `${solution.ref}: ${says}`, summary);
11195
+ done.add(index);
11196
+ await events.write("step", { step: index + 1, says, commit: commit?.sha ?? null, files: commit?.files ?? 0 });
11197
+ }
11198
+ } else if (event.type === "finished") {
11199
+ usage = sumUsage(usage, event.usage);
11200
+ said = event.result;
11201
+ end = null;
11202
+ } else if (event.type === "deferred" && event.tool === ASK_TOOL) {
11203
+ usage = sumUsage(usage, event.usage);
11204
+ end = { park: { ask: askOf(event.input) } };
11205
+ } else if (event.type === "failed") {
11206
+ end = event.code === "rate_limit" ? { park: { resetsAt: event.resetsAt } } : { stop: { status: "failed", error: { code: event.code, message: event.message } } };
11207
+ }
11208
+ }
11209
+ return end;
11210
+ };
11211
+ const resume2 = history.resume;
11212
+ const check = verifyCommand2 ? `
11213
+
11214
+ The project's own check is \`${verifyCommand2}\`. Run it before your last build_step, fix what it reports, and give its result under *How I checked it*.` : "";
11215
+ const finished = () => Boolean(plan && !stepsLeft(plan, done).length);
11216
+ if (!resume2) {
11217
+ await log("build.start", `building ${solution.ref} on ${branch}, in a copy of your code`);
11218
+ await events.write("text", { text: `Working on ${branch}, in a copy of your code. Your own checkout is not touched.` });
11219
+ } else {
11220
+ await events.write("text", { text: AGAIN[resume2.why].said });
11221
+ if (resume2.why === "answer" && spec2.answerFile) await writeFile5(spec2.answerFile, answerForAgent(resume2.answer), { mode: 384 });
11222
+ }
11223
+ let turn = !resume2 ? await session2(spec2.prompt + check, false) : resume2.why === "answer" ? await session2("", true) : finished() ? null : await session2(`${AGAIN[resume2.why].lead} ${carryOn(plan, done)}`, true);
11224
+ for (let resumes = 0; !turn && !finished(); resumes += 1) {
11225
+ if (resumes >= BUILD_RESUMES_MAX) {
11226
+ const left = stepsLeft(plan, done).length;
11227
+ return stop2(plan ? `Nano stopped with ${left} step${left === 1 ? "" : "s"} of its plan left` : "Nano stopped without saying its plan");
11228
+ }
11229
+ turn = await session2(carryOn(plan, done), true);
11230
+ }
11231
+ if (turn && "park" in turn) return park(turn.park);
11232
+ if (turn) return stop2(turn.stop.error?.message ?? "the build stopped");
11233
+ await commitAll(worktree, `${solution.ref}: ${solution.title}`);
11234
+ if (!await commitsSinceFork(worktree, fork)) return stop2("the build finished without changing anything", { keep: false });
11235
+ const rebased = await rebaseOnto(worktree, base);
11236
+ if (!rebased.ok) return stop2(`${base} moved while Nano was building, and the same files changed on both sides: ${rebased.files.join(", ") || "see git status"}`);
11237
+ const [pushed, noPr, page] = await Promise.all([pushBranch(worktree, branch), ghReady(worktree), solutionPage(client, job.project_id, solution.ref)]);
11238
+ if (!pushed.pushed) return stop2(`the push failed: ${pushed.reason}`);
11239
+ const pr = noPr ? null : await openPullRequest(worktree, { branch, base, title: `${solution.ref}: ${solution.title}`, body: prBody(said, page) });
11240
+ if (pr && !pr.ok) return { ...failed(`the branch ${branch} is pushed, but GitHub refused the pull request: ${pr.reason}${pushed.reviewUrl ? `. Open it yourself: ${pushed.reviewUrl}` : ""}`), usage };
11241
+ if (pr) {
11242
+ await events.write("pr", { number: pr.number, url: pr.url });
11243
+ await log("build.pr", `draft pull request #${pr.number} for ${solution.ref}`);
11244
+ }
11245
+ await removeWorktree(clone, dir);
11246
+ return {
11247
+ status: "succeeded",
11248
+ result: [
11249
+ said.trim(),
11250
+ "",
11251
+ pr ? `Draft pull request #${pr.number} \xB7 ${pr.url}` : `Pushed ${branch}. No pull request: ${noPr}.${pushed.reviewUrl ? ` ${pushed.reviewUrl}` : ""}`
11252
+ ].filter(Boolean).join("\n"),
11253
+ data: { branch, session: spec2.sessionId, ...pr ? { pr: { number: pr.number, url: pr.url } } : { review_url: pushed.reviewUrl ?? null } },
11254
+ usage
11255
+ };
11256
+ }
11257
+ var failed = (message) => ({ status: "failed", error: { code: "harness_error", message } });
11258
+ var AGAIN = {
11259
+ answer: { said: "Your answer is in: carrying on.", lead: "" },
11260
+ limit: { said: "Your Claude limit has reset: carrying on.", lead: "Your Claude limit has reset." },
11261
+ lost: { said: "Picked up again where it stopped.", lead: "You were interrupted: the machine stopped." }
11262
+ };
11263
+ function askOf(input) {
11264
+ const { question, options, why } = input ?? {};
11265
+ return {
11266
+ question: String(question ?? ""),
11267
+ ...Array.isArray(options) ? { options: options.map(String) } : {},
11268
+ ...typeof why === "string" ? { why } : {}
11269
+ };
11270
+ }
11271
+ async function loadHistory(client, job) {
11272
+ const named = (job.params ?? {}).origin;
11273
+ const origin = typeof named === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(named) ? named : job.id;
11274
+ const { data: runs } = await client.from("jobs").select("id").eq("project_id", job.project_id).eq("skill", BUILD_SKILL).or(`id.eq.${origin},params->>origin.eq.${origin}`);
11275
+ return loadBuildHistory(client, [.../* @__PURE__ */ new Set([origin, job.id, ...(runs ?? []).map((r) => r.id)])], { home: homedir4(), jobId: origin });
11276
+ }
11277
+ async function loadSolution(client, projectId, id) {
11278
+ const { data, error } = await client.from("solutions").select(BUILD_COLUMNS).eq("id", id).eq("project_id", projectId).is("superseded_by", null).maybeSingle();
11279
+ if (error) throw new Error(`could not read the solution: ${error.message}`);
11280
+ if (!data) throw new Error("that solution is not on this project any more");
11281
+ return data;
11282
+ }
11283
+ async function machineRefusal(adapter) {
11284
+ const found = await adapter.detect();
11285
+ const version = /\d+\.\d+\.\d+/.exec(found.version ?? "")?.[0];
11286
+ if (version && !newer(CLAUDE_CODE_MIN, version)) return null;
11287
+ return `a build needs Claude Code ${CLAUDE_CODE_MIN} or later on this machine${found.version ? ` (it has ${found.version})` : ""}: run claude update`;
11288
+ }
11289
+ async function handOverTheSpec(worktree, agentSpec) {
11290
+ await mkdir5(join13(worktree.dir, ".nanopm"), { recursive: true });
11291
+ await writeFile5(join13(worktree.dir, ".nanopm", "SPEC.md"), agentSpec);
11292
+ const exclude = join13(await commonGitDir(worktree), "info", "exclude");
11293
+ const current = await readFile8(exclude, "utf8").catch(() => "");
11294
+ if (!current.split("\n").includes(".nanopm/")) {
11295
+ await mkdir5(join13(exclude, ".."), { recursive: true });
11296
+ await writeFile5(exclude, `${current}${current && !current.endsWith("\n") ? "\n" : ""}.nanopm/
11297
+ `);
11298
+ }
11299
+ }
11300
+ function stepsOf(input) {
11301
+ const steps = input?.steps;
11302
+ return Array.isArray(steps) && steps.length && steps.every((s) => typeof s === "string") ? steps : null;
11303
+ }
11304
+ function carryOn(plan, done) {
11305
+ if (!plan) return "You stopped before saying your plan. Call build_plan with it now, then build it step by step, calling build_step after each.";
11306
+ const left = stepsLeft(plan, done);
11307
+ return [
11308
+ `Steps ${left.map((i) => i + 1).join(", ")} of your plan are not done yet:`,
11309
+ ...left.map((i) => `${i + 1}. ${plan[i]}`),
11310
+ "",
11311
+ "Check git log and git status to see where you are, then carry on: finish each step, run its checks, and call build_step after it. Do not run anything in the background."
11312
+ ].join("\n");
11313
+ }
11314
+ async function solutionPage(client, projectId, ref) {
11315
+ const { data } = await client.from("projects").select("slug").eq("id", projectId).maybeSingle();
11316
+ return data?.slug ? `${webUrl()}/p/${data.slug}/roadmap/${ref}` : null;
11317
+ }
11318
+ function prBody(said, page) {
11319
+ return [
11320
+ said.trim(),
11321
+ page ? `
11322
+ The solution and its spec: ${page}` : "",
11323
+ "\n\u2014 Built by NanoPM with Claude Code. A draft: review before merging."
11324
+ ].filter(Boolean).join("\n");
11325
+ }
11326
+ async function startSolution(client, projectId, ref, jobId) {
11327
+ const { error } = await client.rpc("start_solution", { p_project_id: projectId, p_ref: ref, p_job_id: jobId });
11328
+ if (error) throw new Error(pgMessage(error));
11329
+ }
11330
+ async function unstartSolution(client, projectId, ref, was) {
11331
+ await client.rpc("unstart_solution", { p_project_id: projectId, p_ref: ref, p_to: was });
11332
+ }
11333
+ async function requeueLostBuild(client, job) {
11334
+ const params = job.params ?? {};
11335
+ if ((job.attempt ?? 1) < 2) {
11336
+ const { data } = await client.from("jobs").update({ status: "queued", attempt: (job.attempt ?? 1) + 1, assigned_daemon_id: null }).eq("id", job.id).eq("status", "running").select("id");
11337
+ if (data?.length) return true;
11338
+ }
11339
+ const { data: solution } = await client.from("solutions").select("ref").eq("id", params.solution_id ?? "").maybeSingle();
11340
+ if (job.project_id && solution) await unstartSolution(client, job.project_id, solution.ref, wasOf(job.params)).catch(() => void 0);
11341
+ return false;
11342
+ }
11343
+ async function resumePausedBuilds(client, daemonId, now = Date.now()) {
11344
+ const { data: parked } = await client.from("jobs").select("id").eq("skill", BUILD_SKILL).eq("status", "waiting").eq("assigned_daemon_id", daemonId);
11345
+ const ids = (parked ?? []).map((j) => j.id);
11346
+ if (!ids.length) return [];
11347
+ const { data: stops } = await client.from("job_events").select("job_id, kind, payload").in("job_id", ids).in("kind", ["paused", "question"]).order("id", { ascending: false });
11348
+ const last = /* @__PURE__ */ new Map();
11349
+ for (const e of stops ?? []) if (!last.has(e.job_id)) last.set(e.job_id, e);
11350
+ const due = ids.filter((id) => {
11351
+ const stop2 = last.get(id);
11352
+ return stop2?.kind === "paused" && Date.parse(String(stop2.payload?.resets_at ?? "")) <= now;
11353
+ });
11354
+ if (!due.length) return [];
11355
+ const { data } = await client.from("jobs").update({ status: "queued", assigned_daemon_id: null }).in("id", due).eq("status", "waiting").select("id");
11356
+ return (data ?? []).map((j) => j.id);
11357
+ }
11358
+ async function shippedBuilds(client, daemonId, userId) {
11359
+ const { data: building } = await client.from("solutions").select("id, ref, project_id").eq("status", "building");
11360
+ if (!building?.length) return [];
11361
+ const [{ data: built }, { data: clones }] = await Promise.all([
11362
+ client.from("jobs").select("id, params, pr:result->pr").eq("skill", BUILD_SKILL).eq("status", "succeeded").eq("assigned_daemon_id", daemonId).eq("requested_by", userId).in("params->>solution_id", building.map((s) => s.id)),
11363
+ client.from("clones").select("project_id, repo_path").eq("daemon_id", daemonId)
11364
+ ]);
11365
+ const cloneOf = new Map((clones ?? []).map((c) => [c.project_id, c.repo_path]));
11366
+ const shipped = [];
11367
+ for (const j of built ?? []) {
11368
+ const solution = building.find((s) => s.id === j.params?.solution_id);
11369
+ const number = j.pr?.number;
11370
+ const clone = solution ? cloneOf.get(solution.project_id) : void 0;
11371
+ if (!solution || typeof number !== "number" || number <= 0 || !clone || !existsSync5(clone) || !await pullRequestMerged(clone, number)) continue;
11372
+ const { error } = await client.rpc("finish_solution", { p_project_id: solution.project_id, p_ref: solution.ref, p_job_id: j.id });
11373
+ if (error) continue;
11374
+ await logActivity({ client }, { id: j.id, project_id: solution.project_id }, "shipped", `Pull request #${number} merged: ${solution.ref} shipped`);
11375
+ shipped.push(j.id);
11376
+ }
11377
+ return shipped;
11378
+ }
11379
+ async function tellTheApp(accessToken, jobId, fetchImpl = fetch) {
11380
+ try {
11381
+ const response = await fetchImpl(`${webUrl()}/api/build/news`, {
11382
+ method: "POST",
11383
+ headers: { "content-type": "application/json", authorization: `Bearer ${accessToken}` },
11384
+ body: JSON.stringify({ job_id: jobId })
11385
+ });
11386
+ const body = await response.json().catch(() => ({}));
11387
+ return response.ok ? body.said ?? "nothing to say" : `not said \u2014 ${body.error ?? `the app answered ${response.status}`}`;
11388
+ } catch (err) {
11389
+ return `not said \u2014 ${errorMessage(err)}`;
11390
+ }
11391
+ }
11392
+
11393
+ // ../daemon/src/review-preflight.ts
11394
+ async function reviewWithoutData(client, job) {
11395
+ if (job.skill !== "review" || !job.project_id) return null;
11396
+ if (job.trigger === "user") return null;
11397
+ const { data: connections } = await client.from("connections").select("id, status").eq("project_id", job.project_id).is("removed_at", null);
11398
+ const usable = (connections ?? []).filter((c) => c.status === "ok");
11399
+ if (usable.length) return null;
11400
+ const attached = (connections ?? []).length;
11401
+ const summary = attached ? `No review this time: ${attached} data source${attached === 1 ? " is" : "s are"} attached but none is working. Run \`nanopm connect --list\` to see what is wrong.` : "No review this time: nothing is connected, so there was nothing to read. Run `nanopm connect` to attach your database.";
11402
+ const { error } = await client.from("reviews").insert({
11403
+ project_id: job.project_id,
11404
+ summary,
11405
+ confirms: [],
11406
+ contradicts: [],
11407
+ job_id: job.id
11408
+ });
11409
+ if (error) return { status: "failed", error: { code: "harness_error", message: `could not record that there was nothing to read: ${error.message}` } };
11410
+ await client.from("activity_log").insert({
11411
+ project_id: job.project_id,
11412
+ job_id: job.id,
11413
+ actor: "daemon",
11414
+ step: "review.skipped",
11415
+ summary: attached ? "no working data source" : "nothing connected",
11416
+ outcome: "ok"
11417
+ });
11418
+ return { status: "succeeded", result: summary };
11419
+ }
11420
+
11190
11421
  // ../daemon/src/daemon.ts
11191
11422
  var SESSION_HEADROOM_MS = 10 * 60 * 1e3;
11192
11423
  var LONGEST_CAP_MS = 3 * 60 * 60 * 1e3;
@@ -11236,6 +11467,10 @@ async function runDaemon(options = {}) {
11236
11467
  const startedAt = Date.now();
11237
11468
  let lastDeployCheck = 0;
11238
11469
  const DEPLOY_CHECK_MS = 30 * 6e4;
11470
+ const PAUSE_CHECK_MS = 6e4;
11471
+ let lastPauseCheck = 0;
11472
+ const SHIP_CHECK_MS = 10 * 6e4;
11473
+ let lastShipCheck = 0;
11239
11474
  const UPDATE_CHECK_MS = 60 * 6e4;
11240
11475
  const updatable = !options.once && installKind() === "installer";
11241
11476
  let lastUpdateCheck = Date.now() - UPDATE_CHECK_MS + 5 * 6e4;
@@ -11287,12 +11522,23 @@ async function runDaemon(options = {}) {
11287
11522
  const digest = await sendDigestFor(auth.client, auth.accessToken, job.id);
11288
11523
  if (digest) log(`digest for job ${job.id}: ${digest.ok ? digest.detail : `not sent \u2014 ${digest.detail}`}`);
11289
11524
  }
11525
+ if (job.skill === BUILD_SKILL && (outcome.status === "waiting" || outcome.status === "succeeded")) {
11526
+ log(`build ${job.id}: ${await tellTheApp(auth.accessToken, job.id)}`);
11527
+ }
11290
11528
  await chainAfter(auth, job, outcome, log);
11291
11529
  processed += 1;
11292
11530
  if (options.once) break;
11293
11531
  continue;
11294
11532
  }
11295
11533
  if (options.once && Date.now() - startedAt > onceTimeoutMs) break;
11534
+ if (!options.once && Date.now() - lastPauseCheck > PAUSE_CHECK_MS) {
11535
+ lastPauseCheck = Date.now();
11536
+ for (const id of await resumePausedBuilds(auth.client, daemonId).catch(() => [])) log(`build ${id}: the Claude limit reset, carrying on`);
11537
+ }
11538
+ if (!options.once && Date.now() - lastShipCheck > SHIP_CHECK_MS) {
11539
+ lastShipCheck = Date.now();
11540
+ for (const id of await shippedBuilds(auth.client, daemonId, auth.userId).catch(() => [])) log(`build ${id}: its pull request merged, shipped`);
11541
+ }
11296
11542
  if (!options.once && Date.now() - lastDeployCheck > DEPLOY_CHECK_MS) {
11297
11543
  lastDeployCheck = Date.now();
11298
11544
  for (const check of await reviewDeploys(auth.client, daemonId).catch(() => [])) {
@@ -11421,7 +11667,7 @@ var heldInThisProcess = false;
11421
11667
  function acquireLock() {
11422
11668
  if (heldInThisProcess) throw new Error("a nanopm daemon is already running in this process");
11423
11669
  mkdirSync(nanopmHome(), { recursive: true, mode: 448 });
11424
- const path = join13(nanopmHome(), "daemon.pid");
11670
+ const path = join14(nanopmHome(), "daemon.pid");
11425
11671
  for (; ; ) {
11426
11672
  try {
11427
11673
  const fd = openSync(path, "wx");
@@ -11493,10 +11739,11 @@ async function claimNext(client, daemonId, skills) {
11493
11739
  return rows2[0] ?? null;
11494
11740
  }
11495
11741
  async function reclaimOwnJobs(client, daemonId) {
11496
- const { data: stale } = await client.from("jobs").select("id, project_id, skill").eq("assigned_daemon_id", daemonId).in("status", ["running", "waiting"]);
11742
+ const { data: stale } = await client.from("jobs").select("id, project_id, skill, status, attempt, params").eq("assigned_daemon_id", daemonId).in("status", ["running", "waiting"]);
11497
11743
  const ids = [];
11498
11744
  for (const job of stale ?? []) {
11499
11745
  await killOrphanedHarness(job.id);
11746
+ if (job.skill === BUILD_SKILL && (job.status === "waiting" || await requeueLostBuild(client, job))) continue;
11500
11747
  const error = { code: "interrupted", message: "the daemon stopped while this job was running" };
11501
11748
  await client.from("jobs").update({ status: "failed", finished_at: (/* @__PURE__ */ new Date()).toISOString(), error }).eq("id", job.id).in("status", ["running", "waiting"]);
11502
11749
  await client.from("job_events").insert({ job_id: job.id, seq: 0, kind: "status", payload: { status: "failed", error }, author_kind: "nanopm" });
@@ -11508,13 +11755,13 @@ async function reclaimOwnJobs(client, daemonId) {
11508
11755
  async function harnessPids(runDir) {
11509
11756
  const dirs = [runDir];
11510
11757
  try {
11511
- for (const d of await readdir3(runDir, { withFileTypes: true })) if (d.isDirectory()) dirs.push(join13(runDir, d.name));
11758
+ for (const d of await readdir3(runDir, { withFileTypes: true })) if (d.isDirectory()) dirs.push(join14(runDir, d.name));
11512
11759
  } catch {
11513
11760
  }
11514
11761
  const pids = [];
11515
11762
  for (const dir of dirs) {
11516
11763
  try {
11517
- const pid = Number((await readFile9(join13(dir, "harness.pid"), "utf8")).trim());
11764
+ const pid = Number((await readFile9(join14(dir, "harness.pid"), "utf8")).trim());
11518
11765
  if (pid > 1) pids.push(pid);
11519
11766
  } catch {
11520
11767
  }
@@ -11522,7 +11769,7 @@ async function harnessPids(runDir) {
11522
11769
  return pids;
11523
11770
  }
11524
11771
  async function killOrphanedHarness(jobId) {
11525
- const runDir = join13(nanopmHome(), "run", jobId);
11772
+ const runDir = join14(nanopmHome(), "run", jobId);
11526
11773
  for (const pid of await harnessPids(runDir)) {
11527
11774
  try {
11528
11775
  process.kill(pid, "SIGTERM");
@@ -11532,7 +11779,7 @@ async function killOrphanedHarness(jobId) {
11532
11779
  await rm5(runDir, { recursive: true, force: true });
11533
11780
  }
11534
11781
  async function registerDaemon(auth, version, opts = {}) {
11535
- const path = join13(nanopmHome(), "daemon.json");
11782
+ const path = join14(nanopmHome(), "daemon.json");
11536
11783
  let identity = null;
11537
11784
  try {
11538
11785
  identity = JSON.parse(await readFile9(path, "utf8"));
@@ -11574,7 +11821,7 @@ async function registerDaemon(auth, version, opts = {}) {
11574
11821
  }
11575
11822
  async function rememberDaemon(id, shared = false) {
11576
11823
  await mkdir6(nanopmHome(), { recursive: true, mode: 448 });
11577
- await writeFile6(join13(nanopmHome(), "daemon.json"), JSON.stringify({ id, shared }, null, 2) + "\n", { mode: 384 });
11824
+ await writeFile6(join14(nanopmHome(), "daemon.json"), JSON.stringify({ id, shared }, null, 2) + "\n", { mode: 384 });
11578
11825
  }
11579
11826
  async function heartbeat(client, daemonId) {
11580
11827
  await client.from("daemons").update({ status: "online", last_seen_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", daemonId);
@@ -11628,13 +11875,15 @@ function contextFromEnv(env = process.env) {
11628
11875
  userId: env.NANOPM_USER_ID,
11629
11876
  actor: env.NANOPM_ACTOR,
11630
11877
  askTimeoutMs: Number(env.NANOPM_ASK_TIMEOUT_MS ?? 3 * 60 * 60 * 1e3),
11631
- workspace: env.NANOPM_WORKSPACE || void 0
11878
+ workspace: env.NANOPM_WORKSPACE || void 0,
11879
+ answerFile: env.NANOPM_ANSWER_FILE || void 0
11632
11880
  };
11633
11881
  }
11634
11882
 
11635
11883
  // ../memory-mcp/src/server.ts
11636
11884
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11637
11885
  import { randomUUID as randomUUID13 } from "crypto";
11886
+ import { readFile as readFile10, unlink } from "fs/promises";
11638
11887
  import { setTimeout as sleep2 } from "timers/promises";
11639
11888
  import { z as z21 } from "zod";
11640
11889
 
@@ -12160,6 +12409,8 @@ ${renderMap()}`,
12160
12409
  }
12161
12410
  },
12162
12411
  async ({ question, options, why }, extra) => {
12412
+ const handed = ctx.answerFile ? await takeAnswer(ctx.answerFile) : null;
12413
+ if (handed !== null) return json({ answer: handed, skipped: false });
12163
12414
  const answer = await askUser(ctx, { question, options, why }, extra.signal);
12164
12415
  if (answer === null) return failure("No answer arrived in time, or the job was cancelled. Do not ask again; continue with what you have.");
12165
12416
  return json({ answer, skipped: /^\s*(skip|passe?r?)\s*$/i.test(answer) || answer.trim() === "" });
@@ -12724,6 +12975,12 @@ async function writeContext(ctx, items, refused = []) {
12724
12975
  await logActivity2(ctx, "memory.write", `wrote ${written.length} items under ${topics}: ${written.map((w) => w.key).join(", ")}${refusedNote}`);
12725
12976
  return { written };
12726
12977
  }
12978
+ async function takeAnswer(answerFile) {
12979
+ const taken = `${answerFile}.taken`;
12980
+ const answer = await readFile10(taken, "utf8").catch(() => null);
12981
+ if (answer !== null) await unlink(taken).catch(() => void 0);
12982
+ return answer;
12983
+ }
12727
12984
  async function askUser(ctx, q, signal) {
12728
12985
  const seq = await nextSeq(ctx);
12729
12986
  const { error } = await ctx.client.from("job_events").insert({ job_id: ctx.jobId, seq, kind: "question", payload: { question: q.question, options: q.options ?? null, why: q.why ?? null }, author_kind: "nanopm" });
@@ -13509,19 +13766,19 @@ async function startMemoryServer(env = process.env) {
13509
13766
  }
13510
13767
 
13511
13768
  // src/commands/add.ts
13512
- import { readFile as readFile11 } from "fs/promises";
13769
+ import { readFile as readFile12 } from "fs/promises";
13513
13770
 
13514
13771
  // src/project.ts
13515
13772
  import { execFile as execFile7 } from "child_process";
13516
- import { basename } from "path";
13773
+ import { basename as basename2 } from "path";
13517
13774
  import { promisify as promisify7 } from "util";
13518
13775
 
13519
13776
  // src/local.ts
13520
- import { readFile as readFile10 } from "fs/promises";
13521
- import { join as join14 } from "path";
13777
+ import { readFile as readFile11 } from "fs/promises";
13778
+ import { join as join15 } from "path";
13522
13779
  async function localDaemonId() {
13523
13780
  try {
13524
- const parsed = JSON.parse(await readFile10(join14(nanopmHome(), "daemon.json"), "utf8"));
13781
+ const parsed = JSON.parse(await readFile11(join15(nanopmHome(), "daemon.json"), "utf8"));
13525
13782
  return parsed.id ?? null;
13526
13783
  } catch {
13527
13784
  return null;
@@ -13546,7 +13803,7 @@ async function detectRepo(cwd) {
13546
13803
  } catch {
13547
13804
  remote = null;
13548
13805
  }
13549
- const name = remote ? remote.split("/").pop() : basename(root2);
13806
+ const name = remote ? remote.split("/").pop() : basename2(root2);
13550
13807
  return { root: root2, remote, name, empty: await repoIsEmpty(root2) };
13551
13808
  }
13552
13809
  async function repoIsEmpty(root2) {
@@ -13557,7 +13814,7 @@ async function repoIsEmpty(root2) {
13557
13814
  }
13558
13815
  const files = (await execFileAsync7("git", ["-C", root2, "ls-files"])).stdout.split("\n").filter(Boolean);
13559
13816
  return files.every((f) => {
13560
- const base = basename(f);
13817
+ const base = basename2(f);
13561
13818
  return base.startsWith(".") || /^(readme|licen[cs]e)(\.|$)/i.test(base);
13562
13819
  });
13563
13820
  }
@@ -13656,7 +13913,7 @@ async function add(file, opts) {
13656
13913
  text2 = await readStdin();
13657
13914
  } else {
13658
13915
  try {
13659
- text2 = await readFile11(file, "utf8");
13916
+ text2 = await readFile12(file, "utf8");
13660
13917
  } catch {
13661
13918
  throw new Error(`could not read ${file}`);
13662
13919
  }
@@ -13671,7 +13928,7 @@ async function add(file, opts) {
13671
13928
  }
13672
13929
 
13673
13930
  // src/commands/run.ts
13674
- import { readFile as readFile12 } from "fs/promises";
13931
+ import { readFile as readFile13 } from "fs/promises";
13675
13932
  import { createInterface as createInterface2 } from "readline/promises";
13676
13933
  import { setTimeout as sleep3 } from "timers/promises";
13677
13934
 
@@ -14003,7 +14260,7 @@ function questionBlock(p) {
14003
14260
 
14004
14261
  // src/commands/run.ts
14005
14262
  async function answersFromFile(path) {
14006
- const raw = JSON.parse(await readFile12(path, "utf8"));
14263
+ const raw = JSON.parse(await readFile13(path, "utf8"));
14007
14264
  const entries = raw.map((a) => typeof a === "string" ? { answer: a } : a);
14008
14265
  return scriptedAnswerer(entries);
14009
14266
  }
@@ -14083,23 +14340,24 @@ async function streamJob(client, jobId, opts) {
14083
14340
  let cancelSent = false;
14084
14341
  let cancelDeadline = Infinity;
14085
14342
  let finalStatus = null;
14086
- const answered = /* @__PURE__ */ new Set();
14343
+ const answered2 = /* @__PURE__ */ new Set();
14087
14344
  const shownAnswers = /* @__PURE__ */ new Set();
14088
14345
  for (; ; ) {
14089
14346
  const { data: events } = await client.from("job_events").select("id, seq, kind, payload, author_kind").eq("job_id", jobId).gt("id", lastId).order("id", { ascending: true });
14347
+ for (const ev of events ?? []) if (ev.kind === "answer") answered2.add(ev.seq);
14090
14348
  for (const ev of events ?? []) {
14091
14349
  lastId = Math.max(lastId, Number(ev.id));
14092
14350
  const payload = ev.payload ?? {};
14093
14351
  if (ev.kind === "answer" && shownAnswers.has(Number(ev.id))) continue;
14094
14352
  show(progress.feed({ kind: ev.kind, payload, author_kind: ev.author_kind }));
14095
- if (ev.kind === "question" && payload.about === "site" && !answered.has(ev.seq)) {
14096
- answered.add(ev.seq);
14353
+ if (ev.kind === "question" && payload.about === "site" && !answered2.has(ev.seq)) {
14354
+ answered2.add(ev.seq);
14097
14355
  renderer.settle(" (answer in the browser)");
14098
14356
  continue;
14099
14357
  }
14100
- if (ev.kind === "question" && opts.answerer && !cancelSent && !answered.has(ev.seq)) {
14101
- if (answered.size === 0 && opts.link) renderer.settle(` (answer here, or in the browser: ${opts.link})`);
14102
- answered.add(ev.seq);
14358
+ if (ev.kind === "question" && opts.answerer && !cancelSent && !answered2.has(ev.seq)) {
14359
+ if (answered2.size === 0 && opts.link) renderer.settle(` (answer here, or in the browser: ${opts.link})`);
14360
+ answered2.add(ev.seq);
14103
14361
  const q = payload;
14104
14362
  renderer.pause();
14105
14363
  const outcome = await answerOrWatch(client, jobId, ev.seq, q, opts.answerer, opts.cancelSignal, opts.pollMs);
@@ -14225,16 +14483,16 @@ async function askOne(auth, projectId, question, daemonId, opts, out) {
14225
14483
 
14226
14484
  // src/commands/daemon-service.ts
14227
14485
  import { execFile as execFile8 } from "child_process";
14228
- import { existsSync as existsSync5 } from "fs";
14229
- import { mkdir as mkdir7, readFile as readFile13, rm as rm6, writeFile as writeFile7 } from "fs/promises";
14486
+ import { existsSync as existsSync6 } from "fs";
14487
+ import { mkdir as mkdir7, readFile as readFile14, rm as rm6, writeFile as writeFile7 } from "fs/promises";
14230
14488
  import { homedir as homedir5 } from "os";
14231
- import { join as join15 } from "path";
14489
+ import { join as join16 } from "path";
14232
14490
  import { promisify as promisify8 } from "util";
14233
14491
 
14234
14492
  // package.json
14235
14493
  var package_default = {
14236
14494
  name: "@nanopm/cli",
14237
- version: "0.1.11",
14495
+ version: "0.1.13",
14238
14496
  description: "An autonomous product manager for builders who run several small products. Runs on your machine, drives your own coding harness.",
14239
14497
  type: "module",
14240
14498
  license: "MIT",
@@ -14286,10 +14544,10 @@ var VERSION = package_default.version;
14286
14544
  var execFileAsync8 = promisify8(execFile8);
14287
14545
  var LAUNCHD_LABEL = "pm.nano.daemon";
14288
14546
  function plistPath() {
14289
- return join15(homedir5(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
14547
+ return join16(homedir5(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
14290
14548
  }
14291
14549
  function daemonLogPath() {
14292
- return join15(nanopmHome(), "daemon.log");
14550
+ return join16(nanopmHome(), "daemon.log");
14293
14551
  }
14294
14552
  function renderPlist(opts) {
14295
14553
  const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -14349,8 +14607,8 @@ async function installDaemon(out = (l) => console.log(l), opts = {}) {
14349
14607
  const bin = process.argv[1];
14350
14608
  if (!bin) throw new Error("cannot locate the nanopm binary");
14351
14609
  const home = homedir5();
14352
- const path = [process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin", join15(home, ".local", "bin")].join(":");
14353
- await mkdir7(join15(home, "Library", "LaunchAgents"), { recursive: true });
14610
+ const path = [process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin", join16(home, ".local", "bin")].join(":");
14611
+ await mkdir7(join16(home, "Library", "LaunchAgents"), { recursive: true });
14354
14612
  await mkdir7(nanopmHome(), { recursive: true, mode: 448 });
14355
14613
  if (opts.shared !== void 0) await rememberShared(opts.shared);
14356
14614
  await found({ ...detection, authenticated: true });
@@ -14367,7 +14625,7 @@ function guiTarget() {
14367
14625
  return `gui/${process.getuid?.() ?? 501}`;
14368
14626
  }
14369
14627
  async function restartDaemon() {
14370
- if (!existsSync5(plistPath())) return false;
14628
+ if (!existsSync6(plistPath())) return false;
14371
14629
  await execFileAsync8("launchctl", ["kickstart", "-k", `${guiTarget()}/${LAUNCHD_LABEL}`]);
14372
14630
  return true;
14373
14631
  }
@@ -14380,7 +14638,7 @@ async function uninstallDaemon(out = (l) => console.log(l)) {
14380
14638
  }
14381
14639
  async function daemonStatus(out = (l) => console.log(l)) {
14382
14640
  const id = await localDaemonId();
14383
- const installed = process.platform === "darwin" && existsSync5(plistPath());
14641
+ const installed = process.platform === "darwin" && existsSync6(plistPath());
14384
14642
  out(`launchd: ${installed ? "installed" : "not installed"}`);
14385
14643
  if (!id) {
14386
14644
  out("daemon: never registered on this machine");
@@ -14402,7 +14660,7 @@ async function daemonStatus(out = (l) => console.log(l)) {
14402
14660
  }
14403
14661
  async function daemonLogs(lines = 50) {
14404
14662
  try {
14405
- const text2 = await readFile13(daemonLogPath(), "utf8");
14663
+ const text2 = await readFile14(daemonLogPath(), "utf8");
14406
14664
  return text2.trimEnd().split("\n").slice(-lines).join("\n");
14407
14665
  } catch {
14408
14666
  return `no log at ${daemonLogPath()} yet`;
@@ -14410,17 +14668,17 @@ async function daemonLogs(lines = 50) {
14410
14668
  }
14411
14669
  async function localWebSearch() {
14412
14670
  try {
14413
- const parsed = JSON.parse(await readFile13(join15(nanopmHome(), "daemon.json"), "utf8"));
14671
+ const parsed = JSON.parse(await readFile14(join16(nanopmHome(), "daemon.json"), "utf8"));
14414
14672
  return parsed.webSearch === true;
14415
14673
  } catch {
14416
14674
  return false;
14417
14675
  }
14418
14676
  }
14419
14677
  async function rememberWebSearch(webSearch2) {
14420
- const path = join15(nanopmHome(), "daemon.json");
14678
+ const path = join16(nanopmHome(), "daemon.json");
14421
14679
  let parsed = {};
14422
14680
  try {
14423
- parsed = JSON.parse(await readFile13(path, "utf8"));
14681
+ parsed = JSON.parse(await readFile14(path, "utf8"));
14424
14682
  } catch {
14425
14683
  parsed = {};
14426
14684
  }
@@ -14429,17 +14687,17 @@ async function rememberWebSearch(webSearch2) {
14429
14687
  }
14430
14688
  async function localShared() {
14431
14689
  try {
14432
- const parsed = JSON.parse(await readFile13(join15(nanopmHome(), "daemon.json"), "utf8"));
14690
+ const parsed = JSON.parse(await readFile14(join16(nanopmHome(), "daemon.json"), "utf8"));
14433
14691
  return Boolean(parsed.shared);
14434
14692
  } catch {
14435
14693
  return false;
14436
14694
  }
14437
14695
  }
14438
14696
  async function rememberShared(shared) {
14439
- const path = join15(nanopmHome(), "daemon.json");
14697
+ const path = join16(nanopmHome(), "daemon.json");
14440
14698
  let parsed = {};
14441
14699
  try {
14442
- parsed = JSON.parse(await readFile13(path, "utf8"));
14700
+ parsed = JSON.parse(await readFile14(path, "utf8"));
14443
14701
  } catch {
14444
14702
  parsed = {};
14445
14703
  }
@@ -14751,6 +15009,12 @@ async function verifyCommand(command, opts) {
14751
15009
  return [`${project.name} will verify with:`, "", ` ${next}`, "", "A build runs it in its sandboxed copy of the repository before its last step."].join("\n");
14752
15010
  }
14753
15011
 
15012
+ // src/commands/build.ts
15013
+ import { execFile as execFile10 } from "child_process";
15014
+ import { existsSync as existsSync7 } from "fs";
15015
+ import { homedir as homedir6 } from "os";
15016
+ import { promisify as promisify9 } from "util";
15017
+
14754
15018
  // src/format/opportunities.ts
14755
15019
  function renderOpportunities(rows2, lens, opts) {
14756
15020
  const paint = painter(opts.color);
@@ -15625,35 +15889,64 @@ function normalizeRef(input, items) {
15625
15889
  }
15626
15890
 
15627
15891
  // src/commands/build.ts
15892
+ var execFileAsync9 = promisify9(execFile10);
15628
15893
  async function build(ref, opts) {
15629
15894
  const { renderer, out } = rendererFor(opts.out);
15630
15895
  const auth = await authenticate();
15631
15896
  const project = await resolveProject(auth.client, { projectId: opts.projectId, cwd: opts.cwd });
15632
15897
  const repo = await detectRepo(opts.cwd);
15633
15898
  if (!repo) throw new Error("run this inside the repository, so NanoPM builds from the clone you are looking at");
15634
- const [daemon, local, { data: refs }] = await Promise.all([
15635
- awakeDaemon(auth.client, auth.userId),
15636
- localDaemonId(),
15637
- auth.client.from("solutions").select("ref").eq("project_id", project.id).is("superseded_by", null)
15638
- ]);
15899
+ const { data: solutions } = await auth.client.from("solutions").select("id, ref, title, status").eq("project_id", project.id).is("superseded_by", null);
15900
+ const wanted = normalizeRef(ref, solutions ?? []);
15901
+ const solution = (solutions ?? []).find((s) => s.ref === wanted);
15902
+ if (!solution) throw new Error(`No solution ${wanted} on the Roadmap.`);
15903
+ const stream = async (jobId2) => {
15904
+ out(`${solution.ref} ${solution.title}`);
15905
+ const answerer = opts.answersFile ? await answersFromFile(opts.answersFile) : answersFromTerminal(out);
15906
+ return streamJob(auth.client, jobId2, { pollMs: 700, out, renderer, cancelSignal: opts.cancelSignal, answerer, verbose: verboseWanted() });
15907
+ };
15908
+ const live = await buildOf(auth.client, project.id, [...ACTIVE_STATUSES]);
15909
+ if (live) {
15910
+ if (live.solutionId !== solution.id) throw new Error(`${project.name} already has a build on its way: one at a time. \`nanopm build\` on that solution comes back to it.`);
15911
+ if (opts.throwAway) throw new Error(`${solution.ref}'s build is still on its way: stop it first (\`nanopm stop\`).`);
15912
+ return stream(live.id);
15913
+ }
15914
+ const stopped = opts.continue || opts.throwAway ? await buildOf(auth.client, project.id, ["failed", "cancelled"], solution.id) : null;
15915
+ if ((opts.continue || opts.throwAway) && !stopped) throw new Error(`${solution.ref} has no stopped build to ${opts.continue ? "continue" : "throw away"}.`);
15916
+ if (opts.throwAway) return throwAway(auth.client, stopped.origin, repo.root, out);
15917
+ const [daemon, local] = await Promise.all([awakeDaemon(auth.client, auth.userId), localDaemonId()]);
15639
15918
  if (!local || daemon?.id !== local) throw new Error("This machine\u2019s daemon is asleep, so nothing was queued. `nanopm daemon install` starts it; then build again.");
15640
15919
  await rememberClone(auth.client, project.id, repo.root);
15641
- const { jobId, solution } = await queueBuild(auth.client, {
15920
+ const { jobId } = await queueBuild(auth.client, {
15642
15921
  projectId: project.id,
15643
15922
  userId: auth.userId,
15644
15923
  daemonId: local,
15645
- ref: normalizeRef(ref, refs ?? []),
15924
+ ref: solution.ref,
15646
15925
  repoPath: repo.root,
15647
- anyway: opts.anyway
15926
+ anyway: opts.anyway,
15927
+ ...stopped ? { resumeOf: { id: stopped.id, was: stopped.was, origin: stopped.origin } } : {}
15648
15928
  });
15649
- out(`${solution.ref} ${solution.title}`);
15650
- const answerer = opts.answersFile ? await answersFromFile(opts.answersFile) : answersFromTerminal(out);
15651
- return streamJob(auth.client, jobId, { pollMs: 700, out, renderer, cancelSignal: opts.cancelSignal, answerer, verbose: verboseWanted() });
15929
+ return stream(jobId);
15930
+ }
15931
+ async function buildOf(client, projectId, statuses, solutionId) {
15932
+ let query = client.from("jobs").select("id, params").eq("project_id", projectId).eq("skill", BUILD_SKILL).in("status", statuses);
15933
+ if (solutionId) query = query.eq("params->>solution_id", solutionId);
15934
+ const { data } = await query.order("created_at", { ascending: false }).limit(1).maybeSingle();
15935
+ const params = data?.params ?? {};
15936
+ return data ? { id: data.id, solutionId: params.solution_id ?? "", was: wasOf(data.params), origin: params.origin ?? data.id } : null;
15937
+ }
15938
+ async function throwAway(client, jobId, clone, out) {
15939
+ const { place } = await loadBuildHistory(client, [jobId], { home: homedir6(), jobId });
15940
+ if (!place) throw new Error("that build left nothing on this machine.");
15941
+ if (existsSync7(place.dir)) await execFileAsync9("git", ["-C", clone, "worktree", "remove", "--force", place.dir]);
15942
+ await execFileAsync9("git", ["-C", clone, "branch", "-D", place.branch]).catch(() => void 0);
15943
+ out(`Thrown away: the copy of the code and the local branch ${place.branch}.`);
15944
+ return 0;
15652
15945
  }
15653
15946
 
15654
15947
  // src/detect-sources.ts
15655
- import { readFile as readFile14 } from "fs/promises";
15656
- import { join as join16 } from "path";
15948
+ import { readFile as readFile15 } from "fs/promises";
15949
+ import { join as join17 } from "path";
15657
15950
  var ENV_FILES = [".env", ".env.local", ".env.development.local", ".env.development", ".env.example"];
15658
15951
  var POSTGRES_KEYS = [
15659
15952
  "DATABASE_URL",
@@ -15703,7 +15996,7 @@ async function envFiles(repoRoot) {
15703
15996
  const out = [];
15704
15997
  for (const file of ENV_FILES) {
15705
15998
  try {
15706
- out.push({ file, env: parseEnv(await readFile14(join16(repoRoot, file), "utf8")) });
15999
+ out.push({ file, env: parseEnv(await readFile15(join17(repoRoot, file), "utf8")) });
15707
16000
  } catch {
15708
16001
  continue;
15709
16002
  }
@@ -15915,13 +16208,13 @@ async function spec(ref, opts) {
15915
16208
  const auth = await authenticate();
15916
16209
  const project = await resolveProject(auth.client, { projectId: opts.projectId, cwd: opts.cwd });
15917
16210
  const wanted = ref.trim().toUpperCase();
15918
- const { data: solution } = await auth.client.from("solutions").select("id, ref, title, agent_spec").eq("project_id", project.id).eq("ref", wanted).maybeSingle();
16211
+ const { data: solution } = await auth.client.from("solutions").select("id, ref, title, agent_spec, spec").eq("project_id", project.id).eq("ref", wanted).maybeSingle();
15919
16212
  if (!solution) throw new Error(`no solution ${wanted}. Run \`nanopm roadmap\` to see them.`);
15920
16213
  if (opts.write && opts.rework) throw new Error("--write or --rework, not both.");
15921
16214
  const mode = opts.rework ? "rework" : opts.write ? "write" : null;
15922
16215
  if (!mode) {
15923
16216
  if (!solution.agent_spec) return `${solution.ref} has no specs yet: \`nanopm spec ${solution.ref} --write\`, or *Write the specs* on its sheet.`;
15924
- return solution.agent_spec.trimEnd();
16217
+ return specForAgent(solution.agent_spec, solution.spec).trimEnd();
15925
16218
  }
15926
16219
  const daemon = await awakeDaemon(auth.client, auth.userId);
15927
16220
  if (!daemon) throw new Error("Your machine is asleep: none of your daemons has been seen in the last two minutes, so nothing was queued. `nanopm daemon install` starts it; then ask again.");
@@ -16472,7 +16765,7 @@ async function seedWatches(watches2, read6) {
16472
16765
  }
16473
16766
 
16474
16767
  // src/commands/watch.ts
16475
- import { readFile as readFile15 } from "fs/promises";
16768
+ import { readFile as readFile16 } from "fs/promises";
16476
16769
  function cursorFor(source, watchId) {
16477
16770
  const all = source.cursor ?? {};
16478
16771
  return all[watchId] ?? {};
@@ -16533,7 +16826,7 @@ async function addWatch(labelOrId, id, opts) {
16533
16826
  const paint = painter(color);
16534
16827
  if (!labelOrId || !id) throw new Error('say which feed and what to call it: nanopm watch add <source> <id> --query "\u2026"');
16535
16828
  if (opts.query && opts.queryFile) throw new Error("--query or --query-file, not both");
16536
- const query = opts.queryFile ? await readFile15(opts.queryFile, "utf8") : opts.query ?? "";
16829
+ const query = opts.queryFile ? await readFile16(opts.queryFile, "utf8") : opts.query ?? "";
16537
16830
  const watch = watchInput(id, query, opts);
16538
16831
  const auth = await authenticate();
16539
16832
  const project = await resolveProject(auth.client, { projectId: opts.projectId, cwd: opts.cwd });
@@ -16807,7 +17100,7 @@ Review this suggestion on the Problems page.`);
16807
17100
  async function problemList(client, projectId, color) {
16808
17101
  const list = await problemRows(client, projectId);
16809
17102
  const { count: manualMerges } = await client.from("decisions").select("id", { count: "exact", head: true }).eq("project_id", projectId).eq("kind", "merge");
16810
- return renderProblems(list, await openQuestions(client, projectId, list), {
17103
+ return renderProblems(list, await openQuestions2(client, projectId, list), {
16811
17104
  color,
16812
17105
  manualMerges: manualMerges ?? 0
16813
17106
  });
@@ -16842,7 +17135,7 @@ async function keptSeparate(client, projectId) {
16842
17135
  return a && b ? [a, b].sort().join("|") : null;
16843
17136
  }).filter((k) => Boolean(k));
16844
17137
  }
16845
- async function openQuestions(client, projectId, list) {
17138
+ async function openQuestions2(client, projectId, list) {
16846
17139
  const { data } = await client.from("questions").select("refs, problems!questions_problem_id_fkey(ref)").eq("project_id", projectId).eq("kind", "same").is("answered_at", null).order("created_at", { ascending: false });
16847
17140
  const open = new Set(list.map((p) => p.ref));
16848
17141
  const pairs = [];
@@ -17466,7 +17759,7 @@ async function invite(email, opts) {
17466
17759
  const [, ...rest] = inviteLines({ name: project.name, code, email: email.trim().toLowerCase(), web: webUrl() });
17467
17760
  return [`Invite to ${paint(ANSI.bold, project.name)}: ${paint(ANSI.bold, code)}`, ...rest.map((l) => l ? paint(ANSI.dim, l) : l)].join("\n");
17468
17761
  }
17469
- async function join17(code, opts) {
17762
+ async function join18(code, opts) {
17470
17763
  const normalized2 = code.trim().toUpperCase();
17471
17764
  if (!INVITE_CODE.test(normalized2)) throw new Error(`that does not look like an invite code: ten letters and digits, like ${"K7PQ2XM9RT"}`);
17472
17765
  const repo = await detectRepo(opts.cwd);
@@ -17751,7 +18044,7 @@ async function showSchedule(opts) {
17751
18044
  }
17752
18045
 
17753
18046
  // src/commands/status.ts
17754
- import { existsSync as existsSync6 } from "fs";
18047
+ import { existsSync as existsSync8 } from "fs";
17755
18048
  async function status(opts) {
17756
18049
  const auth = await authenticate();
17757
18050
  const color = opts.color ?? useColor();
@@ -17804,7 +18097,7 @@ async function projectStatus(client, project) {
17804
18097
  openProblems: (problems2.data ?? []).length,
17805
18098
  unprovenProblems: (problems2.data ?? []).filter((p) => p.importance_basis === "judgment").length,
17806
18099
  search: describeSearch(search, await localWebSearch()),
17807
- onThisMachine: Boolean(project.repoPath && existsSync6(project.repoPath)),
18100
+ onThisMachine: Boolean(project.repoPath && existsSync8(project.repoPath)),
17808
18101
  measures: measures({
17809
18102
  solutionStatuses: (roadmap2.data ?? []).map((r) => r.status),
17810
18103
  decisions: decisions2.data ?? [],
@@ -18167,7 +18460,7 @@ function buildProgram() {
18167
18460
  console.log(await invite(email, { cwd: process.cwd(), projectId: opts.project, color: colorOf(opts) }));
18168
18461
  });
18169
18462
  program.command("join <code>").description("Join a project you were invited to, from inside your clone of its repository").option("--no-color", "plain output").action(async (code, opts) => {
18170
- console.log(await join17(code, { cwd: process.cwd(), color: colorOf(opts) }));
18463
+ console.log(await join18(code, { cwd: process.cwd(), color: colorOf(opts) }));
18171
18464
  });
18172
18465
  program.command("members").description("Who is on this project, and whether their daemon is awake").option("--project <id>", "project id (default: the project of the current repo)").option("--no-color", "plain output").action(async (opts) => {
18173
18466
  console.log(await members({ cwd: process.cwd(), projectId: opts.project, color: colorOf(opts) }));
@@ -18189,10 +18482,11 @@ function buildProgram() {
18189
18482
  program.command("status").description("What is happening with this product: next review, what is waiting on you, whether the daemon is awake").option("--project <id>", "project id (default: the project of the current repo)").option("--no-color", "plain output").action(async (opts) => {
18190
18483
  console.log(await status({ cwd: process.cwd(), projectId: opts.project, color: colorOf(opts) }));
18191
18484
  });
18192
- program.command("build <ref>").description("Build a solution from its agent spec, in a copy of this repository, to a draft pull request").option("--project <id>", "project id (default: the project of the current repo)").option("--anyway", "build although its questions for you are not answered").option("--answers <file>", "answer its questions from a file instead of the terminal").action(async (ref, opts) => {
18485
+ program.command("build <ref>").description("Build a solution from its agent spec, in a copy of this repository, to a draft pull request").option("--project <id>", "project id (default: the project of the current repo)").option("--anyway", "build although its questions for you are not answered").option("--continue", "take up a stopped build where it was").option("--throw-away", "delete a stopped build's copy of the code and its local branch").option("--answers <file>", "answer its questions from a file instead of the terminal").action(async (ref, opts) => {
18193
18486
  const cancel = new AbortController();
18194
18487
  process.once("SIGINT", () => cancel.abort());
18195
- process.exitCode = await build(ref, { cwd: process.cwd(), projectId: opts.project, anyway: opts.anyway, answersFile: opts.answers, cancelSignal: cancel.signal });
18488
+ if (opts.continue && opts.throwAway) throw new Error("--continue or --throw-away, not both.");
18489
+ process.exitCode = await build(ref, { cwd: process.cwd(), projectId: opts.project, anyway: opts.anyway, continue: opts.continue, throwAway: opts.throwAway, answersFile: opts.answers, cancelSignal: cancel.signal });
18196
18490
  });
18197
18491
  program.command("autonomy [level]").description("How far NanoPM may go on its own: read it, or set it").option("--project <id>", "project id (default: the project of the current repo)").action(async (level, opts) => {
18198
18492
  console.log(await autonomy(level, { cwd: process.cwd(), projectId: opts.project }));