@nanopm/cli 0.1.13 → 0.1.16

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
@@ -5420,10 +5420,16 @@ var isDecideForMe = (answer) => /^\s*decide(\s+for\s+me)?\s*\.?\s*$/i.test(answe
5420
5420
  function answerForAgent(answer) {
5421
5421
  return isDecideForMe(answer) ? DECIDE_FOR_ME : answer;
5422
5422
  }
5423
- var BUILD_HISTORY_KINDS = ["worktree", "plan", "step", "question", "answer", "paused", "pr"];
5423
+ var BUILD_HISTORY_KINDS = ["worktree", "plan", "step", "question", "answer", "paused", "pr", "handed"];
5424
+ var BUILD_CONFLICT = { resolve: "Let Nano resolve it", mine: "I\u2019ll do it in my terminal" };
5425
+ var chose = (answer, option) => {
5426
+ const plain = (s) => s.trim().toLowerCase().replace(/[’‘]/g, "'");
5427
+ return plain(answer) === plain(option);
5428
+ };
5429
+ var BUILD_REFUSED = { again: "Try again", open: "Open it on GitHub" };
5424
5430
  function trustedPlace(p, at) {
5425
5431
  const [branch, base, fork] = [p.branch, p.base, p.fork].map((v) => typeof v === "string" ? v : "");
5426
- if (!/^nano\/[a-z0-9][a-z0-9-]*$/.test(branch) || !/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(base) || !/^[0-9a-f]{7,40}$/.test(fork)) return null;
5432
+ if (!/^nano\/[a-z0-9][a-z0-9-]*$/.test(branch) || !/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(base) || /^refs\/|HEAD$/.test(base) || !/^[0-9a-f]{7,40}$/.test(fork)) return null;
5427
5433
  return { dir: buildDir(at.home, at.jobId), branch, base, fork };
5428
5434
  }
5429
5435
  function buildHistory(events, at) {
@@ -5431,20 +5437,30 @@ function buildHistory(events, at) {
5431
5437
  let plan = null;
5432
5438
  const done = /* @__PURE__ */ new Set();
5433
5439
  let last = null;
5440
+ let report = "";
5441
+ let handed = false;
5434
5442
  const answers = /* @__PURE__ */ new Map();
5435
5443
  for (const e of events) {
5436
- if (e.kind === "worktree") place = trustedPlace(e.payload, at);
5444
+ if (e.kind === "worktree" && !place) place = trustedPlace(e.payload, at);
5437
5445
  else if (e.kind === "plan" && Array.isArray(e.payload.steps)) plan = e.payload.steps;
5438
5446
  else if (e.kind === "step" && typeof e.payload.step === "number") done.add(e.payload.step - 1);
5439
5447
  else if (e.kind === "answer") answers.set(e.seq, String(e.payload.answer ?? ""));
5448
+ else if (e.kind === "handed") handed = true;
5449
+ if (e.kind === "question" && (e.payload.about === "conflict" || e.payload.about === "refused")) {
5450
+ if (typeof e.payload.report === "string") report = e.payload.report;
5451
+ if (e.payload.about === "conflict") handed = false;
5452
+ }
5440
5453
  if (e.kind === "question" || e.kind === "paused") last = e;
5441
- else if (e.kind === "plan" || e.kind === "step") last = null;
5454
+ else if (e.kind === "plan" || e.kind === "step" || e.kind === "handed") last = null;
5442
5455
  }
5443
- if (!place) return { place, plan, done, resume: null };
5444
- if (last?.kind === "question" && answers.has(last.seq) && last.payload.about === "usage") return { place, plan, done, resume: { why: "limit" } };
5445
- if (last?.kind === "question" && answers.has(last.seq)) return { place, plan, done, resume: { why: "answer", answer: answers.get(last.seq) } };
5446
- if (last?.kind === "paused") return { place, plan, done, resume: { why: "limit" } };
5447
- return { place, plan, done, resume: { why: "lost" } };
5456
+ const found = (resume2) => ({ place, plan, done, resume: resume2, report, handed });
5457
+ if (!place) return found(null);
5458
+ const answered2 = last?.kind === "question" && answers.has(last.seq) ? answers.get(last.seq) : null;
5459
+ if (answered2 !== null && last.payload.about === "usage") return found({ why: "limit" });
5460
+ if (answered2 !== null && (last.payload.about === "conflict" || last.payload.about === "refused")) return found({ why: last.payload.about, answer: answered2 });
5461
+ if (answered2 !== null) return found({ why: "answer", answer: answered2 });
5462
+ if (last?.kind === "paused") return found({ why: "limit" });
5463
+ return found({ why: "lost" });
5448
5464
  }
5449
5465
  function wasOf(params) {
5450
5466
  return params?.was === "proposed" ? "proposed" : "accepted";
@@ -5461,11 +5477,18 @@ var execFileAsync3 = promisify3(execFile3);
5461
5477
  var MAX_BUFFER = 4 * 1024 * 1024;
5462
5478
  var TIMEOUT_MS = 6e4;
5463
5479
  var LOCKED = {
5464
- GIT_CONFIG_COUNT: "2",
5480
+ GIT_CONFIG_COUNT: "5",
5465
5481
  GIT_CONFIG_KEY_0: "core.hooksPath",
5466
5482
  GIT_CONFIG_VALUE_0: "/dev/null",
5467
5483
  GIT_CONFIG_KEY_1: "core.fsmonitor",
5468
- GIT_CONFIG_VALUE_1: "false"
5484
+ GIT_CONFIG_VALUE_1: "false",
5485
+ // A fetch never opens a nested repository the agent could have planted where a submodule goes.
5486
+ GIT_CONFIG_KEY_2: "fetch.recurseSubmodules",
5487
+ GIT_CONFIG_VALUE_2: "false",
5488
+ GIT_CONFIG_KEY_3: "submodule.recurse",
5489
+ GIT_CONFIG_VALUE_3: "false",
5490
+ GIT_CONFIG_KEY_4: "protocol.ext.allow",
5491
+ GIT_CONFIG_VALUE_4: "never"
5469
5492
  };
5470
5493
  async function exec(cmd, repo, args, timeoutMs = TIMEOUT_MS) {
5471
5494
  if (typeof repo === "string") return execFileAsync3(cmd, cmd === "git" ? ["-C", repo, ...args] : args, { cwd: repo, maxBuffer: MAX_BUFFER, timeout: timeoutMs });
@@ -5483,7 +5506,7 @@ var RepoError = class extends Error {
5483
5506
  async function commitAll(cwd, message, note) {
5484
5507
  await git(cwd, ["add", "-A"]);
5485
5508
  const staged = await git(cwd, ["diff", "--cached", "--name-only"]).catch(() => "");
5486
- if (!staged) return null;
5509
+ if (!staged && !await merging(cwd)) return null;
5487
5510
  await git(cwd, ["commit", "--no-verify", "-m", message, ...note ? ["-m", note] : [], "-m", `Written by NanoPM. Review before merging.
5488
5511
 
5489
5512
  ${BUILD_TRAILER}`]);
@@ -5564,6 +5587,42 @@ async function rebaseOnto(cwd, base) {
5564
5587
  return { ok: false, files };
5565
5588
  }
5566
5589
  }
5590
+ async function containsBase(cwd, base) {
5591
+ const onto = await baseRef(cwd, base);
5592
+ return git(cwd, ["merge-base", "--is-ancestor", onto, "HEAD"]).then(
5593
+ () => true,
5594
+ () => false
5595
+ );
5596
+ }
5597
+ async function merging(cwd) {
5598
+ return Boolean(await git(cwd, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]).catch(() => ""));
5599
+ }
5600
+ async function mergedSince(cwd, fork) {
5601
+ return Boolean(await git(cwd, ["rev-list", "--merges", "-n", "1", `${fork}..HEAD`]).catch(() => ""));
5602
+ }
5603
+ async function mergeBase(cwd, base) {
5604
+ if (await merging(cwd)) return { files: await conflictsLeft(cwd, base) };
5605
+ const onto = await baseRef(cwd, base);
5606
+ try {
5607
+ await git(cwd, ["merge", "--no-ff", "--no-commit", "--no-verify", onto], 12e4);
5608
+ return { files: [] };
5609
+ } catch (err) {
5610
+ const files = (await git(cwd, ["diff", "--name-only", "--diff-filter=U"]).catch(() => "")).split("\n").filter(Boolean);
5611
+ if (!files.length) throw new RepoError(`merging ${base} failed: ${errorMessage(err).split("\n").filter(Boolean).slice(-2).join(" ")}`);
5612
+ return { files };
5613
+ }
5614
+ }
5615
+ async function conflictsLeft(cwd, base) {
5616
+ const onto = await baseRef(cwd, base);
5617
+ const changed = (await git(cwd, ["diff", "--name-only", onto]).catch(() => "")).split("\n").filter(Boolean);
5618
+ if (!changed.length) return [];
5619
+ const marked = await git(cwd, ["grep", "-l", "-E", "^(<<<<<<<|>>>>>>>)( |$)", "--", ...changed]).catch(() => "");
5620
+ return marked.split("\n").filter(Boolean).sort();
5621
+ }
5622
+ async function newPullRequestUrl(cwd, branch) {
5623
+ const repo = /github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/.exec(await originUrl(cwd))?.[1];
5624
+ return repo ? `https://github.com/${repo}/pull/new/${branch}` : null;
5625
+ }
5567
5626
  async function gh(cwd, args) {
5568
5627
  return (await exec("gh", cwd, args, 12e4)).stdout.trim();
5569
5628
  }
@@ -11160,16 +11219,16 @@ async function runBuildJob(input) {
11160
11219
  let plan = history.plan;
11161
11220
  const done = new Set(history.done);
11162
11221
  let usage;
11163
- let said = "";
11222
+ let said = history.report;
11164
11223
  const stop2 = async (message, opts = { keep: true }) => {
11165
11224
  await unstartSolution(client, job.project_id, solution.ref, was).catch(() => void 0);
11166
- if (opts.keep) await commitAll(worktree, `${solution.ref}: unfinished \u2014 ${solution.title}`).catch(() => null);
11225
+ if (opts.keep && !await merging(worktree)) await commitAll(worktree, `${solution.ref}: unfinished \u2014 ${solution.title}`).catch(() => null);
11167
11226
  else await removeWorktree(clone, dir);
11168
11227
  return { ...failed(opts.keep ? `${message}. What was done is on ${branch}, in ${dir}.` : message), usage };
11169
11228
  };
11170
11229
  const park = async (why) => {
11171
11230
  if ("ask" in why) {
11172
- await events.write("question", { ...why.ask, ...plan ? {} : { about: "plan" } });
11231
+ await events.write("question", { ...why.ask, ...why.ask.about ? { report: said } : plan ? {} : { about: "plan" } });
11173
11232
  await log("build.ask", `${solution.ref} waits on the founder`);
11174
11233
  } else if (why.resetsAt) {
11175
11234
  await events.write("paused", { why: "limit", resets_at: new Date(why.resetsAt * 1e3).toISOString() });
@@ -11208,57 +11267,110 @@ async function runBuildJob(input) {
11208
11267
  }
11209
11268
  return end;
11210
11269
  };
11270
+ const resolving = (files) => `\`${base}\` moved while you were building, and it is merged into your branch: ${files.map((f) => `\`${f}\``).join(", ")} still have conflict markers. Resolve each so both sides' intent holds, remove every marker, run the project's check again, then give your report again, whole and in the same form, with what you kept under *Decisions I made*. Do not call build_step, and do not commit: NanoPM commits the merge.`;
11271
+ const left = async () => {
11272
+ if (!finished()) return carryOn(plan, done);
11273
+ if (history.handed) return null;
11274
+ const files = await conflictsLeft(worktree, base);
11275
+ return files.length ? resolving(files) : null;
11276
+ };
11277
+ const openPr = async (reviewUrl) => {
11278
+ const [noPr, page] = await Promise.all([ghReady(worktree), solutionPage(client, job.project_id, solution.ref)]);
11279
+ if (noPr) return pushed(null, reviewUrl, noPr);
11280
+ const pr = await openPullRequest(worktree, { branch, base, title: `${solution.ref}: ${solution.title}`, body: prBody(said, page) });
11281
+ if (!pr.ok) return park({ ask: { question: "The branch is pushed, but GitHub refused the pull request.", why: pr.reason, options: [BUILD_REFUSED.again, BUILD_REFUSED.open], about: "refused" } });
11282
+ return pushed(pr, null);
11283
+ };
11284
+ const pushed = async (pr, reviewUrl, why = "not yet") => {
11285
+ if (pr) {
11286
+ await events.write("pr", { number: pr.number, url: pr.url });
11287
+ await log("build.pr", `draft pull request #${pr.number} for ${solution.ref}`);
11288
+ }
11289
+ await removeWorktree(clone, dir);
11290
+ return {
11291
+ status: "succeeded",
11292
+ result: [said.trim(), "", pr ? `Draft pull request #${pr.number} \xB7 ${pr.url}` : `Pushed ${branch}. No pull request: ${why}.${reviewUrl ? ` ${reviewUrl}` : ""}`].filter(Boolean).join("\n"),
11293
+ data: { branch, session: spec2.sessionId, ...pr ? { pr: { number: pr.number, url: pr.url } } : { review_url: reviewUrl ?? null } },
11294
+ usage
11295
+ };
11296
+ };
11211
11297
  const resume2 = history.resume;
11212
11298
  const check = verifyCommand2 ? `
11213
11299
 
11214
11300
  The project's own check is \`${verifyCommand2}\`. Run it before your last build_step, fix what it reports, and give its result under *How I checked it*.` : "";
11215
11301
  const finished = () => Boolean(plan && !stepsLeft(plan, done).length);
11302
+ if (resume2?.why === "conflict" && chose(resume2.answer, BUILD_CONFLICT.mine)) {
11303
+ const opened = await mergeBase(worktree, base).catch((err) => ({ error: errorMessage(err) }));
11304
+ if ("error" in opened) return stop2(opened.error);
11305
+ await events.write("handed", {});
11306
+ return stop2(`It is yours to resolve: ${opened.files.join(", ")} carry conflict markers in that copy. Edit them, then Continue (nanopm build ${solution.ref} --continue): NanoPM commits the merge`);
11307
+ }
11308
+ if (resume2?.why === "refused" && chose(resume2.answer, BUILD_REFUSED.open)) return pushed(null, await newPullRequestUrl(worktree, branch), "you open it on GitHub");
11216
11309
  if (!resume2) {
11217
11310
  await log("build.start", `building ${solution.ref} on ${branch}, in a copy of your code`);
11218
11311
  await events.write("text", { text: `Working on ${branch}, in a copy of your code. Your own checkout is not touched.` });
11219
11312
  } else {
11220
- await events.write("text", { text: AGAIN[resume2.why].said });
11313
+ await events.write("text", { text: AGAIN[resume2.why] });
11221
11314
  if (resume2.why === "answer" && spec2.answerFile) await writeFile5(spec2.answerFile, answerForAgent(resume2.answer), { mode: 384 });
11222
11315
  }
11223
- let turn = !resume2 ? await session2(spec2.prompt + check, false) : resume2.why === "answer" ? await session2("", true) : finished() ? null : await session2(`${AGAIN[resume2.why].lead} ${carryOn(plan, done)}`, true);
11224
- for (let resumes = 0; !turn && !finished(); resumes += 1) {
11316
+ if (resume2?.why === "refused") return openPr();
11317
+ let turn = null;
11318
+ if (!resume2) turn = await session2(spec2.prompt + check, false);
11319
+ else if (resume2.why === "answer") turn = await session2("", true);
11320
+ else if (resume2.why === "conflict") {
11321
+ const opened = await mergeBase(worktree, base).catch((err) => ({ error: errorMessage(err) }));
11322
+ if ("error" in opened) return stop2(opened.error);
11323
+ if (opened.files.length) turn = await session2(resolving(opened.files), true);
11324
+ } else {
11325
+ const next = await left();
11326
+ if (next) turn = await session2(`${resume2.why === "limit" ? "Your Claude limit has reset." : "You were interrupted: the machine stopped."} ${next}`, true);
11327
+ }
11328
+ for (let resumes = 0; !turn; resumes += 1) {
11329
+ const next = await left();
11330
+ if (!next) break;
11225
11331
  if (resumes >= BUILD_RESUMES_MAX) {
11226
- const left = stepsLeft(plan, done).length;
11227
- return stop2(plan ? `Nano stopped with ${left} step${left === 1 ? "" : "s"} of its plan left` : "Nano stopped without saying its plan");
11332
+ if (finished()) break;
11333
+ const steps = stepsLeft(plan, done).length;
11334
+ return stop2(plan ? `Nano stopped with ${steps} step${steps === 1 ? "" : "s"} of its plan left` : "Nano stopped without saying its plan");
11228
11335
  }
11229
- turn = await session2(carryOn(plan, done), true);
11336
+ turn = await session2(next, true);
11230
11337
  }
11231
11338
  if (turn && "park" in turn) return park(turn.park);
11232
11339
  if (turn) return stop2(turn.stop.error?.message ?? "the build stopped");
11340
+ const marked = await conflictsLeft(worktree, base);
11341
+ if (marked.length) return park({ ask: conflictAsk(base, marked) });
11233
11342
  await commitAll(worktree, `${solution.ref}: ${solution.title}`);
11234
11343
  if (!await commitsSinceFork(worktree, fork)) return stop2("the build finished without changing anything", { keep: false });
11235
- const rebased = await rebaseOnto(worktree, base);
11236
- if (!rebased.ok) return stop2(`${base} moved while Nano was building, and the same files changed on both sides: ${rebased.files.join(", ") || "see git status"}`);
11237
- const [pushed, noPr, page] = await Promise.all([pushBranch(worktree, branch), ghReady(worktree), solutionPage(client, job.project_id, solution.ref)]);
11238
- if (!pushed.pushed) return stop2(`the push failed: ${pushed.reason}`);
11239
- const pr = noPr ? null : await openPullRequest(worktree, { branch, base, title: `${solution.ref}: ${solution.title}`, body: prBody(said, page) });
11240
- if (pr && !pr.ok) return { ...failed(`the branch ${branch} is pushed, but GitHub refused the pull request: ${pr.reason}${pushed.reviewUrl ? `. Open it yourself: ${pushed.reviewUrl}` : ""}`), usage };
11241
- if (pr) {
11242
- await events.write("pr", { number: pr.number, url: pr.url });
11243
- await log("build.pr", `draft pull request #${pr.number} for ${solution.ref}`);
11244
- }
11245
- await removeWorktree(clone, dir);
11246
- return {
11247
- status: "succeeded",
11248
- result: [
11249
- said.trim(),
11250
- "",
11251
- pr ? `Draft pull request #${pr.number} \xB7 ${pr.url}` : `Pushed ${branch}. No pull request: ${noPr}.${pushed.reviewUrl ? ` ${pushed.reviewUrl}` : ""}`
11252
- ].filter(Boolean).join("\n"),
11253
- data: { branch, session: spec2.sessionId, ...pr ? { pr: { number: pr.number, url: pr.url } } : { review_url: pushed.reviewUrl ?? null } },
11254
- usage
11255
- };
11344
+ if (!await containsBase(worktree, base)) {
11345
+ if (await mergedSince(worktree, fork)) {
11346
+ const opened = await mergeBase(worktree, base).catch((err) => ({ error: errorMessage(err) }));
11347
+ if ("error" in opened) return stop2(opened.error);
11348
+ if (opened.files.length) return park({ ask: conflictAsk(base, opened.files) });
11349
+ await commitAll(worktree, `${solution.ref}: ${base} merged in`);
11350
+ } else {
11351
+ const rebased = await rebaseOnto(worktree, base);
11352
+ if (!rebased.ok && !rebased.files.length) return stop2(`the rebase onto ${base} failed: see git status`);
11353
+ if (!rebased.ok) return park({ ask: conflictAsk(base, rebased.files) });
11354
+ }
11355
+ }
11356
+ const push = await pushBranch(worktree, branch);
11357
+ if (!push.pushed) return stop2(`the push failed: ${push.reason}`);
11358
+ return openPr(push.reviewUrl);
11256
11359
  }
11360
+ var conflictAsk = (base, files) => ({
11361
+ question: `Someone changed the same files on ${base} while Nano was building.`,
11362
+ why: "Nano can try to merge them and run your checks again, or you resolve it yourself in your terminal. Nothing is pushed until it is resolved.",
11363
+ files,
11364
+ options: [BUILD_CONFLICT.resolve, BUILD_CONFLICT.mine],
11365
+ about: "conflict"
11366
+ });
11257
11367
  var failed = (message) => ({ status: "failed", error: { code: "harness_error", message } });
11258
11368
  var AGAIN = {
11259
- answer: { said: "Your answer is in: carrying on.", lead: "" },
11260
- limit: { said: "Your Claude limit has reset: carrying on.", lead: "Your Claude limit has reset." },
11261
- lost: { said: "Picked up again where it stopped.", lead: "You were interrupted: the machine stopped." }
11369
+ answer: "Your answer is in: carrying on.",
11370
+ limit: "Your Claude limit has reset: carrying on.",
11371
+ lost: "Picked up again where it stopped.",
11372
+ conflict: "Merging the main branch in, and resolving it.",
11373
+ refused: "Asking GitHub for the pull request again."
11262
11374
  };
11263
11375
  function askOf(input) {
11264
11376
  const { question, options, why } = input ?? {};
@@ -14492,7 +14604,7 @@ import { promisify as promisify8 } from "util";
14492
14604
  // package.json
14493
14605
  var package_default = {
14494
14606
  name: "@nanopm/cli",
14495
- version: "0.1.13",
14607
+ version: "0.1.16",
14496
14608
  description: "An autonomous product manager for builders who run several small products. Runs on your machine, drives your own coding harness.",
14497
14609
  type: "module",
14498
14610
  license: "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanopm/cli",
3
- "version": "0.1.13",
3
+ "version": "0.1.16",
4
4
  "description": "An autonomous product manager for builders who run several small products. Runs on your machine, drives your own coding harness.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -52,7 +52,9 @@ question per `ask_user`, with the options, and what you looked at in `why`.
52
52
  stops to wait on a background task is a build that stops.
53
53
  - The analytics events the spec names ship with the change, named as it says. No word a user
54
54
  typed goes in an event.
55
- - Do not touch secrets. `.env` files and keys are denied to you, deliberately.
55
+ - Do not touch secrets. `.env` files and keys are denied to you, deliberately. Your sandbox keeps
56
+ you from reading them, so `git status` and `git diff` show them as deleted (`D`) and print
57
+ "Operation not permitted": they are not deleted. Never report them, restore them or stage them.
56
58
  - Do not reformat, rename or tidy anything the spec did not ask for. A diff the founder cannot
57
59
  review in one sitting is a diff they will not merge.
58
60
  - Install a new dependency only if the spec allows it; otherwise ask.