symbiot 0.40.1 → 0.40.2

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.
Files changed (3) hide show
  1. package/index.mjs +94 -14
  2. package/package.json +1 -1
  3. package/ui.mjs +10 -3
package/index.mjs CHANGED
@@ -249,6 +249,51 @@ async function learnNpm(paths, registry = REGISTRY) {
249
249
  }));
250
250
  return learned;
251
251
  }
252
+ // An agent's question that needs a release ("Once 0.41.0 is installed: …") names
253
+ // a version of the repo's own npm package. The Agents tab shows it next to the
254
+ // version installed here and the one on npm, and doesn't offer a "Done" answer
255
+ // until the step is possible: a release that isn't out, or isn't installed yet,
256
+ // can't have been tried. The version asked about is the highest one the question
257
+ // names in the package's major, up to its next minor (not "ydotool 0.1.8").
258
+ function releaseNeeded(text, current) {
259
+ const p = (v) => v.split(".").map(Number), [maj, min] = p(current);
260
+ let best = "";
261
+ for (const [v] of String(text || "").matchAll(/\b\d+\.\d+\.\d+\b/g)) { const [a, b] = p(v); if (a === maj && b <= min + 1 && (!best || semverGt(v, best))) best = v; }
262
+ return best;
263
+ }
264
+ // The version npm -g installed, or for Symbiot this app's own; "" if not installed.
265
+ function installedVersion(name) {
266
+ if (name === "symbiot") return VERSION;
267
+ const root = process.platform === "win32" ? join(process.execPath, "..", "node_modules") : join(process.execPath, "..", "..", "lib", "node_modules");
268
+ try { return String(JSON.parse(readFileSync(join(root, name, "package.json"), "utf8")).version || ""); } catch { return ""; }
269
+ }
270
+ // The newest version on npm ("" if unknown), asked again after 5 minutes.
271
+ const NPM_LATEST = new Map();
272
+ async function npmLatest(name, registry = REGISTRY) {
273
+ if (name === "symbiot" && LATEST_VERSION) return LATEST_VERSION;
274
+ const was = NPM_LATEST.get(name); if (was && Date.now() - was.at < 300000) return was.v;
275
+ let v = was ? was.v : "";
276
+ try {
277
+ const r = await fetch(`${registry}/${encodeURIComponent(name).replace(/^%40/, "@")}`, { headers: { accept: "application/vnd.npm.install-v1+json" }, signal: AbortSignal.timeout(5000) });
278
+ if (r.ok) v = String(((await r.json())["dist-tags"] || {}).latest || "");
279
+ } catch {}
280
+ NPM_LATEST.set(name, { v, at: Date.now() });
281
+ return v;
282
+ }
283
+ // Mark each open question that needs a release with { name, needs, installed,
284
+ // npm, waiting }. waiting: "npm" (not published yet), "install" (published, not
285
+ // installed here) or "" (possible now).
286
+ async function withReleases(agents, registry = REGISTRY) {
287
+ for (const a of agents) {
288
+ const qs = (a.ask && a.ask.questions) || []; if (!qs.length) continue;
289
+ let pkg = {}; try { pkg = JSON.parse(readFileSync(join(a.path, "package.json"), "utf8")) || {}; } catch {}
290
+ if (typeof pkg.name !== "string" || !pkg.name || pkg.private || !/^\d+\.\d+\.\d+/.test(String(pkg.version || ""))) continue;
291
+ const asks = qs.map((q) => [q, releaseNeeded(q.q + " " + q.context, pkg.version)]).filter((x) => x[1]); if (!asks.length) continue;
292
+ const npm = await npmLatest(pkg.name, registry), installed = installedVersion(pkg.name);
293
+ for (const [q, needs] of asks) q.release = { name: pkg.name, needs, installed, npm, waiting: npm && semverGt(needs, npm) ? "npm" : installed && semverGt(needs, installed) ? "install" : "" };
294
+ }
295
+ return agents;
296
+ }
252
297
  // Set the version in package.json and in package-lock.json / npm-shrinkwrap.json
253
298
  // (top level and its "" package), keeping each file's indent. Gives back a
254
299
  // function that restores them, for when the ship fails.
@@ -292,20 +337,53 @@ function pendingReview() {
292
337
  // Approving while the agent is still editing would commit its half-done work.
293
338
  const stillWorking = (repo, path) => path && runningHandoff(path) ? { error: `The agent is still working in ${repo}. Approve once it finishes.`, running: true } : null;
294
339
  const branchSlug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "") || "tasks";
340
+ // Whether everything committed on this branch is on the default branch already,
341
+ // because an earlier PR from it merged. Squash-merged, main has the same changes
342
+ // under a different history, so a new PR from the branch carries them twice and
343
+ // can't merge (how #80 got stuck behind #79). Seen two ways: the branch's files
344
+ // are main's though main doesn't hold its commits, or GitHub says a PR from it
345
+ // merged and nothing was committed on it since. Gives the ref to start the next
346
+ // branch from, or "".
347
+ function mergedAlready(path, branch, base) {
348
+ const origin = git(path, ["remote", "get-url", "origin"]);
349
+ if (origin.ok) git(path, ["fetch", "-q", "origin", base], 30000); // best effort: main as GitHub has it
350
+ const ref = git(path, ["rev-parse", "-q", "--verify", "refs/remotes/origin/" + base]).ok ? "origin/" + base : base;
351
+ if (!git(path, ["rev-parse", "-q", "--verify", ref]).ok) return "";
352
+ if (!git(path, ["merge-base", "--is-ancestor", "HEAD", ref]).ok && git(path, ["diff", "--quiet", "HEAD", ref, ...NOT_SYMBIOT]).ok) return ref;
353
+ if (!origin.ok || !/github/i.test(origin.out) || !hasCmd("gh")) return "";
354
+ const r = spawnSync("gh", ["pr", "list", "--head", branch, "--state", "merged", "--limit", "1", "--json", "headRefOid"], { cwd: path, encoding: "utf8", timeout: 30000 });
355
+ let head = ""; try { head = String(JSON.parse(r.stdout)[0].headRefOid || ""); } catch {}
356
+ const since = head && git(path, ["rev-list", "--count", head + "..HEAD"]);
357
+ return since && since.ok && since.out === "0" ? ref : "";
358
+ }
295
359
  // Sync approved work: off the default branch onto symbiot/<task>, commit the
296
- // working tree (minus .symbiot/), push, and open a PR with gh. Each step that
297
- // can't happen (no remote, push rejected, no gh) stops there and says so — the
298
- // commit is never lost. opts.push=false stops after the commit, opts.pr=false
299
- // after the push.
360
+ // working tree (minus .symbiot/), push, and open a PR with gh. On a branch whose
361
+ // earlier PR already merged, it starts a fresh symbiot/ branch from the default
362
+ // one instead (mergedAlready). Each step that can't happen (no remote, push
363
+ // rejected, no gh) stops there and says so — the commit is never lost.
364
+ // opts.push=false stops after the commit, opts.pr=false after the push.
300
365
  function shipChanges(path, texts, opts = {}) {
301
366
  const ch = workingChanges(path);
302
367
  if (!ch.files.length) return { ok: true, nothing: true, note: "No uncommitted changes — approved without a commit." };
303
368
  if (!ch.branch || ch.branch === "HEAD") return { error: "Detached HEAD — check out a branch first." };
304
- const base = gitDefaultBranch(path), day = new Date().toISOString().slice(0, 10); let branch = ch.branch;
305
- if (branch === base) {
369
+ const base = gitDefaultBranch(path), day = new Date().toISOString().slice(0, 10); let branch = ch.branch, fresh = "";
370
+ const newBranch = () => {
306
371
  const stem = "symbiot/" + branchSlug(!texts.length ? `changes-${day}` : texts.length === 1 ? texts[0] : `${texts.length}-tasks-${day}`);
307
- branch = stem; for (let i = 2; git(path, ["rev-parse", "--verify", "-q", "refs/heads/" + branch]).ok; i++) branch = `${stem}-${i}`;
372
+ let b = stem; for (let i = 2; git(path, ["rev-parse", "--verify", "-q", "refs/heads/" + b]).ok; i++) b = `${stem}-${i}`;
373
+ return b;
374
+ };
375
+ if (branch === base) {
376
+ branch = newBranch();
308
377
  const sw = git(path, ["switch", "-c", branch]); if (!sw.ok) return { error: "Could not create branch: " + sw.err };
378
+ } else {
379
+ const from = mergedAlready(path, branch, base);
380
+ if (from) {
381
+ // The changes come along; if main changed a file they touch, git refuses and they stay put.
382
+ const next = newBranch(), sw = git(path, ["switch", "-c", next, "--no-track", from]);
383
+ fresh = sw.ok ? `${branch}'s earlier PR already merged, so this starts a fresh branch from ${from}.`
384
+ : `${branch}'s earlier PR already merged, but these changes couldn't move to a fresh branch from ${from} (${sw.err.split("\n")[0]}), so they're on ${branch}, and its PR may not merge.`;
385
+ if (sw.ok) branch = next;
386
+ }
309
387
  }
310
388
  // Stage everything, then drop .symbiot. A `. :(exclude).symbiot` pathspec
311
389
  // warns+exits-1 once .symbiot is gitignored ("paths are ignored, use -f"),
@@ -316,18 +394,19 @@ function shipChanges(path, texts, opts = {}) {
316
394
  const subject = !texts.length ? "symbiot: changes approved without a task" : texts.length === 1 ? (texts[0].length > 72 ? texts[0].slice(0, 71).replace(/\s+\S*$/, "") + "…" : texts[0]) : `symbiot: ${texts.length} approved tasks`;
317
395
  const body = (texts.length ? texts.map((x) => "- " + x).join("\n") : "No ticked task covers these changes.") + (opts.bumped ? `\n\nBumps the version to ${opts.bumped}. ` + (publishesOnMerge(path) ? `It publishes to npm when this merges.` : `After this merges, tag v${opts.bumped} on ${base} to release it.`) : "");
318
396
  const cm = git(path, ["commit", "-m", subject, "-m", body]); if (!cm.ok) return { error: "Commit failed: " + (cm.err || cm.out), branch };
319
- const out = { ok: true, branch, base, commit: git(path, ["rev-parse", "--short", "HEAD"]).out, subject };
397
+ const out = { ok: true, branch, base, commit: git(path, ["rev-parse", "--short", "HEAD"]).out, subject, ...(fresh ? { note: fresh } : {}) };
398
+ const noted = (note) => ({ ...out, note: (fresh ? fresh + " " : "") + note });
320
399
  if (opts.push === false) return out;
321
- if (!git(path, ["remote", "get-url", "origin"]).ok) return { ...out, note: "No origin remote — committed locally." };
400
+ if (!git(path, ["remote", "get-url", "origin"]).ok) return noted("No origin remote — committed locally.");
322
401
  const ps = git(path, ["push", "-u", "origin", branch], 120000);
323
- if (!ps.ok) return { ...out, note: "Committed, but the push failed: " + (ps.err.split("\n").filter(Boolean).pop() || "unknown error") };
402
+ if (!ps.ok) return noted("Committed, but the push failed: " + (ps.err.split("\n").filter(Boolean).pop() || "unknown error"));
324
403
  out.pushed = true;
325
404
  if (opts.pr === false) return out;
326
- if (!hasCmd("gh")) return { ...out, note: "Pushed. Install the GitHub CLI (gh) to open the PR automatically." };
405
+ if (!hasCmd("gh")) return noted("Pushed. Install the GitHub CLI (gh) to open the PR automatically.");
327
406
  const gh = (args) => spawnSync("gh", args, { cwd: path, encoding: "utf8", timeout: 60000 });
328
407
  const pr = gh(["pr", "create", "--head", branch, "--base", base, "--title", subject, "--body", body + "\n\nApproved in Symbiot."]);
329
408
  const url = (String(pr.stdout || "").match(/https?:\/\/\S+/) || [])[0] || String(gh(["pr", "view", branch, "--json", "url", "-q", ".url"]).stdout || "").trim();
330
- if (!url) return { ...out, note: "Pushed, but gh couldn't open the PR: " + String(pr.stderr || "").trim().split("\n").pop() };
409
+ if (!url) return noted("Pushed, but gh couldn't open the PR: " + String(pr.stderr || "").trim().split("\n").pop());
331
410
  out.pr = url;
332
411
  // Opt-in auto-merge: queue GitHub's native auto-merge, which lands the PR only
333
412
  // once its required checks (CI) pass — never immediately on its own if the repo
@@ -405,6 +484,7 @@ function buildTasksMd(name, ctx, list) {
405
484
  L.push("## When you finish an item", "- Tick it here (`- [x]`) as soon as it's done — that's how it reaches review. Ticking doesn't archive it: the user approves it in Symbiot, which commits it on a branch and opens a PR.", "- Leave your changes **uncommitted**, and don't tick anything you didn't finish or couldn't verify.", "");
406
485
  L.push("## If you need a decision, or have ideas", "You may be running unattended, so you can't ask in chat. Write `.symbiot/QUESTIONS.md` instead: Symbiot shows it to the user on your block in its Agents tab, and their answers come back in `.symbiot/ANSWERS.md` (read that first if it exists).",
407
486
  `- At most ${QUESTIONS_MAX} questions, under a \`## Questions\` heading. Each is a \`### \` heading, then an optional line of context, then 2–4 options as \`- \` bullets (put the one you recommend first).`,
487
+ "- If a step needs a release that isn't out yet, name its version in the question (\"Once 0.41.0 is installed: …\"). Symbiot shows it next to the installed and npm versions, and holds back an answer that starts \"Done\" until that release is out and installed.",
408
488
  "- Ideas, options or follow-ups outside these tasks go under `## Suggestions` as `- ` bullets — the user can add them to their tasks in one click.",
409
489
  "- Carry on with everything that doesn't depend on an answer, and don't tick an item that does. Ask there rather than doing anything destructive.", "");
410
490
  L.push("---", 'To action these, tell your coding agent: "Read `.symbiot/TASKS.md` and implement the unchecked items in this repo, using the context above. Tick each item as you finish it and leave changes uncommitted for review. Confirm with me before anything destructive."');
@@ -1659,7 +1739,7 @@ async function cmdApp() {
1659
1739
  const e = track("ollama pull " + model, "ollama pull " + shSingle(model), homedir(), (code) => { if (code === 0) useOllamaModel(model); });
1660
1740
  return json(res, { started: true, model, id: e ? e.id : "" });
1661
1741
  }
1662
- if (u.pathname === "/api/agents") return json(res, agentsList());
1742
+ if (u.pathname === "/api/agents") return json(res, await withReleases(agentsList()));
1663
1743
  if (u.pathname === "/api/agents/answer" && req.method === "POST") { const b = await readBody(req); return json(res, answerQuestions(String(b.path || ""), b.answers, { rerun: !!b.rerun })); }
1664
1744
  if (u.pathname === "/api/mail") return json(res, mailState());
1665
1745
  if (u.pathname === "/api/mail/set" && req.method === "POST") { const b = await readBody(req); return json(res, setMail(b)); }
@@ -1890,4 +1970,4 @@ const isMain = (() => {
1890
1970
  })();
1891
1971
  if (isMain) main();
1892
1972
 
1893
- export { authorship, repoState, readmeInfo, repoShape, houseRules, findAllRepos, buildMap, reportFooter, detectHardware, recommendModels, computeDrift, driftRepo, gitDefaultBranch, buildTasksMd, taskType, EMBEDDED_UI, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, fillHandoff, handoffCmd, setHandoffCmd, ORCA_CLAUDE_CMD, CLAUDE_CMD, HANDOFF_PROMPT, shipChanges, shipWithBump, bumpOffer, learnNpm, setVersion, syncTasks, pendingReview, unreleased, publishesOnMerge, addTask, approveRepo, approveChanges, sendBack, pushTasks, semverGt, updateCmd, parseQuestions, agentQuestions };
1973
+ export { authorship, repoState, readmeInfo, repoShape, houseRules, findAllRepos, buildMap, reportFooter, detectHardware, recommendModels, computeDrift, driftRepo, gitDefaultBranch, buildTasksMd, taskType, EMBEDDED_UI, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, fillHandoff, handoffCmd, setHandoffCmd, ORCA_CLAUDE_CMD, CLAUDE_CMD, HANDOFF_PROMPT, shipChanges, shipWithBump, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, syncTasks, pendingReview, unreleased, publishesOnMerge, addTask, approveRepo, approveChanges, sendBack, pushTasks, semverGt, updateCmd, parseQuestions, agentQuestions };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.40.1",
3
+ "version": "0.40.2",
4
4
  "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice — Claude, OpenAI, Gemini, or a local model.",
5
5
  "type": "module",
6
6
  "bin": {
package/ui.mjs CHANGED
@@ -112,6 +112,8 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
112
112
  .aq .qc{color:var(--faint);font-size:12.5px;margin-top:3px;line-height:1.5}
113
113
  .aq label.opt{display:flex;gap:8px;align-items:flex-start;margin:6px 0 0;font-size:13px;color:var(--text);cursor:pointer}
114
114
  .aq .opt input{width:auto;margin-top:3px;flex:none}
115
+ .aq .qrel{font-size:12px;margin-top:4px;color:var(--green)}.aq .qrel.wait{color:var(--amber)}
116
+ .aq label.opt.off{color:var(--faint);cursor:default}.aq .opt.off i{font-size:12px}
115
117
  .aq .qother{margin-top:7px;padding:7px 10px;font-size:13px}
116
118
  .aq .row{margin:10px 0 2px}
117
119
  label.check{display:flex;gap:8px;align-items:center;font-size:13px;color:var(--text);margin:6px 0}
@@ -473,18 +475,23 @@ function fmtE(ms){var s=Math.floor(ms/1000);if(s<60)return s+'s';var m=Math.floo
473
475
  var AGENTLIST=[],QDRAFT={};
474
476
  function agentById(id){for(var i=0;i<AGENTLIST.length;i++)if(AGENTLIST[i].id===id)return AGENTLIST[i];return null;}
475
477
  function nQs(a){return (a.ask&&a.ask.questions)?a.ask.questions.length:0;}
478
+ // A question that needs a release shows it next to the installed and npm versions;
479
+ // until it's out and installed, an answer saying it's done or tried isn't offered.
480
+ var DONEOPT=/^(done|did (it|both|that)|i('ve| have)? (did|done|tried)|tried it|it (worked|works|clicked|looks|landed|opened)|looks right)([^a-z]|$)/i;
481
+ function relHtml(rl){return "<div class='qrel"+(rl.waiting?" wait":"")+"'>Needs "+esc(rl.name+" "+rl.needs)+" &middot; installed: "+esc(rl.installed||'none')+" &middot; on npm: "+esc(rl.npm||'unknown')+
482
+ (rl.waiting==='npm'?" &middot; not published yet, so this can't be done yet":rl.waiting==='install'?" &middot; "+(rl.name==='symbiot'?"update Symbiot first":"install it first"):"")+"</div>";}
476
483
  function askHtml(a){var k=a.ask;if(!k)return '';var qs=k.questions||[],ss=k.suggestions||[];if(!qs.length&&!ss.length)return '';
477
484
  var h="<div class='aq' data-id='"+esc(a.id)+"'>";
478
485
  if(qs.length){h+="<h4>&#10067; "+qs.length+" question"+(qs.length>1?"s":"")+" for you</h4>";
479
- qs.forEach(function(q,i){h+="<div class='q' data-i='"+i+"'><div class='qt'>"+esc(q.q)+"</div>"+(q.context?"<div class='qc'>"+esc(q.context)+"</div>":"");
480
- (q.options||[]).forEach(function(o,j){h+="<label class='opt'><input type='radio' name='q_"+esc(a.id)+"_"+i+"' value='"+j+"'><span>"+esc(o)+"</span></label>";});
486
+ qs.forEach(function(q,i){var rl=q.release;h+="<div class='q' data-i='"+i+"'><div class='qt'>"+esc(q.q)+"</div>"+(q.context?"<div class='qc'>"+esc(q.context)+"</div>":"")+(rl?relHtml(rl):"");
487
+ (q.options||[]).forEach(function(o,j){var off=rl&&rl.waiting&&DONEOPT.test(o);h+="<label class='opt"+(off?" off":"")+"'><input type='radio' name='q_"+esc(a.id)+"_"+i+"' value='"+j+"'"+(off?" disabled":"")+"><span>"+esc(o)+(off?" <i>(once "+esc(rl.name+" "+rl.needs)+" is installed)</i>":"")+"</span></label>";});
481
488
  h+="<input class='qother' placeholder='"+((q.options&&q.options.length)?"or answer in your own words":"your answer")+"'></div>";});
482
489
  h+="<div class='row'><button class='act qsend' title='save the answers and hand the repo back to your agent'>Send answers &amp; continue</button><button class='ghost qsave' title='save the answers for the next run'>Save only</button></div>";}
483
490
  if(ss.length){h+="<h4>&#128161; Ideas from the agent</h4>";ss.forEach(function(s,i){h+="<div class='idea'><span style='flex:1'>"+esc(s.text)+"</span>"+(s.added?"<span class='tag'>in Tasks</span>":"<button class='ghost qidea' data-i='"+i+"' style='padding:3px 9px;font-size:12px'>+ task</button>")+"</div>";});}
484
491
  return h+"</div>";}
485
492
  function qKeyOf(qe){var box=qe.closest('.aq');var a=box&&agentById(box.getAttribute('data-id'));var q=a&&a.ask.questions[+qe.getAttribute('data-i')];return q?a.path+'|'+q.q:'';}
486
493
  function saveDrafts(el){el.querySelectorAll('.aq .q').forEach(function(qe){var k=qKeyOf(qe);if(!k)return;var pick=qe.querySelector('input[type=radio]:checked');QDRAFT[k]={o:pick?pick.value:'',t:qe.querySelector('.qother').value};});}
487
- function restoreDrafts(el){el.querySelectorAll('.aq .q').forEach(function(qe){var d=QDRAFT[qKeyOf(qe)];if(!d)return;qe.querySelectorAll('input[type=radio]').forEach(function(r){r.checked=r.value===d.o;});qe.querySelector('.qother').value=d.t||'';});}
494
+ function restoreDrafts(el){el.querySelectorAll('.aq .q').forEach(function(qe){var d=QDRAFT[qKeyOf(qe)];if(!d)return;qe.querySelectorAll('input[type=radio]').forEach(function(r){r.checked=!r.disabled&&r.value===d.o;});qe.querySelector('.qother').value=d.t||'';});}
488
495
  function collectAnswers(box,a){var out=[];box.querySelectorAll('.q').forEach(function(qe){var q=a.ask.questions[+qe.getAttribute('data-i')];if(!q)return;
489
496
  var other=(qe.querySelector('.qother').value||'').trim();var pick=qe.querySelector('input[type=radio]:checked');var ans=other||(pick?q.options[+pick.value]:'');if(ans)out.push({q:q.q,a:ans});});return out;}
490
497
  function wireAsks(el){el.querySelectorAll('.aq').forEach(function(box){var a=agentById(box.getAttribute('data-id'));if(!a||!a.ask)return;