@nanopm/cli 0.1.10 → 0.1.12

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
@@ -156,7 +156,8 @@ var NEEDS_GO_AHEAD = /* @__PURE__ */ new Set(["execute_after_validation"]);
156
156
  function requiresGoAhead(autonomy2) {
157
157
  return NEEDS_GO_AHEAD.has(autonomy2);
158
158
  }
159
- function capCapabilities(requested, autonomy2) {
159
+ function capCapabilities(requested, autonomy2, opts = {}) {
160
+ if (opts.asked) return [...requested];
160
161
  const denied = CAPPED_BELOW[autonomy2];
161
162
  const granted = requested.filter((c) => !denied.has(c));
162
163
  if (granted.includes("write_repo") && requiresGoAhead(autonomy2) && !granted.includes("ask_user")) {
@@ -387,6 +388,12 @@ var SkillManifestSchema = z2.object({
387
388
  },
388
389
  { message: "timeout_deep must look like 3h, 5m, 90s" }
389
390
  ).optional(),
391
+ /**
392
+ * Run only when the founder asks for it on one item, by a click or a command, never by Nano on
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-76-build-it.md, settled 2).
395
+ */
396
+ asked: z2.boolean().default(false),
390
397
  /** The first user message sent to the harness, unless the job params provide one. */
391
398
  prompt: z2.string().min(1),
392
399
  /**
@@ -628,14 +635,6 @@ var SECRET_PATTERNS = [
628
635
  "**/credentials.json",
629
636
  "**/service-account*.json"
630
637
  ];
631
- function looksLikeASecret(path) {
632
- const base = path.replace(/\\/g, "/").split("/").pop() ?? "";
633
- return SECRET_PATTERNS.some((pattern) => {
634
- const glob = pattern.replace(/^\*\*\//, "");
635
- const re = new RegExp(`^${glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")}$`);
636
- return re.test(base);
637
- });
638
- }
639
638
 
640
639
  // ../shared/src/stream.ts
641
640
  function formatStreamEvent(event) {
@@ -674,6 +673,18 @@ function formatStreamEvent(event) {
674
673
  return "\u2298 cancel requested";
675
674
  case "error":
676
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 ?? "")}`;
677
688
  default:
678
689
  return null;
679
690
  }
@@ -944,7 +955,7 @@ var AFTER_DEEP = {
944
955
  function daemonQueues(skill, depth = 0, parent = null, deep = false) {
945
956
  if (deep) return depth >= CHAIN_DEPTH_MAX ? [] : AFTER_DEEP[skill] ?? [];
946
957
  const theirs = new Set(SQL_OWNED[skill] ?? []);
947
- return chainedAfter(skill, depth, parent).filter((next2) => !theirs.has(next2));
958
+ return chainedAfter(skill, depth, parent).filter((next) => !theirs.has(next));
948
959
  }
949
960
  var NOT_CHAINED_BY_DAEMON = ["ingest"];
950
961
  var CHAIN_DEPTH_MAX = 3;
@@ -954,7 +965,7 @@ function chainedAfter(skill, depth = 0, parent = null) {
954
965
  const gates = ONLY_AFTER[skill] ?? {};
955
966
  const bars2 = NEVER_AFTER[skill] ?? {};
956
967
  return (CHAINED[skill] ?? []).filter(
957
- (next2) => (!gates[next2] || parent !== null && gates[next2].includes(parent)) && !(parent !== null && bars2[next2]?.includes(parent))
968
+ (next) => (!gates[next] || parent !== null && gates[next].includes(parent)) && !(parent !== null && bars2[next]?.includes(parent))
958
969
  );
959
970
  }
960
971
  var CHAINED_SKILLS = [...new Set(Object.values(CHAINED).flatMap((s) => s ?? []))];
@@ -974,7 +985,7 @@ var SKILL_SENTENCE = {
974
985
  direction: "working out where this is going",
975
986
  solutions: "proposing options",
976
987
  review: "reviewing",
977
- next: "building",
988
+ build: "building",
978
989
  ask: "answering a question",
979
990
  want: "shaping what you want",
980
991
  ingest: "watching the numbers",
@@ -982,22 +993,22 @@ var SKILL_SENTENCE = {
982
993
  spec: "writing specs"
983
994
  };
984
995
  function skillSentence(skill, opts = {}) {
985
- if (skill === "next") return opts.ref ? `building ${opts.ref}` : "building an option";
996
+ if (skill === "build") return opts.ref ? `building ${opts.ref}` : "building an option";
986
997
  return SKILL_SENTENCE[skill] ?? `running ${skill}`;
987
998
  }
988
999
 
989
1000
  // ../shared/src/time.ts
990
1001
  var DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
991
1002
  function describeNextRun(schedule, now = /* @__PURE__ */ new Date(), opts = {}) {
992
- const next2 = new Date(schedule.next_due_at);
1003
+ const next = new Date(schedule.next_due_at);
993
1004
  const local = new Intl.DateTimeFormat("en-GB", {
994
1005
  timeZone: schedule.timezone,
995
1006
  weekday: "long",
996
1007
  hour: "2-digit",
997
1008
  minute: "2-digit",
998
1009
  hour12: false
999
- }).format(next2);
1000
- const hours = Math.round((next2.getTime() - now.getTime()) / 36e5);
1010
+ }).format(next);
1011
+ const hours = Math.round((next.getTime() - now.getTime()) / 36e5);
1001
1012
  const when = hours < 1 ? "any minute" : hours < 36 ? `in ${hours}h` : `in ${Math.round(hours / 24)} days`;
1002
1013
  return opts.timezone === false ? `${local}, ${when}` : `${local} ${schedule.timezone}, ${when}`;
1003
1014
  }
@@ -2018,10 +2029,10 @@ function placeRanks(crew, pinned) {
2018
2029
  while (slots[at] !== null && at > 0) at -= 1;
2019
2030
  slots[at] = id;
2020
2031
  }
2021
- let next2 = 0;
2032
+ let next = 0;
2022
2033
  for (let i = 0; i < total; i += 1) {
2023
2034
  if (slots[i] !== null) continue;
2024
- slots[i] = loose[next2++] ?? null;
2035
+ slots[i] = loose[next++] ?? null;
2025
2036
  }
2026
2037
  const out = /* @__PURE__ */ new Map();
2027
2038
  slots.forEach((id, i) => id && out.set(id, i + 1));
@@ -2672,7 +2683,7 @@ import { z as z9 } from "zod";
2672
2683
  var PITCH_LINE_KEYS = ["what", "who", "need"];
2673
2684
  var PITCH_TOPIC = "identity";
2674
2685
  var PITCH_KEY = /^pitch-/;
2675
- var pitchKey = (line, next2 = false) => `pitch-${line}${next2 ? "-next" : ""}`;
2686
+ var pitchKey = (line, next = false) => `pitch-${line}${next ? "-next" : ""}`;
2676
2687
  var PITCH_LINE_TITLE = { what: "What it is", who: "Who it is for", need: "What they come for" };
2677
2688
 
2678
2689
  // ../shared/src/pitch-crew.ts
@@ -2724,7 +2735,7 @@ var DIRECTION_ROWS = ["mission", "vision"];
2724
2735
  var MISSION_TOPIC = "mission";
2725
2736
  var VISION_TOPIC = "vision";
2726
2737
  var DIRECTION_KEY = /^(?:mission|vision)(?:-next)?$/;
2727
- var directionKey = (row, next2 = false) => `${row}${next2 ? "-next" : ""}`;
2738
+ var directionKey = (row, next = false) => `${row}${next ? "-next" : ""}`;
2728
2739
  var DIRECTION_ROW_TITLE = { mission: "Mission", vision: "Vision" };
2729
2740
 
2730
2741
  // ../shared/src/direction-crew.ts
@@ -3483,7 +3494,7 @@ function foldRound(r) {
3483
3494
  }
3484
3495
  }
3485
3496
  const answer = TalkAnswer.safeParse(r.result);
3486
- const failed = r.status === "failed" || r.status === "cancelled";
3497
+ const failed2 = r.status === "failed" || r.status === "cancelled";
3487
3498
  const error = r.error ?? {};
3488
3499
  return {
3489
3500
  id: r.id,
@@ -3494,8 +3505,8 @@ function foldRound(r) {
3494
3505
  depth: params.data.depth,
3495
3506
  actions: [...actions.values()],
3496
3507
  answer: answer.success ? answer.data : null,
3497
- state: r.status === "succeeded" ? "done" : failed ? "failed" : "working",
3498
- ...failed ? { error: error.code === "timeout" ? "That took too long. Send it again?" : r.status === "cancelled" ? "Stopped." : `I couldn\u2019t answer${error.message ? `: ${clip(error.message, 140)}` : "."}` } : {},
3508
+ state: r.status === "succeeded" ? "done" : failed2 ? "failed" : "working",
3509
+ ...failed2 ? { error: error.code === "timeout" ? "That took too long. Send it again?" : r.status === "cancelled" ? "Stopped." : `I couldn\u2019t answer${error.message ? `: ${clip(error.message, 140)}` : "."}` } : {},
3499
3510
  ...doing ? { doing } : {}
3500
3511
  };
3501
3512
  }
@@ -3522,7 +3533,7 @@ var PROBLEM_TOOLS = ["propose_problems", "add_evidence", "flag_duplicate", "file
3522
3533
  var READ_WEB_TOOLS = ["web_search", "web_fetch"];
3523
3534
  var STORE_TOOLS = ["app_store_search", "play_store_search"];
3524
3535
  var MARKET_TOOLS = ["write_market"];
3525
- var WRITE_REPO_TOOLS = ["remove_files"];
3536
+ var BUILD_TOOLS = ["build_plan", "build_step"];
3526
3537
  var ALL_MEMORY_TOOLS = [
3527
3538
  ...MEMORY_READ_TOOLS,
3528
3539
  ...MEMORY_WRITE_TOOLS,
@@ -3532,7 +3543,7 @@ var ALL_MEMORY_TOOLS = [
3532
3543
  ...PROBLEM_TOOLS,
3533
3544
  ...READ_WEB_TOOLS,
3534
3545
  ...MARKET_TOOLS,
3535
- ...WRITE_REPO_TOOLS,
3546
+ ...BUILD_TOOLS,
3536
3547
  ...TALK_TOOLS,
3537
3548
  "last_review",
3538
3549
  "list_problems",
@@ -3574,7 +3585,7 @@ function claudeCodeToolFlags(capabilities, opts = {}) {
3574
3585
  tools.add(t);
3575
3586
  allowed.add(`${t}(${pathRule(workspace)})`);
3576
3587
  }
3577
- for (const t of WRITE_REPO_TOOLS) allowed.add(mcpTool(t));
3588
+ for (const t of BUILD_TOOLS) allowed.add(mcpTool(t));
3578
3589
  } else {
3579
3590
  for (const t of WRITE_TOOLS) disallowed.add(t);
3580
3591
  }
@@ -3632,7 +3643,7 @@ function claudeCodeToolFlags(capabilities, opts = {}) {
3632
3643
  allowed.delete(t);
3633
3644
  disallowed.add(t);
3634
3645
  }
3635
- return { tools: [...tools], allowedTools: [...allowed], disallowedTools: [...disallowed] };
3646
+ return { tools: [...tools], allowedTools: [...allowed], disallowedTools: [...disallowed], permissionMode: tools.has("Bash") ? "auto" : "default" };
3636
3647
  }
3637
3648
 
3638
3649
  // ../daemon/src/adapters/claude-code/sandbox.ts
@@ -3674,12 +3685,26 @@ function buildSandboxSettings(home) {
3674
3685
  }
3675
3686
  return { permissions: { deny, defaultMode: "default" } };
3676
3687
  }
3677
- function sandboxSettingsFor(home, grantedTools) {
3688
+ function sandboxSettingsFor(home, grantedTools, network = [], answerFile) {
3678
3689
  const settings = buildSandboxSettings(home);
3679
3690
  const granted = new Set(grantedTools);
3680
3691
  settings.permissions.deny = settings.permissions.deny.filter((rule) => !granted.has(rule));
3692
+ if (granted.has("Bash")) {
3693
+ settings.sandbox = {
3694
+ enabled: true,
3695
+ allowUnsandboxedCommands: false,
3696
+ failIfUnavailable: true,
3697
+ network: { allowedDomains: network.filter((h) => !/(^|\.)github(usercontent)?\.com$/.test(h)) }
3698
+ };
3699
+ }
3700
+ if (answerFile) settings.hooks = { PreToolUse: [{ matcher: "mcp__nanopm__ask_user", hooks: [{ type: "command", command: askHook(answerFile) }] }] };
3681
3701
  return settings;
3682
3702
  }
3703
+ function askHook(answerFile) {
3704
+ const quote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
3705
+ const decision = (d) => quote(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: d } }));
3706
+ return `if [ -f ${quote(answerFile)} ]; then mv ${quote(answerFile)} ${quote(`${answerFile}.taken`)}; printf '%s' ${decision("allow")}; else printf '%s' ${decision("defer")}; fi`;
3707
+ }
3683
3708
 
3684
3709
  // ../daemon/src/adapters/claude-code/stream.ts
3685
3710
  var RESULT_FAILURES = {
@@ -3697,6 +3722,8 @@ var StreamParser = class {
3697
3722
  model = "unknown";
3698
3723
  toolCalls = /* @__PURE__ */ new Map();
3699
3724
  sawResult = false;
3725
+ /** When the Claude window opens again, from the last rejected `rate_limit_event`. */
3726
+ resetsAt;
3700
3727
  // The read size, counted here because the stream is the one place every call passes.
3701
3728
  contextPeak = 0;
3702
3729
  readCalls = {};
@@ -3707,6 +3734,9 @@ var StreamParser = class {
3707
3734
  get currentSessionId() {
3708
3735
  return this.sessionId;
3709
3736
  }
3737
+ get limitResetsAt() {
3738
+ return this.resetsAt;
3739
+ }
3710
3740
  /** Feed one NDJSON line; get zero or more events. Non-JSON lines are ignored. */
3711
3741
  parseLine(line) {
3712
3742
  const trimmed = line.trim();
@@ -3758,10 +3788,18 @@ var StreamParser = class {
3758
3788
  }
3759
3789
  return events;
3760
3790
  }
3791
+ case "rate_limit_event": {
3792
+ const info = obj.rate_limit_info;
3793
+ if (info?.status === "rejected" && typeof info.resetsAt === "number") this.resetsAt = info.resetsAt;
3794
+ return [];
3795
+ }
3761
3796
  case "result": {
3762
3797
  this.sawResult = true;
3763
3798
  this.sessionId = obj.session_id ?? this.sessionId;
3764
3799
  const usage = this.usageFrom(obj);
3800
+ if (obj.stop_reason === "tool_deferred" && obj.deferred_tool_use?.name) {
3801
+ return [{ type: "deferred", sessionId: this.sessionId ?? "", tool: obj.deferred_tool_use.name, input: obj.deferred_tool_use.input, usage }];
3802
+ }
3765
3803
  if (obj.subtype === "success" && !obj.is_error) {
3766
3804
  if (this.structuredOutput && obj.structured_output === void 0) {
3767
3805
  return [{ type: "failed", sessionId: this.sessionId, code: "harness_error", message: "Claude omitted the required structured output." }];
@@ -3770,7 +3808,7 @@ var StreamParser = class {
3770
3808
  }
3771
3809
  const message = obj.result || `claude ended with ${obj.subtype ?? "an unknown result"}`;
3772
3810
  const code = isRateLimit(message) ? "rate_limit" : RESULT_FAILURES[obj.subtype ?? ""] ?? "harness_error";
3773
- return [{ type: "failed", sessionId: this.sessionId, code, message }];
3811
+ return [{ type: "failed", sessionId: this.sessionId, code, message, ...code === "rate_limit" && this.resetsAt ? { resetsAt: this.resetsAt } : {} }];
3774
3812
  }
3775
3813
  default:
3776
3814
  return [];
@@ -3818,8 +3856,8 @@ function classifyExit(stderr, aborted) {
3818
3856
  if (t.includes("not logged in") || t.includes("invalid api key") || t.includes("authentication") || t.includes("subscription") || t.includes("please run /login") || t.includes("oauth")) {
3819
3857
  return { code: "auth", message: "Claude Code is not logged in on this machine. Run `claude login` or set ANTHROPIC_API_KEY." };
3820
3858
  }
3821
- const tail2 = stderr.trim().split("\n").slice(-3).join(" ").trim();
3822
- return { code: "harness_error", message: tail2 || "claude exited without a result" };
3859
+ const tail = stderr.trim().split("\n").slice(-3).join(" ").trim();
3860
+ return { code: "harness_error", message: tail || "claude exited without a result" };
3823
3861
  }
3824
3862
 
3825
3863
  // ../daemon/src/adapters/claude-code/index.ts
@@ -3856,10 +3894,10 @@ var ClaudeCodeAdapter = class {
3856
3894
  const result = parsePrintResult(stdout);
3857
3895
  return result?.is_error ? { ok: false, message: result.result ?? "claude reported an error" } : { ok: true, message: `claude answered in ${result?.subtype ?? "success"}` };
3858
3896
  } catch (err) {
3859
- const failed = err;
3860
- const reported = parsePrintResult(failed.stdout)?.result;
3897
+ const failed2 = err;
3898
+ const reported = parsePrintResult(failed2.stdout)?.result;
3861
3899
  if (reported) return { ok: false, message: reported };
3862
- const text2 = failed.stderr?.trim() || failed.message || String(err);
3900
+ const text2 = failed2.stderr?.trim() || failed2.message || String(err);
3863
3901
  return { ok: false, message: classifyExit(text2, false).message };
3864
3902
  }
3865
3903
  }
@@ -3895,9 +3933,9 @@ var ClaudeCodeAdapter = class {
3895
3933
  child.stderr.on("data", (chunk) => {
3896
3934
  stderr += chunk.toString();
3897
3935
  });
3898
- const exit = new Promise((resolve6) => {
3899
- child.on("close", (code) => resolve6(code));
3900
- child.on("error", () => resolve6(127));
3936
+ const exit = new Promise((resolve5) => {
3937
+ child.on("close", (code) => resolve5(code));
3938
+ child.on("error", () => resolve5(127));
3901
3939
  });
3902
3940
  const parser = new StreamParser();
3903
3941
  let searched = false;
@@ -3937,7 +3975,7 @@ var ClaudeCodeAdapter = class {
3937
3975
  "--append-system-prompt",
3938
3976
  spec2.systemPrompt,
3939
3977
  "--permission-mode",
3940
- "default",
3978
+ flags.permissionMode,
3941
3979
  "--max-turns",
3942
3980
  String(spec2.maxTurns),
3943
3981
  "--setting-sources",
@@ -3986,7 +4024,7 @@ var ClaudeCodeAdapter = class {
3986
4024
  await writeFile2(mcpConfigPath, JSON.stringify({ mcpServers }), { mode: 384 });
3987
4025
  const settingsPath = join3(spec2.runDir, "settings.json");
3988
4026
  const flags = claudeCodeToolFlags(spec2.capabilities, { workspace: spec2.cwd, harnessWeb: spec2.harnessWeb, without: spec2.withoutTools });
3989
- await writeFile2(settingsPath, JSON.stringify(sandboxSettingsFor(spec2.env.HOME ?? "", flags.tools)), { mode: 384 });
4027
+ await writeFile2(settingsPath, JSON.stringify(sandboxSettingsFor(spec2.env.HOME ?? "", flags.tools, spec2.network, spec2.answerFile)), { mode: 384 });
3990
4028
  const child = spawn(this.claudePath, this.buildArgs(spec2, mcpConfigPath, settingsPath), {
3991
4029
  cwd: spec2.cwd,
3992
4030
  env: spec2.env,
@@ -4005,9 +4043,9 @@ var ClaudeCodeAdapter = class {
4005
4043
  stderr += chunk.toString();
4006
4044
  if (stderr.length > 64e3) stderr = stderr.slice(-32e3);
4007
4045
  });
4008
- const exit = new Promise((resolve6) => {
4009
- child.on("close", (code, signal) => resolve6({ code, signal }));
4010
- child.on("error", () => resolve6({ code: 127, signal: null }));
4046
+ const exit = new Promise((resolve5) => {
4047
+ child.on("close", (code, signal) => resolve5({ code, signal }));
4048
+ child.on("error", () => resolve5({ code: 127, signal: null }));
4011
4049
  });
4012
4050
  const parser = new StreamParser(spec2.outputSchema !== void 0);
4013
4051
  try {
@@ -4017,7 +4055,8 @@ var ClaudeCodeAdapter = class {
4017
4055
  const { code } = await exit;
4018
4056
  if (!parser.finished) {
4019
4057
  const failure2 = classifyExit(stderr, aborted);
4020
- yield { type: "failed", sessionId: parser.currentSessionId, ...failure2, message: appendExit(failure2.message, code) };
4058
+ const resetsAt = failure2.code === "rate_limit" ? parser.limitResetsAt : void 0;
4059
+ yield { type: "failed", sessionId: parser.currentSessionId, ...failure2, message: appendExit(failure2.message, code), ...resetsAt ? { resetsAt } : {} };
4021
4060
  }
4022
4061
  } finally {
4023
4062
  spec2.signal.removeEventListener("abort", onAbort);
@@ -4042,10 +4081,10 @@ function parsePrintResult(stdout) {
4042
4081
  }
4043
4082
 
4044
4083
  // ../daemon/src/daemon.ts
4045
- import { mkdir as mkdir5, readdir as readdir2, readFile as readFile7, rm as rm4, writeFile as writeFile4 } from "fs/promises";
4084
+ import { mkdir as mkdir6, readdir as readdir3, readFile as readFile9, rm as rm5, writeFile as writeFile6 } from "fs/promises";
4046
4085
  import { openSync, closeSync, unlinkSync, writeFileSync, readFileSync as readFileSyncNode, mkdirSync } from "fs";
4047
4086
  import { hostname as hostname2 } from "os";
4048
- import { join as join11 } from "path";
4087
+ import { join as join14 } from "path";
4049
4088
  import { setTimeout as sleep } from "timers/promises";
4050
4089
 
4051
4090
  // ../daemon/src/awake.ts
@@ -4114,8 +4153,8 @@ async function installVersion(version, home = homedir2()) {
4114
4153
  });
4115
4154
  return { ok: true };
4116
4155
  } catch (err) {
4117
- const failed = err;
4118
- return { ok: false, why: (failed.stderr?.trim() || failed.message || String(err)).split("\n").slice(-3).join(" ") };
4156
+ const failed2 = err;
4157
+ return { ok: false, why: (failed2.stderr?.trim() || failed2.message || String(err)).split("\n").slice(-3).join(" ") };
4119
4158
  }
4120
4159
  }
4121
4160
  function autoUpdateOff(home = homedir2()) {
@@ -4130,6 +4169,7 @@ function shouldUpdate(s) {
4130
4169
  }
4131
4170
 
4132
4171
  // ../daemon/src/mcp.ts
4172
+ import { join as join5 } from "path";
4133
4173
  function memoryMcpCommand(env = process.env) {
4134
4174
  const override = env.NANOPM_MCP_COMMAND;
4135
4175
  if (override) {
@@ -4156,13 +4196,17 @@ function memoryMcpServer(job, auth, askTimeoutMs, workspace) {
4156
4196
  NANOPM_PROJECT_ID: job.project_id,
4157
4197
  NANOPM_USER_ID: auth.userId,
4158
4198
  NANOPM_ACTOR: job.skill,
4159
- ...workspace ? { NANOPM_WORKSPACE: workspace } : {},
4199
+ // A build: its questions come back through the answer file the hook hands over (buildAnswerFile).
4200
+ ...workspace ? { NANOPM_WORKSPACE: workspace, NANOPM_ANSWER_FILE: buildAnswerFile(job.id) } : {},
4160
4201
  NANOPM_ASK_TIMEOUT_MS: String(askTimeoutMs),
4161
4202
  PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
4162
4203
  HOME: process.env.HOME ?? ""
4163
4204
  }
4164
4205
  };
4165
4206
  }
4207
+ function buildAnswerFile(jobId) {
4208
+ return join5(nanopmHome(), "run", jobId, "answer");
4209
+ }
4166
4210
 
4167
4211
  // ../connectors/src/postgres.ts
4168
4212
  import postgres from "postgres";
@@ -4177,15 +4221,15 @@ function stripLiterals(sql) {
4177
4221
  };
4178
4222
  while (i < n) {
4179
4223
  const c = sql[i];
4180
- const next2 = sql[i + 1];
4181
- if (c === "-" && next2 === "-") {
4224
+ const next = sql[i + 1];
4225
+ if (c === "-" && next === "-") {
4182
4226
  const end = sql.indexOf("\n", i);
4183
4227
  const stop2 = end === -1 ? n : end;
4184
4228
  blank(i, stop2);
4185
4229
  i = stop2;
4186
4230
  continue;
4187
4231
  }
4188
- if (c === "/" && next2 === "*") {
4232
+ if (c === "/" && next === "*") {
4189
4233
  let depth = 1;
4190
4234
  let k = i + 2;
4191
4235
  while (k < n && depth > 0) {
@@ -5273,14 +5317,183 @@ async function sendDigestFor(client, accessToken, jobId, fetchImpl = fetch) {
5273
5317
  }
5274
5318
 
5275
5319
  // ../daemon/src/deploys.ts
5320
+ import { execFile as execFile4 } from "child_process";
5321
+ import { promisify as promisify4 } from "util";
5322
+
5323
+ // ../daemon/src/repo.ts
5276
5324
  import { execFile as execFile3 } from "child_process";
5325
+ import { readFile as readFile2, rm as rm3, writeFile as writeFile4 } from "fs/promises";
5326
+ import { join as join6 } from "path";
5327
+
5328
+ // ../shared/src/build.ts
5329
+ var BUILD_SKILL = "build";
5330
+ var BUILD_STEPS_MIN = 1;
5331
+ var BUILD_STEPS_MAX = 8;
5332
+ var BUILD_STEP_MAX = 120;
5333
+ var BUILD_RESUMES_MAX = 3;
5334
+ var BUILD_TRAILER = "Co-Authored-By: NanoPM <noreply@nanopm.app>";
5335
+ var BUILD_COLUMNS = "id, ref, title, status, actor, agent_spec, spec";
5336
+ function buildRefusal(solution, opts = {}) {
5337
+ if (solution.actor === "founder") return `${solution.ref} is yours to do, not Nano's to build.`;
5338
+ if (!["proposed", "accepted", "building"].includes(solution.status)) return `${solution.ref} is ${solution.status}: only a solution on the roadmap can be built.`;
5339
+ if (!solution.agent_spec) return `${solution.ref} has no agent spec yet. Write its specs first: nanopm spec ${solution.ref} --write`;
5340
+ const open = solution.spec?.questions?.length ?? 0;
5341
+ if (open && !opts.anyway) {
5342
+ 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`;
5343
+ }
5344
+ return null;
5345
+ }
5346
+ function stepsLeft(plan, done) {
5347
+ return (plan ?? []).map((_, i) => i).filter((i) => !done.has(i));
5348
+ }
5349
+ function buildDir(home, jobId) {
5350
+ return `${home.replace(/\/+$/, "")}/.cache/nanopm/builds/${jobId}`;
5351
+ }
5352
+ function buildBranch(ref, title) {
5353
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "");
5354
+ return `nano/${ref.toLowerCase()}${slug ? `-${slug}` : ""}`;
5355
+ }
5356
+ var REGISTRIES = {
5357
+ "package-lock.json": ["registry.npmjs.org"],
5358
+ "npm-shrinkwrap.json": ["registry.npmjs.org"],
5359
+ "pnpm-lock.yaml": ["registry.npmjs.org"],
5360
+ "yarn.lock": ["registry.yarnpkg.com", "registry.npmjs.org"],
5361
+ "bun.lockb": ["registry.npmjs.org"],
5362
+ "bun.lock": ["registry.npmjs.org"],
5363
+ "poetry.lock": ["pypi.org", "files.pythonhosted.org"],
5364
+ "uv.lock": ["pypi.org", "files.pythonhosted.org"],
5365
+ "Pipfile.lock": ["pypi.org", "files.pythonhosted.org"],
5366
+ "requirements.txt": ["pypi.org", "files.pythonhosted.org"],
5367
+ "Cargo.lock": ["crates.io", "index.crates.io", "static.crates.io"],
5368
+ "Gemfile.lock": ["rubygems.org", "index.rubygems.org"],
5369
+ "go.sum": ["proxy.golang.org", "sum.golang.org"],
5370
+ "composer.lock": ["repo.packagist.org"]
5371
+ };
5372
+ function buildNetwork(rootFiles) {
5373
+ return [...new Set(rootFiles.flatMap((f) => REGISTRIES[f] ?? []))].sort();
5374
+ }
5375
+ async function queueBuild(client, ask7) {
5376
+ const { data, error: readError } = await client.from("solutions").select(BUILD_COLUMNS).eq("project_id", ask7.projectId).eq("ref", ask7.ref).is("superseded_by", null).maybeSingle();
5377
+ if (readError) throw new Error(`could not read the solution: ${pgMessage(readError)}`);
5378
+ if (!data) throw new Error(`No solution ${ask7.ref} on the Roadmap.`);
5379
+ const solution = data;
5380
+ const refused = buildRefusal(solution, { anyway: ask7.anyway || Boolean(ask7.resumeOf) });
5381
+ if (refused) throw new Error(refused);
5382
+ const { data: job, error } = await client.from("jobs").insert({
5383
+ project_id: ask7.projectId,
5384
+ skill: BUILD_SKILL,
5385
+ requested_by: ask7.userId,
5386
+ trigger: "user",
5387
+ target_daemon_id: ask7.daemonId,
5388
+ // `was`: where the solution goes back on the board if the build stops (`unstart_solution`).
5389
+ // `origin`: the run that made the copy of the code, which every continuation works in.
5390
+ 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 } : {} },
5391
+ // Continuing a stopped build takes up its worktree, its history and its session.
5392
+ ...ask7.resumeOf ? { resume_of: ask7.resumeOf.id } : {}
5393
+ }).select("id").single();
5394
+ 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.");
5395
+ if (error || !job) throw new Error(`could not queue the build: ${error ? pgMessage(error) : "no row"}`);
5396
+ return { jobId: job.id, solution };
5397
+ }
5398
+ 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*.";
5399
+ function answerForAgent(answer) {
5400
+ return /^\s*decide(\s+for\s+me)?\s*\.?\s*$/i.test(answer) ? DECIDE_FOR_ME : answer;
5401
+ }
5402
+ function trustedPlace(p, at) {
5403
+ const [branch, base, fork] = [p.branch, p.base, p.fork].map((v) => typeof v === "string" ? v : "");
5404
+ 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;
5405
+ return { dir: buildDir(at.home, at.jobId), branch, base, fork };
5406
+ }
5407
+ function buildHistory(events, at) {
5408
+ let place = null;
5409
+ let plan = null;
5410
+ const done = /* @__PURE__ */ new Set();
5411
+ let last = null;
5412
+ const answers = /* @__PURE__ */ new Map();
5413
+ for (const e of events) {
5414
+ if (e.kind === "worktree") place = trustedPlace(e.payload, at);
5415
+ else if (e.kind === "plan" && Array.isArray(e.payload.steps)) plan = e.payload.steps;
5416
+ else if (e.kind === "step" && typeof e.payload.step === "number") done.add(e.payload.step - 1);
5417
+ else if (e.kind === "answer") answers.set(e.seq, String(e.payload.answer ?? ""));
5418
+ if (e.kind === "question" || e.kind === "paused") last = e;
5419
+ else if (e.kind === "plan" || e.kind === "step") last = null;
5420
+ }
5421
+ if (!place) return { place, plan, done, resume: null };
5422
+ if (last?.kind === "question" && answers.has(last.seq) && last.payload.about === "usage") return { place, plan, done, resume: { why: "limit" } };
5423
+ if (last?.kind === "question" && answers.has(last.seq)) return { place, plan, done, resume: { why: "answer", answer: answers.get(last.seq) } };
5424
+ if (last?.kind === "paused") return { place, plan, done, resume: { why: "limit" } };
5425
+ return { place, plan, done, resume: { why: "lost" } };
5426
+ }
5427
+ function wasOf(params) {
5428
+ return params?.was === "proposed" ? "proposed" : "accepted";
5429
+ }
5430
+ async function loadBuildHistory(client, jobIds, at) {
5431
+ const { data, error } = await client.from("job_events").select("kind, seq, payload").in("job_id", [...jobIds]).in("kind", ["worktree", "plan", "step", "question", "answer", "paused"]).order("id");
5432
+ if (error) throw new Error(`could not read the build's history: ${error.message}`);
5433
+ return buildHistory((data ?? []).map((e) => ({ kind: e.kind, seq: e.seq, payload: e.payload ?? {} })), at);
5434
+ }
5435
+
5436
+ // ../daemon/src/repo.ts
5277
5437
  import { promisify as promisify3 } from "util";
5278
5438
  var execFileAsync3 = promisify3(execFile3);
5279
- var SETTLE_HOURS = 3;
5280
- var MIN_COMMITS = 3;
5281
- async function git(cwd, args) {
5282
- const { stdout } = await execFileAsync3("git", ["-C", cwd, ...args], { timeout: 2e4, maxBuffer: 1024 * 1024 });
5283
- return stdout.trim();
5439
+ var MAX_BUFFER = 4 * 1024 * 1024;
5440
+ var TIMEOUT_MS = 6e4;
5441
+ var LOCKED = {
5442
+ GIT_CONFIG_COUNT: "2",
5443
+ GIT_CONFIG_KEY_0: "core.hooksPath",
5444
+ GIT_CONFIG_VALUE_0: "/dev/null",
5445
+ GIT_CONFIG_KEY_1: "core.fsmonitor",
5446
+ GIT_CONFIG_VALUE_1: "false"
5447
+ };
5448
+ async function exec(cmd, repo, args, timeoutMs = TIMEOUT_MS) {
5449
+ if (typeof repo === "string") return execFileAsync3(cmd, cmd === "git" ? ["-C", repo, ...args] : args, { cwd: repo, maxBuffer: MAX_BUFFER, timeout: timeoutMs });
5450
+ await writeFile4(join6(repo.dir, ".git"), `gitdir: ${repo.admin}
5451
+ `);
5452
+ await writeFile4(join6(repo.admin, "commondir"), repo.commondir);
5453
+ await rm3(join6(repo.admin, "config.worktree"), { force: true });
5454
+ 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 });
5455
+ }
5456
+ async function git(repo, args, timeoutMs = TIMEOUT_MS) {
5457
+ return (await exec("git", repo, args, timeoutMs)).stdout.trim();
5458
+ }
5459
+ var RepoError = class extends Error {
5460
+ };
5461
+ async function commitAll(cwd, message, note) {
5462
+ await git(cwd, ["add", "-A"]);
5463
+ const staged = await git(cwd, ["diff", "--cached", "--name-only"]).catch(() => "");
5464
+ if (!staged) return null;
5465
+ await git(cwd, ["commit", "--no-verify", "-m", message, ...note ? ["-m", note] : [], "-m", `Written by NanoPM. Review before merging.
5466
+
5467
+ ${BUILD_TRAILER}`]);
5468
+ const sha = await git(cwd, ["rev-parse", "--short", "HEAD"]);
5469
+ return { sha, ...await diffStat(cwd) };
5470
+ }
5471
+ async function diffStat(cwd) {
5472
+ const numstat = await git(cwd, ["show", "--numstat", "--format=", "HEAD"]).catch(() => "");
5473
+ let files = 0;
5474
+ let insertions = 0;
5475
+ let deletions = 0;
5476
+ for (const line of numstat.split("\n").filter(Boolean)) {
5477
+ const [add2 = "", del = ""] = line.split(" ");
5478
+ files += 1;
5479
+ insertions += Number(add2) || 0;
5480
+ deletions += Number(del) || 0;
5481
+ }
5482
+ return { files, insertions, deletions };
5483
+ }
5484
+ async function pushBranch(cwd, name) {
5485
+ if (!await originUrl(cwd)) return { pushed: false, reason: "this repository has no origin remote" };
5486
+ try {
5487
+ const { stderr } = await exec("git", cwd, ["push", "--no-verify", "--set-upstream", "origin", name], 12e4);
5488
+ return { pushed: true, reviewUrl: reviewUrlFrom(stderr) };
5489
+ } catch (err) {
5490
+ const text2 = errorMessage(err);
5491
+ return { pushed: false, reason: text2.split("\n").slice(-3).join(" ").trim() || "git push failed" };
5492
+ }
5493
+ }
5494
+ function reviewUrlFrom(pushOutput) {
5495
+ const match = /https?:\/\/\S+/.exec(pushOutput);
5496
+ return match?.[0]?.replace(/[).,]+$/, "");
5284
5497
  }
5285
5498
  async function defaultBranch(cwd) {
5286
5499
  try {
@@ -5290,9 +5503,78 @@ async function defaultBranch(cwd) {
5290
5503
  return git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]).catch(() => "HEAD");
5291
5504
  }
5292
5505
  }
5506
+ async function originUrl(cwd) {
5507
+ return git(cwd, ["remote", "get-url", "origin"]).catch(() => "");
5508
+ }
5509
+ async function baseRef(cwd, branch) {
5510
+ if (!await originUrl(cwd)) return branch;
5511
+ await git(cwd, ["fetch", "--quiet", "origin", branch], 12e4).catch(() => "");
5512
+ return await git(cwd, ["rev-parse", "--verify", "--quiet", `origin/${branch}`]).catch(() => "") ? `origin/${branch}` : branch;
5513
+ }
5514
+ async function addWorktree(clone, dir, branch) {
5515
+ if (!await git(clone, ["rev-parse", "HEAD"]).catch(() => "")) throw new RepoError("this repository has no commits yet; make the first commit, then build");
5516
+ const existing = await git(clone, ["branch", "--list", branch]).catch(() => "");
5517
+ if (existing.trim()) throw new RepoError(`branch ${branch} already exists. Delete it or finish what is on it first.`);
5518
+ const base = await defaultBranch(clone);
5519
+ const onto = await baseRef(clone, base);
5520
+ await git(clone, ["worktree", "add", "-b", branch, dir, onto]);
5521
+ const admin = await git(dir, ["rev-parse", "--absolute-git-dir"]);
5522
+ const worktree = { dir, admin, commondir: await readFile2(join6(admin, "commondir"), "utf8") };
5523
+ return { base, fork: await git(worktree, ["rev-parse", "HEAD"]), worktree };
5524
+ }
5525
+ async function removeWorktree(clone, dir) {
5526
+ await git(clone, ["worktree", "remove", "--force", dir]).catch(() => "");
5527
+ }
5528
+ async function commonGitDir(cwd) {
5529
+ return git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
5530
+ }
5531
+ async function commitsSinceFork(cwd, fork) {
5532
+ return Number(await git(cwd, ["rev-list", "--count", `${fork}..HEAD`]).catch(() => "0")) || 0;
5533
+ }
5534
+ async function rebaseOnto(cwd, base) {
5535
+ const onto = await baseRef(cwd, base);
5536
+ try {
5537
+ await git(cwd, ["rebase", "--no-verify", onto], 12e4);
5538
+ return { ok: true };
5539
+ } catch {
5540
+ const files = (await git(cwd, ["diff", "--name-only", "--diff-filter=U"]).catch(() => "")).split("\n").filter(Boolean);
5541
+ await git(cwd, ["rebase", "--abort"]).catch(() => "");
5542
+ return { ok: false, files };
5543
+ }
5544
+ }
5545
+ async function gh(cwd, args) {
5546
+ return (await exec("gh", cwd, args, 12e4)).stdout.trim();
5547
+ }
5548
+ async function ghReady(cwd) {
5549
+ if (!/github\.com[:/]/.test(await originUrl(cwd))) return "this repository has no GitHub origin, so there is nowhere to open a pull request";
5550
+ try {
5551
+ await gh(cwd, ["auth", "status"]);
5552
+ return null;
5553
+ } catch (err) {
5554
+ 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";
5555
+ }
5556
+ }
5557
+ async function openPullRequest(cwd, pr) {
5558
+ try {
5559
+ const out = await gh(cwd, ["pr", "create", "--draft", "--head", pr.branch, "--base", pr.base, "--title", pr.title, "--body", pr.body]);
5560
+ const url = reviewUrlFrom(out) ?? out.split("\n").at(-1) ?? "";
5561
+ return { ok: true, url, number: Number(/\/pull\/(\d+)/.exec(url)?.[1] ?? 0) };
5562
+ } catch (err) {
5563
+ return { ok: false, reason: errorMessage(err).split("\n").filter(Boolean).slice(-2).join(" ") || "gh pr create failed" };
5564
+ }
5565
+ }
5566
+
5567
+ // ../daemon/src/deploys.ts
5568
+ var execFileAsync4 = promisify4(execFile4);
5569
+ var SETTLE_HOURS = 3;
5570
+ var MIN_COMMITS = 3;
5571
+ async function git2(cwd, args) {
5572
+ const { stdout } = await execFileAsync4("git", ["-C", cwd, ...args], { timeout: 2e4, maxBuffer: 1024 * 1024 });
5573
+ return stdout.trim();
5574
+ }
5293
5575
  async function commitsSince(cwd, since) {
5294
5576
  const branch = await defaultBranch(cwd);
5295
- const out = await git(cwd, ["log", branch, `--since=${since.toISOString()}`, "--format=%cI"]);
5577
+ const out = await git2(cwd, ["log", branch, `--since=${since.toISOString()}`, "--format=%cI"]);
5296
5578
  const stamps = out.split("\n").filter(Boolean);
5297
5579
  return { count: stamps.length, newestAt: stamps[0] ? new Date(stamps[0]) : null };
5298
5580
  }
@@ -5346,6 +5628,275 @@ async function reviewDeploys(client, daemonId, now = /* @__PURE__ */ new Date())
5346
5628
  return results;
5347
5629
  }
5348
5630
 
5631
+ // ../daemon/src/build-job.ts
5632
+ import { existsSync as existsSync2 } from "fs";
5633
+ import { mkdir as mkdir2, readdir, readFile as readFile3, writeFile as writeFile5 } from "fs/promises";
5634
+ import { homedir as homedir3 } from "os";
5635
+ import { basename, join as join7 } from "path";
5636
+
5637
+ // ../daemon/src/usage.ts
5638
+ function sumUsage(a, b) {
5639
+ if (!a) return { ...b };
5640
+ return {
5641
+ ...b,
5642
+ inputTokens: a.inputTokens + b.inputTokens,
5643
+ outputTokens: a.outputTokens + b.outputTokens,
5644
+ cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens,
5645
+ cacheWriteTokens: (a.cacheWriteTokens ?? 0) + (b.cacheWriteTokens ?? 0),
5646
+ costUsd: (a.costUsd ?? 0) + (b.costUsd ?? 0),
5647
+ turns: a.turns + b.turns,
5648
+ durationMs: a.durationMs + b.durationMs,
5649
+ ...a.contextPeakTokens || b.contextPeakTokens ? { contextPeakTokens: Math.max(a.contextPeakTokens ?? 0, b.contextPeakTokens ?? 0) } : {},
5650
+ ...a.reads || b.reads ? { reads: sumReads(a.reads, b.reads) } : {}
5651
+ };
5652
+ }
5653
+ function sumReads(a, b) {
5654
+ const calls = { ...a?.calls ?? {} };
5655
+ for (const [tool, n] of Object.entries(b?.calls ?? {})) calls[tool] = (calls[tool] ?? 0) + n;
5656
+ return { files: (a?.files ?? 0) + (b?.files ?? 0), calls };
5657
+ }
5658
+
5659
+ // ../daemon/src/build-job.ts
5660
+ var PLAN_TOOL = "mcp__nanopm__build_plan";
5661
+ var ASK_TOOL = "mcp__nanopm__ask_user";
5662
+ var STEP_TOOL = "mcp__nanopm__build_step";
5663
+ var CLAUDE_CODE_MIN = "2.1.283";
5664
+ async function prepareBuild(client, job, clone, adapter) {
5665
+ const params = job.params ?? {};
5666
+ const dir = buildDir(homedir3(), job.id);
5667
+ let made = false;
5668
+ try {
5669
+ const [solution, machine, history] = await Promise.all([
5670
+ loadSolution(client, job.project_id, String(params.solution_id ?? "")),
5671
+ machineRefusal(adapter),
5672
+ loadHistory(client, job)
5673
+ ]);
5674
+ const refused = buildRefusal(solution, { anyway: params.anyway === true || Boolean(history.place) }) ?? machine;
5675
+ if (refused) return { ok: false, outcome: failed(refused) };
5676
+ const network = async (at) => buildNetwork(await readdir(at));
5677
+ if (history.place) {
5678
+ const { place } = history;
5679
+ if (!existsSync2(place.dir)) return { ok: false, outcome: failed(`this build's copy of the code is gone (${place.dir}): build it again`) };
5680
+ const worktree2 = { dir: place.dir, admin: join7(await commonGitDir(clone), "worktrees", basename(place.dir)), commondir: "../..\n" };
5681
+ await startSolution(client, job.project_id, solution.ref, job.id);
5682
+ return { ok: true, build: { solution, clone, ...place, worktree: worktree2, network: await network(place.dir), history } };
5683
+ }
5684
+ const branch = buildBranch(solution.ref, solution.title);
5685
+ await mkdir2(join7(dir, ".."), { recursive: true, mode: 448 });
5686
+ const { base, fork, worktree } = await addWorktree(clone, dir, branch);
5687
+ made = true;
5688
+ await appendJobEvent(client, job.id, "worktree", { dir, branch, base, fork }, "nanopm");
5689
+ await handOverTheSpec(worktree, solution.agent_spec);
5690
+ await startSolution(client, job.project_id, solution.ref, job.id);
5691
+ return { ok: true, build: { solution, clone, dir, worktree, branch, base, fork, network: await network(dir), history } };
5692
+ } catch (err) {
5693
+ if (made) await removeWorktree(clone, dir);
5694
+ return { ok: false, outcome: failed(errorMessage(err)) };
5695
+ }
5696
+ }
5697
+ async function runBuildJob(input) {
5698
+ const { client, job, adapter, spec: spec2, events, log, verifyCommand: verifyCommand2 } = input;
5699
+ const { solution, clone, dir, worktree, branch, base, fork, history } = input.build;
5700
+ const was = wasOf(job.params);
5701
+ let plan = history.plan;
5702
+ const done = new Set(history.done);
5703
+ let usage;
5704
+ let said = "";
5705
+ const stop2 = async (message, opts = { keep: true }) => {
5706
+ await unstartSolution(client, job.project_id, solution.ref, was).catch(() => void 0);
5707
+ if (opts.keep) await commitAll(worktree, `${solution.ref}: unfinished \u2014 ${solution.title}`).catch(() => null);
5708
+ else await removeWorktree(clone, dir);
5709
+ return { ...failed(opts.keep ? `${message}. What was done is on ${branch}, in ${dir}.` : message), usage };
5710
+ };
5711
+ const park = async (why) => {
5712
+ if ("ask" in why) {
5713
+ await events.write("question", { ...why.ask, ...plan ? {} : { about: "plan" } });
5714
+ await log("build.ask", `${solution.ref} waits on the founder`);
5715
+ } else if (why.resetsAt) {
5716
+ await events.write("paused", { why: "limit", resets_at: new Date(why.resetsAt * 1e3).toISOString() });
5717
+ await log("build.pause", `${solution.ref} paused until the Claude limit resets`);
5718
+ } else {
5719
+ 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" });
5720
+ }
5721
+ return { status: "waiting", usage };
5722
+ };
5723
+ const session2 = async (prompt, resume3) => {
5724
+ let end = { stop: failed("the agent produced no result") };
5725
+ for await (const event of adapter.run({ ...spec2, prompt, resumeSessionId: resume3 ? spec2.sessionId : void 0 })) {
5726
+ await input.onEvent(event);
5727
+ if (event.type === "tool_use" && event.tool === PLAN_TOOL && !plan) {
5728
+ plan = stepsOf(event.input);
5729
+ if (plan) await events.write("plan", { steps: plan });
5730
+ } else if (event.type === "tool_result" && event.tool === STEP_TOOL && !event.isError) {
5731
+ const { step, summary } = event.input ?? {};
5732
+ const index = Number(step) - 1;
5733
+ if (Number.isInteger(index) && index >= 0 && !done.has(index)) {
5734
+ const says = plan?.[index] ?? `step ${index + 1}`;
5735
+ const commit = await commitAll(worktree, `${solution.ref}: ${says}`, summary);
5736
+ done.add(index);
5737
+ await events.write("step", { step: index + 1, says, commit: commit?.sha ?? null, files: commit?.files ?? 0 });
5738
+ }
5739
+ } else if (event.type === "finished") {
5740
+ usage = sumUsage(usage, event.usage);
5741
+ said = event.result;
5742
+ end = null;
5743
+ } else if (event.type === "deferred" && event.tool === ASK_TOOL) {
5744
+ usage = sumUsage(usage, event.usage);
5745
+ end = { park: { ask: askOf(event.input) } };
5746
+ } else if (event.type === "failed") {
5747
+ end = event.code === "rate_limit" ? { park: { resetsAt: event.resetsAt } } : { stop: { status: "failed", error: { code: event.code, message: event.message } } };
5748
+ }
5749
+ }
5750
+ return end;
5751
+ };
5752
+ const resume2 = history.resume;
5753
+ const check = verifyCommand2 ? `
5754
+
5755
+ 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*.` : "";
5756
+ const finished = () => Boolean(plan && !stepsLeft(plan, done).length);
5757
+ if (!resume2) {
5758
+ await log("build.start", `building ${solution.ref} on ${branch}, in a copy of your code`);
5759
+ await events.write("text", { text: `Working on ${branch}, in a copy of your code. Your own checkout is not touched.` });
5760
+ } else {
5761
+ await events.write("text", { text: AGAIN[resume2.why].said });
5762
+ if (resume2.why === "answer" && spec2.answerFile) await writeFile5(spec2.answerFile, answerForAgent(resume2.answer), { mode: 384 });
5763
+ }
5764
+ 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);
5765
+ for (let resumes = 0; !turn && !finished(); resumes += 1) {
5766
+ if (resumes >= BUILD_RESUMES_MAX) {
5767
+ const left = stepsLeft(plan, done).length;
5768
+ return stop2(plan ? `Nano stopped with ${left} step${left === 1 ? "" : "s"} of its plan left` : "Nano stopped without saying its plan");
5769
+ }
5770
+ turn = await session2(carryOn(plan, done), true);
5771
+ }
5772
+ if (turn && "park" in turn) return park(turn.park);
5773
+ if (turn) return stop2(turn.stop.error?.message ?? "the build stopped");
5774
+ await commitAll(worktree, `${solution.ref}: ${solution.title}`);
5775
+ if (!await commitsSinceFork(worktree, fork)) return stop2("the build finished without changing anything", { keep: false });
5776
+ const rebased = await rebaseOnto(worktree, base);
5777
+ 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"}`);
5778
+ const [pushed, noPr, page] = await Promise.all([pushBranch(worktree, branch), ghReady(worktree), solutionPage(client, job.project_id, solution.ref)]);
5779
+ if (!pushed.pushed) return stop2(`the push failed: ${pushed.reason}`);
5780
+ const pr = noPr ? null : await openPullRequest(worktree, { branch, base, title: `${solution.ref}: ${solution.title}`, body: prBody(said, page) });
5781
+ 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 };
5782
+ if (pr) {
5783
+ await events.write("pr", { number: pr.number, url: pr.url });
5784
+ await log("build.pr", `draft pull request #${pr.number} for ${solution.ref}`);
5785
+ }
5786
+ await removeWorktree(clone, dir);
5787
+ return {
5788
+ status: "succeeded",
5789
+ result: [
5790
+ said.trim(),
5791
+ "",
5792
+ pr ? `Draft pull request #${pr.number} \xB7 ${pr.url}` : `Pushed ${branch}. No pull request: ${noPr}.${pushed.reviewUrl ? ` ${pushed.reviewUrl}` : ""}`
5793
+ ].filter(Boolean).join("\n"),
5794
+ data: { branch, session: spec2.sessionId, ...pr ? { pr: { number: pr.number, url: pr.url } } : { review_url: pushed.reviewUrl ?? null } },
5795
+ usage
5796
+ };
5797
+ }
5798
+ var failed = (message) => ({ status: "failed", error: { code: "harness_error", message } });
5799
+ var AGAIN = {
5800
+ answer: { said: "Your answer is in: carrying on.", lead: "" },
5801
+ limit: { said: "Your Claude limit has reset: carrying on.", lead: "Your Claude limit has reset." },
5802
+ lost: { said: "Picked up again where it stopped.", lead: "You were interrupted: the machine stopped." }
5803
+ };
5804
+ function askOf(input) {
5805
+ const { question, options, why } = input ?? {};
5806
+ return {
5807
+ question: String(question ?? ""),
5808
+ ...Array.isArray(options) ? { options: options.map(String) } : {},
5809
+ ...typeof why === "string" ? { why } : {}
5810
+ };
5811
+ }
5812
+ async function loadHistory(client, job) {
5813
+ const named = (job.params ?? {}).origin;
5814
+ 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;
5815
+ 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}`);
5816
+ return loadBuildHistory(client, [.../* @__PURE__ */ new Set([origin, job.id, ...(runs ?? []).map((r) => r.id)])], { home: homedir3(), jobId: origin });
5817
+ }
5818
+ async function loadSolution(client, projectId, id) {
5819
+ const { data, error } = await client.from("solutions").select(BUILD_COLUMNS).eq("id", id).eq("project_id", projectId).is("superseded_by", null).maybeSingle();
5820
+ if (error) throw new Error(`could not read the solution: ${error.message}`);
5821
+ if (!data) throw new Error("that solution is not on this project any more");
5822
+ return data;
5823
+ }
5824
+ async function machineRefusal(adapter) {
5825
+ const found = await adapter.detect();
5826
+ const version = /\d+\.\d+\.\d+/.exec(found.version ?? "")?.[0];
5827
+ if (version && !newer(CLAUDE_CODE_MIN, version)) return null;
5828
+ return `a build needs Claude Code ${CLAUDE_CODE_MIN} or later on this machine${found.version ? ` (it has ${found.version})` : ""}: run claude update`;
5829
+ }
5830
+ async function handOverTheSpec(worktree, agentSpec) {
5831
+ await mkdir2(join7(worktree.dir, ".nanopm"), { recursive: true });
5832
+ await writeFile5(join7(worktree.dir, ".nanopm", "SPEC.md"), agentSpec);
5833
+ const exclude = join7(await commonGitDir(worktree), "info", "exclude");
5834
+ const current = await readFile3(exclude, "utf8").catch(() => "");
5835
+ if (!current.split("\n").includes(".nanopm/")) {
5836
+ await mkdir2(join7(exclude, ".."), { recursive: true });
5837
+ await writeFile5(exclude, `${current}${current && !current.endsWith("\n") ? "\n" : ""}.nanopm/
5838
+ `);
5839
+ }
5840
+ }
5841
+ function stepsOf(input) {
5842
+ const steps = input?.steps;
5843
+ return Array.isArray(steps) && steps.length && steps.every((s) => typeof s === "string") ? steps : null;
5844
+ }
5845
+ function carryOn(plan, done) {
5846
+ 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.";
5847
+ const left = stepsLeft(plan, done);
5848
+ return [
5849
+ `Steps ${left.map((i) => i + 1).join(", ")} of your plan are not done yet:`,
5850
+ ...left.map((i) => `${i + 1}. ${plan[i]}`),
5851
+ "",
5852
+ "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."
5853
+ ].join("\n");
5854
+ }
5855
+ async function solutionPage(client, projectId, ref) {
5856
+ const { data } = await client.from("projects").select("slug").eq("id", projectId).maybeSingle();
5857
+ return data?.slug ? `${webUrl()}/p/${data.slug}/roadmap/${ref}` : null;
5858
+ }
5859
+ function prBody(said, page) {
5860
+ return [
5861
+ said.trim(),
5862
+ page ? `
5863
+ The solution and its spec: ${page}` : "",
5864
+ "\n\u2014 Built by NanoPM with Claude Code. A draft: review before merging."
5865
+ ].filter(Boolean).join("\n");
5866
+ }
5867
+ async function startSolution(client, projectId, ref, jobId) {
5868
+ const { error } = await client.rpc("start_solution", { p_project_id: projectId, p_ref: ref, p_job_id: jobId });
5869
+ if (error) throw new Error(pgMessage(error));
5870
+ }
5871
+ async function unstartSolution(client, projectId, ref, was) {
5872
+ await client.rpc("unstart_solution", { p_project_id: projectId, p_ref: ref, p_to: was });
5873
+ }
5874
+ async function requeueLostBuild(client, job) {
5875
+ const params = job.params ?? {};
5876
+ if ((job.attempt ?? 1) < 2) {
5877
+ 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");
5878
+ if (data?.length) return true;
5879
+ }
5880
+ const { data: solution } = await client.from("solutions").select("ref").eq("id", params.solution_id ?? "").maybeSingle();
5881
+ if (job.project_id && solution) await unstartSolution(client, job.project_id, solution.ref, wasOf(job.params)).catch(() => void 0);
5882
+ return false;
5883
+ }
5884
+ async function resumePausedBuilds(client, daemonId, now = Date.now()) {
5885
+ const { data: parked } = await client.from("jobs").select("id").eq("skill", BUILD_SKILL).eq("status", "waiting").eq("assigned_daemon_id", daemonId);
5886
+ const ids = (parked ?? []).map((j) => j.id);
5887
+ if (!ids.length) return [];
5888
+ 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 });
5889
+ const last = /* @__PURE__ */ new Map();
5890
+ for (const e of stops ?? []) if (!last.has(e.job_id)) last.set(e.job_id, e);
5891
+ const due = ids.filter((id) => {
5892
+ const stop2 = last.get(id);
5893
+ return stop2?.kind === "paused" && Date.parse(String(stop2.payload?.resets_at ?? "")) <= now;
5894
+ });
5895
+ if (!due.length) return [];
5896
+ const { data } = await client.from("jobs").update({ status: "queued", assigned_daemon_id: null }).in("id", due).eq("status", "waiting").select("id");
5897
+ return (data ?? []).map((j) => j.id);
5898
+ }
5899
+
5349
5900
  // ../daemon/src/review-preflight.ts
5350
5901
  async function reviewWithoutData(client, job) {
5351
5902
  if (job.skill !== "review" || !job.project_id) return null;
@@ -5375,17 +5926,17 @@ async function reviewWithoutData(client, job) {
5375
5926
  }
5376
5927
 
5377
5928
  // ../daemon/src/skills.ts
5378
- import { existsSync as existsSync2, readdirSync } from "fs";
5379
- import { readFile as readFile2 } from "fs/promises";
5380
- import { dirname as dirname2, join as join5, resolve as resolve2 } from "path";
5929
+ import { existsSync as existsSync3, readdirSync } from "fs";
5930
+ import { readFile as readFile4 } from "fs/promises";
5931
+ import { dirname as dirname2, join as join8, resolve as resolve2 } from "path";
5381
5932
  function findSkillsDir(env = process.env) {
5382
5933
  if (env.NANOPM_SKILLS_DIR) return env.NANOPM_SKILLS_DIR;
5383
5934
  const candidates = [
5384
- join5(process.cwd(), "skills"),
5935
+ join8(process.cwd(), "skills"),
5385
5936
  ...process.argv[1] ? [resolve2(dirname2(process.argv[1]), "..", "skills")] : []
5386
5937
  ];
5387
5938
  for (const dir of candidates) {
5388
- if (existsSync2(join5(dir, "onboard", "skill.json"))) return dir;
5939
+ if (existsSync3(join8(dir, "onboard", "skill.json"))) return dir;
5389
5940
  }
5390
5941
  throw new Error(`Cannot find the skills directory (tried ${candidates.join(", ")}). Set NANOPM_SKILLS_DIR.`);
5391
5942
  }
@@ -5395,9 +5946,9 @@ function skillDirs(skillsDir, env = process.env) {
5395
5946
  }
5396
5947
  async function loadSkill(name, skillsDir = findSkillsDir()) {
5397
5948
  if (!/^[a-z][a-z0-9-]*$/.test(name)) throw new Error(`Invalid skill name "${name}"`);
5398
- const dir = skillDirs(skillsDir).map((d) => join5(d, name)).find((d) => existsSync2(join5(d, "skill.json")));
5949
+ const dir = skillDirs(skillsDir).map((d) => join8(d, name)).find((d) => existsSync3(join8(d, "skill.json")));
5399
5950
  if (!dir) throw new Error(`Unknown skill "${name}"`);
5400
- const raw = JSON.parse(await readFile2(join5(dir, "skill.json"), "utf8"));
5951
+ const raw = JSON.parse(await readFile4(join8(dir, "skill.json"), "utf8"));
5401
5952
  let manifest;
5402
5953
  try {
5403
5954
  manifest = parseSkillManifest(raw);
@@ -5406,7 +5957,7 @@ async function loadSkill(name, skillsDir = findSkillsDir()) {
5406
5957
  }
5407
5958
  const tainted = taintedByTheWeb(manifest);
5408
5959
  if (tainted) throw new Error(`the "${name}" skill cannot be loaded: ${tainted}`);
5409
- const body = await readFile2(join5(dir, "SKILL.md"), "utf8");
5960
+ const body = await readFile4(join8(dir, "SKILL.md"), "utf8");
5410
5961
  return { manifest, body, dir };
5411
5962
  }
5412
5963
  var NOT_WITH_READ_WEB = ["write_memory", "problems", "propose", "review", "write_repo", "run_commands"];
@@ -5441,7 +5992,7 @@ function renderPrompt(template, params) {
5441
5992
  var NEEDS_THE_REQUESTERS_CLONE = ["write_repo", "run_commands"];
5442
5993
  var NEEDS_THE_REQUESTERS_IDENTITY = /* @__PURE__ */ new Set(["want", "start"]);
5443
5994
  async function clonelessSkills(skillsDir = findSkillsDir()) {
5444
- const names = readdirSync(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync2(join5(skillsDir, d.name, "skill.json"))).map((d) => d.name);
5995
+ const names = readdirSync(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync3(join8(skillsDir, d.name, "skill.json"))).map((d) => d.name);
5445
5996
  const out = [];
5446
5997
  for (const name of names) {
5447
5998
  try {
@@ -5495,30 +6046,6 @@ ${JSON.stringify(mappingOutputSchema(input.role))}`,
5495
6046
 
5496
6047
  // ../daemon/src/problem-mapping.ts
5497
6048
  import { createHash as createHash2 } from "crypto";
5498
-
5499
- // ../daemon/src/usage.ts
5500
- function sumUsage(a, b) {
5501
- if (!a) return { ...b };
5502
- return {
5503
- ...b,
5504
- inputTokens: a.inputTokens + b.inputTokens,
5505
- outputTokens: a.outputTokens + b.outputTokens,
5506
- cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens,
5507
- cacheWriteTokens: (a.cacheWriteTokens ?? 0) + (b.cacheWriteTokens ?? 0),
5508
- costUsd: (a.costUsd ?? 0) + (b.costUsd ?? 0),
5509
- turns: a.turns + b.turns,
5510
- durationMs: a.durationMs + b.durationMs,
5511
- ...a.contextPeakTokens || b.contextPeakTokens ? { contextPeakTokens: Math.max(a.contextPeakTokens ?? 0, b.contextPeakTokens ?? 0) } : {},
5512
- ...a.reads || b.reads ? { reads: sumReads(a.reads, b.reads) } : {}
5513
- };
5514
- }
5515
- function sumReads(a, b) {
5516
- const calls = { ...a?.calls ?? {} };
5517
- for (const [tool, n] of Object.entries(b?.calls ?? {})) calls[tool] = (calls[tool] ?? 0) + n;
5518
- return { files: (a?.files ?? 0) + (b?.files ?? 0), calls };
5519
- }
5520
-
5521
- // ../daemon/src/problem-mapping.ts
5522
6049
  var MAPPING_TURN_LIMIT = 40;
5523
6050
  var MAPPING_TIMEOUT_MS = 20 * 6e4;
5524
6051
  async function runProblemMapping(ports) {
@@ -5568,9 +6095,9 @@ async function runProblemMapping(ports) {
5568
6095
  if (cycle === 1 && ports.collect) {
5569
6096
  const maxTurns = Math.min(8, MAPPING_TURN_LIMIT - (usage?.turns ?? 0) - 2);
5570
6097
  await ports.checkpoint("collector.started", { maxTurns: Math.max(0, maxTurns), startedAt: Date.now() });
5571
- let next2 = null;
6098
+ let next = null;
5572
6099
  try {
5573
- next2 = await ports.collect(proposal.investigation, signal, maxTurns);
6100
+ next = await ports.collect(proposal.investigation, signal, maxTurns);
5574
6101
  } catch (err) {
5575
6102
  signal.throwIfAborted();
5576
6103
  failure2 = errorMessage(err);
@@ -5578,13 +6105,13 @@ async function runProblemMapping(ports) {
5578
6105
  usage = sumUsage(usage, spent);
5579
6106
  await ports.checkpoint("collector", { usage: spent, failed: failure2 });
5580
6107
  }
5581
- if (!next2 && !failure2) await ports.checkpoint("collector", { unavailable: true });
6108
+ if (!next && !failure2) await ports.checkpoint("collector", { unavailable: true });
5582
6109
  signal.throwIfAborted();
5583
- if (next2) {
5584
- if (next2.usage.turns > 0) usage = sumUsage(usage, next2.usage);
5585
- await ports.checkpoint("collector", { usage: next2.usage });
6110
+ if (next) {
6111
+ if (next.usage.turns > 0) usage = sumUsage(usage, next.usage);
6112
+ await ports.checkpoint("collector", { usage: next.usage });
5586
6113
  if ((usage?.turns ?? 0) >= MAPPING_TURN_LIMIT) throw new Error("Collection exhausted the shared turn budget.");
5587
- snapshot3 = copySnapshot(next2.snapshot);
6114
+ snapshot3 = copySnapshot(next.snapshot);
5588
6115
  feedback = { previous_proposal: proposal, instruction: proposal.investigation.collector === "sources" ? "The seen sources you named are now in the snapshot in full. Reconsider the proposal with them, then return the final version." : "Reconsider the proposal with the collected findings, then return the final version." };
5589
6116
  await ports.checkpoint("collection", { snapshot_hash: mappingHash(snapshot3), request: proposal.investigation });
5590
6117
  continue;
@@ -5770,8 +6297,8 @@ async function askAgainIfUnanswered(attempt7, say, role7) {
5770
6297
  var UNANSWERED = /structured[ _]output|returned no result/i;
5771
6298
 
5772
6299
  // ../daemon/src/site.ts
5773
- import { readFile as readFile3 } from "fs/promises";
5774
- import { join as join6 } from "path";
6300
+ import { readFile as readFile5 } from "fs/promises";
6301
+ import { join as join9 } from "path";
5775
6302
  var SITE_PATHS = ["/", "/about", "/pricing"];
5776
6303
  var PAGE_CHARS = 6e3;
5777
6304
  var SITE_TIMEOUT_MS = 2e4;
@@ -5894,7 +6421,7 @@ function siteFromSearch(name, hits) {
5894
6421
  }
5895
6422
  async function read(repoRoot, name) {
5896
6423
  try {
5897
- const text2 = await readFile3(join6(repoRoot, name), "utf8");
6424
+ const text2 = await readFile5(join9(repoRoot, name), "utf8");
5898
6425
  return text2.length > 2e5 ? null : text2;
5899
6426
  } catch {
5900
6427
  return null;
@@ -5948,8 +6475,8 @@ async function fromReadme(repoRoot) {
5948
6475
  }
5949
6476
 
5950
6477
  // ../daemon/src/repo-docs.ts
5951
- import { readFile as readFile4, readdir, stat } from "fs/promises";
5952
- import { join as join7, relative } from "path";
6478
+ import { readFile as readFile6, readdir as readdir2, stat } from "fs/promises";
6479
+ import { join as join10, relative } from "path";
5953
6480
  var WRITTEN = /(persona|audience|customer|user-research|research\/|icp|segment)/i;
5954
6481
  var ABOUT_PEOPLE = /(persona|audience|customer|user-research|research\/|interview|feedback|survey|verbatim|voice|icp|segment)/i;
5955
6482
  var DOC = /\.(md|mdx|txt)$/i;
@@ -5962,12 +6489,12 @@ async function readDocs(cwd, opts = {}) {
5962
6489
  if (depth > 3 || found.length >= DOCS_MAX * 4) return;
5963
6490
  let entries;
5964
6491
  try {
5965
- entries = await readdir(dir, { withFileTypes: true });
6492
+ entries = await readdir2(dir, { withFileTypes: true });
5966
6493
  } catch {
5967
6494
  return;
5968
6495
  }
5969
6496
  for (const entry of entries) {
5970
- const full = join7(dir, entry.name);
6497
+ const full = join10(dir, entry.name);
5971
6498
  if (SKIP.test(`/${relative(cwd, full)}`)) continue;
5972
6499
  if (entry.isDirectory()) await walk(full, depth + 1);
5973
6500
  else if (DOC.test(entry.name) && (!opts.onlyAboutPeople || ABOUT_PEOPLE.test(relative(cwd, full)))) found.push(full);
@@ -5979,7 +6506,7 @@ async function readDocs(cwd, opts = {}) {
5979
6506
  for (const path of found.slice(0, DOCS_MAX)) {
5980
6507
  try {
5981
6508
  if ((await stat(path)).size > 4e5) continue;
5982
- out.push({ path: relative(cwd, path), text: (await readFile4(path, "utf8")).slice(0, DOC_CHARS) });
6509
+ out.push({ path: relative(cwd, path), text: (await readFile6(path, "utf8")).slice(0, DOC_CHARS) });
5983
6510
  } catch {
5984
6511
  }
5985
6512
  }
@@ -6451,10 +6978,10 @@ function rank(candidates, readings, verdict, faults) {
6451
6978
  const order = (lands) => DIRECTION_SHAPE_ORDER.indexOf(lands);
6452
6979
  return candidates.filter((c) => readings.some((r) => r.lands === c.lands)).map((candidate) => {
6453
6980
  const v = verdict.verdicts.find((x) => x.lands === candidate.lands);
6454
- const failed = v?.failed ?? [];
6455
- const fatal = failed.filter((t) => MUST_PASS_TESTS.includes(t.test));
6981
+ const failed2 = v?.failed ?? [];
6982
+ const fatal = failed2.filter((t) => MUST_PASS_TESTS.includes(t.test));
6456
6983
  const gates = faults.get(candidate.lands) ?? [];
6457
- const answered = /* @__PURE__ */ new Set([...v?.passed ?? [], ...failed.map((t) => t.test)]);
6984
+ const answered = /* @__PURE__ */ new Set([...v?.passed ?? [], ...failed2.map((t) => t.test)]);
6458
6985
  const unanswered = MUST_PASS_TESTS.filter((t) => !answered.has(t));
6459
6986
  const reservations = [
6460
6987
  ...fatal.map((t) => `${t.test} \u2014 ${t.because}`),
@@ -6527,17 +7054,17 @@ async function publish2(client, projectId, job, winner, current, because, confid
6527
7054
  for (const row of DIRECTION_ROWS) {
6528
7055
  const content = winner[row].trim();
6529
7056
  const base = current.find((r) => r.topic === row && r.key === directionKey(row));
6530
- const next2 = current.find((r) => r.topic === row && r.key === directionKey(row, true));
7057
+ const next = current.find((r) => r.topic === row && r.key === directionKey(row, true));
6531
7058
  const sources2 = [
6532
7059
  ...winner.restsOn.map((ref) => ({ kind: "memory", ref, note: because })),
6533
7060
  ...runnerUp && runnerUp[row]?.trim() && runnerUp[row].trim() !== content ? [{ kind: CONSIDERED, ref: runnerUp.lands, note: runnerUp[row].trim() }] : []
6534
7061
  ];
6535
7062
  if (base?.provenance === "stated") {
6536
7063
  if (base.content.trim() === content) {
6537
- if (next2) await retire(client, projectId, next2.id, "withdrawn: the crew now agrees with your line");
7064
+ if (next) await retire(client, projectId, next.id, "withdrawn: the crew now agrees with your line");
6538
7065
  continue;
6539
7066
  }
6540
- await write2(client, projectId, jobId, row, { key: directionKey(row, true), content, confidence, sources: sources2, supersedes: next2?.id ?? null });
7067
+ await write2(client, projectId, jobId, row, { key: directionKey(row, true), content, confidence, sources: sources2, supersedes: next?.id ?? null });
6541
7068
  proposed.push(row);
6542
7069
  } else {
6543
7070
  if (base && base.content.trim() === content) continue;
@@ -6758,17 +7285,17 @@ async function publish3(client, projectId, job, winner, current, because, runner
6758
7285
  for (const line of PITCH_LINE_KEYS) {
6759
7286
  const content = winner[line].trim();
6760
7287
  const base = current.find((r) => r.key === pitchKey(line));
6761
- const next2 = current.find((r) => r.key === pitchKey(line, true));
7288
+ const next = current.find((r) => r.key === pitchKey(line, true));
6762
7289
  const sources2 = [
6763
7290
  ...winner.restsOn.map((ref) => ({ kind: "memory", ref, note: because })),
6764
7291
  ...runnerUp && !sameLine(runnerUp[line], content) ? [{ kind: CONSIDERED, ref: runnerUp.form, note: runnerUp[line].trim() }] : []
6765
7292
  ];
6766
7293
  if (base?.provenance === "stated") {
6767
7294
  if (sameLine(base.content, content)) {
6768
- if (next2) await retire2(client, projectId, next2.id, "withdrawn: the crew now agrees with your line");
7295
+ if (next) await retire2(client, projectId, next.id, "withdrawn: the crew now agrees with your line");
6769
7296
  continue;
6770
7297
  }
6771
- await write3(client, projectId, jobId, { key: pitchKey(line, true), title: PITCH_LINE_TITLE[line], content, confidence: winner.confidence, sources: sources2, supersedes: next2?.id ?? null });
7298
+ await write3(client, projectId, jobId, { key: pitchKey(line, true), title: PITCH_LINE_TITLE[line], content, confidence: winner.confidence, sources: sources2, supersedes: next?.id ?? null });
6772
7299
  proposed.push(line);
6773
7300
  } else {
6774
7301
  if (base && sameLine(base.content, content)) continue;
@@ -7023,10 +7550,10 @@ async function runMarketJob({ client, job, adapter, spec: spec2, onEvent }) {
7023
7550
  }
7024
7551
  async function writeRow(client, projectId, job, rows2, item) {
7025
7552
  const base = rows2.find((r) => r.key === item.key);
7026
- const next2 = rows2.find((r) => r.key === `${item.key}-next`);
7553
+ const next = rows2.find((r) => r.key === `${item.key}-next`);
7027
7554
  if (base && base.content.trim() === item.content) return { kind: "written" };
7028
7555
  if (base?.provenance === "stated") {
7029
- await write4(client, projectId, job.id, { ...item, key: `${item.key}-next`, supersedes: next2?.id ?? null });
7556
+ await write4(client, projectId, job.id, { ...item, key: `${item.key}-next`, supersedes: next?.id ?? null });
7030
7557
  return { kind: "written" };
7031
7558
  }
7032
7559
  const held = keepsTheLine(job, base, item.confidence);
@@ -7781,8 +8308,8 @@ ${body}
7781
8308
 
7782
8309
  // ../daemon/src/crew.ts
7783
8310
  import { randomUUID as randomUUID8 } from "crypto";
7784
- import { mkdir as mkdir2 } from "fs/promises";
7785
- import { join as join8 } from "path";
8311
+ import { mkdir as mkdir3 } from "fs/promises";
8312
+ import { join as join11 } from "path";
7786
8313
  var READ_ONLY = {
7787
8314
  read_repo: /^(Read|Glob|Grep)$/,
7788
8315
  read_data: /(^|__)(list_connections|describe_source|query)$/
@@ -7797,8 +8324,8 @@ async function attempt6({ adapter, spec: spec2, onEvent, skill: skillName, name,
7797
8324
  if (skill.manifest.capabilities.some((c) => !holds.includes(c))) throw new Error(`The ${name} may not hold tools.`);
7798
8325
  const refused = grant.find((c) => !READ_ONLY[c] || !skill.manifest.capabilities.includes(c));
7799
8326
  if (refused) throw new Error(`The ${name} may not hold ${refused}.`);
7800
- const runDir = dir ? join8(spec2.runDir, dir) : spec2.runDir;
7801
- if (dir) await mkdir2(runDir, { recursive: true, mode: 448 });
8327
+ const runDir = dir ? join11(spec2.runDir, dir) : spec2.runDir;
8328
+ if (dir) await mkdir3(runDir, { recursive: true, mode: 448 });
7802
8329
  let result;
7803
8330
  let usage = null;
7804
8331
  await onEvent({ type: "text", text: say });
@@ -8595,7 +9122,7 @@ function critiqueWording(o, v, reworked) {
8595
9122
  return { problem: problem && problem.length <= PROBLEM_MAX ? problem : o.problem, description: v.description?.trim() || o.description };
8596
9123
  }
8597
9124
  function shape(o, input) {
8598
- const resolve6 = (ids, stance) => ids.map((id) => {
9125
+ const resolve5 = (ids, stance) => ids.map((id) => {
8599
9126
  const source = input.cites.get(id.trim().toUpperCase());
8600
9127
  if (!source) input.unresolved?.add(id.trim());
8601
9128
  return source;
@@ -8607,7 +9134,7 @@ function shape(o, input) {
8607
9134
  if (source) quoted.push({ ...source, note: clip3(stripContactDetails(e.quote), 140), stance: "supports" });
8608
9135
  }
8609
9136
  const quotedRefs = new Set(quoted.map((q) => q.ref));
8610
- const cited = [...resolve6(o.restsOn, "supports"), ...resolve6(o.against.filter((id) => !supporting.has(id.trim().toUpperCase())), "contradicts")].filter((c) => !(c.stance === "supports" && quotedRefs.has(c.ref)));
9137
+ const cited = [...resolve5(o.restsOn, "supports"), ...resolve5(o.against.filter((id) => !supporting.has(id.trim().toUpperCase())), "contradicts")].filter((c) => !(c.stance === "supports" && quotedRefs.has(c.ref)));
8611
9138
  const all = [...quoted, ...cited];
8612
9139
  let sources2 = all.filter((c, i) => all.findIndex((x) => x.kind === c.kind && x.ref === c.ref && x.stance === c.stance && x.note === c.note) === i);
8613
9140
  if (!sources2.some((s) => s.stance === "supports")) sources2 = [{ kind: "inference", ref: "nanopm", note: null, stance: "supports" }, ...sources2];
@@ -8897,11 +9424,11 @@ async function runSolutionsJob({ client, job, adapter, spec: spec2, onEvent }) {
8897
9424
  await journal("solutions.citations", `${ids.length} citation${ids.length === 1 ? "" : "s"} named nothing the crew was handed, and ${ids.length === 1 ? "was" : "were"} left out: ${ids.slice(0, 12).join(", ")}.`);
8898
9425
  }
8899
9426
  const recorded = results.filter((r) => "kept" in r);
8900
- const failed = results.filter((r) => "failed" in r);
9427
+ const failed2 = results.filter((r) => "failed" in r);
8901
9428
  const took = `Took ${duration(Date.now() - started)}${timings.length ? `: ${timings.map((t) => `${t.what} ${duration(t.ms)}`).join(", ")}` : ""}.`;
8902
9429
  const summary = [
8903
- deep ? leaves.length ? recorded.length ? `Deep dive on \u201C${clip3(leaves[0].problem, 120)}\u201D: kept ${recorded[0].kept.map((t) => `\u201C${t}\u201D`).join(", ")}.` : `Deep dive on \u201C${clip3(leaves[0].problem, 120)}\u201D: nothing recorded \u2014 ${failed[0]?.failed ?? "no solution came back"}.` : "Deep dive: nothing to think through." : `Flash: solutions for ${recorded.length} of ${leaves.length} problem${leaves.length === 1 ? "" : "s"} in ${branches.length} branch${branches.length === 1 ? "" : "es"}.`,
8904
- !deep && failed.length ? `Not recorded: ${failed.map((f) => `${f.leaf.ref} (${f.failed})`).join("; ")}.` : "",
9430
+ deep ? leaves.length ? recorded.length ? `Deep dive on \u201C${clip3(leaves[0].problem, 120)}\u201D: kept ${recorded[0].kept.map((t) => `\u201C${t}\u201D`).join(", ")}.` : `Deep dive on \u201C${clip3(leaves[0].problem, 120)}\u201D: nothing recorded \u2014 ${failed2[0]?.failed ?? "no solution came back"}.` : "Deep dive: nothing to think through." : `Flash: solutions for ${recorded.length} of ${leaves.length} problem${leaves.length === 1 ? "" : "s"} in ${branches.length} branch${branches.length === 1 ? "" : "es"}.`,
9431
+ !deep && failed2.length ? `Not recorded: ${failed2.map((f) => `${f.leaf.ref} (${f.failed})`).join("; ")}.` : "",
8905
9432
  skipped.length ? `Skipped: ${skipped.join("; ")}.` : "",
8906
9433
  took
8907
9434
  ].filter(Boolean).join(" ");
@@ -9071,9 +9598,9 @@ async function flashBranch(ctx, w, n, places) {
9071
9598
  const why = clip3(error instanceof Error ? error.message : String(error), 300);
9072
9599
  for (const leaf of w.leaves) if (!out.some((o) => o.leaf === leaf)) out.push({ leaf, failed: why });
9073
9600
  }
9074
- const failed = out.filter((o) => "failed" in o);
9075
- if (failed.length) {
9076
- await ctx.journal("solutions.failed", `\u201C${name}\u201D could not finish: ${failed.map((f) => `${f.leaf.ref} \u2014 ${f.failed}`).join("; ")}. The other branches were not held.`);
9601
+ const failed2 = out.filter((o) => "failed" in o);
9602
+ if (failed2.length) {
9603
+ await ctx.journal("solutions.failed", `\u201C${name}\u201D could not finish: ${failed2.map((f) => `${f.leaf.ref} \u2014 ${f.failed}`).join("; ")}. The other branches were not held.`);
9077
9604
  }
9078
9605
  return out;
9079
9606
  }
@@ -9438,32 +9965,32 @@ function specOutputSchema(role7) {
9438
9965
  }
9439
9966
 
9440
9967
  // ../daemon/src/git.ts
9441
- import { execFile as execFile4 } from "child_process";
9442
- import { promisify as promisify4 } from "util";
9443
- var execFileAsync4 = promisify4(execFile4);
9444
- var MAX_BUFFER = 4 * 1024 * 1024;
9968
+ import { execFile as execFile5 } from "child_process";
9969
+ import { promisify as promisify5 } from "util";
9970
+ var execFileAsync5 = promisify5(execFile5);
9971
+ var MAX_BUFFER2 = 4 * 1024 * 1024;
9445
9972
  var WIKI_DIR = ".nanopm/wiki/docs";
9446
- async function git2(cwd, args) {
9447
- const { stdout } = await execFileAsync4("git", ["-C", cwd, ...args], { maxBuffer: MAX_BUFFER, timeout: 2e4 });
9973
+ async function git3(cwd, args) {
9974
+ const { stdout } = await execFileAsync5("git", ["-C", cwd, ...args], { maxBuffer: MAX_BUFFER2, timeout: 2e4 });
9448
9975
  return stdout.trim();
9449
9976
  }
9450
9977
  async function listTrackedFiles(cwd) {
9451
- const lines = (args) => git2(cwd, args).then((out) => out ? out.split("\n") : [], () => []);
9978
+ const lines = (args) => git3(cwd, args).then((out) => out ? out.split("\n") : [], () => []);
9452
9979
  const [files, wiki] = await Promise.all([lines(["ls-files"]), lines(["ls-files", "--others", "--ignored", "--exclude-standard", "--", WIKI_DIR])]);
9453
9980
  return { files, wiki };
9454
9981
  }
9455
9982
  async function headCommit(cwd) {
9456
- return git2(cwd, ["rev-parse", "--short", "HEAD"]).then((sha) => sha || null, () => null);
9983
+ return git3(cwd, ["rev-parse", "--short", "HEAD"]).then((sha) => sha || null, () => null);
9457
9984
  }
9458
9985
  async function collectGitDigest(cwd, recent = 30) {
9459
9986
  try {
9460
- await git2(cwd, ["rev-parse", "--git-dir"]);
9987
+ await git3(cwd, ["rev-parse", "--git-dir"]);
9461
9988
  } catch {
9462
9989
  return null;
9463
9990
  }
9464
9991
  const safe = async (args) => {
9465
9992
  try {
9466
- return await git2(cwd, args);
9993
+ return await git3(cwd, args);
9467
9994
  } catch {
9468
9995
  return "";
9469
9996
  }
@@ -9853,13 +10380,13 @@ var told = (r) => ({
9853
10380
  });
9854
10381
 
9855
10382
  // ../daemon/src/objective-definitions.ts
9856
- import { execFile as execFile5 } from "child_process";
10383
+ import { execFile as execFile6 } from "child_process";
9857
10384
  import { randomUUID as randomUUID11 } from "crypto";
9858
- import { existsSync as existsSync3 } from "fs";
9859
- import { mkdir as mkdir3, readFile as readFile5 } from "fs/promises";
9860
- import { join as join9 } from "path";
9861
- import { promisify as promisify5 } from "util";
9862
- var execFileAsync5 = promisify5(execFile5);
10385
+ import { existsSync as existsSync4 } from "fs";
10386
+ import { mkdir as mkdir4, readFile as readFile7 } from "fs/promises";
10387
+ import { join as join12 } from "path";
10388
+ import { promisify as promisify6 } from "util";
10389
+ var execFileAsync6 = promisify6(execFile6);
9863
10390
  var DEFINITIONS_TIMEOUT_MS = 9e4;
9864
10391
  var DEFINITIONS_THINKING_TOKENS = 2e3;
9865
10392
  var READ_TOOLS3 = /* @__PURE__ */ new Set(["Read", "Glob", "Grep", "StructuredOutput"]);
@@ -9868,7 +10395,7 @@ var COUNTING_LINES_MAX = 80;
9868
10395
  var PER_FILE = 4;
9869
10396
  async function countingLines(cwd) {
9870
10397
  try {
9871
- const { stdout } = await execFileAsync5("git", ["grep", "-n", "-I", "-i", "-E", COUNTING, "--", ".", ":!*lock*", ":!*.min.*", ":!*.map", ":!*.svg", ":!*.snap"], {
10398
+ const { stdout } = await execFileAsync6("git", ["grep", "-n", "-I", "-i", "-E", COUNTING, "--", ".", ":!*lock*", ":!*.min.*", ":!*.map", ":!*.svg", ":!*.snap"], {
9872
10399
  cwd,
9873
10400
  maxBuffer: 16 * 1024 * 1024,
9874
10401
  timeout: 5e3
@@ -9893,7 +10420,7 @@ function pickLines(raw) {
9893
10420
  }
9894
10421
  async function killRecorded(runDir) {
9895
10422
  try {
9896
- const pid = Number((await readFile5(join9(runDir, "harness.pid"), "utf8")).trim());
10423
+ const pid = Number((await readFile7(join12(runDir, "harness.pid"), "utf8")).trim());
9897
10424
  if (pid > 1) process.kill(pid, "SIGKILL");
9898
10425
  } catch {
9899
10426
  }
@@ -9913,8 +10440,8 @@ async function readDefinitions({ adapter, spec: spec2, publish: publish5, journa
9913
10440
  if (!hasRepo) return give({}, "No repository to read: the card offers the common definitions.", "ok");
9914
10441
  try {
9915
10442
  const skill = await loadSkill("define-objective");
9916
- const runDir = join9(spec2.runDir, "definitions");
9917
- await mkdir3(runDir, { recursive: true, mode: 448 });
10443
+ const runDir = join12(spec2.runDir, "definitions");
10444
+ await mkdir4(runDir, { recursive: true, mode: 448 });
9918
10445
  const schema = definitionsOutputSchema();
9919
10446
  const [lines, listed] = await Promise.all([countingLines(spec2.cwd), listTrackedFiles(spec2.cwd)]);
9920
10447
  const signal = AbortSignal.any([spec2.signal, AbortSignal.timeout(timeoutMs)]);
@@ -9975,7 +10502,7 @@ ${renderTrackedFiles(listed.files, void 0, listed.wiki)}
9975
10502
  if (!result) throw new Error("no answer");
9976
10503
  const parsed = DefinitionsAnswer.safeParse(JSON.parse(result));
9977
10504
  if (!parsed.success) throw new Error("an answer in the wrong shape");
9978
- const checked = checkProposals(parsed.data, (file) => existsSync3(join9(spec2.cwd, file)));
10505
+ const checked = checkProposals(parsed.data, (file) => existsSync4(join12(spec2.cwd, file)));
9979
10506
  const definitions2 = lever ? checked[lever] ? { [lever]: checked[lever] } : {} : checked;
9980
10507
  const kept = Object.values(definitions2).reduce((n, list) => n + (list?.length ?? 0), 0);
9981
10508
  const read6 = Object.values(definitions2).flat().filter((d) => d?.found).length;
@@ -10248,10 +10775,10 @@ function localDate(timezone, at) {
10248
10775
 
10249
10776
  // ../daemon/src/run-job.ts
10250
10777
  import { randomUUID as randomUUID12 } from "crypto";
10251
- import { existsSync as existsSync4 } from "fs";
10252
- import { mkdir as mkdir4, readFile as readFile6, rm as rm3 } from "fs/promises";
10253
- import { homedir as homedir3, tmpdir } from "os";
10254
- import { join as join10, resolve as resolve4 } from "path";
10778
+ import { existsSync as existsSync5 } from "fs";
10779
+ import { mkdir as mkdir5, readFile as readFile8, rm as rm4 } from "fs/promises";
10780
+ import { homedir as homedir4, tmpdir } from "os";
10781
+ import { join as join13, resolve as resolve4 } from "path";
10255
10782
 
10256
10783
  // ../daemon/src/events.ts
10257
10784
  var JobEventWriter = class {
@@ -10331,320 +10858,28 @@ function lighten(value) {
10331
10858
  return value ?? null;
10332
10859
  }
10333
10860
 
10334
- // ../daemon/src/repo.ts
10335
- import { execFile as execFile6 } from "child_process";
10336
- import { promisify as promisify6 } from "util";
10337
- var execFileAsync6 = promisify6(execFile6);
10338
- var MAX_BUFFER2 = 4 * 1024 * 1024;
10339
- var TIMEOUT_MS = 6e4;
10340
- async function git3(cwd, args, timeoutMs = TIMEOUT_MS) {
10341
- const { stdout } = await execFileAsync6("git", ["-C", cwd, ...args], { maxBuffer: MAX_BUFFER2, timeout: timeoutMs });
10342
- return stdout.trim();
10343
- }
10344
- var RepoError = class extends Error {
10345
- };
10346
- function branchName(ref) {
10347
- const slug = ref.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
10348
- if (!slug) throw new RepoError(`"${ref}" has no letters or digits to make a branch name from`);
10349
- return `nanopm/${slug}`;
10861
+ // ../daemon/src/run-job.ts
10862
+ import { hostname } from "os";
10863
+
10864
+ // ../daemon/src/site-ask.ts
10865
+ async function askAboutSite(opts) {
10866
+ const host = opts.candidate ? new URL(opts.candidate).hostname : null;
10867
+ await opts.events.write("question", {
10868
+ question: host ? `Is ${host} your site?` : "What is your product's website?",
10869
+ holds: false,
10870
+ // The terminal leaves this one to the browser (`run.ts`), and the page draws its own card.
10871
+ about: "site",
10872
+ ...host ? { site: host } : {}
10873
+ });
10350
10874
  }
10351
- async function readState(cwd) {
10352
- try {
10353
- await git3(cwd, ["rev-parse", "--git-dir"]);
10354
- } catch {
10355
- throw new RepoError(`${cwd} is not a git repository`);
10356
- }
10357
- const [branch, head, status2, remoteUrl] = await Promise.all([
10358
- git3(cwd, ["branch", "--show-current"]).catch(() => ""),
10359
- git3(cwd, ["rev-parse", "HEAD"]).catch(() => ""),
10360
- git3(cwd, ["status", "--porcelain"]).catch(() => ""),
10361
- git3(cwd, ["remote", "get-url", "origin"]).catch(() => "")
10362
- ]);
10363
- const dirty = status2.split("\n").filter(Boolean).map((l) => l.slice(3));
10364
- return { branch, head, clean: dirty.length === 0, dirty, hasRemote: Boolean(remoteUrl), remoteUrl: remoteUrl || void 0 };
10365
- }
10366
- async function ensureCleanTree(cwd) {
10367
- const state = await readState(cwd);
10368
- if (!state.head) {
10369
- throw new RepoError("this repository has no commits yet; make the first commit, then run nanopm next");
10370
- }
10371
- if (!state.clean) {
10372
- const shown = state.dirty.slice(0, 5).join(", ");
10373
- const more = state.dirty.length > 5 ? `, and ${state.dirty.length - 5} more` : "";
10374
- throw new RepoError(
10375
- `the working tree has uncommitted changes (${shown}${more}). NanoPM commits only its own work, so commit or stash yours first.`
10376
- );
10377
- }
10378
- return state;
10379
- }
10380
- async function createBranch(cwd, name) {
10381
- const existing = await git3(cwd, ["branch", "--list", name]).catch(() => "");
10382
- if (existing.trim()) {
10383
- throw new RepoError(`branch ${name} already exists. Delete it or finish what is on it first.`);
10384
- }
10385
- await git3(cwd, ["checkout", "-b", name]);
10386
- }
10387
- async function commitAll(cwd, message) {
10388
- await git3(cwd, ["add", "-A"]);
10389
- const staged = await git3(cwd, ["diff", "--cached", "--name-only"]).catch(() => "");
10390
- if (!staged) return null;
10391
- await git3(cwd, ["commit", "-m", message, "-m", "Written by NanoPM. Review before merging."]);
10392
- const sha = await git3(cwd, ["rev-parse", "--short", "HEAD"]);
10393
- return { sha, ...await diffStat(cwd) };
10394
- }
10395
- async function diffStat(cwd) {
10396
- const numstat = await git3(cwd, ["show", "--numstat", "--format=", "HEAD"]).catch(() => "");
10397
- let files = 0;
10398
- let insertions = 0;
10399
- let deletions = 0;
10400
- for (const line of numstat.split("\n").filter(Boolean)) {
10401
- const [add2 = "", del = ""] = line.split(" ");
10402
- files += 1;
10403
- insertions += Number(add2) || 0;
10404
- deletions += Number(del) || 0;
10405
- }
10406
- return { files, insertions, deletions };
10407
- }
10408
- async function pushBranch(cwd, name) {
10409
- const state = await readState(cwd);
10410
- if (!state.hasRemote) return { pushed: false, reason: "this repository has no origin remote" };
10411
- try {
10412
- const { stderr } = await execFileAsync6("git", ["-C", cwd, "push", "--set-upstream", "origin", name], {
10413
- maxBuffer: MAX_BUFFER2,
10414
- timeout: 12e4
10415
- });
10416
- return { pushed: true, reviewUrl: reviewUrlFrom(stderr) };
10417
- } catch (err) {
10418
- const text2 = errorMessage(err);
10419
- return { pushed: false, reason: text2.split("\n").slice(-3).join(" ").trim() || "git push failed" };
10420
- }
10421
- }
10422
- function reviewUrlFrom(pushOutput) {
10423
- const match = /https?:\/\/\S+/.exec(pushOutput);
10424
- return match?.[0]?.replace(/[).,]+$/, "");
10425
- }
10426
- async function checkout(cwd, ref) {
10427
- await git3(cwd, ["checkout", ref]);
10428
- }
10429
- async function deleteBranch(cwd, name) {
10430
- await git3(cwd, ["branch", "-D", name]).catch(() => "");
10431
- }
10432
- async function changedFiles(cwd) {
10433
- const status2 = await git3(cwd, ["status", "--porcelain"]).catch(() => "");
10434
- return status2.split("\n").filter(Boolean).map((line) => line.slice(3).trim()).map((path) => path.includes(" -> ") ? path.split(" -> ")[1].trim() : path).map((path) => path.replace(/^"|"$/g, ""));
10435
- }
10436
-
10437
- // ../daemon/src/next-run.ts
10438
- async function loadAcceptedSolution(client, projectId, solutionId) {
10439
- const { data, error } = await client.from("solutions").select("id, ref, title, reasoning, status, assumption, eval, sources").eq("id", solutionId).eq("project_id", projectId).is("superseded_by", null).maybeSingle();
10440
- if (error) throw new Error(`could not read the solution: ${error.message}`);
10441
- if (!data) throw new Error("that solution is not on this project any more");
10442
- if (data.status !== "accepted" && data.status !== "building") {
10443
- throw new Error(`${data.ref} is ${data.status}. Only a solution you accepted can be built.`);
10444
- }
10445
- return { ...data, sources: data.sources ?? [] };
10446
- }
10447
- async function startSolution(client, projectId, ref, jobId) {
10448
- const { error } = await client.rpc("start_solution", { p_project_id: projectId, p_ref: ref, p_job_id: jobId });
10449
- if (error && !/only an accepted solution/.test(error.message)) throw new Error(pgMessage(error));
10450
- }
10451
- async function unstartSolution(client, projectId, ref) {
10452
- await client.rpc("unstart_solution", { p_project_id: projectId, p_ref: ref });
10453
- }
10454
- function renderSolution(solution) {
10455
- const lines = [`${solution.ref} ${solution.title}`, "", `Why: ${solution.reasoning}`];
10456
- if (solution.assumption) lines.push(`Bets on: ${solution.assumption}`);
10457
- if (solution.eval) lines.push(`We'll know: ${solution.eval}`);
10458
- if (solution.sources.length) {
10459
- lines.push(`Rests on: ${solution.sources.map((s) => `${s.kind}:${s.ref}`).join(", ")}`);
10460
- }
10461
- return lines.join("\n");
10462
- }
10463
- async function openRepoSession(cwd, ref) {
10464
- const state = await ensureCleanTree(cwd);
10465
- if (!state.branch) {
10466
- throw new RepoError("this repository has a detached HEAD; check out a branch first");
10467
- }
10468
- const branch = branchName(ref);
10469
- await createBranch(cwd, branch);
10470
- return { branch, baseBranch: state.branch };
10471
- }
10472
- async function closeRepoSession(cwd, session2, item) {
10473
- return closeBranch(cwd, session2, `${item.ref}: ${item.title}`, { commitErrorThrows: true });
10474
- }
10475
- async function abandonRepoSession(cwd, session2, item) {
10476
- return closeBranch(cwd, session2, item ? `${item.ref}: ${item.title}` : "WIP: nanopm did not finish", { commitErrorThrows: false });
10477
- }
10478
- async function closeBranch(cwd, session2, message, { commitErrorThrows }) {
10479
- let commit;
10480
- try {
10481
- commit = await commitAll(cwd, message);
10482
- } catch (err) {
10483
- await checkout(cwd, session2.baseBranch).catch(() => void 0);
10484
- if (commitErrorThrows) throw err;
10485
- commit = null;
10486
- }
10487
- if (!commit) {
10488
- await checkout(cwd, session2.baseBranch).catch(() => void 0);
10489
- await deleteBranch(cwd, session2.branch);
10490
- return { branch: session2.branch, pushed: false, note: "nothing was changed, so no branch was kept" };
10491
- }
10492
- const push = await pushBranch(cwd, session2.branch);
10493
- await checkout(cwd, session2.baseBranch).catch(() => void 0);
10494
- return {
10495
- branch: session2.branch,
10496
- commit,
10497
- pushed: push.pushed,
10498
- reviewUrl: push.reviewUrl,
10499
- note: push.pushed ? void 0 : `not pushed: ${push.reason}`
10500
- };
10501
- }
10502
- async function finishSolution(client, job, solution, result) {
10503
- const sources2 = [{ kind: "branch", ref: result.branch }];
10504
- if (result.commit?.sha) sources2.push({ kind: "git", ref: result.commit.sha });
10505
- if (result.reviewUrl) sources2.push({ kind: "url", ref: result.reviewUrl });
10506
- sources2.push({ kind: "job", ref: job.id });
10507
- const { error } = await client.rpc("finish_solution", {
10508
- p_project_id: job.project_id,
10509
- p_ref: solution.ref,
10510
- p_job_id: job.id,
10511
- p_sources: sources2
10512
- });
10513
- if (error) throw new Error(`the work is committed on ${result.branch}, but recording it failed: ${error.message}`);
10514
- }
10515
- function renderRepoResult(result) {
10516
- if (!result.commit) return result.note ?? "nothing changed";
10517
- const { files, insertions, deletions } = result.commit;
10518
- const stat2 = `${files} file${files === 1 ? "" : "s"} changed, ${insertions} insertion${insertions === 1 ? "" : "s"}, ${deletions} deletion${deletions === 1 ? "" : "s"}`;
10519
- const lines = [`${result.branch} \xB7 ${result.commit.sha} \xB7 ${stat2}`];
10520
- if (result.reviewUrl) lines.push(result.reviewUrl);
10521
- else if (result.note) lines.push(result.note);
10522
- return lines.join("\n");
10523
- }
10524
-
10525
- // ../daemon/src/run-job.ts
10526
- import { hostname } from "os";
10527
-
10528
- // ../daemon/src/site-ask.ts
10529
- async function askAboutSite(opts) {
10530
- const host = opts.candidate ? new URL(opts.candidate).hostname : null;
10531
- await opts.events.write("question", {
10532
- question: host ? `Is ${host} your site?` : "What is your product's website?",
10533
- holds: false,
10534
- // The terminal leaves this one to the browser (`run.ts`), and the page draws its own card.
10535
- about: "site",
10536
- ...host ? { site: host } : {}
10537
- });
10538
- }
10539
-
10540
- // ../daemon/src/verify.ts
10541
- import { spawn as spawn3 } from "child_process";
10542
- var VERIFY_TIMEOUT_MS = 10 * 6e4;
10543
- var OUTPUT_TAIL = 4e3;
10544
- var DEFINES_THE_COMMAND = [
10545
- /(^|\/)package\.json$/,
10546
- /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb)$/,
10547
- /(^|\/)(Makefile|justfile|Justfile|Taskfile\.ya?ml)$/,
10548
- /(^|\/)\.husky\//,
10549
- /(^|\/)\.github\//,
10550
- /(^|\/)(pyproject\.toml|uv\.lock|Cargo\.toml|Cargo\.lock|Gemfile|Gemfile\.lock)$/
10551
- ];
10552
- function pathsNamedIn(command) {
10553
- return command.split(/[\s;&|()<>]+/).filter((t) => /[/.]/.test(t) && !/^-/.test(t) && t.length > 2).map((t) => t.replace(/^\.\//, "").replace(/^["']|["']$/g, ""));
10554
- }
10555
- function guardVerify(changed, command) {
10556
- for (const file of changed) {
10557
- const pattern = DEFINES_THE_COMMAND.find((p) => p.test(file));
10558
- if (pattern) {
10559
- return `the change touched ${file}, which decides what the verify command does, so the command was not run`;
10560
- }
10561
- }
10562
- const named = pathsNamedIn(command);
10563
- for (const file of changed) {
10564
- if (named.some((n) => file === n || file.endsWith(`/${n}`) || n.endsWith(file))) {
10565
- return `the change touched ${file}, which the verify command runs, so the command was not run`;
10566
- }
10567
- }
10568
- return null;
10569
- }
10570
- async function verify(opts) {
10571
- const skipped = guardVerify(opts.changed, opts.command);
10572
- if (skipped) return { ran: false, passed: false, skipped, output: "", durationMs: 0, timedOut: false };
10573
- return runCommand(opts);
10574
- }
10575
- async function runCommand(opts) {
10576
- const timeoutMs = opts.timeoutMs ?? VERIFY_TIMEOUT_MS;
10577
- const startedAt = Date.now();
10578
- const child = spawn3("sh", ["-c", opts.command], {
10579
- cwd: opts.cwd,
10580
- env: opts.env,
10581
- stdio: ["ignore", "pipe", "pipe"]
10582
- });
10583
- let output = "";
10584
- const collect6 = (chunk) => {
10585
- output += chunk.toString();
10586
- if (output.length > OUTPUT_TAIL * 4) output = output.slice(-OUTPUT_TAIL * 2);
10587
- };
10588
- child.stdout.on("data", collect6);
10589
- child.stderr.on("data", collect6);
10590
- let timedOut = false;
10591
- const timer = setTimeout(() => {
10592
- timedOut = true;
10593
- child.kill("SIGTERM");
10594
- setTimeout(() => child.kill("SIGKILL"), 5e3).unref();
10595
- }, timeoutMs);
10596
- const onAbort = () => child.kill("SIGTERM");
10597
- opts.signal?.addEventListener("abort", onAbort, { once: true });
10598
- const exitCode = await new Promise((resolve6) => {
10599
- child.on("close", (code) => resolve6(code));
10600
- child.on("error", () => resolve6(127));
10601
- });
10602
- clearTimeout(timer);
10603
- opts.signal?.removeEventListener("abort", onAbort);
10604
- return {
10605
- ran: true,
10606
- passed: exitCode === 0 && !timedOut,
10607
- output: tail(output, OUTPUT_TAIL),
10608
- exitCode: exitCode ?? void 0,
10609
- durationMs: Date.now() - startedAt,
10610
- timedOut
10611
- };
10612
- }
10613
- function tail(text2, max) {
10614
- const trimmed = text2.trimEnd();
10615
- if (trimmed.length <= max) return trimmed;
10616
- return `\u2026 ${trimmed.slice(-max)}`;
10617
- }
10618
- function renderVerifyResult(result, command) {
10619
- if (!result.ran) return result.skipped ?? "no verify command is set for this project";
10620
- const secs = (result.durationMs / 1e3).toFixed(0);
10621
- if (result.timedOut) return `the check NanoPM could not run itself, \`${command}\`, did not finish within ${secs}s and was stopped`;
10622
- return result.passed ? `the check NanoPM could not run itself, \`${command}\`, passed in ${secs}s` : `the check NanoPM could not run itself, \`${command}\`, failed (exit ${result.exitCode}) after ${secs}s`;
10623
- }
10624
- function retryPrompt(result, command) {
10625
- return [
10626
- `Your change does not pass this project's own check.`,
10627
- ``,
10628
- `The daemon ran \`${command}\` in the repository root and it failed with exit ${result.exitCode}.`,
10629
- `You did not run this and cannot run it; this output is the only evidence you get.`,
10630
- ``,
10631
- "```",
10632
- result.output || "(the command produced no output)",
10633
- "```",
10634
- ``,
10635
- `Fix what this reports, in the files you already changed where possible. Do not disable`,
10636
- `the check, do not edit what the command runs, and do not change unrelated code to make`,
10637
- `it pass. If the failure is not caused by your change, say so plainly and stop.`
10638
- ].join("\n");
10639
- }
10640
-
10641
- // ../daemon/src/run-job.ts
10642
- var QUICK_RUNS = { reword: runRewordJob, talk: runTalkJob };
10643
- async function runJob(job, ctx) {
10644
- const startedAt = Date.now();
10645
- let events;
10646
- events = new JobEventWriter(job.id, supabaseEventInsert(ctx.client, (seq) => events.bump(seq)));
10647
- let outcome;
10875
+
10876
+ // ../daemon/src/run-job.ts
10877
+ var QUICK_RUNS = { reword: runRewordJob, talk: runTalkJob };
10878
+ async function runJob(job, ctx) {
10879
+ const startedAt = Date.now();
10880
+ let events;
10881
+ events = new JobEventWriter(job.id, supabaseEventInsert(ctx.client, (seq) => events.bump(seq)));
10882
+ let outcome;
10648
10883
  try {
10649
10884
  await events.status("running", { daemon_id: ctx.daemonId, hostname: hostname() });
10650
10885
  const skill = await loadSkill(job.skill);
@@ -10657,11 +10892,13 @@ async function runJob(job, ctx) {
10657
10892
  };
10658
10893
  }
10659
10894
  const durationMs = Date.now() - startedAt;
10895
+ const over = outcome.status !== "waiting";
10660
10896
  await events.status(outcome.status, outcome.error ? { error: outcome.error } : crewRun(job) && outcome.result ? { text: outcome.result } : {});
10661
10897
  await withRetry(
10662
10898
  () => ctx.client.from("jobs").update({
10663
10899
  status: outcome.status,
10664
- finished_at: (/* @__PURE__ */ new Date()).toISOString(),
10900
+ // A parked job is not over: it waits, with no heartbeat, for the founder or the limit.
10901
+ finished_at: over ? (/* @__PURE__ */ new Date()).toISOString() : null,
10665
10902
  // A mapping run's root row belongs to SQL: checkpoints, receipt and text land there
10666
10903
  // with the publication. A resumed job's own row is free to carry the text.
10667
10904
  ...job.skill === "problems" && !job.resume_of ? {} : { result: outcome.result !== void 0 ? { text: outcome.result, ...outcome.data } : null },
@@ -10674,7 +10911,7 @@ async function runJob(job, ctx) {
10674
10911
  ctx,
10675
10912
  job,
10676
10913
  "job.end",
10677
- outcome.status === "succeeded" ? `finished ${job.skill}` : outcome.status === "cancelled" ? `${job.skill} cancelled by the user` : `${job.skill} failed: ${outcome.error?.message}`,
10914
+ 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}`,
10678
10915
  {
10679
10916
  usage: {
10680
10917
  tokens: usageTokens(outcome.usage),
@@ -10683,15 +10920,15 @@ async function runJob(job, ctx) {
10683
10920
  ...outcome.usage?.contextPeakTokens ? { context_peak: outcome.usage.contextPeakTokens } : {},
10684
10921
  ...outcome.usage?.reads ? { files_read: outcome.usage.reads.files, calls: outcome.usage.reads.calls } : {}
10685
10922
  },
10686
- outcome: outcome.status === "succeeded" ? "ok" : "error"
10923
+ outcome: outcome.status === "succeeded" || !over ? "ok" : "error"
10687
10924
  }
10688
10925
  );
10689
10926
  return outcome;
10690
10927
  }
10691
10928
  async function execute(job, skill, ctx, events) {
10692
- const runDir = join10(nanopmHome(), "run", job.id);
10693
- await mkdir4(runDir, { recursive: true, mode: 448 });
10694
- const resumeSessionId = job.resume_of ? await sessionOf(ctx.client, job.resume_of) : void 0;
10929
+ const runDir = join13(nanopmHome(), "run", job.id);
10930
+ await mkdir5(runDir, { recursive: true, mode: 448 });
10931
+ const resumeSessionId = job.resume_of ? await sessionOf(ctx.client, job.resume_of) : job.harness_session_id ?? void 0;
10695
10932
  const sessionId = resumeSessionId ?? randomUUID12();
10696
10933
  await ctx.client.from("jobs").update({ harness_session_id: sessionId }).eq("id", job.id);
10697
10934
  const adapter = ctx.adapter ?? new ClaudeCodeAdapter();
@@ -10734,14 +10971,14 @@ async function execute(job, skill, ctx, events) {
10734
10971
  clearTimeout(timer);
10735
10972
  clearInterval(heartbeat2);
10736
10973
  clearInterval(cancelPoll);
10737
- await rm3(runDir, { recursive: true, force: true });
10974
+ await rm4(runDir, { recursive: true, force: true });
10738
10975
  }
10739
10976
  }
10740
10977
  const quickRun2 = QUICK_RUNS[job.skill];
10741
10978
  if (quickRun2) {
10742
10979
  const cwd2 = scratchDir(job.id);
10743
10980
  try {
10744
- await mkdir4(cwd2, { recursive: true, mode: 448 });
10981
+ await mkdir5(cwd2, { recursive: true, mode: 448 });
10745
10982
  const caps = skill.manifest.capabilities;
10746
10983
  return await quickRun2({
10747
10984
  client: ctx.client,
@@ -10761,13 +10998,22 @@ async function execute(job, skill, ctx, events) {
10761
10998
  clearTimeout(timer);
10762
10999
  clearInterval(heartbeat2);
10763
11000
  clearInterval(cancelPoll);
10764
- await rm3(runDir, { recursive: true, force: true });
10765
- await rm3(cwd2, { recursive: true, force: true });
11001
+ await rm4(runDir, { recursive: true, force: true });
11002
+ await rm4(cwd2, { recursive: true, force: true });
10766
11003
  }
10767
11004
  }
10768
- const capabilities = capCapabilities(skill.manifest.capabilities, autonomy2);
11005
+ const capabilities = capCapabilities(skill.manifest.capabilities, autonomy2, { asked: skill.manifest.asked && job.requested_by === ctx.userId });
10769
11006
  const harnessWeb = await claudeSearches(searchProvider);
10770
- const cwd = await resolveCwd(ctx, job, params, scratchDir(job.id), capabilities.includes("write_repo"));
11007
+ let cwd = await resolveCwd(ctx, job, params, scratchDir(job.id), capabilities.includes("write_repo"));
11008
+ const prepared = job.skill === BUILD_SKILL ? await prepareBuild(ctx.client, job, cwd, adapter) : null;
11009
+ if (prepared && !prepared.ok) {
11010
+ clearTimeout(timer);
11011
+ clearInterval(heartbeat2);
11012
+ clearInterval(cancelPoll);
11013
+ return prepared.outcome;
11014
+ }
11015
+ const build2 = prepared?.build;
11016
+ if (build2) cwd = build2.dir;
10771
11017
  const definitionsRead = job.skill === "onboard" && !job.resume_of ? readDefinitions({
10772
11018
  adapter,
10773
11019
  spec: { cwd, runDir, systemPrompt: "", prompt: "", capabilities: [], mcp: [], maxTurns: 1, sessionId, env: harnessEnv(timeoutMs), signal: abort.signal },
@@ -10791,29 +11037,6 @@ async function execute(job, skill, ctx, events) {
10791
11037
  const read6 = site.pages.length ? `read ${site.pages.map((p) => new URL(p.url).pathname).join(", ")} on ${site.host}` : `could not read ${site.host}`;
10792
11038
  await logActivity(ctx, job, "scan.site", site.missed.length ? `${read6} (${site.missed.length} not read)` : read6);
10793
11039
  }
10794
- let item;
10795
- let session2;
10796
- if (capabilities.includes("write_repo")) {
10797
- const solutionId = params.solution_id;
10798
- if (typeof solutionId !== "string" || !solutionId) {
10799
- clearTimeout(timer);
10800
- clearInterval(heartbeat2);
10801
- clearInterval(cancelPoll);
10802
- return { status: "failed", error: { code: "harness_error", message: "this job names no solution to build" } };
10803
- }
10804
- try {
10805
- item = await loadAcceptedSolution(ctx.client, job.project_id, solutionId);
10806
- session2 = await openRepoSession(cwd, item.ref);
10807
- await startSolution(ctx.client, job.project_id, item.ref, job.id);
10808
- params.item = renderSolution(item);
10809
- await logActivity(ctx, job, "repo.branch", `working on ${session2.branch}`);
10810
- } catch (err) {
10811
- clearTimeout(timer);
10812
- clearInterval(heartbeat2);
10813
- clearInterval(cancelPoll);
10814
- return { status: "failed", error: { code: "harness_error", message: errorMessage(err) } };
10815
- }
10816
- }
10817
11040
  const spec2 = {
10818
11041
  cwd,
10819
11042
  runDir,
@@ -10833,20 +11056,22 @@ async function execute(job, skill, ctx, events) {
10833
11056
  ].filter(Boolean).join("\n\n"),
10834
11057
  capabilities,
10835
11058
  harnessWeb,
11059
+ network: build2?.network,
11060
+ answerFile: build2 ? buildAnswerFile(job.id) : void 0,
10836
11061
  withoutTools: skill.manifest.withoutTools,
10837
11062
  // The workspace reaches the memory server only for a job that changes files; that is
10838
- // what lets `remove_files` refuse everywhere else.
11063
+ // what lets the build's tools refuse everywhere else.
10839
11064
  mcp: ctx.mcpServers?.(job, timeoutMs, capabilities.includes("write_repo") ? cwd : void 0) ?? [],
10840
11065
  model: skill.manifest.model,
10841
11066
  maxTurns: skill.manifest.maxTurns,
10842
11067
  sessionId,
10843
11068
  resumeSessionId,
10844
- env: harnessEnv(timeoutMs),
11069
+ // Print mode kills a background task when the turn ends; a build waits on its commands (§9).
11070
+ env: build2 ? { ...harnessEnv(timeoutMs), CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1" } : harnessEnv(timeoutMs),
10845
11071
  signal: abort.signal
10846
11072
  };
10847
11073
  const verifyCommand2 = project?.preferences?.verify_command?.trim() || null;
10848
11074
  let outcome;
10849
- let verified;
10850
11075
  let webCalls = 0;
10851
11076
  const onHarnessEvent = async (event) => {
10852
11077
  await events.harness(event);
@@ -10864,6 +11089,24 @@ async function execute(job, skill, ctx, events) {
10864
11089
  return result;
10865
11090
  };
10866
11091
  try {
11092
+ if (build2) {
11093
+ try {
11094
+ return await runBuildJob({
11095
+ client: ctx.client,
11096
+ job,
11097
+ adapter,
11098
+ build: build2,
11099
+ spec: spec2,
11100
+ verifyCommand: verifyCommand2,
11101
+ events,
11102
+ onEvent: onHarnessEvent,
11103
+ log: (step, summary, result) => logActivity(ctx, job, step, summary, result ? { outcome: result } : {})
11104
+ });
11105
+ } catch (error) {
11106
+ if (cancelled) return { status: "cancelled" };
11107
+ return { status: "failed", error: { code: timedOut ? "timeout" : "harness_error", message: errorMessage(error) } };
11108
+ }
11109
+ }
10867
11110
  if (job.skill === "personas") {
10868
11111
  try {
10869
11112
  return await runPersonaJob({ client: ctx.client, job, adapter, spec: spec2, onEvent: onHarnessEvent });
@@ -10944,83 +11187,23 @@ async function execute(job, skill, ctx, events) {
10944
11187
  return { status: "failed", error: { code: timedOut ? "timeout" : "harness_error", message: errorMessage(error) } };
10945
11188
  }
10946
11189
  }
10947
- for (let attempt7 = 1; attempt7 <= 2; attempt7 += 1) {
10948
- const attemptSpec = attempt7 === 1 ? spec2 : { ...spec2, prompt: retryPrompt(verified, verifyCommand2), resumeSessionId: sessionId };
10949
- let attemptOutcome;
10950
- webCalls = 0;
10951
- for await (const event of adapter.run(attemptSpec)) {
10952
- await onHarnessEvent(event);
10953
- if (event.type === "finished") {
10954
- attemptOutcome = { status: "succeeded", result: event.result, usage: event.usage };
10955
- } else if (event.type === "failed") {
10956
- attemptOutcome = { status: "failed", error: { code: event.code, message: event.message } };
10957
- }
11190
+ for await (const event of adapter.run(spec2)) {
11191
+ await onHarnessEvent(event);
11192
+ if (event.type === "finished") {
11193
+ outcome = { status: "succeeded", result: event.result, usage: event.usage };
11194
+ } else if (event.type === "failed") {
11195
+ outcome = { status: "failed", error: { code: event.code, message: event.message } };
10958
11196
  }
10959
- if (attemptOutcome) await noteIfItNeverSearched(attemptOutcome, capabilities.includes("read_web"));
10960
- outcome = mergeAttempts(outcome, attemptOutcome);
10961
- if (attemptOutcome?.status !== "succeeded" || !session2 || !verifyCommand2) break;
10962
- verified = await verify({
10963
- cwd,
10964
- command: verifyCommand2,
10965
- env: harnessEnv(timeoutMs),
10966
- changed: await changedFiles(cwd),
10967
- signal: abort.signal
10968
- });
10969
- await logActivity(ctx, job, "repo.verify", renderVerifyResult(verified, verifyCommand2), {
10970
- outcome: verified.passed ? "ok" : "error",
10971
- usage: { duration_ms: verified.durationMs }
10972
- });
10973
- await events.write("text", { text: renderVerifyResult(verified, verifyCommand2) });
10974
- if (verified.passed || !verified.ran || attempt7 === 2) break;
10975
11197
  }
11198
+ if (outcome) await noteIfItNeverSearched(outcome, capabilities.includes("read_web"));
10976
11199
  } finally {
10977
11200
  clearTimeout(timer);
10978
11201
  clearInterval(heartbeat2);
10979
11202
  clearInterval(cancelPoll);
10980
11203
  await definitionsRead;
10981
- await rm3(runDir, { recursive: true, force: true });
10982
- }
10983
- if (outcome?.status === "succeeded" && verified && verified.ran && !verified.passed) {
10984
- outcome = {
10985
- ...outcome,
10986
- status: "failed",
10987
- error: {
10988
- code: "harness_error",
10989
- message: `${renderVerifyResult(verified, verifyCommand2)}. The work is on the branch; the solution stays accepted.`
10990
- }
10991
- };
10992
- }
10993
- if (outcome?.status === "succeeded") {
10994
- if (!session2 || !item) return outcome;
10995
- try {
10996
- const repo = await closeRepoSession(cwd, session2, item);
10997
- await logActivity(ctx, job, "repo.commit", renderRepoResult(repo).split("\n")[0]);
10998
- if (!repo.commit) {
10999
- await unstartSolution(ctx.client, job.project_id, item.ref).catch(() => void 0);
11000
- return {
11001
- ...outcome,
11002
- status: "failed",
11003
- error: { code: "harness_error", message: "the skill finished without changing anything" }
11004
- };
11005
- }
11006
- await finishSolution(ctx.client, job, item, repo);
11007
- return { ...outcome, result: [outcome.result, "", renderRepoResult(repo)].filter((l) => l !== void 0).join("\n") };
11008
- } catch (err) {
11009
- return {
11010
- ...outcome,
11011
- status: "failed",
11012
- error: { code: "harness_error", message: errorMessage(err) }
11013
- };
11014
- }
11015
- }
11016
- if (session2) {
11017
- const kept = await abandonRepoSession(cwd, session2, verified?.ran && !verified.passed ? item : void 0).catch(() => void 0);
11018
- if (item) await unstartSolution(ctx.client, job.project_id, item.ref).catch(() => void 0);
11019
- if (kept?.commit) {
11020
- await logActivity(ctx, job, "repo.commit", renderRepoResult(kept).split("\n")[0], { outcome: "error" });
11021
- if (outcome) outcome = { ...outcome, result: [outcome.result, "", renderRepoResult(kept)].filter(Boolean).join("\n") };
11022
- }
11204
+ await rm4(runDir, { recursive: true, force: true });
11023
11205
  }
11206
+ if (outcome?.status === "succeeded") return outcome;
11024
11207
  if (cancelled) return { status: "cancelled" };
11025
11208
  if (timedOut) {
11026
11209
  return { status: "failed", error: { code: "timeout", message: `exceeded the ${skill.manifest.timeout} cap` } };
@@ -11031,10 +11214,6 @@ async function sessionOf(client, jobId) {
11031
11214
  const { data } = await client.from("jobs").select("harness_session_id").eq("id", jobId).maybeSingle();
11032
11215
  return data?.harness_session_id ?? void 0;
11033
11216
  }
11034
- function mergeAttempts(first, second) {
11035
- if (!first?.usage || !second?.usage) return second ?? first;
11036
- return { ...second, usage: sumUsage(first.usage, second.usage) };
11037
- }
11038
11217
  async function askForTheSite(ctx, job, project, events) {
11039
11218
  const started = Date.now();
11040
11219
  const search = await searchForSite(project.name);
@@ -11060,7 +11239,7 @@ function harnessWebFor(searchProvider, machineCanSearch) {
11060
11239
  }
11061
11240
  async function machineWebSearch() {
11062
11241
  try {
11063
- const parsed = JSON.parse(await readFile6(join10(nanopmHome(), "daemon.json"), "utf8"));
11242
+ const parsed = JSON.parse(await readFile8(join13(nanopmHome(), "daemon.json"), "utf8"));
11064
11243
  return parsed.webSearch === true;
11065
11244
  } catch {
11066
11245
  return false;
@@ -11102,7 +11281,7 @@ function buildHeader(job, project, cwd, harnessWeb = false, memory = false) {
11102
11281
  return lines.join("\n");
11103
11282
  }
11104
11283
  function scratchDir(jobId) {
11105
- return join10(tmpdir(), `nanopm-run-${jobId}`);
11284
+ return join13(tmpdir(), `nanopm-run-${jobId}`);
11106
11285
  }
11107
11286
  async function resolveCwd(ctx, job, params, fallback, mustBeTheClone = false) {
11108
11287
  const refuse2 = (why) => {
@@ -11111,12 +11290,12 @@ async function resolveCwd(ctx, job, params, fallback, mustBeTheClone = false) {
11111
11290
  const requested = params.repo_path;
11112
11291
  if (typeof requested !== "string" || !requested) {
11113
11292
  if (mustBeTheClone) refuse2("the job named no repository");
11114
- await mkdir4(fallback, { recursive: true, mode: 448 });
11293
+ await mkdir5(fallback, { recursive: true, mode: 448 });
11115
11294
  return fallback;
11116
11295
  }
11117
11296
  if (!job.project_id) {
11118
11297
  if (mustBeTheClone) refuse2("the job belongs to no project");
11119
- await mkdir4(fallback, { recursive: true, mode: 448 });
11298
+ await mkdir5(fallback, { recursive: true, mode: 448 });
11120
11299
  return fallback;
11121
11300
  }
11122
11301
  const { data: clone } = await ctx.client.from("clones").select("repo_path").eq("project_id", job.project_id).eq("daemon_id", ctx.daemonId).maybeSingle();
@@ -11124,12 +11303,12 @@ async function resolveCwd(ctx, job, params, fallback, mustBeTheClone = false) {
11124
11303
  if (!allowed || resolve4(allowed) !== resolve4(requested)) {
11125
11304
  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`);
11126
11305
  console.error(`job ${job.id}: repo_path ${requested} is not this machine's clone; running in a scratch directory instead`);
11127
- await mkdir4(fallback, { recursive: true, mode: 448 });
11306
+ await mkdir5(fallback, { recursive: true, mode: 448 });
11128
11307
  return fallback;
11129
11308
  }
11130
- if (existsSync4(allowed)) return resolve4(allowed);
11309
+ if (existsSync5(allowed)) return resolve4(allowed);
11131
11310
  if (mustBeTheClone) refuse2(`${allowed} is not on this machine any more`);
11132
- await mkdir4(fallback, { recursive: true, mode: 448 });
11311
+ await mkdir5(fallback, { recursive: true, mode: 448 });
11133
11312
  return fallback;
11134
11313
  }
11135
11314
  function asData8(label, body) {
@@ -11138,10 +11317,10 @@ ${body}
11138
11317
  </${label}>`;
11139
11318
  }
11140
11319
  function harnessEnv(timeoutMs, source = process.env) {
11141
- const home = source.HOME ?? homedir3();
11320
+ const home = source.HOME ?? homedir4();
11142
11321
  const env = {
11143
11322
  HOME: home,
11144
- PATH: [source.PATH ?? "/usr/local/bin:/usr/bin:/bin", join10(home, ".local", "bin")].join(":"),
11323
+ PATH: [source.PATH ?? "/usr/local/bin:/usr/bin:/bin", join13(home, ".local", "bin")].join(":"),
11145
11324
  MCP_TOOL_TIMEOUT: String(timeoutMs)
11146
11325
  };
11147
11326
  for (const key of ["USER", "LANG", "TMPDIR", "SHELL", "ANTHROPIC_API_KEY", "CLAUDE_CONFIG_DIR"]) {
@@ -11221,6 +11400,8 @@ async function runDaemon(options = {}) {
11221
11400
  const startedAt = Date.now();
11222
11401
  let lastDeployCheck = 0;
11223
11402
  const DEPLOY_CHECK_MS = 30 * 6e4;
11403
+ const PAUSE_CHECK_MS = 6e4;
11404
+ let lastPauseCheck = 0;
11224
11405
  const UPDATE_CHECK_MS = 60 * 6e4;
11225
11406
  const updatable = !options.once && installKind() === "installer";
11226
11407
  let lastUpdateCheck = Date.now() - UPDATE_CHECK_MS + 5 * 6e4;
@@ -11278,6 +11459,10 @@ async function runDaemon(options = {}) {
11278
11459
  continue;
11279
11460
  }
11280
11461
  if (options.once && Date.now() - startedAt > onceTimeoutMs) break;
11462
+ if (!options.once && Date.now() - lastPauseCheck > PAUSE_CHECK_MS) {
11463
+ lastPauseCheck = Date.now();
11464
+ for (const id of await resumePausedBuilds(auth.client, daemonId).catch(() => [])) log(`build ${id}: the Claude limit reset, carrying on`);
11465
+ }
11281
11466
  if (!options.once && Date.now() - lastDeployCheck > DEPLOY_CHECK_MS) {
11282
11467
  lastDeployCheck = Date.now();
11283
11468
  for (const check of await reviewDeploys(auth.client, daemonId).catch(() => [])) {
@@ -11318,12 +11503,12 @@ async function chainAfter(auth, job, outcome, log) {
11318
11503
  const parent = job.parent_job_id ? await parentOf(auth, job.parent_job_id) : null;
11319
11504
  const own = job.params ?? {};
11320
11505
  const deep = own.depth === "deep" && job.trigger === "hook";
11321
- const next2 = daemonQueues(job.skill, 0, parent?.skill ?? null, deep);
11322
- if (!next2.length) return;
11506
+ const next = daemonQueues(job.skill, 0, parent?.skill ?? null, deep);
11507
+ if (!next.length) return;
11323
11508
  if (await chainDepth(auth, job) >= CHAIN_DEPTH_MAX) return;
11324
11509
  const from = deep ? own.repo_path : parent?.params?.repo_path;
11325
11510
  const repo = typeof from === "string" ? { repo_path: from } : null;
11326
- for (const skill of next2) {
11511
+ for (const skill of next) {
11327
11512
  const params = { ...repo ?? {}, ...deep ? { depth: "deep", background: true } : {} };
11328
11513
  const { data, error } = await auth.client.from("jobs").insert({
11329
11514
  project_id: job.project_id,
@@ -11406,7 +11591,7 @@ var heldInThisProcess = false;
11406
11591
  function acquireLock() {
11407
11592
  if (heldInThisProcess) throw new Error("a nanopm daemon is already running in this process");
11408
11593
  mkdirSync(nanopmHome(), { recursive: true, mode: 448 });
11409
- const path = join11(nanopmHome(), "daemon.pid");
11594
+ const path = join14(nanopmHome(), "daemon.pid");
11410
11595
  for (; ; ) {
11411
11596
  try {
11412
11597
  const fd = openSync(path, "wx");
@@ -11478,10 +11663,11 @@ async function claimNext(client, daemonId, skills) {
11478
11663
  return rows2[0] ?? null;
11479
11664
  }
11480
11665
  async function reclaimOwnJobs(client, daemonId) {
11481
- const { data: stale } = await client.from("jobs").select("id, project_id, skill").eq("assigned_daemon_id", daemonId).in("status", ["running", "waiting"]);
11666
+ const { data: stale } = await client.from("jobs").select("id, project_id, skill, status, attempt, params").eq("assigned_daemon_id", daemonId).in("status", ["running", "waiting"]);
11482
11667
  const ids = [];
11483
11668
  for (const job of stale ?? []) {
11484
11669
  await killOrphanedHarness(job.id);
11670
+ if (job.skill === BUILD_SKILL && (job.status === "waiting" || await requeueLostBuild(client, job))) continue;
11485
11671
  const error = { code: "interrupted", message: "the daemon stopped while this job was running" };
11486
11672
  await client.from("jobs").update({ status: "failed", finished_at: (/* @__PURE__ */ new Date()).toISOString(), error }).eq("id", job.id).in("status", ["running", "waiting"]);
11487
11673
  await client.from("job_events").insert({ job_id: job.id, seq: 0, kind: "status", payload: { status: "failed", error }, author_kind: "nanopm" });
@@ -11493,13 +11679,13 @@ async function reclaimOwnJobs(client, daemonId) {
11493
11679
  async function harnessPids(runDir) {
11494
11680
  const dirs = [runDir];
11495
11681
  try {
11496
- for (const d of await readdir2(runDir, { withFileTypes: true })) if (d.isDirectory()) dirs.push(join11(runDir, d.name));
11682
+ for (const d of await readdir3(runDir, { withFileTypes: true })) if (d.isDirectory()) dirs.push(join14(runDir, d.name));
11497
11683
  } catch {
11498
11684
  }
11499
11685
  const pids = [];
11500
11686
  for (const dir of dirs) {
11501
11687
  try {
11502
- const pid = Number((await readFile7(join11(dir, "harness.pid"), "utf8")).trim());
11688
+ const pid = Number((await readFile9(join14(dir, "harness.pid"), "utf8")).trim());
11503
11689
  if (pid > 1) pids.push(pid);
11504
11690
  } catch {
11505
11691
  }
@@ -11507,20 +11693,20 @@ async function harnessPids(runDir) {
11507
11693
  return pids;
11508
11694
  }
11509
11695
  async function killOrphanedHarness(jobId) {
11510
- const runDir = join11(nanopmHome(), "run", jobId);
11696
+ const runDir = join14(nanopmHome(), "run", jobId);
11511
11697
  for (const pid of await harnessPids(runDir)) {
11512
11698
  try {
11513
11699
  process.kill(pid, "SIGTERM");
11514
11700
  } catch {
11515
11701
  }
11516
11702
  }
11517
- await rm4(runDir, { recursive: true, force: true });
11703
+ await rm5(runDir, { recursive: true, force: true });
11518
11704
  }
11519
11705
  async function registerDaemon(auth, version, opts = {}) {
11520
- const path = join11(nanopmHome(), "daemon.json");
11706
+ const path = join14(nanopmHome(), "daemon.json");
11521
11707
  let identity = null;
11522
11708
  try {
11523
- identity = JSON.parse(await readFile7(path, "utf8"));
11709
+ identity = JSON.parse(await readFile9(path, "utf8"));
11524
11710
  } catch {
11525
11711
  identity = null;
11526
11712
  }
@@ -11558,8 +11744,8 @@ async function registerDaemon(auth, version, opts = {}) {
11558
11744
  return data.id;
11559
11745
  }
11560
11746
  async function rememberDaemon(id, shared = false) {
11561
- await mkdir5(nanopmHome(), { recursive: true, mode: 448 });
11562
- await writeFile4(join11(nanopmHome(), "daemon.json"), JSON.stringify({ id, shared }, null, 2) + "\n", { mode: 384 });
11747
+ await mkdir6(nanopmHome(), { recursive: true, mode: 448 });
11748
+ await writeFile6(join14(nanopmHome(), "daemon.json"), JSON.stringify({ id, shared }, null, 2) + "\n", { mode: 384 });
11563
11749
  }
11564
11750
  async function heartbeat(client, daemonId) {
11565
11751
  await client.from("daemons").update({ status: "online", last_seen_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", daemonId);
@@ -11613,13 +11799,15 @@ function contextFromEnv(env = process.env) {
11613
11799
  userId: env.NANOPM_USER_ID,
11614
11800
  actor: env.NANOPM_ACTOR,
11615
11801
  askTimeoutMs: Number(env.NANOPM_ASK_TIMEOUT_MS ?? 3 * 60 * 60 * 1e3),
11616
- workspace: env.NANOPM_WORKSPACE || void 0
11802
+ workspace: env.NANOPM_WORKSPACE || void 0,
11803
+ answerFile: env.NANOPM_ANSWER_FILE || void 0
11617
11804
  };
11618
11805
  }
11619
11806
 
11620
11807
  // ../memory-mcp/src/server.ts
11621
11808
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11622
11809
  import { randomUUID as randomUUID13 } from "crypto";
11810
+ import { readFile as readFile10, unlink } from "fs/promises";
11623
11811
  import { setTimeout as sleep2 } from "timers/promises";
11624
11812
  import { z as z21 } from "zod";
11625
11813
 
@@ -11910,48 +12098,6 @@ function refuseFetch(url, allowlist, open = false) {
11910
12098
  return `${host} is not on this project's allowlist, so it was not read. The founder can allow it: if it is a competitor, \`nanopm market confirm <ref>\` on the entry that names them, or \`nanopm market add <name> ${host}\`. Until then, search results and the sites already allowed are what there is.`;
11911
12099
  }
11912
12100
 
11913
- // ../memory-mcp/src/repo-rules.ts
11914
- import { lstat, unlink } from "fs/promises";
11915
- import { relative as relative3, resolve as resolve5, sep as sep2 } from "path";
11916
- var REMOVE_MAX = 50;
11917
- function refuseRemove(workspace, requested) {
11918
- const root2 = resolve5(workspace);
11919
- const target = resolve5(root2, requested);
11920
- const rel = relative3(root2, target);
11921
- if (!rel || rel.startsWith("..") || rel.startsWith(`..${sep2}`)) return "outside the workspace";
11922
- if (rel === ".git" || rel.startsWith(`.git${sep2}`)) return "inside .git";
11923
- if (looksLikeASecret(rel)) return "looks like a secret, which the agent may neither read nor remove";
11924
- return null;
11925
- }
11926
- async function removeFiles(workspace, paths) {
11927
- const out = [];
11928
- for (const requested of paths.slice(0, REMOVE_MAX)) {
11929
- const refusal = refuseRemove(workspace, requested);
11930
- if (refusal) {
11931
- out.push({ path: requested, removed: false, reason: refusal });
11932
- continue;
11933
- }
11934
- const target = resolve5(workspace, requested);
11935
- try {
11936
- const stat2 = await lstat(target);
11937
- if (stat2.isSymbolicLink()) {
11938
- out.push({ path: requested, removed: false, reason: "a symlink; what it points at is not touched" });
11939
- continue;
11940
- }
11941
- if (stat2.isDirectory()) {
11942
- out.push({ path: requested, removed: false, reason: "a directory; name the files in it" });
11943
- continue;
11944
- }
11945
- await unlink(target);
11946
- out.push({ path: requested, removed: true });
11947
- } catch (err) {
11948
- const code = err.code;
11949
- out.push({ path: requested, removed: false, reason: code === "ENOENT" ? "not there" : err instanceof Error ? err.message : String(err) });
11950
- }
11951
- }
11952
- return out;
11953
- }
11954
-
11955
12101
  // ../memory-mcp/src/problem-rules.ts
11956
12102
  import { z as z20 } from "zod";
11957
12103
  var STATEMENT_MAX = 300;
@@ -12187,6 +12333,8 @@ ${renderMap()}`,
12187
12333
  }
12188
12334
  },
12189
12335
  async ({ question, options, why }, extra) => {
12336
+ const handed = ctx.answerFile ? await takeAnswer(ctx.answerFile) : null;
12337
+ if (handed !== null) return json({ answer: handed, skipped: false });
12190
12338
  const answer = await askUser(ctx, { question, options, why }, extra.signal);
12191
12339
  if (answer === null) return failure("No answer arrived in time, or the job was cancelled. Do not ask again; continue with what you have.");
12192
12340
  return json({ answer, skipped: /^\s*(skip|passe?r?)\s*$/i.test(answer) || answer.trim() === "" });
@@ -12326,7 +12474,7 @@ ${renderMap()}`,
12326
12474
  }
12327
12475
  },
12328
12476
  async ({ ref, outcome, note }) => {
12329
- return json(await finishSolution2(ctx, ref, outcome, note));
12477
+ return json(await finishSolution(ctx, ref, outcome, note));
12330
12478
  }
12331
12479
  );
12332
12480
  server.registerTool(
@@ -12426,20 +12574,20 @@ ${renderMap()}`,
12426
12574
  }
12427
12575
  );
12428
12576
  server.registerTool(
12429
- "remove_files",
12577
+ "build_plan",
12430
12578
  {
12431
- description: "Delete files in the repository you are working in. Paths relative to the repository root, at most 50. Refused: anything outside the repository, inside .git, a symlink, a directory, or a file that looks like a secret. A file you mean to delete should be deleted with this, not emptied or replaced with a note \u2014 a stub is not a deletion.",
12432
- inputSchema: { paths: z21.array(z21.string().min(1).max(500)).min(1).max(REMOVE_MAX) }
12579
+ description: `Say the plan before you change anything: ${BUILD_STEPS_MIN} to ${BUILD_STEPS_MAX} steps, in order, each one change the founder can recognise, in their words (at most ${BUILD_STEP_MAX} characters). The last step is your own check of the whole change. Call it once; the founder sees it as a checklist.`,
12580
+ inputSchema: { steps: z21.array(z21.string().min(3).max(BUILD_STEP_MAX)).min(BUILD_STEPS_MIN).max(BUILD_STEPS_MAX) }
12433
12581
  },
12434
- async ({ paths }) => {
12435
- if (!ctx.workspace) return failure("this job has no repository to remove files from");
12436
- const outcomes = await removeFiles(ctx.workspace, paths);
12437
- const removed = outcomes.filter((o) => o.removed).map((o) => o.path);
12438
- await logActivity2(ctx, "repo.remove", `removed ${removed.length} of ${paths.length} files${removed.length ? `: ${trim2(removed.join(", "), 200)}` : ""}`, {
12439
- outcome: removed.length === paths.length ? "ok" : "error"
12440
- });
12441
- return json({ outcomes, note: removed.length ? "Removed. The commit at the end of this job will record the deletions." : "Nothing was removed." });
12442
- }
12582
+ async ({ steps }) => ctx.workspace ? json({ recorded: steps.length, note: "Recorded. Build step 1 now; call build_step when it is done." }) : failure("this job builds nothing")
12583
+ );
12584
+ server.registerTool(
12585
+ "build_step",
12586
+ {
12587
+ description: "A step of your plan is done and its checks pass: NanoPM commits the worktree now, with the step as the message, and ticks it for the founder. Call it once per step, in order, after the step works. Do not run git commit yourself.",
12588
+ inputSchema: { step: z21.number().int().min(1).max(BUILD_STEPS_MAX), summary: z21.string().min(3).max(300) }
12589
+ },
12590
+ async ({ step }) => ctx.workspace ? json({ step, note: "Committed by NanoPM. Go on with the next step, or stop when the plan is done." }) : failure("this job builds nothing")
12443
12591
  );
12444
12592
  server.registerTool(
12445
12593
  "web_search",
@@ -12751,6 +12899,12 @@ async function writeContext(ctx, items, refused = []) {
12751
12899
  await logActivity2(ctx, "memory.write", `wrote ${written.length} items under ${topics}: ${written.map((w) => w.key).join(", ")}${refusedNote}`);
12752
12900
  return { written };
12753
12901
  }
12902
+ async function takeAnswer(answerFile) {
12903
+ const taken = `${answerFile}.taken`;
12904
+ const answer = await readFile10(taken, "utf8").catch(() => null);
12905
+ if (answer !== null) await unlink(taken).catch(() => void 0);
12906
+ return answer;
12907
+ }
12754
12908
  async function askUser(ctx, q, signal) {
12755
12909
  const seq = await nextSeq(ctx);
12756
12910
  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" });
@@ -13226,7 +13380,7 @@ async function markFast(ctx, problemRef) {
13226
13380
  await logActivity2(ctx, "problems.fast", `${problem.ref} is one small change`);
13227
13381
  return { problem: problem.ref, fast: true };
13228
13382
  }
13229
- async function finishSolution2(ctx, ref, outcome, note) {
13383
+ async function finishSolution(ctx, ref, outcome, note) {
13230
13384
  if (ctx.actor === "mapping-review") throw new Error("A targeted collection cannot decide solution outcomes.");
13231
13385
  const upper = ref.toUpperCase();
13232
13386
  const { data: current } = await ctx.client.from("solutions").select("status").eq("project_id", ctx.projectId).eq("ref", upper).is("superseded_by", null).maybeSingle();
@@ -13524,7 +13678,8 @@ var MEMORY_TOOL_NAMES = [
13524
13678
  "play_store_search",
13525
13679
  "list_market",
13526
13680
  "write_market",
13527
- "remove_files",
13681
+ "build_plan",
13682
+ "build_step",
13528
13683
  // Talk to Nano (docs/✅nanopm-68-talk-to-nano.md): the map read, and changed as the founder asked.
13529
13684
  ...TALK_TOOLS
13530
13685
  ];
@@ -13535,19 +13690,19 @@ async function startMemoryServer(env = process.env) {
13535
13690
  }
13536
13691
 
13537
13692
  // src/commands/add.ts
13538
- import { readFile as readFile9 } from "fs/promises";
13693
+ import { readFile as readFile12 } from "fs/promises";
13539
13694
 
13540
13695
  // src/project.ts
13541
13696
  import { execFile as execFile7 } from "child_process";
13542
- import { basename } from "path";
13697
+ import { basename as basename2 } from "path";
13543
13698
  import { promisify as promisify7 } from "util";
13544
13699
 
13545
13700
  // src/local.ts
13546
- import { readFile as readFile8 } from "fs/promises";
13547
- import { join as join12 } from "path";
13701
+ import { readFile as readFile11 } from "fs/promises";
13702
+ import { join as join15 } from "path";
13548
13703
  async function localDaemonId() {
13549
13704
  try {
13550
- const parsed = JSON.parse(await readFile8(join12(nanopmHome(), "daemon.json"), "utf8"));
13705
+ const parsed = JSON.parse(await readFile11(join15(nanopmHome(), "daemon.json"), "utf8"));
13551
13706
  return parsed.id ?? null;
13552
13707
  } catch {
13553
13708
  return null;
@@ -13572,7 +13727,7 @@ async function detectRepo(cwd) {
13572
13727
  } catch {
13573
13728
  remote = null;
13574
13729
  }
13575
- const name = remote ? remote.split("/").pop() : basename(root2);
13730
+ const name = remote ? remote.split("/").pop() : basename2(root2);
13576
13731
  return { root: root2, remote, name, empty: await repoIsEmpty(root2) };
13577
13732
  }
13578
13733
  async function repoIsEmpty(root2) {
@@ -13583,7 +13738,7 @@ async function repoIsEmpty(root2) {
13583
13738
  }
13584
13739
  const files = (await execFileAsync7("git", ["-C", root2, "ls-files"])).stdout.split("\n").filter(Boolean);
13585
13740
  return files.every((f) => {
13586
- const base = basename(f);
13741
+ const base = basename2(f);
13587
13742
  return base.startsWith(".") || /^(readme|licen[cs]e)(\.|$)/i.test(base);
13588
13743
  });
13589
13744
  }
@@ -13682,7 +13837,7 @@ async function add(file, opts) {
13682
13837
  text2 = await readStdin();
13683
13838
  } else {
13684
13839
  try {
13685
- text2 = await readFile9(file, "utf8");
13840
+ text2 = await readFile12(file, "utf8");
13686
13841
  } catch {
13687
13842
  throw new Error(`could not read ${file}`);
13688
13843
  }
@@ -13697,7 +13852,7 @@ async function add(file, opts) {
13697
13852
  }
13698
13853
 
13699
13854
  // src/commands/run.ts
13700
- import { readFile as readFile10 } from "fs/promises";
13855
+ import { readFile as readFile13 } from "fs/promises";
13701
13856
  import { createInterface as createInterface2 } from "readline/promises";
13702
13857
  import { setTimeout as sleep3 } from "timers/promises";
13703
13858
 
@@ -13864,7 +14019,7 @@ function phaseOf(tool) {
13864
14019
  if (name === "query") return "data";
13865
14020
  if (name === "web_search" || name === "web_fetch") return "web";
13866
14021
  if (REVIEW_WRITES.has(name)) return "review";
13867
- if (name === "remove_files") return "build";
14022
+ if (name === "build_plan" || name === "build_step") return "build";
13868
14023
  return null;
13869
14024
  }
13870
14025
  function touched(tool, input) {
@@ -14029,7 +14184,7 @@ function questionBlock(p) {
14029
14184
 
14030
14185
  // src/commands/run.ts
14031
14186
  async function answersFromFile(path) {
14032
- const raw = JSON.parse(await readFile10(path, "utf8"));
14187
+ const raw = JSON.parse(await readFile13(path, "utf8"));
14033
14188
  const entries = raw.map((a) => typeof a === "string" ? { answer: a } : a);
14034
14189
  return scriptedAnswerer(entries);
14035
14190
  }
@@ -14113,6 +14268,7 @@ async function streamJob(client, jobId, opts) {
14113
14268
  const shownAnswers = /* @__PURE__ */ new Set();
14114
14269
  for (; ; ) {
14115
14270
  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 });
14271
+ for (const ev of events ?? []) if (ev.kind === "answer") answered.add(ev.seq);
14116
14272
  for (const ev of events ?? []) {
14117
14273
  lastId = Math.max(lastId, Number(ev.id));
14118
14274
  const payload = ev.payload ?? {};
@@ -14251,16 +14407,16 @@ async function askOne(auth, projectId, question, daemonId, opts, out) {
14251
14407
 
14252
14408
  // src/commands/daemon-service.ts
14253
14409
  import { execFile as execFile8 } from "child_process";
14254
- import { existsSync as existsSync5 } from "fs";
14255
- import { mkdir as mkdir6, readFile as readFile11, rm as rm5, writeFile as writeFile5 } from "fs/promises";
14256
- import { homedir as homedir4 } from "os";
14257
- import { join as join13 } from "path";
14410
+ import { existsSync as existsSync6 } from "fs";
14411
+ import { mkdir as mkdir7, readFile as readFile14, rm as rm6, writeFile as writeFile7 } from "fs/promises";
14412
+ import { homedir as homedir5 } from "os";
14413
+ import { join as join16 } from "path";
14258
14414
  import { promisify as promisify8 } from "util";
14259
14415
 
14260
14416
  // package.json
14261
14417
  var package_default = {
14262
14418
  name: "@nanopm/cli",
14263
- version: "0.1.10",
14419
+ version: "0.1.12",
14264
14420
  description: "An autonomous product manager for builders who run several small products. Runs on your machine, drives your own coding harness.",
14265
14421
  type: "module",
14266
14422
  license: "MIT",
@@ -14312,10 +14468,10 @@ var VERSION = package_default.version;
14312
14468
  var execFileAsync8 = promisify8(execFile8);
14313
14469
  var LAUNCHD_LABEL = "pm.nano.daemon";
14314
14470
  function plistPath() {
14315
- return join13(homedir4(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
14471
+ return join16(homedir5(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
14316
14472
  }
14317
14473
  function daemonLogPath() {
14318
- return join13(nanopmHome(), "daemon.log");
14474
+ return join16(nanopmHome(), "daemon.log");
14319
14475
  }
14320
14476
  function renderPlist(opts) {
14321
14477
  const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -14374,13 +14530,13 @@ async function installDaemon(out = (l) => console.log(l), opts = {}) {
14374
14530
  out(web.ok ? "Claude's own search answers; searches go through your subscription." : `Claude's search is not available on this machine (${web.message}); searches use DuckDuckGo.`);
14375
14531
  const bin = process.argv[1];
14376
14532
  if (!bin) throw new Error("cannot locate the nanopm binary");
14377
- const home = homedir4();
14378
- const path = [process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin", join13(home, ".local", "bin")].join(":");
14379
- await mkdir6(join13(home, "Library", "LaunchAgents"), { recursive: true });
14380
- await mkdir6(nanopmHome(), { recursive: true, mode: 448 });
14533
+ const home = homedir5();
14534
+ const path = [process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin", join16(home, ".local", "bin")].join(":");
14535
+ await mkdir7(join16(home, "Library", "LaunchAgents"), { recursive: true });
14536
+ await mkdir7(nanopmHome(), { recursive: true, mode: 448 });
14381
14537
  if (opts.shared !== void 0) await rememberShared(opts.shared);
14382
14538
  await found({ ...detection, authenticated: true });
14383
- await writeFile5(plistPath(), renderPlist({ node: process.execPath, bin, home, path, logPath: daemonLogPath(), nanopmHome: nanopmHome() }), { mode: 420 });
14539
+ await writeFile7(plistPath(), renderPlist({ node: process.execPath, bin, home, path, logPath: daemonLogPath(), nanopmHome: nanopmHome() }), { mode: 420 });
14384
14540
  const target = guiTarget();
14385
14541
  await execFileAsync8("launchctl", ["bootout", target, plistPath()]).catch(() => void 0);
14386
14542
  await execFileAsync8("launchctl", ["bootstrap", target, plistPath()]);
@@ -14393,7 +14549,7 @@ function guiTarget() {
14393
14549
  return `gui/${process.getuid?.() ?? 501}`;
14394
14550
  }
14395
14551
  async function restartDaemon() {
14396
- if (!existsSync5(plistPath())) return false;
14552
+ if (!existsSync6(plistPath())) return false;
14397
14553
  await execFileAsync8("launchctl", ["kickstart", "-k", `${guiTarget()}/${LAUNCHD_LABEL}`]);
14398
14554
  return true;
14399
14555
  }
@@ -14401,12 +14557,12 @@ async function uninstallDaemon(out = (l) => console.log(l)) {
14401
14557
  requireMac();
14402
14558
  const target = guiTarget();
14403
14559
  await execFileAsync8("launchctl", ["bootout", target, plistPath()]).catch(() => void 0);
14404
- await rm5(plistPath(), { force: true });
14560
+ await rm6(plistPath(), { force: true });
14405
14561
  out(`Removed ${LAUNCHD_LABEL}.`);
14406
14562
  }
14407
14563
  async function daemonStatus(out = (l) => console.log(l)) {
14408
14564
  const id = await localDaemonId();
14409
- const installed = process.platform === "darwin" && existsSync5(plistPath());
14565
+ const installed = process.platform === "darwin" && existsSync6(plistPath());
14410
14566
  out(`launchd: ${installed ? "installed" : "not installed"}`);
14411
14567
  if (!id) {
14412
14568
  out("daemon: never registered on this machine");
@@ -14428,7 +14584,7 @@ async function daemonStatus(out = (l) => console.log(l)) {
14428
14584
  }
14429
14585
  async function daemonLogs(lines = 50) {
14430
14586
  try {
14431
- const text2 = await readFile11(daemonLogPath(), "utf8");
14587
+ const text2 = await readFile14(daemonLogPath(), "utf8");
14432
14588
  return text2.trimEnd().split("\n").slice(-lines).join("\n");
14433
14589
  } catch {
14434
14590
  return `no log at ${daemonLogPath()} yet`;
@@ -14436,41 +14592,41 @@ async function daemonLogs(lines = 50) {
14436
14592
  }
14437
14593
  async function localWebSearch() {
14438
14594
  try {
14439
- const parsed = JSON.parse(await readFile11(join13(nanopmHome(), "daemon.json"), "utf8"));
14595
+ const parsed = JSON.parse(await readFile14(join16(nanopmHome(), "daemon.json"), "utf8"));
14440
14596
  return parsed.webSearch === true;
14441
14597
  } catch {
14442
14598
  return false;
14443
14599
  }
14444
14600
  }
14445
14601
  async function rememberWebSearch(webSearch2) {
14446
- const path = join13(nanopmHome(), "daemon.json");
14602
+ const path = join16(nanopmHome(), "daemon.json");
14447
14603
  let parsed = {};
14448
14604
  try {
14449
- parsed = JSON.parse(await readFile11(path, "utf8"));
14605
+ parsed = JSON.parse(await readFile14(path, "utf8"));
14450
14606
  } catch {
14451
14607
  parsed = {};
14452
14608
  }
14453
- await mkdir6(nanopmHome(), { recursive: true, mode: 448 });
14454
- await writeFile5(path, JSON.stringify({ ...parsed, webSearch: webSearch2 }, null, 2) + "\n", { mode: 384 });
14609
+ await mkdir7(nanopmHome(), { recursive: true, mode: 448 });
14610
+ await writeFile7(path, JSON.stringify({ ...parsed, webSearch: webSearch2 }, null, 2) + "\n", { mode: 384 });
14455
14611
  }
14456
14612
  async function localShared() {
14457
14613
  try {
14458
- const parsed = JSON.parse(await readFile11(join13(nanopmHome(), "daemon.json"), "utf8"));
14614
+ const parsed = JSON.parse(await readFile14(join16(nanopmHome(), "daemon.json"), "utf8"));
14459
14615
  return Boolean(parsed.shared);
14460
14616
  } catch {
14461
14617
  return false;
14462
14618
  }
14463
14619
  }
14464
14620
  async function rememberShared(shared) {
14465
- const path = join13(nanopmHome(), "daemon.json");
14621
+ const path = join16(nanopmHome(), "daemon.json");
14466
14622
  let parsed = {};
14467
14623
  try {
14468
- parsed = JSON.parse(await readFile11(path, "utf8"));
14624
+ parsed = JSON.parse(await readFile14(path, "utf8"));
14469
14625
  } catch {
14470
14626
  parsed = {};
14471
14627
  }
14472
- await mkdir6(nanopmHome(), { recursive: true, mode: 448 });
14473
- await writeFile5(path, JSON.stringify({ ...parsed, shared }, null, 2) + "\n", { mode: 384 });
14628
+ await mkdir7(nanopmHome(), { recursive: true, mode: 448 });
14629
+ await writeFile7(path, JSON.stringify({ ...parsed, shared }, null, 2) + "\n", { mode: 384 });
14474
14630
  }
14475
14631
 
14476
14632
  // src/format/log.ts
@@ -14713,702 +14869,772 @@ The install line does the same: curl -fsSL https://nanopm.app/install | sh`);
14713
14869
  out(await restartDaemon() ? `Updated ${VERSION} \u2192 ${latest}; the daemon restarted on it.` : `Updated ${VERSION} \u2192 ${latest}.`);
14714
14870
  }
14715
14871
 
14716
- // src/format/roadmap.ts
14717
- var SHAPE = {
14718
- proposed: { mark: "\u25CB", colour: ANSI.amber, label: "proposed" },
14719
- accepted: { mark: "\u25D0", colour: ANSI.green, label: "accepted" },
14720
- building: { mark: "\u25D4", colour: ANSI.blue, label: "building" },
14721
- rejected: { mark: "\u2298", colour: ANSI.red, label: "rejected" },
14722
- done: { mark: "\u25CF", colour: ANSI.green, label: "done" },
14723
- dropped: { mark: "\u2298", colour: ANSI.dim, label: "dropped" }
14872
+ // src/commands/autonomy.ts
14873
+ async function readAutonomy(client, projectId) {
14874
+ const { data } = await client.from("project_preferences").select("autonomy").eq("project_id", projectId).maybeSingle();
14875
+ return data?.autonomy ?? "propose";
14876
+ }
14877
+ var EXPLAINED = {
14878
+ propose: "NanoPM reads and proposes. It never changes a file.",
14879
+ execute_after_validation: "NanoPM changes files, on its own branch, after showing you what it will do and waiting for a yes.",
14880
+ execute_then_report: "NanoPM acts first and tells you after. Not available yet."
14724
14881
  };
14725
- var WORK_KINDS = /* @__PURE__ */ new Set(["branch", "git", "url", "job"]);
14726
- function renderRoadmapItem(item, opts, problem, builtBy) {
14727
- const paint = painter(opts.color);
14728
- const shape2 = SHAPE[item.status] ?? { mark: "\xB7", colour: "", label: item.status };
14729
- const out = [`${paint(shape2.colour, shape2.mark)} ${paint(ANSI.bold, item.ref)} ${item.title}`];
14730
- if (problem) out.push(paint(ANSI.dim, ` answers ${problem.ref} ${clip(problem.statement, Math.max(30, columns() - 20))}`));
14731
- out.push("");
14732
- for (const line of wrap(item.reasoning, Math.max(40, columns() - 4))) out.push(` ${line}`);
14733
- out.push("");
14734
- const facts = [
14735
- ["status", shape2.label + (item.outcome ? ` \xB7 ${item.outcome}${item.outcome_note ? `: ${item.outcome_note}` : ""}` : "")],
14736
- ["rank", String(item.rank)],
14737
- ["who", item.actor === "founder" ? "you" : "NanoPM"],
14738
- ["bets on", item.assumption ?? "\u2014"],
14739
- ["we'll know", item.eval ?? "\u2014"]
14740
- ];
14741
- for (const [k, v] of facts) out.push(paint(ANSI.dim, ` ${k.padEnd(9)} ${v}`));
14742
- const sources2 = item.sources ?? [];
14743
- const restsOn = sources2.filter((s) => !WORK_KINDS.has(s.kind));
14744
- if (restsOn.length) {
14745
- out.push(paint(ANSI.dim, ` ${"rests on".padEnd(9)} ${restsOn.map((s) => `${s.kind}:${s.ref}`).join(", ")}`));
14882
+ async function autonomy(level, opts) {
14883
+ const auth = await authenticate();
14884
+ const project = await resolveProject(auth.client, { projectId: opts.projectId, cwd: opts.cwd });
14885
+ const current = await readAutonomy(auth.client, project.id);
14886
+ if (!level) {
14887
+ const lines = [`${project.name} is on "${current}".`, ""];
14888
+ for (const l of AUTONOMY_LEVELS) {
14889
+ lines.push(` ${l === current ? "\u25CF" : "\u25CB"} ${l.padEnd(26)} ${EXPLAINED[l]}`);
14890
+ }
14891
+ lines.push("", " nanopm autonomy <level>");
14892
+ return lines.join("\n");
14746
14893
  }
14747
- const where = renderWhere(sources2);
14748
- if (where) out.push(paint(ANSI.dim, ` ${"where".padEnd(9)} ${where}${builtBy ? ` \xB7 built by ${builtBy}` : ""}`));
14749
- return out.join("\n");
14750
- }
14751
- function renderWhere(sources2) {
14752
- const branch = sources2.find((s) => s.kind === "branch")?.ref;
14753
- const commit = sources2.find((s) => s.kind === "git")?.ref;
14754
- const url = sources2.find((s) => s.kind === "url")?.ref;
14755
- return [branch, commit, url].filter(Boolean).join(" \xB7 ");
14894
+ const wanted = level.trim();
14895
+ if (!AUTONOMY_LEVELS.includes(wanted)) {
14896
+ throw new Error(`"${level}" is not a level. Pick one of: ${AUTONOMY_LEVELS.join(", ")}.`);
14897
+ }
14898
+ if (wanted === "execute_then_report") {
14899
+ throw new Error(
14900
+ "execute_then_report is not available yet. NanoPM asks before it changes anything until the review loop has been used in anger."
14901
+ );
14902
+ }
14903
+ if (wanted === current) return `${project.name} is already on "${current}".`;
14904
+ const { error } = await auth.client.from("project_preferences").update({ autonomy: wanted }).eq("project_id", project.id);
14905
+ if (error) {
14906
+ throw new Error(`could not change the dial: ${error.message}`);
14907
+ }
14908
+ return [`${project.name} is now on "${wanted}".`, EXPLAINED[wanted]].join("\n");
14756
14909
  }
14757
- var VERB = {
14758
- want: { text: "wanted", colour: ANSI.amber },
14759
- confirm: { text: "confirmed", colour: ANSI.green },
14760
- drop: { text: "dropped", colour: ANSI.red },
14761
- merge: { text: "merged into", colour: ANSI.blue },
14762
- separate: { text: "kept apart", colour: ANSI.blue },
14763
- weight: { text: "weighed", colour: ANSI.blue },
14764
- area: { text: "filed", colour: ANSI.blue },
14765
- accept: { text: "accepted", colour: ANSI.green },
14766
- reject: { text: "rejected", colour: ANSI.red },
14767
- first: { text: "put first", colour: ANSI.blue },
14768
- done: { text: "did", colour: ANSI.green },
14769
- dismiss: { text: "dismissed", colour: ANSI.red }
14770
- };
14771
- function renderDecisions(rows2, opts) {
14772
- const paint = painter(opts.color);
14773
- if (!rows2.length) return "No decisions yet.";
14774
- const out = [];
14775
- for (const row of rows2) {
14776
- const when = row.created_at.slice(0, 10);
14777
- const verb = VERB[row.kind] ?? { text: row.kind, colour: ANSI.blue };
14778
- const about = row.solutions ? `${paint(ANSI.dim, row.solutions.ref)} ${row.solutions.title}` : row.problems ? paint(ANSI.dim, row.problems.ref) : row.market_entries ? `${paint(ANSI.dim, row.market_entries.ref)} ${row.market_entries.competitor ?? ""}`.trimEnd() : "";
14779
- const by = row.by ? paint(ANSI.dim, ` \xB7 by ${row.by}`) : "";
14780
- out.push(`${paint(ANSI.dim, when)} ${paint(verb.colour, verb.text)} ${about}${by}`.trimEnd());
14781
- if (row.body) {
14782
- for (const line of wrap(row.body, 72)) out.push(paint(ANSI.dim, ` ${line}`));
14910
+ async function verifyCommand(command, opts) {
14911
+ const auth = await authenticate();
14912
+ const project = await resolveProject(auth.client, { projectId: opts.projectId, cwd: opts.cwd });
14913
+ const { data } = await auth.client.from("project_preferences").select("verify_command").eq("project_id", project.id).maybeSingle();
14914
+ const current = data?.verify_command ?? null;
14915
+ if (!command && !opts.clear) {
14916
+ if (!current) {
14917
+ return [
14918
+ `${project.name} has no check, so nothing verifies what NanoPM writes.`,
14919
+ "",
14920
+ ' nanopm verify "npm run lint && npm test"',
14921
+ "",
14922
+ "A build runs it in its sandboxed copy of the repository before its last step,",
14923
+ "fixes what it reports, and says the result in the pull request."
14924
+ ].join("\n");
14783
14925
  }
14926
+ return [`${project.name} verifies with:`, "", ` ${current}`, "", ' nanopm verify "<command>" \xB7 nanopm verify --clear'].join("\n");
14784
14927
  }
14785
- return out.join("\n");
14786
- }
14787
- var HUE = ["\x1B[34m", "\x1B[35m", "\x1B[36m", "\x1B[32m", "\x1B[95m"];
14788
- function themesOf(tree) {
14789
- return new Map(tree.map((b) => [b.id, { name: themeName(b), colour: HUE[themeHue(tree, b) - 1] }]));
14790
- }
14791
- function sizeWord(size, paint) {
14792
- return size === "big" ? `${paint(ANSI.bold, "\u25AA")} ${SIZE_WORD.big}` : size === "small" ? paint(ANSI.dim, SIZE_WORD.small) : "";
14928
+ const next = opts.clear ? null : command.trim();
14929
+ if (next !== null && !next) throw new Error('a check needs a command: nanopm verify "npm test"');
14930
+ const { error } = await auth.client.from("project_preferences").update({ verify_command: next }).eq("project_id", project.id);
14931
+ if (error) throw new Error(`could not save the check: ${error.message}`);
14932
+ if (next === null) return `${project.name} no longer runs a check. Nothing will verify what NanoPM writes.`;
14933
+ return [`${project.name} will verify with:`, "", ` ${next}`, "", "A build runs it in its sandboxed copy of the repository before its last step."].join("\n");
14793
14934
  }
14794
- function renderSolutionsBoard(board, opts) {
14935
+
14936
+ // src/commands/build.ts
14937
+ import { execFile as execFile10 } from "child_process";
14938
+ import { existsSync as existsSync7 } from "fs";
14939
+ import { homedir as homedir6 } from "os";
14940
+ import { promisify as promisify9 } from "util";
14941
+
14942
+ // src/format/opportunities.ts
14943
+ function renderOpportunities(rows2, lens, opts) {
14795
14944
  const paint = painter(opts.color);
14796
14945
  const width = Math.max(40, columns());
14797
- const themes = themesOf(board.tree);
14798
- const out = [`${paint(ANSI.bold, "Roadmap")}${board.objective ? ` ${paint(ANSI.dim, `${board.objective.lever} \u2014 ${board.objective.sentence}`)}` : ""}`];
14799
- const chips = board.tree.flatMap((b) => {
14800
- const n = board.cards.filter((c) => c.branch.id === b.id).length;
14801
- const t = themes.get(b.id);
14802
- return n ? [`${paint(t.colour, "\u25CF")} ${t.name} ${paint(ANSI.dim, String(n))}`] : [];
14803
- });
14804
- const legend = chips.reduce((lines, chip) => {
14805
- const last = lines.at(-1);
14806
- if (last !== void 0 && visibleLength(last) + 3 + visibleLength(chip) <= width) lines[lines.length - 1] = `${last} ${chip}`;
14807
- else lines.push(chip);
14808
- return lines;
14809
- }, []);
14810
- out.push("", ...legend);
14811
- if (!board.cards.some((c) => c.pick.placed_by === "founder")) out.push(paint(ANSI.dim, "Nano placed these in the order of your opportunity map. Move them where you want them."));
14812
- const refWidth = Math.max(5, ...board.cards.map((c) => c.pick.ref.length));
14813
- const gutter = " ".repeat(refWidth + 4);
14814
- const titleWidth = Math.min(Math.max(...board.cards.map((c) => c.pick.title.length)), width - gutter.length - 8);
14815
- for (const h of HORIZONS) {
14816
- const column = board.cards.filter((c) => c.horizon === h);
14817
- out.push("", `${paint(ANSI.bold, HORIZON_WORD[h])} ${paint(ANSI.dim, String(column.length))}`);
14818
- if (!column.length) out.push(paint(ANSI.dim, ` Move a card here: nanopm roadmap move <ref> ${h}`));
14819
- for (const c of column) {
14820
- const t = themes.get(c.branch.id);
14821
- out.push(` ${paint(ANSI.dim, c.pick.ref.padEnd(refWidth))} ${paint(t.colour, "\u25CF")} ${paint(ANSI.dim, t.name)}`);
14822
- out.push(`${gutter}${clip(c.pick.title, titleWidth).padEnd(titleWidth)} ${sizeWord(c.pick.size, paint)}`.trimEnd());
14823
- const depth = board.working.get(c.leaf.id);
14824
- if (depth) out.push(`${gutter}${paint(ANSI.blue, "\u25CF")} ${paint(ANSI.dim, WORK_WORD[depth])}`);
14825
- }
14946
+ const out = [];
14947
+ if (!lens) return "No objective yet: the map is drawn for it. Say what matters most right now on Your business, then `nanopm opportunities run`.";
14948
+ out.push(`${paint(ANSI.bold, "Opportunities")} ${paint(ANSI.dim, `${lens.lever} \u2014 ${lens.sentence}`)}`);
14949
+ out.push(paint(ANSI.dim, lens.definition ? `Counts: ${lens.definition}` : "What it counts is not defined yet: drawn against the lever and your sentence."));
14950
+ if (opts.previous) out.push(paint(ANSI.amber, "Drawn for your previous objective. `nanopm opportunities run` draws it for this one."));
14951
+ if (!rows2.length) {
14952
+ out.push("", "No map yet. `nanopm opportunities run` draws it from what NanoPM knows; add --deep to check it against your users, code and data.");
14953
+ return out.join("\n");
14826
14954
  }
14827
- if (board.earlier) out.push("", paint(ANSI.bold, "On the problem list"), "", board.earlier);
14828
- out.push(
14829
- "",
14830
- paint(ANSI.dim, 'nanopm roadmap <ref> \xB7 nanopm roadmap move <ref> now|next|later [position] \xB7 nanopm reject <ref> ["why"]'),
14831
- paint(ANSI.dim, "nanopm opportunities brainstorm <ref> [--deep]: solutions to another problem, or deeper ones")
14832
- );
14955
+ const tree = liveTree(rows2);
14956
+ const live = rows2.filter(isLive);
14957
+ const evidenced = live.filter((r) => r.evidence_status === "evidence_backed").length;
14958
+ out.push(paint(ANSI.dim, `${live.length} on the map: ${evidenced} evidence-backed, ${live.length - evidenced} hypotheses \u2014 what users or their behaviour have not shown yet.`));
14959
+ const who = (r) => r.personas.map((p) => opts.names?.get(p) ?? p).join(", ");
14960
+ const one = (r, place) => {
14961
+ const indent = " ".repeat(r.depth - 1);
14962
+ const yours = r.status === "confirmed" ? paint(ANSI.green, " yours") : "";
14963
+ out.push("", `${indent}${paint(ANSI.bold, place.padStart(2))} ${r.depth === 1 ? paint(ANSI.bold, r.problem) : r.problem}${yours} ${paint(ANSI.dim, r.ref)}`);
14964
+ const pad = `${indent} `;
14965
+ const facts = [PRIORITY_WORD[r.priority], EVIDENCE_STATUS_WORD[r.evidence_status], NEXT_ACTION_WORD[r.next_action], who(r)].filter(Boolean).join(" \xB7 ");
14966
+ out.push(`${pad}${paint(ANSI.dim, facts)}`);
14967
+ const gist = r.description ? paragraphs(r.description)[0] : null;
14968
+ if (gist) for (const l of wrap(gist, width - pad.length)) out.push(`${pad}${l}`);
14969
+ if (r.learn) for (const l of wrap(`To learn more: ${r.learn}`, width - pad.length)) out.push(`${pad}${paint(ANSI.dim, l)}`);
14970
+ if (r.founder_rank !== null && r.now_rank) out.push(`${pad}${paint(ANSI.dim, `Its place is yours; NanoPM would now put it at #${r.now_rank}.`)}`);
14971
+ if (r.reconsider) for (const l of wrap(`Worth reconsidering: ${r.reconsider}`, width - pad.length)) out.push(`${pad}${paint(ANSI.amber, l)}`);
14972
+ for (const c of r.children) one(c, `${place}.${c.rank}`);
14973
+ };
14974
+ for (const r of tree) one(r, String(r.rank));
14975
+ const aside = rows2.filter((r) => r.status === "set_aside");
14976
+ if (aside.length) {
14977
+ out.push("", paint(ANSI.bold, `Set aside (${aside.length})`));
14978
+ for (const r of aside) out.push(` ${paint(ANSI.dim, r.ref.padEnd(8))} ${r.problem}${r.set_aside_reason ? paint(ANSI.dim, ` \u2014 ${r.set_aside_reason}`) : ""}`);
14979
+ }
14980
+ out.push("", paint(ANSI.dim, '`nanopm opportunities confirm <ref>`, `set-aside <ref> "why"`, `move <ref> <place>`, `reword <ref> "\u2026"`, `brainstorm <ref> [--deep]`, `run [--deep] [--note "\u2026"] [--use signals,data,repo,market] [--skip \u2026]`.'));
14833
14981
  return out.join("\n");
14834
14982
  }
14835
- function renderSolutionSheet(s, at, opts) {
14983
+
14984
+ // src/format/needs.ts
14985
+ var MARK = { observed: "\u25CF", stated: "\u2013", inferred: "\u25CB" };
14986
+ function renderNeeds(persona, tree, opts) {
14836
14987
  const paint = painter(opts.color);
14837
14988
  const width = Math.max(40, columns());
14838
- const theme = at.branch ? themesOf(at.tree).get(at.branch.id) : void 0;
14839
14989
  const out = [];
14840
- if (theme) out.push(`${paint(theme.colour, "\u25CF")} ${paint(ANSI.dim, theme.name)}`);
14841
- const size = sizeWord(s.size, paint);
14842
- out.push(`${paint(ANSI.dim, s.ref)} ${paint(ANSI.bold, s.title)}${size ? ` ${size}` : ""}`);
14843
- const pad = " ".repeat(12);
14844
- const heading = (text2) => out.push("", ` ${paint(ANSI.bold, text2)}`);
14845
- heading("Problem we are solving");
14846
- if (at.leaf) {
14847
- const solves = wrap(at.leaf.problem, width - 6 - at.leaf.ref.length - 2);
14848
- solves[solves.length - 1] += ` ${paint(ANSI.dim, at.leaf.ref)}`;
14849
- for (const line of solves) out.push(` ${line}`);
14850
- if (at.branch && at.branch.id !== at.leaf.id) {
14851
- for (const line of wrap(`Part of: ${at.branch.problem} ${at.branch.ref}`, width - 6)) out.push(` ${paint(ANSI.dim, line)}`);
14852
- }
14853
- } else {
14854
- out.push(paint(ANSI.dim, " The problem it answered is no longer on the map."));
14855
- }
14856
- const described = paragraphs(s.description);
14857
- if (described.length) {
14858
- heading("Proposed solution");
14859
- described.forEach((p, i) => {
14860
- if (i) out.push("");
14861
- for (const line of wrap(p, width - 6)) out.push(` ${line}`);
14862
- });
14863
- }
14864
- const changelog = parseChangelog(s.changelog);
14865
- if (changelog) {
14866
- heading("Future changelog");
14867
- for (const line of wrap(changelog.headline, width - 6)) out.push(` ${paint(ANSI.bold, line)}`);
14868
- for (const point of changelog.points) wrap(point, width - 8).forEach((line, i) => out.push(` ${i ? " " : "\u2022"} ${line}`));
14869
- }
14870
- if (s.steps.length) {
14871
- heading("Plan");
14872
- s.steps.forEach((step, i) => {
14873
- const who = STEP_WHO_WORD[step.who] ?? step.who;
14874
- wrap(step.do, width - pad.length - 8).forEach((line, j) => out.push(`${j ? pad : ` \u2610 ${who.padEnd(6)}`}${line}`));
14875
- if (step.checks) out.push(pad + paint(ANSI.amber, i === 0 ? "checks the problem first" : "checks the problem"));
14876
- });
14990
+ const counts = needCounts(tree);
14991
+ out.push(`${paint(ANSI.bold, `${persona.name}\u2019s needs`)} ${paint(ANSI.dim, `${counts.needs} needs \xB7 ${counts.difficulties + counts.precise} difficulties`)}`);
14992
+ if (!tree.length) {
14993
+ out.push("", `Nothing mapped yet. \`nanopm needs --refresh ${persona.slug}\` maps it in depth; add --short for a quick pass.`);
14994
+ return out.join("\n");
14877
14995
  }
14878
- const card = at.leaf ? at.cards.find((c) => c.leaf.id === at.leaf.id) : void 0;
14879
- const others = card ? card.others.filter((o) => o.id !== s.id) : [];
14880
- out.push("");
14881
- if (s.status === "rejected") out.push(paint(ANSI.dim, " Thrown out."));
14882
- else if (card?.pick.id === s.id) {
14883
- const column = at.cards.filter((c) => c.horizon === card.horizon);
14884
- const yours = s.placed_by === "founder" ? ", where you put it" : "";
14885
- out.push(paint(ANSI.dim, ` On the board: ${HORIZON_WORD[card.horizon]}, ${column.indexOf(card) + 1} of ${column.length}${yours}.`));
14886
- } else out.push(paint(ANSI.dim, card ? ` Not on the board: ${card.pick.ref} is this problem's card.` : " Not on the board."));
14887
- if (others.length) out.push(paint(ANSI.dim, ` Other solutions to this problem: ${others.map((o) => o.ref).join(", ")}.`));
14888
- if (s.status === "proposed") {
14889
- const move = card?.pick.id === s.id ? `nanopm roadmap move ${s.ref} now|next|later [position] \xB7 ` : "";
14890
- out.push(paint(ANSI.dim, ` ${move}nanopm reject ${s.ref} ["why"]`));
14996
+ const line = (n, depth) => {
14997
+ const indent = " ".repeat(depth);
14998
+ const lead = n.level === 1 ? `${n.emoji ?? " "} ` : `${MARK[n.provenance]} `;
14999
+ const where = n.provenance === "inferred" ? paint(ANSI.dim, ` guess ${n.confidence ?? 0}%`) : n.sources.length ? paint(ANSI.dim, ` ${n.sources.length} source${n.sources.length === 1 ? "" : "s"}`) : "";
15000
+ const state = n.status === "dropped" ? paint(ANSI.dim, " dropped") : n.status === "confirmed" || n.author_kind === "user" ? "" : paint(ANSI.amber, " to review");
15001
+ const label = n.status === "dropped" ? paint(ANSI.dim, n.label) : n.level === 1 ? paint(ANSI.bold, n.label) : n.label;
15002
+ const body = wrap(`${label}${where}${state}${n.proposal ? paint(ANSI.amber, " \xB7 NanoPM suggests a new wording") : ""}`, width - indent.length - 12);
15003
+ out.push(`${indent}${lead}${paint(ANSI.dim, n.ref.padEnd(8))} ${body[0] ?? ""}`);
15004
+ for (const more of body.slice(1)) out.push(`${indent} ${more}`);
15005
+ for (const c of n.children) line(c, depth + 1);
15006
+ };
15007
+ for (const need of tree) {
15008
+ out.push("");
15009
+ line(need, 0);
14891
15010
  }
15011
+ out.push("", paint(ANSI.dim, `\u25CF observed \u2013 stated by you \u25CB NanoPM\u2019s guess. \`nanopm needs confirm <ref>\`, \`nanopm needs drop <ref> "why"\`, \`nanopm needs dig <ref>\`.`));
15012
+ if (opts.others?.length) out.push(paint(ANSI.dim, `Other personas: ${opts.others.join(", ")}.`));
14892
15013
  return out.join("\n");
14893
15014
  }
14894
15015
 
14895
- // src/format/problems.ts
14896
- var SHAPE2 = {
14897
- candidate: { mark: "\u25CB", colour: ANSI.dim, label: "candidate" },
14898
- confirmed: { mark: "\u25C9", colour: ANSI.amber, label: "confirmed" },
14899
- addressed: { mark: "\u25CF", colour: ANSI.green, label: "addressed" },
14900
- dropped: { mark: "\u2298", colour: ANSI.dim, label: "dropped" }
14901
- };
14902
- function renderImportance(problem, opts) {
14903
- const paint = painter(opts.color);
14904
- if (problem.importance_basis === "evidence") {
14905
- return paint(ANSI.bold, String(Math.round(problem.importance)).padStart(3));
14906
- }
14907
- const value = problem.importance;
14908
- const word = value >= 67 ? "high" : value >= 34 ? "med" : value > 0 ? "low" : "\u2014";
14909
- return paint(ANSI.dim, `~${word}`.padStart(5));
15016
+ // src/commands/needs.ts
15017
+ async function people(client, projectId) {
15018
+ const { data, error } = await client.from("memory").select("key, title, content").eq("project_id", projectId).eq("topic", PERSONA_TOPIC).is("superseded_by", null);
15019
+ if (error) throw new Error(`could not read the personas: ${error.message}`);
15020
+ return (data ?? []).filter((r) => PERSONA_KEY.test(r.key)).map((r) => {
15021
+ const p = parsePersona(r.key, r.content);
15022
+ return { slug: p.slug, name: personaName(r.title, p.slug), primary: p.primary };
15023
+ }).sort((a, b) => Number(b.primary) - Number(a.primary));
14910
15024
  }
14911
- function renderProblems(problems2, duplicates, opts) {
14912
- const paint = painter(opts.color);
14913
- if (!problems2.length) {
14914
- return [
14915
- "No problems on the list yet.",
14916
- "",
14917
- paint(ANSI.dim, " nanopm review reads your data, then works out what they say about your users"),
14918
- paint(ANSI.dim, " nanopm opportunities what to work on, for your objective")
14919
- ].join("\n");
14920
- }
14921
- const refWidth = Math.max(...problems2.map((p) => p.ref.length));
14922
- const out = [];
14923
- const map = groupProblemMap(problems2.map((p) => ({ ...p, parentRef: p.parent_ref })));
14924
- const ordered = [...map.branches.flatMap((b) => [b.parent, ...b.children]), ...map.unplaced];
14925
- for (const problem of ordered) {
14926
- const shape2 = SHAPE2[problem.status] ?? { mark: "\xB7", colour: ANSI.dim, label: problem.status };
14927
- const score = renderImportance(problem, opts);
14928
- const head = `${paint(shape2.colour, shape2.mark)} ${paint(ANSI.dim, problem.ref.padEnd(refWidth))} ${score} ${problem.parent_ref ? paint(ANSI.dim, "\u21B3 ") : ""}${clip(firstSentence(problem.statement), Math.max(40, columns() - refWidth - 14))}`;
14929
- out.push(head);
14930
- if (problem.mapping) out.push(paint(ANSI.dim, ` ${problem.mapping.level === 1 ? "Need" : "Difficulty"} \xB7 ${problem.mapping.knowledge} \xB7 ${problem.mapping.origin === "expert_inference" ? "expert inference" : "source analysis"}`));
14931
- const indent = ` ${" ".repeat(refWidth)} `;
14932
- const tail2 = problem.proofs > 0 ? `${problem.proofs} proof${problem.proofs === 1 ? "" : "s"}${problem.proof_kinds?.length ? ` \xB7 ${problem.proof_kinds.join(", ")}` : ""}` : problem.importance_note ? `no proof yet \xB7 ${problem.importance_note}` : "no proof yet";
14933
- out.push(paint(ANSI.dim, indent + clip(tail2, Math.max(40, columns() - indent.length))));
14934
- }
14935
- const unproven = problems2.filter((p) => p.proofs === 0).length;
14936
- if (unproven) {
14937
- out.push("");
14938
- out.push(
14939
- paint(
14940
- ANSI.dim,
14941
- `${unproven} of ${problems2.length} rest on judgement, not proof. A tilde marks those.`
14942
- )
14943
- );
14944
- }
14945
- if (duplicates.length) {
14946
- out.push("");
14947
- for (const pair of duplicates.slice(0, 3)) {
14948
- const why = pair.flagged ? `NanoPM is not sure ${pair.a} and ${pair.b} are different problems` : `${pair.a} and ${pair.b} read alike`;
14949
- out.push(paint(ANSI.amber, `? ${why}`));
14950
- }
14951
- out.push(paint(ANSI.dim, ` nanopm decisions why \xB7 nanopm problems merge ${duplicates[0].b} ${duplicates[0].a}`));
14952
- }
14953
- if (opts.manualMerges !== void 0) {
14954
- out.push("");
14955
- out.push(
14956
- paint(
14957
- ANSI.dim,
14958
- `${opts.manualMerges} merge${opts.manualMerges === 1 ? "" : "s"} you have had to make by hand.`
14959
- )
14960
- );
14961
- }
14962
- out.push("");
14963
- out.push(paint(ANSI.dim, " nanopm problems <ref> \xB7 nanopm problems merge <from> <into> \xB7 nanopm problems weight <ref> <0-100>"));
14964
- return out.join("\n");
15025
+ function pick2(list, named) {
15026
+ if (!list.length) throw new Error("there is no persona on the business card yet. `nanopm personas --refresh` works out who this is for.");
15027
+ if (!named) return list[0];
15028
+ const one = list.find((p) => p.slug === named.toLowerCase() || p.name.toLowerCase() === named.toLowerCase());
15029
+ if (!one) throw new Error(`no persona "${named}". The personas are: ${list.map((p) => p.slug).join(", ")}.`);
15030
+ return one;
14965
15031
  }
14966
- function renderProblem(problem, evidence2, addressedBy, opts) {
14967
- const paint = painter(opts.color);
14968
- const shape2 = SHAPE2[problem.status] ?? { mark: "\xB7", colour: ANSI.dim, label: problem.status };
14969
- const width = columns();
14970
- const out = [];
14971
- out.push(`${paint(shape2.colour, shape2.mark)} ${paint(ANSI.dim, problem.ref)} ${paint(ANSI.bold, shape2.label)}`);
14972
- out.push("");
14973
- for (const line of wrap(problem.statement, width)) out.push(line);
14974
- out.push("");
14975
- const facts = [];
14976
- const m = problem.mapping ?? null;
14977
- if (m) {
14978
- const story = problemStory(m, { proofs: problem.proofs, kinds: problem.proof_kinds ?? [], provenance: problem.provenance ?? "inferred", authorKind: problem.author_kind ?? "nanopm", createdAt: problem.created_at ?? "" });
14979
- out.push(...wrap(story.story, width));
14980
- out.push("");
14981
- out.push(...wrap(story.doubt, width).map((l) => paint(ANSI.dim, l)));
14982
- out.push(...wrap(story.assumes ? `${story.test} ${story.assumes}` : story.test, width).map((l) => paint(ANSI.dim, l)));
14983
- out.push("");
14984
- out.push(paint(ANSI.dim, "how I got here"));
14985
- facts.push(["kind", `${m.level === 1 ? "a need" : "a difficulty"} \xB7 ${m.knowledge} \xB7 ${m.origin === "expert_inference" ? "expert inference" : "source analysis"}`]);
14986
- if (m.parent_reason) facts.push(["grouping", m.parent_reason]);
14987
- }
14988
- if (problem.segment) facts.push(["who", problem.segment]);
14989
- facts.push([
14990
- "weight",
14991
- problem.importance_basis === "evidence" ? `${Math.round(problem.importance)} from ${problem.proofs} piece${problem.proofs === 1 ? "" : "s"} of proof` : `${Math.round(problem.importance)}, a judgement${problem.importance_note ? ` \u2014 ${problem.importance_note}` : ""}`
15032
+ async function rows(client, projectId, persona) {
15033
+ let q = client.from("need_nodes").select(NEED_NODE_COLUMNS).eq("project_id", projectId);
15034
+ if (persona) q = q.eq("persona", persona);
15035
+ const { data, error } = await q.order("created_at");
15036
+ if (error) throw new Error(`could not read the needs map: ${error.message}`);
15037
+ return data ?? [];
15038
+ }
15039
+ async function needs(persona, opts) {
15040
+ const auth = await authenticate();
15041
+ const project = await resolveProject(auth.client, { projectId: opts.projectId, cwd: opts.cwd });
15042
+ const list = await people(auth.client, project.id);
15043
+ const who = pick2(list, persona);
15044
+ const tree = needTree(await rows(auth.client, project.id, who.slug));
15045
+ return renderNeeds(who, tree, { color: opts.color ?? useColor(), others: list.filter((p) => p.slug !== who.slug).map((p) => p.slug) });
15046
+ }
15047
+ async function mapNeeds(target, opts) {
15048
+ const auth = await authenticate();
15049
+ const project = await resolveProject(auth.client, { projectId: opts.projectId, cwd: opts.cwd });
15050
+ const list = await people(auth.client, project.id);
15051
+ let persona;
15052
+ let scope = "map";
15053
+ if (target.ref) {
15054
+ const ref = target.ref.toUpperCase();
15055
+ const node = (await rows(auth.client, project.id)).find((r) => r.ref === ref);
15056
+ if (!node) throw new Error(`no node ${target.ref} on any needs map. \`nanopm needs\` shows the refs.`);
15057
+ persona = pick2(list, node.persona);
15058
+ scope = ref;
15059
+ } else persona = pick2(list, target.persona);
15060
+ const [{ data: active }, tree] = await Promise.all([
15061
+ auth.client.from("jobs").select("params").eq("project_id", project.id).eq("skill", "needs").in("status", [...ACTIVE_STATUSES]),
15062
+ rows(auth.client, project.id, persona.slug).then(needTree)
14992
15063
  ]);
14993
- if (problem.parent_ref) facts.push(["part of", problem.parent_ref]);
14994
- for (const [label, value] of facts) {
14995
- out.push(`${paint(ANSI.dim, label.padEnd(9))}${clip(value, width - 9)}`);
14996
- }
14997
- if (evidence2.length) {
14998
- out.push("");
14999
- out.push(paint(ANSI.dim, "sources"));
15000
- for (const item of evidence2) {
15001
- const when = (item.occurred_at ?? item.created_at).slice(0, 10);
15002
- const where = item.metric_key ?? item.source_ref ?? item.kind;
15003
- for (const line of wrap(item.body, width - 2)) out.push(` ${line}`);
15004
- out.push(paint(ANSI.dim, ` \u2014 ${item.relation ?? "supports"} \xB7 ${item.kind} \xB7 ${where} \xB7 ${when}`));
15005
- }
15006
- } else {
15007
- out.push("");
15008
- out.push(paint(ANSI.dim, "No proof yet. This ranking is a judgement, and the next review may settle it."));
15009
- }
15010
- if (addressedBy.length) {
15011
- out.push("");
15012
- out.push(paint(ANSI.dim, "addressed by"));
15013
- for (const item of addressedBy) {
15014
- out.push(` ${paint(ANSI.dim, item.ref)} ${clip(item.title, width - 12)} ${paint(ANSI.dim, `(${item.status})`)}`);
15015
- }
15064
+ const lineage = (ref) => {
15065
+ const walk = (l) => {
15066
+ for (const n of l) {
15067
+ if (n.ref === ref.toUpperCase()) return n.path.map((p) => p.ref);
15068
+ const deeper = walk(n.children);
15069
+ if (deeper) return deeper;
15070
+ }
15071
+ return null;
15072
+ };
15073
+ return walk(tree) ?? [ref];
15074
+ };
15075
+ const clash = needsRunConflict((active ?? []).map((j) => runScopeOf(j.params)), { persona: persona.slug, scope }, { primary: list.find((p) => p.primary)?.slug ?? list[0]?.slug ?? null, lineage, name: (s) => list.find((p) => p.slug === s)?.name ?? s });
15076
+ if (clash) throw new Error(clash);
15077
+ const repo = await detectRepo(opts.cwd);
15078
+ if (repo) await rememberClone(auth.client, project.id, repo.root);
15079
+ const params = {
15080
+ persona: persona.slug,
15081
+ ...scope === "map" ? {} : { scope },
15082
+ depth: opts.short ? "short" : "deep",
15083
+ ...opts.note?.trim() ? { note: opts.note.trim().slice(0, 600) } : {},
15084
+ ...repo ? { repo_path: repo.root } : {}
15085
+ };
15086
+ return runSkill("needs", { project: project.id, params, cancelSignal: opts.cancelSignal });
15087
+ }
15088
+ async function confirmNeed(ref, opts) {
15089
+ const auth = await authenticate();
15090
+ const project = await resolveProject(auth.client, { projectId: opts.projectId, cwd: opts.cwd });
15091
+ const { data, error } = await auth.client.from("need_nodes").update({ status: "confirmed" }).eq("project_id", project.id).eq("ref", ref.toUpperCase()).eq("status", "proposed").select("ref, label");
15092
+ if (error) throw new Error(pgMessage(error));
15093
+ if (!data?.length) throw new Error(`${ref} is not a node waiting on your word. \`nanopm needs\` shows which are.`);
15094
+ return `Confirmed ${data[0].ref}: ${data[0].label}. A run proposes beside it from now on, never over it.`;
15095
+ }
15096
+ async function dropNeed(ref, why, opts) {
15097
+ const auth = await authenticate();
15098
+ const project = await resolveProject(auth.client, { projectId: opts.projectId, cwd: opts.cwd });
15099
+ const { data, error } = await auth.client.from("need_nodes").update({ status: "dropped", drop_reason: why?.trim().slice(0, 400) || "not true of them" }).eq("project_id", project.id).eq("ref", ref.toUpperCase()).in("status", ["proposed", "confirmed"]).select("ref, label");
15100
+ if (error) throw new Error(pgMessage(error));
15101
+ if (!data?.length) throw new Error(`${ref} is not on the map any more.`);
15102
+ return `Dropped ${data[0].ref}: ${data[0].label}. It stays on the record, and NanoPM will not propose it again in the same place.`;
15103
+ }
15104
+
15105
+ // src/commands/opportunities.ts
15106
+ async function lensOf2(client, projectId) {
15107
+ const { data } = await client.from("memory").select("id, key, content").eq("project_id", projectId).eq("topic", GOALS_TOPIC).in("key", [OBJECTIVE_KEY, DEFINITION_KEY]).is("superseded_by", null);
15108
+ const rows2 = data ?? [];
15109
+ const aim = rows2.find((r) => r.key === OBJECTIVE_KEY);
15110
+ const m = aim ? /^Lever:\s*([a-z]+)\.\s*(.*)$/s.exec(aim.content.trim()) : null;
15111
+ const lever = m?.[1];
15112
+ if (!aim || !lever || !OUTCOME_LEVERS.includes(lever)) return null;
15113
+ const def = rows2.find((r) => r.key === DEFINITION_KEY);
15114
+ const parsed = def ? parseDefinition(def.content) : null;
15115
+ const sentence2 = m[2].trim();
15116
+ const definition = parsed && parsed.lever === lever ? parsed.sentence : null;
15117
+ return { id: aim.id, lever, sentence: sentence2, definition, key: lensKey(lever, sentence2, definition) };
15118
+ }
15119
+ async function loadOpportunities(client, projectId) {
15120
+ const [standing, replaced] = await Promise.all([
15121
+ client.from("opportunities").select(OPPORTUNITY_COLUMNS).eq("project_id", projectId).neq("status", "replaced"),
15122
+ client.from("opportunities").select(REPLACED_COLUMNS).eq("project_id", projectId).eq("status", "replaced")
15123
+ ]);
15124
+ const error = standing.error ?? replaced.error;
15125
+ if (error) throw new Error(`could not read the opportunities: ${error.message}`);
15126
+ return [...standing.data ?? [], ...replaced.data ?? []];
15127
+ }
15128
+ async function workingOn(client, projectId) {
15129
+ const { data } = await client.from("jobs").select("params").eq("project_id", projectId).eq("skill", SOLUTIONS_SKILL).in("status", [...ACTIVE_STATUSES]);
15130
+ return leavesAtWork((data ?? []).map((j) => j.params));
15131
+ }
15132
+ async function opportunities(opts) {
15133
+ const auth = await authenticate();
15134
+ const project = await resolveProject(auth.client, { projectId: opts.projectId, cwd: opts.cwd });
15135
+ const [lens, all, personas2] = await Promise.all([lensOf2(auth.client, project.id), loadOpportunities(auth.client, project.id), people(auth.client, project.id)]);
15136
+ const { shown, drawnFor } = shownMap(all, lens?.key ?? null);
15137
+ const names = new Map(personas2.map((p) => [p.slug, p.name]));
15138
+ return renderOpportunities(shown, lens, { color: opts.color ?? useColor(), previous: drawnFor === "previous", names });
15139
+ }
15140
+ async function rankOpportunities(opts) {
15141
+ const auth = await authenticate();
15142
+ const project = await resolveProject(auth.client, { projectId: opts.projectId, cwd: opts.cwd });
15143
+ const list = (s) => s ? s.split(",").map((x) => x.trim()).filter(Boolean) : void 0;
15144
+ const sources2 = opts.use || opts.skip ? { ...opts.use ? { use: list(opts.use) } : {}, ...opts.skip ? { skip: list(opts.skip) } : {} } : void 0;
15145
+ const params = {
15146
+ depth: opts.deep ? "deep" : "short",
15147
+ ...opts.note?.trim() ? { note: opts.note.trim().slice(0, OPPORTUNITIES_NOTE_MAX) } : {},
15148
+ ...sources2 ? { sources: sources2 } : {}
15149
+ };
15150
+ const checked = opportunitiesAsk(params);
15151
+ if (!checked.ok) throw new Error(checked.why);
15152
+ const { data: active } = await auth.client.from("jobs").select("id").eq("project_id", project.id).eq("skill", "opportunities").in("status", [...ACTIVE_STATUSES]);
15153
+ const clash = opportunitiesRunConflict(active ?? []);
15154
+ if (clash) throw new Error(clash);
15155
+ const repo = await detectRepo(opts.cwd);
15156
+ if (repo) await rememberClone(auth.client, project.id, repo.root);
15157
+ return runSkill("opportunities", { project: project.id, params: { ...params, ...repo ? { repo_path: repo.root } : {} }, cancelSignal: opts.cancelSignal });
15158
+ }
15159
+ async function rpc2(name, args, opts) {
15160
+ const auth = await authenticate();
15161
+ const project = await resolveProject(auth.client, { projectId: opts.projectId, cwd: opts.cwd });
15162
+ const { error } = await auth.client.rpc(name, { p_project_id: project.id, ...args });
15163
+ if (error) throw new Error(pgMessage(error));
15164
+ }
15165
+ async function confirmOpportunity(ref, opts) {
15166
+ await rpc2("confirm_opportunity", { p_ref: ref }, opts);
15167
+ return `${ref.toUpperCase()} is yours. A later run ranks around it and never replaces it.`;
15168
+ }
15169
+ async function setAsideOpportunity(ref, why, opts) {
15170
+ await rpc2("set_aside_opportunity", { p_ref: ref, p_reason: why ?? "not now" }, opts);
15171
+ return `${ref.toUpperCase()} is set aside for this objective, with the narrower problems under it. It comes back if the objective changes.`;
15172
+ }
15173
+ async function moveOpportunity(ref, place, opts) {
15174
+ const rank2 = Number(place);
15175
+ if (!Number.isInteger(rank2) || rank2 < 1) throw new Error("say where: nanopm opportunities move <ref> <place>, a place from 1");
15176
+ await rpc2("move_opportunity", { p_ref: ref, p_rank: rank2 }, opts);
15177
+ return `${ref.toUpperCase()} is at #${rank2} among its siblings, and the place is yours. Moved above a higher priority, it takes that priority.`;
15178
+ }
15179
+ async function rewordOpportunity(ref, problem, opts) {
15180
+ const text2 = problem?.trim().replace(/\s+/g, " ") ?? "";
15181
+ if (!text2) throw new Error('say it: nanopm opportunities reword <ref> "the problem, in your words"');
15182
+ if (text2.length > PROBLEM_MAX) throw new Error(`one line, under ${PROBLEM_MAX} characters`);
15183
+ await rpc2("restate_opportunity", { p_ref: ref, p_problem: text2 }, opts);
15184
+ return `Kept in your words: ${text2}`;
15185
+ }
15186
+ var AWAKE_MS = 12e4;
15187
+ async function awakeDaemon(client, userId) {
15188
+ const since = new Date(Date.now() - AWAKE_MS).toISOString();
15189
+ const [{ data }, local] = await Promise.all([
15190
+ client.from("daemons").select("id, hostname").eq("user_id", userId).in("status", ["online", "running"]).gte("last_seen_at", since).order("last_seen_at", { ascending: false }),
15191
+ localDaemonId()
15192
+ ]);
15193
+ return data?.find((d) => d.id === local) ?? data?.[0] ?? null;
15194
+ }
15195
+ async function brainstorm(ref, opts) {
15196
+ const auth = await authenticate();
15197
+ const project = await resolveProject(auth.client, { projectId: opts.projectId, cwd: opts.cwd });
15198
+ const [lens, all, busy] = await Promise.all([lensOf2(auth.client, project.id), loadOpportunities(auth.client, project.id), workingOn(auth.client, project.id)]);
15199
+ const deep = Boolean(opts.deep);
15200
+ const plan = brainstormPlan(shownMap(all, lens?.key ?? null).shown, ref, deep, new Set(busy.keys()));
15201
+ const daemon = await awakeDaemon(auth.client, auth.userId);
15202
+ 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 brainstorm again.");
15203
+ const repo = deep ? await detectRepo(opts.cwd) : null;
15204
+ if (repo) await rememberClone(auth.client, project.id, repo.root);
15205
+ for (const params of plan.jobs) {
15206
+ const { error } = await auth.client.from("jobs").insert({ project_id: project.id, skill: SOLUTIONS_SKILL, params: { ...params, ...repo ? { repo_path: repo.root } : {} }, requested_by: auth.userId, trigger: "user", target_daemon_id: daemon.id });
15207
+ if (error) throw new Error(`could not queue the brainstorm: ${error.message}`);
15016
15208
  }
15017
- out.push("");
15018
- out.push(paint(ANSI.dim, " nanopm problems \xB7 nanopm roadmap"));
15209
+ const n = plan.leaves.length;
15210
+ const minutes = deep ? DEEP_MINUTES * n : FLASH_MINUTES;
15211
+ const what = plan.problem.children.length ? `the ${n} narrower problem${n === 1 ? "" : "s"} under ${plan.problem.ref}` : `${plan.problem.ref} \u201C${clip(plan.problem.problem, 60)}\u201D`;
15212
+ const how = deep ? `${DEPTH_WORD.deep}${n > 1 ? ", one after the other" : ""}` : `${DEPTH_WORD.quick}, a first pass`;
15213
+ const out = [`${deep ? "Going deeper on" : "Brainstorming"} ${what}: ${how}, about ${minutes} minutes on ${daemon.hostname}.`];
15214
+ if (plan.problem.children.length) for (const l of plan.leaves) out.push(` ${l.ref.padEnd(8)} ${clip(l.problem, Math.max(30, columns() - 11))}`);
15215
+ if (plan.busy) out.push(`${plan.busy} left out: Nano is already finding solutions to ${plan.busy === 1 ? "it" : "them"}.`);
15216
+ out.push("The solutions land on `nanopm roadmap`.");
15019
15217
  return out.join("\n");
15020
15218
  }
15021
15219
 
15022
- // src/format/tree.ts
15023
- var SOLUTION_SHAPE = {
15220
+ // src/format/roadmap.ts
15221
+ var SHAPE = {
15024
15222
  proposed: { mark: "\u25CB", colour: ANSI.amber, label: "proposed" },
15025
15223
  accepted: { mark: "\u25D0", colour: ANSI.green, label: "accepted" },
15026
15224
  building: { mark: "\u25D4", colour: ANSI.blue, label: "building" },
15027
- done: { mark: "\u25CF", colour: ANSI.green, label: "done" },
15028
15225
  rejected: { mark: "\u2298", colour: ANSI.red, label: "rejected" },
15226
+ done: { mark: "\u25CF", colour: ANSI.green, label: "done" },
15029
15227
  dropped: { mark: "\u2298", colour: ANSI.dim, label: "dropped" }
15030
15228
  };
15031
- var PROBLEM_SHAPE = {
15032
- candidate: { mark: "\u25CB", colour: ANSI.dim },
15033
- confirmed: { mark: "\u25C9", colour: ANSI.amber },
15034
- addressed: { mark: "\u25CF", colour: ANSI.green },
15035
- dropped: { mark: "\u2298", colour: ANSI.dim }
15229
+ var WORK_KINDS = /* @__PURE__ */ new Set(["branch", "git", "url", "job"]);
15230
+ function renderRoadmapItem(item, opts, problem, builtBy) {
15231
+ const paint = painter(opts.color);
15232
+ const shape2 = SHAPE[item.status] ?? { mark: "\xB7", colour: "", label: item.status };
15233
+ const out = [`${paint(shape2.colour, shape2.mark)} ${paint(ANSI.bold, item.ref)} ${item.title}`];
15234
+ if (problem) out.push(paint(ANSI.dim, ` answers ${problem.ref} ${clip(problem.statement, Math.max(30, columns() - 20))}`));
15235
+ out.push("");
15236
+ for (const line of wrap(item.reasoning, Math.max(40, columns() - 4))) out.push(` ${line}`);
15237
+ out.push("");
15238
+ const facts = [
15239
+ ["status", shape2.label + (item.outcome ? ` \xB7 ${item.outcome}${item.outcome_note ? `: ${item.outcome_note}` : ""}` : "")],
15240
+ ["rank", String(item.rank)],
15241
+ ["who", item.actor === "founder" ? "you" : "NanoPM"],
15242
+ ["bets on", item.assumption ?? "\u2014"],
15243
+ ["we'll know", item.eval ?? "\u2014"]
15244
+ ];
15245
+ for (const [k, v] of facts) out.push(paint(ANSI.dim, ` ${k.padEnd(9)} ${v}`));
15246
+ const sources2 = item.sources ?? [];
15247
+ const restsOn = sources2.filter((s) => !WORK_KINDS.has(s.kind));
15248
+ if (restsOn.length) {
15249
+ out.push(paint(ANSI.dim, ` ${"rests on".padEnd(9)} ${restsOn.map((s) => `${s.kind}:${s.ref}`).join(", ")}`));
15250
+ }
15251
+ const where = renderWhere(sources2);
15252
+ if (where) out.push(paint(ANSI.dim, ` ${"where".padEnd(9)} ${where}${builtBy ? ` \xB7 built by ${builtBy}` : ""}`));
15253
+ return out.join("\n");
15254
+ }
15255
+ function renderWhere(sources2) {
15256
+ const branch = sources2.find((s) => s.kind === "branch")?.ref;
15257
+ const commit = sources2.find((s) => s.kind === "git")?.ref;
15258
+ const url = sources2.find((s) => s.kind === "url")?.ref;
15259
+ return [branch, commit, url].filter(Boolean).join(" \xB7 ");
15260
+ }
15261
+ var VERB = {
15262
+ want: { text: "wanted", colour: ANSI.amber },
15263
+ confirm: { text: "confirmed", colour: ANSI.green },
15264
+ drop: { text: "dropped", colour: ANSI.red },
15265
+ merge: { text: "merged into", colour: ANSI.blue },
15266
+ separate: { text: "kept apart", colour: ANSI.blue },
15267
+ weight: { text: "weighed", colour: ANSI.blue },
15268
+ area: { text: "filed", colour: ANSI.blue },
15269
+ accept: { text: "accepted", colour: ANSI.green },
15270
+ reject: { text: "rejected", colour: ANSI.red },
15271
+ first: { text: "put first", colour: ANSI.blue },
15272
+ done: { text: "did", colour: ANSI.green },
15273
+ dismiss: { text: "dismissed", colour: ANSI.red }
15036
15274
  };
15037
- var NO_AREA = "no area yet";
15038
- var SHOWN = /* @__PURE__ */ new Set(["proposed", "accepted", "building", "done"]);
15039
- function renderTree(problems2, solutions, strategy, opts) {
15275
+ function renderDecisions(rows2, opts) {
15040
15276
  const paint = painter(opts.color);
15277
+ if (!rows2.length) return "No decisions yet.";
15041
15278
  const out = [];
15042
- const width = Math.max(40, columns());
15043
- if (strategy) {
15044
- out.push(paint(ANSI.bold, "Strategy"));
15045
- for (const line of wrap(strategy, width - 4)) out.push(` ${line}`);
15046
- out.push("");
15047
- }
15048
- const shown = solutions.filter((s) => SHOWN.has(s.status));
15049
- const open = problems2.filter((p) => p.status !== "dropped" && (!opts.alongside || shown.some((s) => s.problem_id === p.id)));
15050
- if (opts.alongside && !open.length) return "";
15051
- if (!open.length && !shown.length) {
15052
- out.push("No problems yet. `nanopm opportunities` shows what to work on.");
15053
- return out.join("\n");
15054
- }
15055
- const byProblem = /* @__PURE__ */ new Map();
15056
- for (const s of shown) byProblem.set(s.problem_id, [...byProblem.get(s.problem_id) ?? [], s]);
15057
- const refWidth = Math.max(...[...open.map((p) => p.ref), ...shown.map((s) => s.ref)].map((r) => r.length), 5);
15058
- for (const [area, rows2] of groupByArea(open)) {
15059
- out.push(paint(ANSI.bold, area));
15060
- for (const p of rows2) {
15061
- const shape2 = PROBLEM_SHAPE[p.status] ?? { mark: "\xB7", colour: "" };
15062
- const kind = p.kind === "founder" ? paint(ANSI.dim, " (founder)") : "";
15063
- const proof = p.proofs ? `${p.proofs} proof${p.proofs === 1 ? "" : "s"}` : "no proof";
15064
- const tail2 = `${renderImportance(p, opts)} ${paint(ANSI.dim, proof)}`;
15065
- const head = ` ${paint(shape2.colour, shape2.mark)} ${paint(ANSI.dim, p.ref.padEnd(refWidth))} `;
15066
- out.push(head + clip(p.statement, Math.max(30, width - visibleLength(head) - visibleLength(tail2) - 2)) + kind + " " + tail2);
15067
- const under = byProblem.get(p.id) ?? [];
15068
- if (!under.length) {
15069
- out.push(paint(ANSI.dim, ` ${" ".repeat(refWidth + 2)} no solution yet`));
15070
- continue;
15071
- }
15072
- for (const s of under) out.push(...solutionLines(s, refWidth, width, paint));
15279
+ for (const row of rows2) {
15280
+ const when = row.created_at.slice(0, 10);
15281
+ const verb = VERB[row.kind] ?? { text: row.kind, colour: ANSI.blue };
15282
+ const about = row.solutions ? `${paint(ANSI.dim, row.solutions.ref)} ${row.solutions.title}` : row.problems ? paint(ANSI.dim, row.problems.ref) : row.market_entries ? `${paint(ANSI.dim, row.market_entries.ref)} ${row.market_entries.competitor ?? ""}`.trimEnd() : "";
15283
+ const by = row.by ? paint(ANSI.dim, ` \xB7 by ${row.by}`) : "";
15284
+ out.push(`${paint(ANSI.dim, when)} ${paint(verb.colour, verb.text)} ${about}${by}`.trimEnd());
15285
+ if (row.body) {
15286
+ for (const line of wrap(row.body, 72)) out.push(paint(ANSI.dim, ` ${line}`));
15073
15287
  }
15074
- out.push("");
15075
- }
15076
- const waiting = shown.filter((s) => s.status === "proposed").length;
15077
- if (waiting) {
15078
- out.push(`${waiting} waiting on you.`);
15079
- out.push(paint(ANSI.dim, ' nanopm accept <ref> \xB7 nanopm reject <ref> "why" \xB7 nanopm roadmap <ref> \xB7 nanopm roadmap --board'));
15080
- } else if (!opts.alongside) {
15081
- out.push(paint(ANSI.dim, "Nothing waiting on you."));
15082
15288
  }
15083
- return out.join("\n").trimEnd();
15289
+ return out.join("\n");
15084
15290
  }
15085
- function solutionLines(s, refWidth, width, paint, indent = 6) {
15086
- const shape2 = SOLUTION_SHAPE[s.status] ?? { mark: "\xB7", colour: "", label: s.status };
15087
- const pad = " ".repeat(indent);
15088
- const STATE = { accepted: "chosen", building: "building", done: "done" };
15089
- const tail2 = s.actor === "founder" ? "yours to do" : s.outcome ? s.outcome : s.status === "proposed" ? s.assumption ? clip(s.assumption, 40) : null : STATE[s.status] ?? null;
15090
- const badge = tail2 ? paint(ANSI.dim, ` ${tail2}`) : "";
15091
- const head = `${pad}${paint(shape2.colour, shape2.mark)} ${paint(ANSI.dim, s.ref.padEnd(refWidth))} `;
15092
- const lines = [head + clip(s.title, Math.max(30, width - visibleLength(head) - visibleLength(badge))) + badge];
15093
- const reason2 = `${pad} ${" ".repeat(refWidth)} `;
15094
- lines.push(paint(ANSI.dim, reason2 + clip(firstSentence(s.reasoning), Math.max(30, width - reason2.length))));
15095
- return lines;
15291
+ var HUE = ["\x1B[34m", "\x1B[35m", "\x1B[36m", "\x1B[32m", "\x1B[95m"];
15292
+ function themesOf(tree) {
15293
+ return new Map(tree.map((b) => [b.id, { name: themeName(b), colour: HUE[themeHue(tree, b) - 1] }]));
15096
15294
  }
15097
- function groupByArea(problems2) {
15098
- const groups = /* @__PURE__ */ new Map();
15099
- for (const p of problems2) {
15100
- const area = p.area ?? NO_AREA;
15101
- groups.set(area, [...groups.get(area) ?? [], p]);
15102
- }
15103
- const named = [...groups.entries()].filter(([a]) => a !== NO_AREA);
15104
- const rest = groups.get(NO_AREA);
15105
- return rest ? [...named, [NO_AREA, rest]] : named;
15295
+ function sizeWord(size, paint) {
15296
+ return size === "big" ? `${paint(ANSI.bold, "\u25AA")} ${SIZE_WORD.big}` : size === "small" ? paint(ANSI.dim, SIZE_WORD.small) : "";
15106
15297
  }
15107
- var BOARD_COLUMNS = [
15108
- { status: "proposed", label: "Proposed" },
15109
- { status: "accepted", label: "Chosen" },
15110
- { status: "building", label: "Building" },
15111
- { status: "done", label: "Done" }
15112
- ];
15113
- function renderBoard(problems2, solutions, opts) {
15298
+ function renderSolutionsBoard(board, opts) {
15114
15299
  const paint = painter(opts.color);
15115
- const out = [];
15116
- const byId = new Map(problems2.map((p) => [p.id, p]));
15117
15300
  const width = Math.max(40, columns());
15118
- const refWidth = Math.max(...solutions.map((s) => s.ref.length), 5);
15119
- for (const col of BOARD_COLUMNS) {
15120
- const rows2 = solutions.filter((s) => s.status === col.status);
15121
- out.push(`${paint(ANSI.bold, col.label)} ${paint(ANSI.dim, String(rows2.length))}`);
15122
- if (!rows2.length) out.push(paint(ANSI.dim, " \u2014"));
15123
- for (const s of rows2) {
15124
- const shape2 = SOLUTION_SHAPE[s.status];
15125
- const problem = byId.get(s.problem_id);
15126
- const head = ` ${paint(shape2.colour, shape2.mark)} ${paint(ANSI.dim, s.ref.padEnd(refWidth))} `;
15127
- out.push(head + clip(s.title, Math.max(30, width - visibleLength(head))));
15128
- const under = [problem ? `${problem.ref} ${clip(problem.statement, 50)}` : "its problem was dropped", s.assumption ? clip(s.assumption, 40) : null].filter(Boolean).join(" \xB7 ");
15129
- out.push(paint(ANSI.dim, ` ${" ".repeat(refWidth + 2)} ${under}`));
15301
+ const themes = themesOf(board.tree);
15302
+ const out = [`${paint(ANSI.bold, "Roadmap")}${board.objective ? ` ${paint(ANSI.dim, `${board.objective.lever} \u2014 ${board.objective.sentence}`)}` : ""}`];
15303
+ const chips = board.tree.flatMap((b) => {
15304
+ const n = board.cards.filter((c) => c.branch.id === b.id).length;
15305
+ const t = themes.get(b.id);
15306
+ return n ? [`${paint(t.colour, "\u25CF")} ${t.name} ${paint(ANSI.dim, String(n))}`] : [];
15307
+ });
15308
+ const legend = chips.reduce((lines, chip) => {
15309
+ const last = lines.at(-1);
15310
+ if (last !== void 0 && visibleLength(last) + 3 + visibleLength(chip) <= width) lines[lines.length - 1] = `${last} ${chip}`;
15311
+ else lines.push(chip);
15312
+ return lines;
15313
+ }, []);
15314
+ out.push("", ...legend);
15315
+ if (!board.cards.some((c) => c.pick.placed_by === "founder")) out.push(paint(ANSI.dim, "Nano placed these in the order of your opportunity map. Move them where you want them."));
15316
+ const refWidth = Math.max(5, ...board.cards.map((c) => c.pick.ref.length));
15317
+ const gutter = " ".repeat(refWidth + 4);
15318
+ const titleWidth = Math.min(Math.max(...board.cards.map((c) => c.pick.title.length)), width - gutter.length - 8);
15319
+ for (const h of HORIZONS) {
15320
+ const column = board.cards.filter((c) => c.horizon === h);
15321
+ out.push("", `${paint(ANSI.bold, HORIZON_WORD[h])} ${paint(ANSI.dim, String(column.length))}`);
15322
+ if (!column.length) out.push(paint(ANSI.dim, ` Move a card here: nanopm roadmap move <ref> ${h}`));
15323
+ for (const c of column) {
15324
+ const t = themes.get(c.branch.id);
15325
+ out.push(` ${paint(ANSI.dim, c.pick.ref.padEnd(refWidth))} ${paint(t.colour, "\u25CF")} ${paint(ANSI.dim, t.name)}`);
15326
+ out.push(`${gutter}${clip(c.pick.title, titleWidth).padEnd(titleWidth)} ${sizeWord(c.pick.size, paint)}`.trimEnd());
15327
+ const depth = board.working.get(c.leaf.id);
15328
+ if (depth) out.push(`${gutter}${paint(ANSI.blue, "\u25CF")} ${paint(ANSI.dim, WORK_WORD[depth])}`);
15130
15329
  }
15131
- out.push("");
15132
15330
  }
15133
- return out.join("\n").trimEnd();
15331
+ if (board.earlier) out.push("", paint(ANSI.bold, "On the problem list"), "", board.earlier);
15332
+ out.push(
15333
+ "",
15334
+ paint(ANSI.dim, 'nanopm roadmap <ref> \xB7 nanopm roadmap move <ref> now|next|later [position] \xB7 nanopm reject <ref> ["why"]'),
15335
+ paint(ANSI.dim, "nanopm opportunities brainstorm <ref> [--deep]: solutions to another problem, or deeper ones")
15336
+ );
15337
+ return out.join("\n");
15134
15338
  }
15135
-
15136
- // src/format/opportunities.ts
15137
- function renderOpportunities(rows2, lens, opts) {
15339
+ function renderSolutionSheet(s, at, opts) {
15138
15340
  const paint = painter(opts.color);
15139
15341
  const width = Math.max(40, columns());
15342
+ const theme = at.branch ? themesOf(at.tree).get(at.branch.id) : void 0;
15140
15343
  const out = [];
15141
- if (!lens) return "No objective yet: the map is drawn for it. Say what matters most right now on Your business, then `nanopm opportunities run`.";
15142
- out.push(`${paint(ANSI.bold, "Opportunities")} ${paint(ANSI.dim, `${lens.lever} \u2014 ${lens.sentence}`)}`);
15143
- out.push(paint(ANSI.dim, lens.definition ? `Counts: ${lens.definition}` : "What it counts is not defined yet: drawn against the lever and your sentence."));
15144
- if (opts.previous) out.push(paint(ANSI.amber, "Drawn for your previous objective. `nanopm opportunities run` draws it for this one."));
15145
- if (!rows2.length) {
15146
- out.push("", "No map yet. `nanopm opportunities run` draws it from what NanoPM knows; add --deep to check it against your users, code and data.");
15147
- return out.join("\n");
15344
+ if (theme) out.push(`${paint(theme.colour, "\u25CF")} ${paint(ANSI.dim, theme.name)}`);
15345
+ const size = sizeWord(s.size, paint);
15346
+ out.push(`${paint(ANSI.dim, s.ref)} ${paint(ANSI.bold, s.title)}${size ? ` ${size}` : ""}`);
15347
+ const pad = " ".repeat(12);
15348
+ const heading = (text2) => out.push("", ` ${paint(ANSI.bold, text2)}`);
15349
+ heading("Problem we are solving");
15350
+ if (at.leaf) {
15351
+ const solves = wrap(at.leaf.problem, width - 6 - at.leaf.ref.length - 2);
15352
+ solves[solves.length - 1] += ` ${paint(ANSI.dim, at.leaf.ref)}`;
15353
+ for (const line of solves) out.push(` ${line}`);
15354
+ if (at.branch && at.branch.id !== at.leaf.id) {
15355
+ for (const line of wrap(`Part of: ${at.branch.problem} ${at.branch.ref}`, width - 6)) out.push(` ${paint(ANSI.dim, line)}`);
15356
+ }
15357
+ } else {
15358
+ out.push(paint(ANSI.dim, " The problem it answered is no longer on the map."));
15148
15359
  }
15149
- const tree = liveTree(rows2);
15150
- const live = rows2.filter(isLive);
15151
- const evidenced = live.filter((r) => r.evidence_status === "evidence_backed").length;
15152
- out.push(paint(ANSI.dim, `${live.length} on the map: ${evidenced} evidence-backed, ${live.length - evidenced} hypotheses \u2014 what users or their behaviour have not shown yet.`));
15153
- const who = (r) => r.personas.map((p) => opts.names?.get(p) ?? p).join(", ");
15154
- const one = (r, place) => {
15155
- const indent = " ".repeat(r.depth - 1);
15156
- const yours = r.status === "confirmed" ? paint(ANSI.green, " yours") : "";
15157
- out.push("", `${indent}${paint(ANSI.bold, place.padStart(2))} ${r.depth === 1 ? paint(ANSI.bold, r.problem) : r.problem}${yours} ${paint(ANSI.dim, r.ref)}`);
15158
- const pad = `${indent} `;
15159
- const facts = [PRIORITY_WORD[r.priority], EVIDENCE_STATUS_WORD[r.evidence_status], NEXT_ACTION_WORD[r.next_action], who(r)].filter(Boolean).join(" \xB7 ");
15160
- out.push(`${pad}${paint(ANSI.dim, facts)}`);
15161
- const gist = r.description ? paragraphs(r.description)[0] : null;
15162
- if (gist) for (const l of wrap(gist, width - pad.length)) out.push(`${pad}${l}`);
15163
- if (r.learn) for (const l of wrap(`To learn more: ${r.learn}`, width - pad.length)) out.push(`${pad}${paint(ANSI.dim, l)}`);
15164
- if (r.founder_rank !== null && r.now_rank) out.push(`${pad}${paint(ANSI.dim, `Its place is yours; NanoPM would now put it at #${r.now_rank}.`)}`);
15165
- if (r.reconsider) for (const l of wrap(`Worth reconsidering: ${r.reconsider}`, width - pad.length)) out.push(`${pad}${paint(ANSI.amber, l)}`);
15166
- for (const c of r.children) one(c, `${place}.${c.rank}`);
15167
- };
15168
- for (const r of tree) one(r, String(r.rank));
15169
- const aside = rows2.filter((r) => r.status === "set_aside");
15170
- if (aside.length) {
15171
- out.push("", paint(ANSI.bold, `Set aside (${aside.length})`));
15172
- for (const r of aside) out.push(` ${paint(ANSI.dim, r.ref.padEnd(8))} ${r.problem}${r.set_aside_reason ? paint(ANSI.dim, ` \u2014 ${r.set_aside_reason}`) : ""}`);
15360
+ const described = paragraphs(s.description);
15361
+ if (described.length) {
15362
+ heading("Proposed solution");
15363
+ described.forEach((p, i) => {
15364
+ if (i) out.push("");
15365
+ for (const line of wrap(p, width - 6)) out.push(` ${line}`);
15366
+ });
15367
+ }
15368
+ const changelog = parseChangelog(s.changelog);
15369
+ if (changelog) {
15370
+ heading("Future changelog");
15371
+ for (const line of wrap(changelog.headline, width - 6)) out.push(` ${paint(ANSI.bold, line)}`);
15372
+ for (const point of changelog.points) wrap(point, width - 8).forEach((line, i) => out.push(` ${i ? " " : "\u2022"} ${line}`));
15373
+ }
15374
+ if (s.steps.length) {
15375
+ heading("Plan");
15376
+ s.steps.forEach((step, i) => {
15377
+ const who = STEP_WHO_WORD[step.who] ?? step.who;
15378
+ wrap(step.do, width - pad.length - 8).forEach((line, j) => out.push(`${j ? pad : ` \u2610 ${who.padEnd(6)}`}${line}`));
15379
+ if (step.checks) out.push(pad + paint(ANSI.amber, i === 0 ? "checks the problem first" : "checks the problem"));
15380
+ });
15381
+ }
15382
+ const card = at.leaf ? at.cards.find((c) => c.leaf.id === at.leaf.id) : void 0;
15383
+ const others = card ? card.others.filter((o) => o.id !== s.id) : [];
15384
+ out.push("");
15385
+ if (s.status === "rejected") out.push(paint(ANSI.dim, " Thrown out."));
15386
+ else if (card?.pick.id === s.id) {
15387
+ const column = at.cards.filter((c) => c.horizon === card.horizon);
15388
+ const yours = s.placed_by === "founder" ? ", where you put it" : "";
15389
+ out.push(paint(ANSI.dim, ` On the board: ${HORIZON_WORD[card.horizon]}, ${column.indexOf(card) + 1} of ${column.length}${yours}.`));
15390
+ } else out.push(paint(ANSI.dim, card ? ` Not on the board: ${card.pick.ref} is this problem's card.` : " Not on the board."));
15391
+ if (others.length) out.push(paint(ANSI.dim, ` Other solutions to this problem: ${others.map((o) => o.ref).join(", ")}.`));
15392
+ if (s.status === "proposed") {
15393
+ const move = card?.pick.id === s.id ? `nanopm roadmap move ${s.ref} now|next|later [position] \xB7 ` : "";
15394
+ out.push(paint(ANSI.dim, ` ${move}nanopm reject ${s.ref} ["why"]`));
15173
15395
  }
15174
- out.push("", paint(ANSI.dim, '`nanopm opportunities confirm <ref>`, `set-aside <ref> "why"`, `move <ref> <place>`, `reword <ref> "\u2026"`, `brainstorm <ref> [--deep]`, `run [--deep] [--note "\u2026"] [--use signals,data,repo,market] [--skip \u2026]`.'));
15175
15396
  return out.join("\n");
15176
15397
  }
15177
-
15178
- // src/format/needs.ts
15179
- var MARK = { observed: "\u25CF", stated: "\u2013", inferred: "\u25CB" };
15180
- function renderNeeds(persona, tree, opts) {
15398
+
15399
+ // src/format/problems.ts
15400
+ var SHAPE2 = {
15401
+ candidate: { mark: "\u25CB", colour: ANSI.dim, label: "candidate" },
15402
+ confirmed: { mark: "\u25C9", colour: ANSI.amber, label: "confirmed" },
15403
+ addressed: { mark: "\u25CF", colour: ANSI.green, label: "addressed" },
15404
+ dropped: { mark: "\u2298", colour: ANSI.dim, label: "dropped" }
15405
+ };
15406
+ function renderImportance(problem, opts) {
15407
+ const paint = painter(opts.color);
15408
+ if (problem.importance_basis === "evidence") {
15409
+ return paint(ANSI.bold, String(Math.round(problem.importance)).padStart(3));
15410
+ }
15411
+ const value = problem.importance;
15412
+ const word = value >= 67 ? "high" : value >= 34 ? "med" : value > 0 ? "low" : "\u2014";
15413
+ return paint(ANSI.dim, `~${word}`.padStart(5));
15414
+ }
15415
+ function renderProblems(problems2, duplicates, opts) {
15181
15416
  const paint = painter(opts.color);
15182
- const width = Math.max(40, columns());
15417
+ if (!problems2.length) {
15418
+ return [
15419
+ "No problems on the list yet.",
15420
+ "",
15421
+ paint(ANSI.dim, " nanopm review reads your data, then works out what they say about your users"),
15422
+ paint(ANSI.dim, " nanopm opportunities what to work on, for your objective")
15423
+ ].join("\n");
15424
+ }
15425
+ const refWidth = Math.max(...problems2.map((p) => p.ref.length));
15183
15426
  const out = [];
15184
- const counts = needCounts(tree);
15185
- out.push(`${paint(ANSI.bold, `${persona.name}\u2019s needs`)} ${paint(ANSI.dim, `${counts.needs} needs \xB7 ${counts.difficulties + counts.precise} difficulties`)}`);
15186
- if (!tree.length) {
15187
- out.push("", `Nothing mapped yet. \`nanopm needs --refresh ${persona.slug}\` maps it in depth; add --short for a quick pass.`);
15188
- return out.join("\n");
15427
+ const map = groupProblemMap(problems2.map((p) => ({ ...p, parentRef: p.parent_ref })));
15428
+ const ordered = [...map.branches.flatMap((b) => [b.parent, ...b.children]), ...map.unplaced];
15429
+ for (const problem of ordered) {
15430
+ const shape2 = SHAPE2[problem.status] ?? { mark: "\xB7", colour: ANSI.dim, label: problem.status };
15431
+ const score = renderImportance(problem, opts);
15432
+ const head = `${paint(shape2.colour, shape2.mark)} ${paint(ANSI.dim, problem.ref.padEnd(refWidth))} ${score} ${problem.parent_ref ? paint(ANSI.dim, "\u21B3 ") : ""}${clip(firstSentence(problem.statement), Math.max(40, columns() - refWidth - 14))}`;
15433
+ out.push(head);
15434
+ if (problem.mapping) out.push(paint(ANSI.dim, ` ${problem.mapping.level === 1 ? "Need" : "Difficulty"} \xB7 ${problem.mapping.knowledge} \xB7 ${problem.mapping.origin === "expert_inference" ? "expert inference" : "source analysis"}`));
15435
+ const indent = ` ${" ".repeat(refWidth)} `;
15436
+ const tail = problem.proofs > 0 ? `${problem.proofs} proof${problem.proofs === 1 ? "" : "s"}${problem.proof_kinds?.length ? ` \xB7 ${problem.proof_kinds.join(", ")}` : ""}` : problem.importance_note ? `no proof yet \xB7 ${problem.importance_note}` : "no proof yet";
15437
+ out.push(paint(ANSI.dim, indent + clip(tail, Math.max(40, columns() - indent.length))));
15189
15438
  }
15190
- const line = (n, depth) => {
15191
- const indent = " ".repeat(depth);
15192
- const lead = n.level === 1 ? `${n.emoji ?? " "} ` : `${MARK[n.provenance]} `;
15193
- const where = n.provenance === "inferred" ? paint(ANSI.dim, ` guess ${n.confidence ?? 0}%`) : n.sources.length ? paint(ANSI.dim, ` ${n.sources.length} source${n.sources.length === 1 ? "" : "s"}`) : "";
15194
- const state = n.status === "dropped" ? paint(ANSI.dim, " dropped") : n.status === "confirmed" || n.author_kind === "user" ? "" : paint(ANSI.amber, " to review");
15195
- const label = n.status === "dropped" ? paint(ANSI.dim, n.label) : n.level === 1 ? paint(ANSI.bold, n.label) : n.label;
15196
- const body = wrap(`${label}${where}${state}${n.proposal ? paint(ANSI.amber, " \xB7 NanoPM suggests a new wording") : ""}`, width - indent.length - 12);
15197
- out.push(`${indent}${lead}${paint(ANSI.dim, n.ref.padEnd(8))} ${body[0] ?? ""}`);
15198
- for (const more of body.slice(1)) out.push(`${indent} ${more}`);
15199
- for (const c of n.children) line(c, depth + 1);
15200
- };
15201
- for (const need of tree) {
15439
+ const unproven = problems2.filter((p) => p.proofs === 0).length;
15440
+ if (unproven) {
15202
15441
  out.push("");
15203
- line(need, 0);
15442
+ out.push(
15443
+ paint(
15444
+ ANSI.dim,
15445
+ `${unproven} of ${problems2.length} rest on judgement, not proof. A tilde marks those.`
15446
+ )
15447
+ );
15204
15448
  }
15205
- out.push("", paint(ANSI.dim, `\u25CF observed \u2013 stated by you \u25CB NanoPM\u2019s guess. \`nanopm needs confirm <ref>\`, \`nanopm needs drop <ref> "why"\`, \`nanopm needs dig <ref>\`.`));
15206
- if (opts.others?.length) out.push(paint(ANSI.dim, `Other personas: ${opts.others.join(", ")}.`));
15449
+ if (duplicates.length) {
15450
+ out.push("");
15451
+ for (const pair of duplicates.slice(0, 3)) {
15452
+ const why = pair.flagged ? `NanoPM is not sure ${pair.a} and ${pair.b} are different problems` : `${pair.a} and ${pair.b} read alike`;
15453
+ out.push(paint(ANSI.amber, `? ${why}`));
15454
+ }
15455
+ out.push(paint(ANSI.dim, ` nanopm decisions why \xB7 nanopm problems merge ${duplicates[0].b} ${duplicates[0].a}`));
15456
+ }
15457
+ if (opts.manualMerges !== void 0) {
15458
+ out.push("");
15459
+ out.push(
15460
+ paint(
15461
+ ANSI.dim,
15462
+ `${opts.manualMerges} merge${opts.manualMerges === 1 ? "" : "s"} you have had to make by hand.`
15463
+ )
15464
+ );
15465
+ }
15466
+ out.push("");
15467
+ out.push(paint(ANSI.dim, " nanopm problems <ref> \xB7 nanopm problems merge <from> <into> \xB7 nanopm problems weight <ref> <0-100>"));
15207
15468
  return out.join("\n");
15208
15469
  }
15209
-
15210
- // src/commands/needs.ts
15211
- async function people(client, projectId) {
15212
- const { data, error } = await client.from("memory").select("key, title, content").eq("project_id", projectId).eq("topic", PERSONA_TOPIC).is("superseded_by", null);
15213
- if (error) throw new Error(`could not read the personas: ${error.message}`);
15214
- return (data ?? []).filter((r) => PERSONA_KEY.test(r.key)).map((r) => {
15215
- const p = parsePersona(r.key, r.content);
15216
- return { slug: p.slug, name: personaName(r.title, p.slug), primary: p.primary };
15217
- }).sort((a, b) => Number(b.primary) - Number(a.primary));
15218
- }
15219
- function pick2(list, named) {
15220
- if (!list.length) throw new Error("there is no persona on the business card yet. `nanopm personas --refresh` works out who this is for.");
15221
- if (!named) return list[0];
15222
- const one = list.find((p) => p.slug === named.toLowerCase() || p.name.toLowerCase() === named.toLowerCase());
15223
- if (!one) throw new Error(`no persona "${named}". The personas are: ${list.map((p) => p.slug).join(", ")}.`);
15224
- return one;
15225
- }
15226
- async function rows(client, projectId, persona) {
15227
- let q = client.from("need_nodes").select(NEED_NODE_COLUMNS).eq("project_id", projectId);
15228
- if (persona) q = q.eq("persona", persona);
15229
- const { data, error } = await q.order("created_at");
15230
- if (error) throw new Error(`could not read the needs map: ${error.message}`);
15231
- return data ?? [];
15232
- }
15233
- async function needs(persona, opts) {
15234
- const auth = await authenticate();
15235
- const project = await resolveProject(auth.client, { projectId: opts.projectId, cwd: opts.cwd });
15236
- const list = await people(auth.client, project.id);
15237
- const who = pick2(list, persona);
15238
- const tree = needTree(await rows(auth.client, project.id, who.slug));
15239
- return renderNeeds(who, tree, { color: opts.color ?? useColor(), others: list.filter((p) => p.slug !== who.slug).map((p) => p.slug) });
15240
- }
15241
- async function mapNeeds(target, opts) {
15242
- const auth = await authenticate();
15243
- const project = await resolveProject(auth.client, { projectId: opts.projectId, cwd: opts.cwd });
15244
- const list = await people(auth.client, project.id);
15245
- let persona;
15246
- let scope = "map";
15247
- if (target.ref) {
15248
- const ref = target.ref.toUpperCase();
15249
- const node = (await rows(auth.client, project.id)).find((r) => r.ref === ref);
15250
- if (!node) throw new Error(`no node ${target.ref} on any needs map. \`nanopm needs\` shows the refs.`);
15251
- persona = pick2(list, node.persona);
15252
- scope = ref;
15253
- } else persona = pick2(list, target.persona);
15254
- const [{ data: active }, tree] = await Promise.all([
15255
- auth.client.from("jobs").select("params").eq("project_id", project.id).eq("skill", "needs").in("status", [...ACTIVE_STATUSES]),
15256
- rows(auth.client, project.id, persona.slug).then(needTree)
15257
- ]);
15258
- const lineage = (ref) => {
15259
- const walk = (l) => {
15260
- for (const n of l) {
15261
- if (n.ref === ref.toUpperCase()) return n.path.map((p) => p.ref);
15262
- const deeper = walk(n.children);
15263
- if (deeper) return deeper;
15264
- }
15265
- return null;
15266
- };
15267
- return walk(tree) ?? [ref];
15268
- };
15269
- const clash = needsRunConflict((active ?? []).map((j) => runScopeOf(j.params)), { persona: persona.slug, scope }, { primary: list.find((p) => p.primary)?.slug ?? list[0]?.slug ?? null, lineage, name: (s) => list.find((p) => p.slug === s)?.name ?? s });
15270
- if (clash) throw new Error(clash);
15271
- const repo = await detectRepo(opts.cwd);
15272
- if (repo) await rememberClone(auth.client, project.id, repo.root);
15273
- const params = {
15274
- persona: persona.slug,
15275
- ...scope === "map" ? {} : { scope },
15276
- depth: opts.short ? "short" : "deep",
15277
- ...opts.note?.trim() ? { note: opts.note.trim().slice(0, 600) } : {},
15278
- ...repo ? { repo_path: repo.root } : {}
15279
- };
15280
- return runSkill("needs", { project: project.id, params, cancelSignal: opts.cancelSignal });
15281
- }
15282
- async function confirmNeed(ref, opts) {
15283
- const auth = await authenticate();
15284
- const project = await resolveProject(auth.client, { projectId: opts.projectId, cwd: opts.cwd });
15285
- const { data, error } = await auth.client.from("need_nodes").update({ status: "confirmed" }).eq("project_id", project.id).eq("ref", ref.toUpperCase()).eq("status", "proposed").select("ref, label");
15286
- if (error) throw new Error(pgMessage(error));
15287
- if (!data?.length) throw new Error(`${ref} is not a node waiting on your word. \`nanopm needs\` shows which are.`);
15288
- return `Confirmed ${data[0].ref}: ${data[0].label}. A run proposes beside it from now on, never over it.`;
15289
- }
15290
- async function dropNeed(ref, why, opts) {
15291
- const auth = await authenticate();
15292
- const project = await resolveProject(auth.client, { projectId: opts.projectId, cwd: opts.cwd });
15293
- const { data, error } = await auth.client.from("need_nodes").update({ status: "dropped", drop_reason: why?.trim().slice(0, 400) || "not true of them" }).eq("project_id", project.id).eq("ref", ref.toUpperCase()).in("status", ["proposed", "confirmed"]).select("ref, label");
15294
- if (error) throw new Error(pgMessage(error));
15295
- if (!data?.length) throw new Error(`${ref} is not on the map any more.`);
15296
- return `Dropped ${data[0].ref}: ${data[0].label}. It stays on the record, and NanoPM will not propose it again in the same place.`;
15297
- }
15298
-
15299
- // src/commands/opportunities.ts
15300
- async function lensOf2(client, projectId) {
15301
- const { data } = await client.from("memory").select("id, key, content").eq("project_id", projectId).eq("topic", GOALS_TOPIC).in("key", [OBJECTIVE_KEY, DEFINITION_KEY]).is("superseded_by", null);
15302
- const rows2 = data ?? [];
15303
- const aim = rows2.find((r) => r.key === OBJECTIVE_KEY);
15304
- const m = aim ? /^Lever:\s*([a-z]+)\.\s*(.*)$/s.exec(aim.content.trim()) : null;
15305
- const lever = m?.[1];
15306
- if (!aim || !lever || !OUTCOME_LEVERS.includes(lever)) return null;
15307
- const def = rows2.find((r) => r.key === DEFINITION_KEY);
15308
- const parsed = def ? parseDefinition(def.content) : null;
15309
- const sentence2 = m[2].trim();
15310
- const definition = parsed && parsed.lever === lever ? parsed.sentence : null;
15311
- return { id: aim.id, lever, sentence: sentence2, definition, key: lensKey(lever, sentence2, definition) };
15312
- }
15313
- async function loadOpportunities(client, projectId) {
15314
- const [standing, replaced] = await Promise.all([
15315
- client.from("opportunities").select(OPPORTUNITY_COLUMNS).eq("project_id", projectId).neq("status", "replaced"),
15316
- client.from("opportunities").select(REPLACED_COLUMNS).eq("project_id", projectId).eq("status", "replaced")
15317
- ]);
15318
- const error = standing.error ?? replaced.error;
15319
- if (error) throw new Error(`could not read the opportunities: ${error.message}`);
15320
- return [...standing.data ?? [], ...replaced.data ?? []];
15321
- }
15322
- async function workingOn(client, projectId) {
15323
- const { data } = await client.from("jobs").select("params").eq("project_id", projectId).eq("skill", SOLUTIONS_SKILL).in("status", [...ACTIVE_STATUSES]);
15324
- return leavesAtWork((data ?? []).map((j) => j.params));
15325
- }
15326
- async function opportunities(opts) {
15327
- const auth = await authenticate();
15328
- const project = await resolveProject(auth.client, { projectId: opts.projectId, cwd: opts.cwd });
15329
- const [lens, all, personas2] = await Promise.all([lensOf2(auth.client, project.id), loadOpportunities(auth.client, project.id), people(auth.client, project.id)]);
15330
- const { shown, drawnFor } = shownMap(all, lens?.key ?? null);
15331
- const names = new Map(personas2.map((p) => [p.slug, p.name]));
15332
- return renderOpportunities(shown, lens, { color: opts.color ?? useColor(), previous: drawnFor === "previous", names });
15333
- }
15334
- async function rankOpportunities(opts) {
15335
- const auth = await authenticate();
15336
- const project = await resolveProject(auth.client, { projectId: opts.projectId, cwd: opts.cwd });
15337
- const list = (s) => s ? s.split(",").map((x) => x.trim()).filter(Boolean) : void 0;
15338
- const sources2 = opts.use || opts.skip ? { ...opts.use ? { use: list(opts.use) } : {}, ...opts.skip ? { skip: list(opts.skip) } : {} } : void 0;
15339
- const params = {
15340
- depth: opts.deep ? "deep" : "short",
15341
- ...opts.note?.trim() ? { note: opts.note.trim().slice(0, OPPORTUNITIES_NOTE_MAX) } : {},
15342
- ...sources2 ? { sources: sources2 } : {}
15343
- };
15344
- const checked = opportunitiesAsk(params);
15345
- if (!checked.ok) throw new Error(checked.why);
15346
- const { data: active } = await auth.client.from("jobs").select("id").eq("project_id", project.id).eq("skill", "opportunities").in("status", [...ACTIVE_STATUSES]);
15347
- const clash = opportunitiesRunConflict(active ?? []);
15348
- if (clash) throw new Error(clash);
15349
- const repo = await detectRepo(opts.cwd);
15350
- if (repo) await rememberClone(auth.client, project.id, repo.root);
15351
- return runSkill("opportunities", { project: project.id, params: { ...params, ...repo ? { repo_path: repo.root } : {} }, cancelSignal: opts.cancelSignal });
15352
- }
15353
- async function rpc2(name, args, opts) {
15354
- const auth = await authenticate();
15355
- const project = await resolveProject(auth.client, { projectId: opts.projectId, cwd: opts.cwd });
15356
- const { error } = await auth.client.rpc(name, { p_project_id: project.id, ...args });
15357
- if (error) throw new Error(pgMessage(error));
15358
- }
15359
- async function confirmOpportunity(ref, opts) {
15360
- await rpc2("confirm_opportunity", { p_ref: ref }, opts);
15361
- return `${ref.toUpperCase()} is yours. A later run ranks around it and never replaces it.`;
15362
- }
15363
- async function setAsideOpportunity(ref, why, opts) {
15364
- await rpc2("set_aside_opportunity", { p_ref: ref, p_reason: why ?? "not now" }, opts);
15365
- return `${ref.toUpperCase()} is set aside for this objective, with the narrower problems under it. It comes back if the objective changes.`;
15470
+ function renderProblem(problem, evidence2, addressedBy, opts) {
15471
+ const paint = painter(opts.color);
15472
+ const shape2 = SHAPE2[problem.status] ?? { mark: "\xB7", colour: ANSI.dim, label: problem.status };
15473
+ const width = columns();
15474
+ const out = [];
15475
+ out.push(`${paint(shape2.colour, shape2.mark)} ${paint(ANSI.dim, problem.ref)} ${paint(ANSI.bold, shape2.label)}`);
15476
+ out.push("");
15477
+ for (const line of wrap(problem.statement, width)) out.push(line);
15478
+ out.push("");
15479
+ const facts = [];
15480
+ const m = problem.mapping ?? null;
15481
+ if (m) {
15482
+ const story = problemStory(m, { proofs: problem.proofs, kinds: problem.proof_kinds ?? [], provenance: problem.provenance ?? "inferred", authorKind: problem.author_kind ?? "nanopm", createdAt: problem.created_at ?? "" });
15483
+ out.push(...wrap(story.story, width));
15484
+ out.push("");
15485
+ out.push(...wrap(story.doubt, width).map((l) => paint(ANSI.dim, l)));
15486
+ out.push(...wrap(story.assumes ? `${story.test} ${story.assumes}` : story.test, width).map((l) => paint(ANSI.dim, l)));
15487
+ out.push("");
15488
+ out.push(paint(ANSI.dim, "how I got here"));
15489
+ facts.push(["kind", `${m.level === 1 ? "a need" : "a difficulty"} \xB7 ${m.knowledge} \xB7 ${m.origin === "expert_inference" ? "expert inference" : "source analysis"}`]);
15490
+ if (m.parent_reason) facts.push(["grouping", m.parent_reason]);
15491
+ }
15492
+ if (problem.segment) facts.push(["who", problem.segment]);
15493
+ facts.push([
15494
+ "weight",
15495
+ problem.importance_basis === "evidence" ? `${Math.round(problem.importance)} from ${problem.proofs} piece${problem.proofs === 1 ? "" : "s"} of proof` : `${Math.round(problem.importance)}, a judgement${problem.importance_note ? ` \u2014 ${problem.importance_note}` : ""}`
15496
+ ]);
15497
+ if (problem.parent_ref) facts.push(["part of", problem.parent_ref]);
15498
+ for (const [label, value] of facts) {
15499
+ out.push(`${paint(ANSI.dim, label.padEnd(9))}${clip(value, width - 9)}`);
15500
+ }
15501
+ if (evidence2.length) {
15502
+ out.push("");
15503
+ out.push(paint(ANSI.dim, "sources"));
15504
+ for (const item of evidence2) {
15505
+ const when = (item.occurred_at ?? item.created_at).slice(0, 10);
15506
+ const where = item.metric_key ?? item.source_ref ?? item.kind;
15507
+ for (const line of wrap(item.body, width - 2)) out.push(` ${line}`);
15508
+ out.push(paint(ANSI.dim, ` \u2014 ${item.relation ?? "supports"} \xB7 ${item.kind} \xB7 ${where} \xB7 ${when}`));
15509
+ }
15510
+ } else {
15511
+ out.push("");
15512
+ out.push(paint(ANSI.dim, "No proof yet. This ranking is a judgement, and the next review may settle it."));
15513
+ }
15514
+ if (addressedBy.length) {
15515
+ out.push("");
15516
+ out.push(paint(ANSI.dim, "addressed by"));
15517
+ for (const item of addressedBy) {
15518
+ out.push(` ${paint(ANSI.dim, item.ref)} ${clip(item.title, width - 12)} ${paint(ANSI.dim, `(${item.status})`)}`);
15519
+ }
15520
+ }
15521
+ out.push("");
15522
+ out.push(paint(ANSI.dim, " nanopm problems \xB7 nanopm roadmap"));
15523
+ return out.join("\n");
15366
15524
  }
15367
- async function moveOpportunity(ref, place, opts) {
15368
- const rank2 = Number(place);
15369
- if (!Number.isInteger(rank2) || rank2 < 1) throw new Error("say where: nanopm opportunities move <ref> <place>, a place from 1");
15370
- await rpc2("move_opportunity", { p_ref: ref, p_rank: rank2 }, opts);
15371
- return `${ref.toUpperCase()} is at #${rank2} among its siblings, and the place is yours. Moved above a higher priority, it takes that priority.`;
15525
+
15526
+ // src/format/tree.ts
15527
+ var SOLUTION_SHAPE = {
15528
+ proposed: { mark: "\u25CB", colour: ANSI.amber, label: "proposed" },
15529
+ accepted: { mark: "\u25D0", colour: ANSI.green, label: "accepted" },
15530
+ building: { mark: "\u25D4", colour: ANSI.blue, label: "building" },
15531
+ done: { mark: "\u25CF", colour: ANSI.green, label: "done" },
15532
+ rejected: { mark: "\u2298", colour: ANSI.red, label: "rejected" },
15533
+ dropped: { mark: "\u2298", colour: ANSI.dim, label: "dropped" }
15534
+ };
15535
+ var PROBLEM_SHAPE = {
15536
+ candidate: { mark: "\u25CB", colour: ANSI.dim },
15537
+ confirmed: { mark: "\u25C9", colour: ANSI.amber },
15538
+ addressed: { mark: "\u25CF", colour: ANSI.green },
15539
+ dropped: { mark: "\u2298", colour: ANSI.dim }
15540
+ };
15541
+ var NO_AREA = "no area yet";
15542
+ var SHOWN = /* @__PURE__ */ new Set(["proposed", "accepted", "building", "done"]);
15543
+ function renderTree(problems2, solutions, strategy, opts) {
15544
+ const paint = painter(opts.color);
15545
+ const out = [];
15546
+ const width = Math.max(40, columns());
15547
+ if (strategy) {
15548
+ out.push(paint(ANSI.bold, "Strategy"));
15549
+ for (const line of wrap(strategy, width - 4)) out.push(` ${line}`);
15550
+ out.push("");
15551
+ }
15552
+ const shown = solutions.filter((s) => SHOWN.has(s.status));
15553
+ const open = problems2.filter((p) => p.status !== "dropped" && (!opts.alongside || shown.some((s) => s.problem_id === p.id)));
15554
+ if (opts.alongside && !open.length) return "";
15555
+ if (!open.length && !shown.length) {
15556
+ out.push("No problems yet. `nanopm opportunities` shows what to work on.");
15557
+ return out.join("\n");
15558
+ }
15559
+ const byProblem = /* @__PURE__ */ new Map();
15560
+ for (const s of shown) byProblem.set(s.problem_id, [...byProblem.get(s.problem_id) ?? [], s]);
15561
+ const refWidth = Math.max(...[...open.map((p) => p.ref), ...shown.map((s) => s.ref)].map((r) => r.length), 5);
15562
+ for (const [area, rows2] of groupByArea(open)) {
15563
+ out.push(paint(ANSI.bold, area));
15564
+ for (const p of rows2) {
15565
+ const shape2 = PROBLEM_SHAPE[p.status] ?? { mark: "\xB7", colour: "" };
15566
+ const kind = p.kind === "founder" ? paint(ANSI.dim, " (founder)") : "";
15567
+ const proof = p.proofs ? `${p.proofs} proof${p.proofs === 1 ? "" : "s"}` : "no proof";
15568
+ const tail = `${renderImportance(p, opts)} ${paint(ANSI.dim, proof)}`;
15569
+ const head = ` ${paint(shape2.colour, shape2.mark)} ${paint(ANSI.dim, p.ref.padEnd(refWidth))} `;
15570
+ out.push(head + clip(p.statement, Math.max(30, width - visibleLength(head) - visibleLength(tail) - 2)) + kind + " " + tail);
15571
+ const under = byProblem.get(p.id) ?? [];
15572
+ if (!under.length) {
15573
+ out.push(paint(ANSI.dim, ` ${" ".repeat(refWidth + 2)} no solution yet`));
15574
+ continue;
15575
+ }
15576
+ for (const s of under) out.push(...solutionLines(s, refWidth, width, paint));
15577
+ }
15578
+ out.push("");
15579
+ }
15580
+ const waiting = shown.filter((s) => s.status === "proposed").length;
15581
+ if (waiting) {
15582
+ out.push(`${waiting} waiting on you.`);
15583
+ out.push(paint(ANSI.dim, ' nanopm accept <ref> \xB7 nanopm reject <ref> "why" \xB7 nanopm roadmap <ref> \xB7 nanopm roadmap --board'));
15584
+ } else if (!opts.alongside) {
15585
+ out.push(paint(ANSI.dim, "Nothing waiting on you."));
15586
+ }
15587
+ return out.join("\n").trimEnd();
15372
15588
  }
15373
- async function rewordOpportunity(ref, problem, opts) {
15374
- const text2 = problem?.trim().replace(/\s+/g, " ") ?? "";
15375
- if (!text2) throw new Error('say it: nanopm opportunities reword <ref> "the problem, in your words"');
15376
- if (text2.length > PROBLEM_MAX) throw new Error(`one line, under ${PROBLEM_MAX} characters`);
15377
- await rpc2("restate_opportunity", { p_ref: ref, p_problem: text2 }, opts);
15378
- return `Kept in your words: ${text2}`;
15589
+ function solutionLines(s, refWidth, width, paint, indent = 6) {
15590
+ const shape2 = SOLUTION_SHAPE[s.status] ?? { mark: "\xB7", colour: "", label: s.status };
15591
+ const pad = " ".repeat(indent);
15592
+ const STATE = { accepted: "chosen", building: "building", done: "done" };
15593
+ const tail = s.actor === "founder" ? "yours to do" : s.outcome ? s.outcome : s.status === "proposed" ? s.assumption ? clip(s.assumption, 40) : null : STATE[s.status] ?? null;
15594
+ const badge = tail ? paint(ANSI.dim, ` ${tail}`) : "";
15595
+ const head = `${pad}${paint(shape2.colour, shape2.mark)} ${paint(ANSI.dim, s.ref.padEnd(refWidth))} `;
15596
+ const lines = [head + clip(s.title, Math.max(30, width - visibleLength(head) - visibleLength(badge))) + badge];
15597
+ const reason2 = `${pad} ${" ".repeat(refWidth)} `;
15598
+ lines.push(paint(ANSI.dim, reason2 + clip(firstSentence(s.reasoning), Math.max(30, width - reason2.length))));
15599
+ return lines;
15379
15600
  }
15380
- var AWAKE_MS = 12e4;
15381
- async function awakeDaemon(client, userId) {
15382
- const since = new Date(Date.now() - AWAKE_MS).toISOString();
15383
- const [{ data }, local] = await Promise.all([
15384
- client.from("daemons").select("id, hostname").eq("user_id", userId).in("status", ["online", "running"]).gte("last_seen_at", since).order("last_seen_at", { ascending: false }),
15385
- localDaemonId()
15386
- ]);
15387
- return data?.find((d) => d.id === local) ?? data?.[0] ?? null;
15601
+ function groupByArea(problems2) {
15602
+ const groups = /* @__PURE__ */ new Map();
15603
+ for (const p of problems2) {
15604
+ const area = p.area ?? NO_AREA;
15605
+ groups.set(area, [...groups.get(area) ?? [], p]);
15606
+ }
15607
+ const named = [...groups.entries()].filter(([a]) => a !== NO_AREA);
15608
+ const rest = groups.get(NO_AREA);
15609
+ return rest ? [...named, [NO_AREA, rest]] : named;
15388
15610
  }
15389
- async function brainstorm(ref, opts) {
15390
- const auth = await authenticate();
15391
- const project = await resolveProject(auth.client, { projectId: opts.projectId, cwd: opts.cwd });
15392
- const [lens, all, busy] = await Promise.all([lensOf2(auth.client, project.id), loadOpportunities(auth.client, project.id), workingOn(auth.client, project.id)]);
15393
- const deep = Boolean(opts.deep);
15394
- const plan = brainstormPlan(shownMap(all, lens?.key ?? null).shown, ref, deep, new Set(busy.keys()));
15395
- const daemon = await awakeDaemon(auth.client, auth.userId);
15396
- 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 brainstorm again.");
15397
- const repo = deep ? await detectRepo(opts.cwd) : null;
15398
- if (repo) await rememberClone(auth.client, project.id, repo.root);
15399
- for (const params of plan.jobs) {
15400
- const { error } = await auth.client.from("jobs").insert({ project_id: project.id, skill: SOLUTIONS_SKILL, params: { ...params, ...repo ? { repo_path: repo.root } : {} }, requested_by: auth.userId, trigger: "user", target_daemon_id: daemon.id });
15401
- if (error) throw new Error(`could not queue the brainstorm: ${error.message}`);
15611
+ var BOARD_COLUMNS = [
15612
+ { status: "proposed", label: "Proposed" },
15613
+ { status: "accepted", label: "Chosen" },
15614
+ { status: "building", label: "Building" },
15615
+ { status: "done", label: "Done" }
15616
+ ];
15617
+ function renderBoard(problems2, solutions, opts) {
15618
+ const paint = painter(opts.color);
15619
+ const out = [];
15620
+ const byId = new Map(problems2.map((p) => [p.id, p]));
15621
+ const width = Math.max(40, columns());
15622
+ const refWidth = Math.max(...solutions.map((s) => s.ref.length), 5);
15623
+ for (const col of BOARD_COLUMNS) {
15624
+ const rows2 = solutions.filter((s) => s.status === col.status);
15625
+ out.push(`${paint(ANSI.bold, col.label)} ${paint(ANSI.dim, String(rows2.length))}`);
15626
+ if (!rows2.length) out.push(paint(ANSI.dim, " \u2014"));
15627
+ for (const s of rows2) {
15628
+ const shape2 = SOLUTION_SHAPE[s.status];
15629
+ const problem = byId.get(s.problem_id);
15630
+ const head = ` ${paint(shape2.colour, shape2.mark)} ${paint(ANSI.dim, s.ref.padEnd(refWidth))} `;
15631
+ out.push(head + clip(s.title, Math.max(30, width - visibleLength(head))));
15632
+ const under = [problem ? `${problem.ref} ${clip(problem.statement, 50)}` : "its problem was dropped", s.assumption ? clip(s.assumption, 40) : null].filter(Boolean).join(" \xB7 ");
15633
+ out.push(paint(ANSI.dim, ` ${" ".repeat(refWidth + 2)} ${under}`));
15634
+ }
15635
+ out.push("");
15402
15636
  }
15403
- const n = plan.leaves.length;
15404
- const minutes = deep ? DEEP_MINUTES * n : FLASH_MINUTES;
15405
- const what = plan.problem.children.length ? `the ${n} narrower problem${n === 1 ? "" : "s"} under ${plan.problem.ref}` : `${plan.problem.ref} \u201C${clip(plan.problem.problem, 60)}\u201D`;
15406
- const how = deep ? `${DEPTH_WORD.deep}${n > 1 ? ", one after the other" : ""}` : `${DEPTH_WORD.quick}, a first pass`;
15407
- const out = [`${deep ? "Going deeper on" : "Brainstorming"} ${what}: ${how}, about ${minutes} minutes on ${daemon.hostname}.`];
15408
- if (plan.problem.children.length) for (const l of plan.leaves) out.push(` ${l.ref.padEnd(8)} ${clip(l.problem, Math.max(30, columns() - 11))}`);
15409
- if (plan.busy) out.push(`${plan.busy} left out: Nano is already finding solutions to ${plan.busy === 1 ? "it" : "them"}.`);
15410
- out.push("The solutions land on `nanopm roadmap`.");
15411
- return out.join("\n");
15637
+ return out.join("\n").trimEnd();
15412
15638
  }
15413
15639
 
15414
15640
  // src/commands/roadmap.ts
@@ -15568,8 +15794,8 @@ async function decide(kind, ref, body, opts) {
15568
15794
  const row = Array.isArray(data) ? data[0] : void 0;
15569
15795
  if (!row) throw new Error(`nothing changed for ${ref}`);
15570
15796
  if (onMap) {
15571
- const next2 = card?.others[0];
15572
- const after = !card ? "" : next2 ? ` ${next2.ref} \u201C${next2.title}\u201D takes its card in ${HORIZON_WORD[card.horizon]}.` : ` Its card leaves the board: nothing else answers ${card.leaf.ref} yet. \`nanopm opportunities brainstorm ${card.leaf.ref}\` finds more.`;
15797
+ const next = card?.others[0];
15798
+ const after = !card ? "" : next ? ` ${next.ref} \u201C${next.title}\u201D takes its card in ${HORIZON_WORD[card.horizon]}.` : ` Its card leaves the board: nothing else answers ${card.leaf.ref} yet. \`nanopm opportunities brainstorm ${card.leaf.ref}\` finds more.`;
15573
15799
  return `Thrown out: ${row.ref}${body?.trim() ? ` \u2014 ${body.trim()}` : ""}.${after}`;
15574
15800
  }
15575
15801
  const verb = kind === "accept" ? "Accepted" : "Rejected";
@@ -15586,123 +15812,65 @@ function normalizeRef(input, items) {
15586
15812
  return given;
15587
15813
  }
15588
15814
 
15589
- // src/commands/next.ts
15590
- async function next(opts) {
15815
+ // src/commands/build.ts
15816
+ var execFileAsync9 = promisify9(execFile10);
15817
+ async function build(ref, opts) {
15591
15818
  const { renderer, out } = rendererFor(opts.out);
15592
15819
  const auth = await authenticate();
15593
15820
  const project = await resolveProject(auth.client, { projectId: opts.projectId, cwd: opts.cwd });
15594
- const autonomy2 = await readAutonomy(auth.client, project.id);
15595
- if (autonomy2 === "propose") {
15596
- throw new Error(
15597
- `${project.name} is on "propose", so NanoPM may look but not touch.
15598
- nanopm autonomy execute_after_validation to let it change files, asking you first`
15599
- );
15600
- }
15601
- const { data: items, error } = await auth.client.from("solutions").select("id, ref, title, rank, status").eq("project_id", project.id).is("superseded_by", null).eq("status", "accepted").eq("actor", "nanopm").order("rank");
15602
- if (error) throw new Error(`could not read the solutions: ${error.message}`);
15603
- const accepted = items ?? [];
15604
- if (!accepted.length) {
15605
- throw new Error("nothing is accepted yet. Run `nanopm roadmap`, then `nanopm accept <ref>`.");
15606
- }
15607
- let item = accepted[0];
15608
- if (opts.ref) {
15609
- const wanted = normalizeRef(opts.ref, accepted);
15610
- const found = accepted.find((i) => i.ref === wanted);
15611
- if (!found) {
15612
- throw new Error(`${opts.ref} is not an accepted solution NanoPM can build. Accepted: ${accepted.map((i) => i.ref).join(", ")}.`);
15613
- }
15614
- item = found;
15615
- }
15616
15821
  const repo = await detectRepo(opts.cwd);
15617
- if (!repo) throw new Error("run this inside the repository, so NanoPM changes the clone you are looking at");
15822
+ if (!repo) throw new Error("run this inside the repository, so NanoPM builds from the clone you are looking at");
15823
+ const { data: solutions } = await auth.client.from("solutions").select("id, ref, title, status").eq("project_id", project.id).is("superseded_by", null);
15824
+ const wanted = normalizeRef(ref, solutions ?? []);
15825
+ const solution = (solutions ?? []).find((s) => s.ref === wanted);
15826
+ if (!solution) throw new Error(`No solution ${wanted} on the Roadmap.`);
15827
+ const stream = async (jobId2) => {
15828
+ out(`${solution.ref} ${solution.title}`);
15829
+ const answerer = opts.answersFile ? await answersFromFile(opts.answersFile) : answersFromTerminal(out);
15830
+ return streamJob(auth.client, jobId2, { pollMs: 700, out, renderer, cancelSignal: opts.cancelSignal, answerer, verbose: verboseWanted() });
15831
+ };
15832
+ const live = await buildOf(auth.client, project.id, [...ACTIVE_STATUSES]);
15833
+ if (live) {
15834
+ 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.`);
15835
+ if (opts.throwAway) throw new Error(`${solution.ref}'s build is still on its way: stop it first (\`nanopm stop\`).`);
15836
+ return stream(live.id);
15837
+ }
15838
+ const stopped = opts.continue || opts.throwAway ? await buildOf(auth.client, project.id, ["failed", "cancelled"], solution.id) : null;
15839
+ if ((opts.continue || opts.throwAway) && !stopped) throw new Error(`${solution.ref} has no stopped build to ${opts.continue ? "continue" : "throw away"}.`);
15840
+ if (opts.throwAway) return throwAway(auth.client, stopped.origin, repo.root, out);
15841
+ const [daemon, local] = await Promise.all([awakeDaemon(auth.client, auth.userId), localDaemonId()]);
15842
+ 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.");
15618
15843
  await rememberClone(auth.client, project.id, repo.root);
15619
- const daemonId = await localDaemonId();
15620
- const { data: job, error: queueError } = await auth.client.from("jobs").insert({
15621
- project_id: project.id,
15622
- skill: "next",
15623
- params: { solution_id: item.id, repo_path: repo.root },
15624
- requested_by: auth.userId,
15625
- target_daemon_id: daemonId
15626
- }).select("id").single();
15627
- if (queueError || !job) throw new Error(`could not queue the work: ${queueError?.message}`);
15628
- out(`${item.ref} ${item.title}`);
15629
- const answerer = opts.answersFile ? await answersFromFile(opts.answersFile) : answersFromTerminal(out);
15630
- return streamJob(auth.client, job.id, { pollMs: 700, out, renderer, cancelSignal: opts.cancelSignal, answerer, verbose: verboseWanted() });
15631
- }
15632
- async function readAutonomy(client, projectId) {
15633
- const { data } = await client.from("project_preferences").select("autonomy").eq("project_id", projectId).maybeSingle();
15634
- return data?.autonomy ?? "propose";
15635
- }
15636
- var EXPLAINED = {
15637
- propose: "NanoPM reads and proposes. It never changes a file.",
15638
- execute_after_validation: "NanoPM changes files, on its own branch, after showing you what it will do and waiting for a yes.",
15639
- execute_then_report: "NanoPM acts first and tells you after. Not available yet."
15640
- };
15641
- async function autonomy(level, opts) {
15642
- const auth = await authenticate();
15643
- const project = await resolveProject(auth.client, { projectId: opts.projectId, cwd: opts.cwd });
15644
- const current = await readAutonomy(auth.client, project.id);
15645
- if (!level) {
15646
- const lines = [`${project.name} is on "${current}".`, ""];
15647
- for (const l of AUTONOMY_LEVELS) {
15648
- lines.push(` ${l === current ? "\u25CF" : "\u25CB"} ${l.padEnd(26)} ${EXPLAINED[l]}`);
15649
- }
15650
- lines.push("", " nanopm autonomy <level>");
15651
- return lines.join("\n");
15652
- }
15653
- const wanted = level.trim();
15654
- if (!AUTONOMY_LEVELS.includes(wanted)) {
15655
- throw new Error(`"${level}" is not a level. Pick one of: ${AUTONOMY_LEVELS.join(", ")}.`);
15656
- }
15657
- if (wanted === "execute_then_report") {
15658
- throw new Error(
15659
- "execute_then_report is not available yet. NanoPM asks before it changes anything until the review loop has been used in anger."
15660
- );
15661
- }
15662
- if (wanted === current) return `${project.name} is already on "${current}".`;
15663
- const { error } = await auth.client.from("project_preferences").update({ autonomy: wanted }).eq("project_id", project.id);
15664
- if (error) {
15665
- throw new Error(`could not change the dial: ${error.message}`);
15666
- }
15667
- return [`${project.name} is now on "${wanted}".`, EXPLAINED[wanted]].join("\n");
15668
- }
15669
- async function verifyCommand(command, opts) {
15670
- const auth = await authenticate();
15671
- const project = await resolveProject(auth.client, { projectId: opts.projectId, cwd: opts.cwd });
15672
- const { data } = await auth.client.from("project_preferences").select("verify_command").eq("project_id", project.id).maybeSingle();
15673
- const current = data?.verify_command ?? null;
15674
- if (!command && !opts.clear) {
15675
- if (!current) {
15676
- return [
15677
- `${project.name} has no check, so nothing verifies what NanoPM writes.`,
15678
- "",
15679
- ' nanopm verify "npm run lint && npm test"',
15680
- "",
15681
- "The daemon runs it in the repository root after a change and before the solution is",
15682
- "marked done. A failure goes back to NanoPM once, to fix."
15683
- ].join("\n");
15684
- }
15685
- return [`${project.name} verifies with:`, "", ` ${current}`, "", ' nanopm verify "<command>" \xB7 nanopm verify --clear'].join("\n");
15686
- }
15687
- const next2 = opts.clear ? null : command.trim();
15688
- if (next2 !== null && !next2) throw new Error('a check needs a command: nanopm verify "npm test"');
15689
- const { error } = await auth.client.from("project_preferences").update({ verify_command: next2 }).eq("project_id", project.id);
15690
- if (error) throw new Error(`could not save the check: ${error.message}`);
15691
- if (next2 === null) return `${project.name} no longer runs a check. Nothing will verify what NanoPM writes.`;
15692
- return [
15693
- `${project.name} will verify with:`,
15694
- "",
15695
- ` ${next2}`,
15696
- "",
15697
- "It runs in the repository root. If your change touches what the command itself runs \u2014",
15698
- "package.json, a lockfile, the script it calls \u2014 the check is skipped and you are told,",
15699
- "because an agent that can rewrite its own examiner is not being examined."
15700
- ].join("\n");
15844
+ const { jobId } = await queueBuild(auth.client, {
15845
+ projectId: project.id,
15846
+ userId: auth.userId,
15847
+ daemonId: local,
15848
+ ref: solution.ref,
15849
+ repoPath: repo.root,
15850
+ anyway: opts.anyway,
15851
+ ...stopped ? { resumeOf: { id: stopped.id, was: stopped.was, origin: stopped.origin } } : {}
15852
+ });
15853
+ return stream(jobId);
15854
+ }
15855
+ async function buildOf(client, projectId, statuses, solutionId) {
15856
+ let query = client.from("jobs").select("id, params").eq("project_id", projectId).eq("skill", BUILD_SKILL).in("status", statuses);
15857
+ if (solutionId) query = query.eq("params->>solution_id", solutionId);
15858
+ const { data } = await query.order("created_at", { ascending: false }).limit(1).maybeSingle();
15859
+ const params = data?.params ?? {};
15860
+ return data ? { id: data.id, solutionId: params.solution_id ?? "", was: wasOf(data.params), origin: params.origin ?? data.id } : null;
15861
+ }
15862
+ async function throwAway(client, jobId, clone, out) {
15863
+ const { place } = await loadBuildHistory(client, [jobId], { home: homedir6(), jobId });
15864
+ if (!place) throw new Error("that build left nothing on this machine.");
15865
+ if (existsSync7(place.dir)) await execFileAsync9("git", ["-C", clone, "worktree", "remove", "--force", place.dir]);
15866
+ await execFileAsync9("git", ["-C", clone, "branch", "-D", place.branch]).catch(() => void 0);
15867
+ out(`Thrown away: the copy of the code and the local branch ${place.branch}.`);
15868
+ return 0;
15701
15869
  }
15702
15870
 
15703
15871
  // src/detect-sources.ts
15704
- import { readFile as readFile12 } from "fs/promises";
15705
- import { join as join14 } from "path";
15872
+ import { readFile as readFile15 } from "fs/promises";
15873
+ import { join as join17 } from "path";
15706
15874
  var ENV_FILES = [".env", ".env.local", ".env.development.local", ".env.development", ".env.example"];
15707
15875
  var POSTGRES_KEYS = [
15708
15876
  "DATABASE_URL",
@@ -15752,7 +15920,7 @@ async function envFiles(repoRoot) {
15752
15920
  const out = [];
15753
15921
  for (const file of ENV_FILES) {
15754
15922
  try {
15755
- out.push({ file, env: parseEnv(await readFile12(join14(repoRoot, file), "utf8")) });
15923
+ out.push({ file, env: parseEnv(await readFile15(join17(repoRoot, file), "utf8")) });
15756
15924
  } catch {
15757
15925
  continue;
15758
15926
  }
@@ -15812,7 +15980,7 @@ function describeUrl(url) {
15812
15980
  // src/format/closing.ts
15813
15981
  function closingLines(project, exit, env = process.env, mode = "onboard") {
15814
15982
  if (exit !== 0) return [];
15815
- const build = mode === "start" ? `nanopm accept <ref> takes an option; when one needs code, in its repo: nanopm onboard --project ${project.id ?? "<id>"} attaches it, then nanopm next builds it` : "nanopm accept <ref> takes an option, nanopm next builds it";
15983
+ const build2 = mode === "start" ? `nanopm accept <ref> takes an option; when one needs code, in its repo: nanopm onboard --project ${project.id ?? "<id>"} attaches it, then nanopm build <ref> builds it` : "nanopm spec <ref> --write writes its specs, nanopm build <ref> builds it";
15816
15984
  const chain = chainedAfter(mode).map((skill) => skillSentence(skill));
15817
15985
  return [
15818
15986
  "",
@@ -15820,7 +15988,7 @@ function closingLines(project, exit, env = process.env, mode = "onboard") {
15820
15988
  ...chain.length ? [`NanoPM is now ${joinAnd(chain)}; the Roadmap fills in as it goes. Watch it at ${webUrl(env)}/p/${project.slug}/journal`] : [],
15821
15989
  // The map is no longer chained after onboarding, so the first three clauses are what
15822
15990
  // builds it: what users said, where they leave traces, then the map that reads both.
15823
- `Next: nanopm add <file> adds what users said \xB7 nanopm connect adds where they leave traces \xB7 nanopm opportunities run ranks what to work on \xB7 ${build} \xB7 nanopm schedule weekly monday 7 sets the review`,
15991
+ `Next: nanopm add <file> adds what users said \xB7 nanopm connect adds where they leave traces \xB7 nanopm opportunities run ranks what to work on \xB7 ${build2} \xB7 nanopm schedule weekly monday 7 sets the review`,
15824
15992
  "With a partner: nanopm invite <email> prints a link; they open it, or in their clone: nanopm login, then nanopm join <code>"
15825
15993
  ];
15826
15994
  }
@@ -16521,7 +16689,7 @@ async function seedWatches(watches2, read6) {
16521
16689
  }
16522
16690
 
16523
16691
  // src/commands/watch.ts
16524
- import { readFile as readFile13 } from "fs/promises";
16692
+ import { readFile as readFile16 } from "fs/promises";
16525
16693
  function cursorFor(source, watchId) {
16526
16694
  const all = source.cursor ?? {};
16527
16695
  return all[watchId] ?? {};
@@ -16582,7 +16750,7 @@ async function addWatch(labelOrId, id, opts) {
16582
16750
  const paint = painter(color);
16583
16751
  if (!labelOrId || !id) throw new Error('say which feed and what to call it: nanopm watch add <source> <id> --query "\u2026"');
16584
16752
  if (opts.query && opts.queryFile) throw new Error("--query or --query-file, not both");
16585
- const query = opts.queryFile ? await readFile13(opts.queryFile, "utf8") : opts.query ?? "";
16753
+ const query = opts.queryFile ? await readFile16(opts.queryFile, "utf8") : opts.query ?? "";
16586
16754
  const watch = watchInput(id, query, opts);
16587
16755
  const auth = await authenticate();
16588
16756
  const project = await resolveProject(auth.client, { projectId: opts.projectId, cwd: opts.cwd });
@@ -16769,9 +16937,9 @@ async function review(opts) {
16769
16937
  }
16770
16938
  const usable = connections.filter((c) => c.status === "ok");
16771
16939
  if (!usable.length) {
16772
- const failed = connections.map((c) => c.label).join(", ");
16940
+ const failed2 = connections.map((c) => c.label).join(", ");
16773
16941
  throw new Error(
16774
- `no connection is working (${failed}), so a review would have nothing to read.
16942
+ `no connection is working (${failed2}), so a review would have nothing to read.
16775
16943
  nanopm connect --list see what is wrong`
16776
16944
  );
16777
16945
  }
@@ -17406,12 +17574,12 @@ function renderDirection(rows2) {
17406
17574
  const out = [];
17407
17575
  for (const line of DIRECTION_ROWS) {
17408
17576
  const row = rows2.find((r) => r.key === directionKey(line));
17409
- const next2 = rows2.find((r) => r.key === directionKey(line, true));
17577
+ const next = rows2.find((r) => r.key === directionKey(line, true));
17410
17578
  out.push(`${DIRECTION_ROW_TITLE[line]}${mark(row)}`);
17411
17579
  out.push(` ${row?.content ?? "\u2014"}`);
17412
- if (next2) {
17413
- const because = reasonOf2(next2.sources);
17414
- out.push(` proposed: ${next2.content}${because ? ` \xB7 ${because}` : ""}`);
17580
+ if (next) {
17581
+ const because = reasonOf2(next.sources);
17582
+ out.push(` proposed: ${next.content}${because ? ` \xB7 ${because}` : ""}`);
17415
17583
  }
17416
17584
  }
17417
17585
  out.push("Objective");
@@ -17451,13 +17619,13 @@ function render2(rows2) {
17451
17619
  const out = [];
17452
17620
  for (const line of PITCH_LINE_KEYS) {
17453
17621
  const row = rows2.find((r) => r.key === pitchKey(line));
17454
- const next2 = rows2.find((r) => r.key === pitchKey(line, true));
17622
+ const next = rows2.find((r) => r.key === pitchKey(line, true));
17455
17623
  const mark2 = row && row.provenance !== "stated" ? ` \xB7 to review${row.confidence != null ? ` (${row.confidence}% sure)` : ""}` : "";
17456
17624
  out.push(`${PITCH_LINE_TITLE[line]}${mark2}`);
17457
17625
  out.push(` ${row?.content ?? "\u2014"}`);
17458
- if (next2) {
17459
- const because = reasonOf3(next2.sources);
17460
- out.push(` proposed: ${next2.content}${because ? ` \xB7 ${because}` : ""}`);
17626
+ if (next) {
17627
+ const because = reasonOf3(next.sources);
17628
+ out.push(` proposed: ${next.content}${because ? ` \xB7 ${because}` : ""}`);
17461
17629
  }
17462
17630
  }
17463
17631
  return out.join("\n");
@@ -17515,7 +17683,7 @@ async function invite(email, opts) {
17515
17683
  const [, ...rest] = inviteLines({ name: project.name, code, email: email.trim().toLowerCase(), web: webUrl() });
17516
17684
  return [`Invite to ${paint(ANSI.bold, project.name)}: ${paint(ANSI.bold, code)}`, ...rest.map((l) => l ? paint(ANSI.dim, l) : l)].join("\n");
17517
17685
  }
17518
- async function join15(code, opts) {
17686
+ async function join18(code, opts) {
17519
17687
  const normalized2 = code.trim().toUpperCase();
17520
17688
  if (!INVITE_CODE.test(normalized2)) throw new Error(`that does not look like an invite code: ten letters and digits, like ${"K7PQ2XM9RT"}`);
17521
17689
  const repo = await detectRepo(opts.cwd);
@@ -17800,7 +17968,7 @@ async function showSchedule(opts) {
17800
17968
  }
17801
17969
 
17802
17970
  // src/commands/status.ts
17803
- import { existsSync as existsSync6 } from "fs";
17971
+ import { existsSync as existsSync8 } from "fs";
17804
17972
  async function status(opts) {
17805
17973
  const auth = await authenticate();
17806
17974
  const color = opts.color ?? useColor();
@@ -17853,7 +18021,7 @@ async function projectStatus(client, project) {
17853
18021
  openProblems: (problems2.data ?? []).length,
17854
18022
  unprovenProblems: (problems2.data ?? []).filter((p) => p.importance_basis === "judgment").length,
17855
18023
  search: describeSearch(search, await localWebSearch()),
17856
- onThisMachine: Boolean(project.repoPath && existsSync6(project.repoPath)),
18024
+ onThisMachine: Boolean(project.repoPath && existsSync8(project.repoPath)),
17857
18025
  measures: measures({
17858
18026
  solutionStatuses: (roadmap2.data ?? []).map((r) => r.status),
17859
18027
  decisions: decisions2.data ?? [],
@@ -18216,7 +18384,7 @@ function buildProgram() {
18216
18384
  console.log(await invite(email, { cwd: process.cwd(), projectId: opts.project, color: colorOf(opts) }));
18217
18385
  });
18218
18386
  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) => {
18219
- console.log(await join15(code, { cwd: process.cwd(), color: colorOf(opts) }));
18387
+ console.log(await join18(code, { cwd: process.cwd(), color: colorOf(opts) }));
18220
18388
  });
18221
18389
  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) => {
18222
18390
  console.log(await members({ cwd: process.cwd(), projectId: opts.project, color: colorOf(opts) }));
@@ -18238,16 +18406,11 @@ function buildProgram() {
18238
18406
  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) => {
18239
18407
  console.log(await status({ cwd: process.cwd(), projectId: opts.project, color: colorOf(opts) }));
18240
18408
  });
18241
- program.command("next [ref]").description("Build an accepted solution, or the top one: a branch, a diff, and a pull request").option("--project <id>", "project id (default: the project of the current repo)").option("--ref <ref>", "same as the positional argument").option("--answers <file>", "answer its questions from a file instead of the terminal").action(async (ref, opts) => {
18409
+ 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) => {
18242
18410
  const cancel = new AbortController();
18243
18411
  process.once("SIGINT", () => cancel.abort());
18244
- process.exitCode = await next({
18245
- cwd: process.cwd(),
18246
- projectId: opts.project,
18247
- ref: ref ?? opts.ref,
18248
- answersFile: opts.answers,
18249
- cancelSignal: cancel.signal
18250
- });
18412
+ if (opts.continue && opts.throwAway) throw new Error("--continue or --throw-away, not both.");
18413
+ 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 });
18251
18414
  });
18252
18415
  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) => {
18253
18416
  console.log(await autonomy(level, { cwd: process.cwd(), projectId: opts.project }));