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.
- package/index.mjs +94 -14
- package/package.json +1 -1
- 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.
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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)+" · installed: "+esc(rl.installed||'none')+" · on npm: "+esc(rl.npm||'unknown')+
|
|
482
|
+
(rl.waiting==='npm'?" · not published yet, so this can't be done yet":rl.waiting==='install'?" · "+(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>❓ "+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 & 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>💡 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
|
|
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;
|