strom-research 1.7.0 → 1.8.0

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 (42) hide show
  1. package/README.md +3 -0
  2. package/assets/lang/cs.json +117 -2
  3. package/assets/lang/de.json +117 -2
  4. package/assets/method/narrate.md +5 -1
  5. package/assets/plugins/gates/claude-usage/gate.ts +2 -1
  6. package/dist/agents/files.js +6 -4
  7. package/dist/cli/main.js +21 -3
  8. package/dist/cli/menu-links.js +35 -1
  9. package/dist/cli/menu-research.js +41 -2
  10. package/dist/cli/menu-settings.js +17 -1
  11. package/dist/cli/menu.js +19 -8
  12. package/dist/cli/ui.js +57 -1
  13. package/dist/commands/guide.js +6 -1
  14. package/dist/commands/links.js +39 -3
  15. package/dist/commands/pack.js +21 -18
  16. package/dist/commands/research.js +42 -1
  17. package/dist/commands/session.js +79 -9
  18. package/dist/commands/setup.js +3 -0
  19. package/dist/commands/start.js +6 -3
  20. package/dist/commands/story.js +47 -6
  21. package/dist/commands/tasks.js +15 -6
  22. package/dist/commands/trees.js +82 -2
  23. package/dist/core/changelog.js +223 -0
  24. package/dist/core/clock.js +20 -0
  25. package/dist/core/config.js +2 -0
  26. package/dist/core/connector.js +25 -1
  27. package/dist/core/directions.js +41 -0
  28. package/dist/core/links.js +17 -4
  29. package/dist/core/live.js +393 -50
  30. package/dist/core/pack.js +31 -0
  31. package/dist/core/phrases.js +61 -0
  32. package/dist/core/queue.js +7 -2
  33. package/dist/core/relocate.js +4 -4
  34. package/dist/core/session.js +8 -2
  35. package/dist/core/trash.js +72 -0
  36. package/dist/core/tree.js +1 -1
  37. package/dist/core/workers.js +28 -3
  38. package/dist/runners/claude.js +2 -1
  39. package/dist/runners/jsonl.js +2 -2
  40. package/dist/runners/runner.js +16 -3
  41. package/dist/runners/script.js +2 -1
  42. package/package.json +1 -1
@@ -5,12 +5,14 @@
5
5
  // closes what the agent left open, exports the GEDCOM and commits. Between
6
6
  // sessions not a single token is spent.
7
7
  import fs from "node:fs";
8
+ import { spawnSync } from "node:child_process";
8
9
  import path from "node:path";
9
10
  import { register } from "../cli/registry.js";
10
11
  import { ui } from "../cli/ui.js";
11
12
  import { lines, table, truncate } from "../cli/format.js";
12
13
  import { NeedsConsentError, UsageError, StromError } from "../core/errors.js";
13
14
  import { closeSession, currentSession, openSessions, othersAtWork, sessionNote, startSession } from "../core/session.js";
15
+ import { reviveLive } from "../core/live.js";
14
16
  import { buildBrief } from "../brief/brief.js";
15
17
  import { DEFAULT_BUDGET, DEFAULT_RUN_MINUTES, Settings } from "../core/config.js";
16
18
  import { prependPath } from "../runners/runner.js";
@@ -28,15 +30,16 @@ import { PROFILES } from "../agents/profiles.js";
28
30
  import { AGENTS, detectAgent, isAgent, which, withoutAgentMarks } from "../core/which.js";
29
31
  import { askGate, ensureGatesDir, loadGate } from "../core/gate.js";
30
32
  import { keepAwake } from "../core/awake.js";
31
- import { deadlineOf, WRAP_UP_MS } from "../core/clock.js";
33
+ import { askFinish, deadlineOf, finishAsked, WRAP_UP_MS } from "../core/clock.js";
34
+ import { OWN_GROUP } from "../runners/runner.js";
32
35
  import { stromLauncher } from "../core/self.js";
33
36
  import { phrase } from "../core/phrases.js";
34
- import { enterWorker, runAlive, runsAtWork } from "../core/workers.js";
37
+ import { enterWorker, markPaused, runAlive, runsAtWork } from "../core/workers.js";
35
38
  import { assertIntact, snapshot, verifyFast } from "../core/integrity.js";
36
39
  import { guard } from "../core/guard.js";
37
40
  import { hasErrors } from "../core/check.js";
38
41
  import { Tree } from "../core/tree.js";
39
- import { treeBrowserConnectors } from "../core/connector.js";
42
+ import { agentBrowser, treeBrowserConnectors } from "../core/connector.js";
40
43
  import { browserNote } from "./connectors.js";
41
44
  import { reviewProposals } from "../core/review.js";
42
45
  function written(tree) {
@@ -70,6 +73,9 @@ export function researchProposals(tree, research) {
70
73
  }
71
74
  /** Create the tasks strom proposes for a research; returns their IDs. */
72
75
  export function applyFrontier(tree, research) {
76
+ // a direction paused or ended proposes nothing (resumed: what it would, at once)
77
+ if (research.state !== "active")
78
+ return [];
73
79
  return researchProposals(tree, research).map(({ proposal, kind }) => create(tree, "task", { ...proposal, state: "open" }, (id) => `+${id} task "${truncate(proposal.what, 60)}" (${kind})`).id);
74
80
  }
75
81
  /** Commit behind the same gate as every write. */
@@ -102,6 +108,7 @@ register({
102
108
  if (task && !["open", "doing", "parked"].includes(task.state))
103
109
  throw new UsageError(`${task.id} is ${task.state}`);
104
110
  const s = startSession(tree, { ...(task ? { task } : {}), ...(research ? { research: research.id } : {}) });
111
+ reviveLive(tree.root, ctx.env);
105
112
  const brief = buildBrief(tree, { ...(task ? { task } : {}), session: s, budget: ctx.settings.number("brief.budget", tree.config, DEFAULT_BUDGET), shared: ctx.settings.shared()?.value });
106
113
  return { text: lines(written(tree), "", brief.text), data: { session: s, brief: brief.text, sections: brief.sections } };
107
114
  },
@@ -120,6 +127,30 @@ register({
120
127
  sessionNote(tree, s, args[0]);
121
128
  return { text: written(tree) };
122
129
  },
130
+ }, {
131
+ path: ["session", "finish"],
132
+ summary: "Ask a session at work to finish now: its agent writes down what it found and closes it",
133
+ group: "research",
134
+ tree: true,
135
+ description: "For the user: a session of strom run (or a conversation) is told, from the next strom command its agent runs, to\n" +
136
+ "start nothing new, record what it has and close the session (the task back to the queue when unfinished).\n" +
137
+ "Nothing is stopped by force; the run it belongs to stops after it (as at the first Ctrl-C in its terminal).",
138
+ args: [{ name: "session", description: "session ID (default: the one open session)" }],
139
+ examples: ["strom session finish", "strom session finish N0001"],
140
+ run(ctx, { args }) {
141
+ const tree = ctx.tree();
142
+ const lang = tree.lang;
143
+ const open = openSessions(tree);
144
+ const s = args[0] ? requireRecord(tree, args[0], "session") : open.length === 1 ? open[0] : undefined;
145
+ if (!s && !open.length)
146
+ throw new UsageError(ui(lang, "ui.session.finish.none"), { hint: "strom session list" });
147
+ if (!s)
148
+ throw new UsageError(ui(lang, "ui.session.finish.which", { sessions: open.map((x) => x.id).join(", ") }), { hint: `strom session finish ${open[0].id}` });
149
+ if (s.state !== "open")
150
+ throw new UsageError(ui(lang, "ui.session.finish.closed", { session: s.id }), { hint: "strom session list" });
151
+ askFinish(tree.root, s.id, ctx.env.STROM_WORKER ?? "user");
152
+ return { text: ui(lang, "ui.session.finish.done", { session: s.id, task: s.task ?? "–" }), data: { session: s.id, task: s.task ?? null, asked: true } };
153
+ },
123
154
  }, {
124
155
  path: ["session", "close"],
125
156
  summary: "Finish the session: summary, next step; exports the GEDCOM and fills the task queue",
@@ -402,6 +433,7 @@ register({
402
433
  { name: "loop", type: "boolean", description: "session after session for as long as there is work (and the gate lets it)" },
403
434
  { name: "gate", type: "string", value: "<name [n]>", description: 'ask this gate before each session, with what it is given, e.g. "claude-usage 20" (default: run.gate)' },
404
435
  { name: "no-gate", type: "boolean", description: "ask no gate this time (only you: an agent cannot)" },
436
+ { name: "follow", type: "boolean", description: "open the Strom app to follow the work live when the first session starts (after the gate)" },
405
437
  { name: "minutes", type: "string", value: "<n>", description: `time limit of one session (default: run.minutes, ${DEFAULT_RUN_MINUTES})` },
406
438
  { name: "budget", type: "string", value: "<tokens>", description: `brief size (default: brief.budget, ${DEFAULT_BUDGET})` },
407
439
  { name: "model", type: "string", value: "<model>", description: "model of the main agent (default: model.lead)" },
@@ -439,15 +471,27 @@ register({
439
471
  const report = [];
440
472
  // What the gate answered, for the record of the run.
441
473
  const gates = [];
474
+ // The Strom app opened to follow the run (--follow): once, when its first session starts.
475
+ let followed = false;
442
476
  // Why the run stopped: a code for the exit status and for data, words for the user (their language).
443
477
  const lang = Tree.open(root, runEnv).lang;
444
478
  let stopCode = "done";
445
479
  let stopValues = {};
446
- // Ctrl-C, a closed terminal, a shutdown: stop the agent, close its session, give the task back.
480
+ // The first Ctrl-C while a session works: it is asked to finish (the agent writes down what it found and closes
481
+ // it), then the run stops — no next session. Ctrl-C again, a closed terminal, a shutdown: stop the agent now,
482
+ // close its session, give the task back. (Windows: the console stops the agent itself — now.)
447
483
  const stop = new AbortController();
484
+ let atWorkOn;
485
+ let finishing = false;
448
486
  const onSignal = (sig) => {
449
487
  if (stop.signal.aborted)
450
- process.exit(130); // a second Ctrl-C: now
488
+ process.exit(130); // a third Ctrl-C: at once
489
+ if (sig === "SIGINT" && atWorkOn && !finishing && OWN_GROUP && !opts.interactive) {
490
+ finishing = true;
491
+ askFinish(root, atWorkOn, "user");
492
+ out(`\n${ui(lang, "ui.run.finishing", { session: atWorkOn })}`);
493
+ return;
494
+ }
451
495
  out(`\n${ui(lang, "ui.run.stopping")} (${sig})`);
452
496
  stop.abort();
453
497
  };
@@ -478,6 +522,10 @@ register({
478
522
  const permissions = ctx.settings.agentPermissions();
479
523
  for (const c of browser)
480
524
  out(ui(lang, "ui.run.browser", { name: c.manifest.title ?? c.name, hosts: c.manifest.hosts.join(", ") }));
525
+ // agent.browser always (the user's choice): the browser in every session, for the research's archives
526
+ const web = runnerId === "claude" ? agentBrowser(Tree.open(root, runEnv), ctx.settings.shared()?.value) : undefined;
527
+ if (web?.always)
528
+ out(ui(lang, "ui.run.browser.always", { hosts: web.hosts.length ? web.hosts.join(", ") : "–" }));
481
529
  if (browser.length && /haiku/i.test(models.lead ?? ""))
482
530
  out(ui(lang, "ui.run.browser.model", { model: models.lead }));
483
531
  // Archives through the browser with an agent that has no browser tools, or without the extension: said before it starts.
@@ -493,7 +541,7 @@ register({
493
541
  if (gate)
494
542
  out(ui(lang, gateHolds ? "ui.run.gate" : "ui.run.gate.once", { name: gate.manifest.title ?? gate.name }));
495
543
  for (let i = 0; i < max; i++) {
496
- if (stop.signal.aborted) {
544
+ if (stop.signal.aborted || finishing) {
497
545
  stopCode = "user";
498
546
  break;
499
547
  }
@@ -558,7 +606,14 @@ register({
558
606
  out(ui(lang, "ui.run.gate.wait", { at: new Date(at).toLocaleString(lang, { weekday: "short", hour: "2-digit", minute: "2-digit" }), reason: said.reason ?? "" }));
559
607
  // nothing to do meanwhile: the computer may sleep
560
608
  stopAwake();
561
- await pause(at - Date.now(), stop.signal);
609
+ // the Strom app shows the run paused until then, not at work
610
+ markPaused(root, worker, { until: new Date(at).toISOString(), ...(said.reason ? { reason: said.reason } : {}) });
611
+ try {
612
+ await pause(at - Date.now(), stop.signal);
613
+ }
614
+ finally {
615
+ markPaused(root, worker, undefined);
616
+ }
562
617
  stopAwake = keepAwake(ctx.env);
563
618
  i--; // this was no session: ask again, with the queue as it is then
564
619
  continue;
@@ -566,6 +621,17 @@ register({
566
621
  }
567
622
  const session = startSession(tree, { task, ...(research ? { research: research.id } : {}), runner: runnerId, ...(models.lead ? { model: models.lead } : {}) });
568
623
  commitNow(tree, `${session.id} session started on ${task.id}`);
624
+ // the Strom app followed the research and its bridge ended without a word: on its address again
625
+ reviveLive(root, ctx.env);
626
+ // The Strom app follows it from the first session on — not while the run still waits for its gate.
627
+ if (opts.follow && !followed) {
628
+ followed = true;
629
+ const { command, args: self } = stromLauncher();
630
+ const app = spawnSync(command, [...self, "app", "--live"], { cwd: root, env: ctx.env, encoding: "utf8", windowsHide: true, timeout: 60_000 });
631
+ const said = `${app.stdout ?? ""}${app.stderr ?? ""}`.trim();
632
+ if (said)
633
+ out(said);
634
+ }
569
635
  // When the agent is stopped: it knows (the brief, strom's reminders near the end), and gets a few minutes more to write down what it found.
570
636
  const deadline = Date.now() + minutes * 60_000;
571
637
  const brief = buildBrief(tree, { task, session, budget, shared: ctx.settings.shared()?.value, deadline });
@@ -584,6 +650,7 @@ register({
584
650
  STROM_MINUTES: String(minutes),
585
651
  ...(opts.interactive ? {} : { STROM_NONINTERACTIVE: "1" }),
586
652
  };
653
+ atWorkOn = session.id;
587
654
  const result = await runner.run({
588
655
  cwd: root,
589
656
  prompt,
@@ -605,16 +672,19 @@ register({
605
672
  ...(opts.interactive ? { interactive: true } : {}),
606
673
  // the level for every agent (each maps it to its own switches); the browser and Remote Control are Claude Code's
607
674
  permissions,
608
- ...(runnerId === "claude" ? { chrome: browser.length > 0, remote: ctx.settings.agentRemote() } : {}),
675
+ ...(runnerId === "claude" ? { chrome: Boolean(web?.on), remote: ctx.settings.agentRemote() } : {}),
609
676
  ...(extra?.length ? { extraArgs: extra } : {}),
610
677
  onProgress: (l) => out(` · ${l}`),
611
678
  signal: stop.signal,
612
679
  });
680
+ atWorkOn = undefined;
613
681
  // Close what the agent left open, record the metrics, export, commit.
614
682
  const after = Tree.open(root, runEnv);
615
683
  let s = after.get(session.id);
616
684
  // strom worked for the agent when it closed the session itself or recorded something
617
685
  const ranStrom = s.state !== "open" || sessionWrites(after, s.id) > 0;
686
+ // the user asked it to finish (strom session finish, the Strom app's "finish and stop"): no next session
687
+ const askedToFinish = finishAsked(root, s.id) || s.endedBy === "user";
618
688
  if (s.state === "open")
619
689
  s = closeSession(after, s, {
620
690
  summary: result.outcome === "stopped" ? phrase(after.lang, "session.user") : phrase(after.lang, "session.agent", { outcome: result.outcome }),
@@ -653,7 +723,7 @@ register({
653
723
  stopCode = "problems";
654
724
  break;
655
725
  }
656
- if (stop.signal.aborted) {
726
+ if (stop.signal.aborted || finishing || askedToFinish) {
657
727
  stopCode = "user";
658
728
  break;
659
729
  }
@@ -453,6 +453,9 @@ function setUserSetting(ctx, key, value) {
453
453
  // Loosening the agent's permissions is the user's decision alone.
454
454
  if (key === "agent.permissions" && raises(s.agentPermissions(), value))
455
455
  ctx.requireHuman(`Let the agent ${value === "full" ? "do everything but what the tree's permissions deny" : "work on its own, asking only about risky steps"}, without asking?`, `strom config set agent.permissions ${value}`, "agent.permissions", ui(ctx.uiLang(), value === "full" ? "ui.consent.level.full" : "ui.consent.level.auto"));
456
+ // The browser in every session: the user's decision alone.
457
+ if (key === "agent.browser" && value === "always" && s.resolve("agent.browser", ctx.hasTree() ? ctx.tree().config : undefined)?.value !== "always")
458
+ ctx.requireHuman("Give the agent browser tools (Claude in Chrome) in every research session?", "strom config set agent.browser always", "agent.browser", ui(ctx.uiLang(), "ui.consent.browser"));
456
459
  // Sessions steered from elsewhere (Remote Control): the user's decision alone.
457
460
  if (key === "agent.remote" && value === "on" && !s.agentRemote())
458
461
  ctx.requireHuman("Start the Claude Code sessions with Remote Control (followed and steered from claude.ai or your phone)?", "strom config set agent.remote on", "agent.remote", ui(ctx.uiLang(), "ui.consent.remote"));
@@ -16,7 +16,7 @@ import { syncAgentFiles } from "../agents/files.js";
16
16
  import { AGENTS, findAgent, isAgent, withoutAgentMarks } from "../core/which.js";
17
17
  import { DESKTOP_APPS, agentsHere, whereToTalk } from "../core/apps.js";
18
18
  import { assertIntact } from "../core/integrity.js";
19
- import { treeBrowserConnectors } from "../core/connector.js";
19
+ import { agentBrowser } from "../core/connector.js";
20
20
  import { browserNote } from "./connectors.js";
21
21
  import { offerLinks } from "../cli/wizard.js";
22
22
  import { appOpensLinks, appOpensResearch, importAppUrl, installedStromApp, liveAppUrl, noticeStromApp, STROM_APP_URL, stromAppUrl } from "../core/stromapp.js";
@@ -134,7 +134,7 @@ register({
134
134
  // its research when there is one, the day.
135
135
  name: ["Strom", tree.config.name, researches.length === 1 ? researches[0].name : undefined, new Date().toLocaleDateString(lang, { day: "numeric", month: "numeric" })].filter(Boolean).join(" · "),
136
136
  shared,
137
- ...(agent === "claude" ? { chrome: treeBrowserConnectors(tree, shared).length > 0, remote: ctx.settings.agentRemote() } : {}),
137
+ ...(agent === "claude" ? { chrome: agentBrowser(tree, shared).on, remote: ctx.settings.agentRemote() } : {}),
138
138
  };
139
139
  const args = conversationArgs(agent, base);
140
140
  if (opts.print)
@@ -263,7 +263,10 @@ register({
263
263
  tree: true,
264
264
  description: "A small web server on this computer only (127.0.0.1, a secret address), that only reads: the Strom app, opened with\n" +
265
265
  "strom app --live, takes the tree from it and hears what changes — who is at work on what, what was recorded, what\n" +
266
- "waits for the user. It ends by itself when nobody asks it anything for two hours, or with strom live stop.",
266
+ "waits for the user. It ends by itself when nobody asks it anything for two hours, or with strom live stop.\n" +
267
+ "One error does not end it (a read that fails is tried again); what it did and what went wrong is in .strom/live.log.\n" +
268
+ "Started again it takes the address it had (its port while free), so the app following it goes on by itself;\n" +
269
+ "one that ended without a word is started again when a session starts.",
267
270
  examples: ["strom live", "strom live start", "strom live stop"],
268
271
  run(ctx) {
269
272
  const tree = ctx.tree();
@@ -8,6 +8,42 @@ import { approveStory, setStory, findEventOwner } from "../core/actions.js";
8
8
  import { UsageError } from "../core/errors.js";
9
9
  import { csvOpt, normId, textOpt } from "../core/records.js";
10
10
  import { resolvePerson } from "../core/people.js";
11
+ import { foldText } from "../core/text.js";
12
+ /**
13
+ * The one heading a story has is its title (--title). A first line "# …" is taken as the title when none is given,
14
+ * and dropped when it only repeats it; any other "# …" line stays (the Strom app shows it as a subheading).
15
+ */
16
+ function titleOf(text, title) {
17
+ const [first = "", ...rest] = text.replace(/\r\n?/g, "\n").trimStart().split("\n");
18
+ const heading = /^#[ \t]+(.+?)[ \t#]*$/.exec(first)?.[1]?.trim();
19
+ if (!heading)
20
+ return { text, ...(title ? { title } : {}) };
21
+ if (!title?.trim())
22
+ return { text: rest.join("\n"), title: heading };
23
+ return foldText(heading) === foldText(title) ? { text: rest.join("\n"), title } : { text, title };
24
+ }
25
+ /**
26
+ * What the Strom app shows as plain text (its set: paragraphs, "## " subheadings, "- " bullets, **bold**,
27
+ * *italic*): said, never refused. Numbered lists count only as two such lines in a row — a date may start a line.
28
+ */
29
+ function outsideSet(text) {
30
+ const found = [];
31
+ const lns = text.split("\n");
32
+ if (/\[[^\]\n]+\]\([^)\n]+\)/.test(text))
33
+ found.push("links");
34
+ if (lns.filter((l) => /^\s*\|.*\|\s*$/.test(l)).length >= 2)
35
+ found.push("tables");
36
+ if (lns.some((l) => /^\s*>/.test(l)))
37
+ found.push("quotes (> )");
38
+ if (/`[^`\n]+`/.test(text))
39
+ found.push("code");
40
+ if (lns.some((l, i) => /^\s*\d+[.)]\s/.test(l) && /^\s*\d+[.)]\s/.test(lns[i + 1] ?? "")))
41
+ found.push("numbered lists");
42
+ // "* 1831" is the sign of a birth, no bullet
43
+ if (lns.some((l) => /^\s*[*+]\s+(?!\d)/.test(l)))
44
+ found.push('bullets other than "- "');
45
+ return found;
46
+ }
11
47
  function owner(tree, ref) {
12
48
  return /^F\d+$/i.test(ref.trim()) ? normId(ref, "family") : resolvePerson(tree, ref).id;
13
49
  }
@@ -17,8 +53,10 @@ register({
17
53
  group: "people",
18
54
  tree: true,
19
55
  writes: true,
20
- description: "Every statement rests on a recorded fact: list them with --fact. Paragraphs are separated by a blank line;\n" +
21
- "**bold** is kept. Writing it again replaces it (the history keeps the old one).",
56
+ description: "Every statement rests on a recorded fact: list them with --fact. Paragraphs are separated by a blank line.\n" +
57
+ "Kept: \"## subheading\" lines, \"- \" bullet lines, **bold**, *italic*; anything else shows as plain text in the\n" +
58
+ "Strom app. The title goes in --title (a first line \"# …\" is taken as it). Writing it again replaces it (the\n" +
59
+ "history keeps the old one).",
22
60
  args: [{ name: "who", description: "person (ID or name) or family (F…)", required: true }],
23
61
  options: [
24
62
  { name: "text", type: "string", value: "<text|@file>", description: "the story, in the research language" },
@@ -30,18 +68,21 @@ register({
30
68
  examples: ['strom story set P0001 --text @notes/story-P0001.md --title "The miller of Týnec" --fact E0001 --fact E0002 --note "The house is inferred from the census."'],
31
69
  run(ctx, { args, opts }) {
32
70
  const tree = ctx.tree();
33
- const text = textOpt(opts.text, (p) => fs.readFileSync(ctx.resolvePath(p), "utf8"));
34
- if (!text?.trim())
71
+ const given = textOpt(opts.text, (p) => fs.readFileSync(ctx.resolvePath(p), "utf8"));
72
+ if (!given?.trim())
35
73
  throw new UsageError("--text is required", { hint: "--text @notes/story.md (a file) or the text itself" });
74
+ const { text, title } = titleOf(given, opts.title);
36
75
  const rec = setStory(tree, owner(tree, args[0]), {
37
76
  text,
38
- title: opts.title,
77
+ title,
39
78
  facts: csvOpt(opts.fact).map((f) => normId(f)),
40
79
  note: opts.note,
41
80
  final: Boolean(opts.final),
42
81
  });
43
82
  const missing = rec.story.facts.length === 0 ? "note: no --fact given — say which facts the story rests on" : undefined;
44
- return { text: lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined, missing), data: { story: rec.story } };
83
+ const outside = outsideSet(rec.story.text);
84
+ const plain = outside.length ? `note: the Strom app shows ${outside.join(", ")} as plain text — keep to paragraphs, "## " subheadings, "- " bullets, **bold**, *italic*` : undefined;
85
+ return { text: lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined, missing, plain), data: { story: rec.story } };
45
86
  },
46
87
  }, {
47
88
  path: ["story", "approve"],
@@ -11,6 +11,7 @@ import { lines, moreLine, paginate, runs, table, truncate } from "../cli/format.
11
11
  import { UsageError } from "../core/errors.js";
12
12
  import { TASK_LEVELS } from "../core/model.js";
13
13
  import { create, csvOpt, listOpt, normId, requireRecord, update } from "../core/records.js";
14
+ import { directionOf, ofResearch, scopes } from "../core/directions.js";
14
15
  import { resolvePerson } from "../core/people.js";
15
16
  import { foldText } from "../core/text.js";
16
17
  import { now, typeOfId } from "../core/tree.js";
@@ -35,11 +36,17 @@ function subjects(tree, refs) {
35
36
  return resolvePerson(tree, r).id;
36
37
  });
37
38
  }
38
- function defaultResearch(tree, ref) {
39
+ /** A new task's research: the one named, else the session's that adds it, else the one active research its people belong to. */
40
+ function defaultResearch(tree, ref, subject = []) {
39
41
  if (typeof ref === "string")
40
42
  return resolveResearch(tree, ref).id;
43
+ const session = typeOfId(tree.actor) === "session" ? tree.get(tree.actor) : undefined;
44
+ if (session?.research)
45
+ return session.research;
41
46
  const active = tree.list("research").filter((r) => r.state === "active");
42
- return active.length === 1 ? active[0].id : undefined;
47
+ if (active.length === 1)
48
+ return active[0].id;
49
+ return directionOf(tree, { subject }, scopes(tree).filter((s) => s.research.state === "active"));
43
50
  }
44
51
  /** What the research waits for from outside — mostly from the user: scans to download, a book to find, a reply. */
45
52
  export function waitingForUser(tree) {
@@ -75,9 +82,10 @@ export function taskQueue(tree, filter = {}) {
75
82
  return rankedQueue(tree, filter).map((r) => r.task);
76
83
  }
77
84
  export function rankedQueue(tree, filter = {}) {
85
+ const scope = filter.research ? scopes(tree).find((s) => s.research.id === filter.research) : undefined;
78
86
  const tasks = tree
79
87
  .list("task")
80
- .filter((t) => !filter.research || !t.research || t.research === filter.research)
88
+ .filter((t) => !filter.research || (scope ? ofResearch(tree, t, scope) : t.research === filter.research))
81
89
  .filter((t) => !filter.level || t.level === filter.level);
82
90
  return rankTasks(tree, tasks, filter.strategy, { storyTurn: filter.storyTurn });
83
91
  }
@@ -183,7 +191,7 @@ register({
183
191
  throw new UsageError("--priority must be 1..5");
184
192
  const where = listOpt(opts.where).map((w) => (/^[Bb]\d+$/.test(w) ? requireRecord(tree, w, "recordset").id : w));
185
193
  const subject = subjects(tree, listOpt(opts.about));
186
- const research = defaultResearch(tree, opts.research);
194
+ const research = defaultResearch(tree, opts.research, subject);
187
195
  // The same work again: an open task of the same person and level that reads the same (or nearly, in the same
188
196
  // books) — not added; what is new goes into that one.
189
197
  if (!opts.anyway) {
@@ -278,8 +286,9 @@ register({
278
286
  }
279
287
  else {
280
288
  all = tree.list("task").filter((t) => opts.state === "all" || t.state === opts.state);
281
- if (research)
282
- all = all.filter((t) => t.research === research);
289
+ const scope = research ? scopes(tree).find((s) => s.research.id === research) : undefined;
290
+ if (scope)
291
+ all = all.filter((t) => ofResearch(tree, t, scope));
283
292
  if (opts.level)
284
293
  all = all.filter((t) => t.level === opts.level);
285
294
  }
@@ -6,13 +6,19 @@ import { register } from "../cli/registry.js";
6
6
  import { Context } from "../cli/context.js";
7
7
  import { lines, table } from "../cli/format.js";
8
8
  import { Tree } from "../core/tree.js";
9
- import { safeFolderName } from "../core/text.js";
9
+ import { foldText, safeFolderName } from "../core/text.js";
10
10
  import { isValidLang, langName } from "../core/lang.js";
11
- import { UsageError } from "../core/errors.js";
11
+ import { NeedsConsentError, UsageError } from "../core/errors.js";
12
12
  import { verifyFast } from "../core/integrity.js";
13
13
  import * as git from "../core/git.js";
14
14
  import { ensureShared, setTreeSetting } from "./setup.js";
15
15
  import { syncAgentFiles } from "../agents/files.js";
16
+ import { ui } from "../cli/ui.js";
17
+ import { atWork } from "../core/relocate.js";
18
+ import { moveToTrash } from "../core/trash.js";
19
+ import { stopLive } from "../core/live.js";
20
+ import { onlyItsFiles, packPlan } from "../core/pack.js";
21
+ import { defaultPackFile, packTree } from "./pack.js";
16
22
  register({
17
23
  path: ["init"],
18
24
  summary: "Create a family tree (a folder with its own history) to hold researches",
@@ -81,6 +87,80 @@ register({
81
87
  return { text: `now working on "${tree.config.name}" (${ctx.display(root)})`, data: { name: tree.config.name, root } };
82
88
  },
83
89
  });
90
+ register({
91
+ path: ["trees", "remove"],
92
+ summary: "Take a family tree off this computer — into the system's trash, a backup offered first (the person runs this)",
93
+ group: "research",
94
+ description: "Offers a backup first (strom pack --all-images: it comes back with strom unpack), then the images no other tree\n" +
95
+ "uses; the person types the tree's name to confirm. The tree's folder goes into the system's trash (never deleted\n" +
96
+ "for good), so do the images they chose. Never while somebody works on it; only the person at the computer —\n" +
97
+ "never an agent.",
98
+ args: [{ name: "tree", description: "tree name or folder", required: true }],
99
+ examples: ['strom trees remove "Novákovi"'],
100
+ async run(ctx, { args }) {
101
+ const lang = ctx.uiLang();
102
+ const probe = Context.fromOptions({ env: ctx.env, cwd: ctx.cwd, io: ctx.io, json: ctx.json, values: { tree: args[0] } });
103
+ const root = probe.locateTree();
104
+ if (!ctx.interactive)
105
+ throw new NeedsConsentError([{ key: "remove", kind: "consent", question: ui(lang, "ui.remove.person"), set: `strom trees remove "${root}"` }]);
106
+ const tree = Tree.open(root, ctx.env);
107
+ const name = tree.config.name;
108
+ // the Strom app following it live is no work: its bridge only reads, and stops before the tree goes
109
+ if (atWork(root, { bridge: false }))
110
+ throw new UsageError(ui(lang, "ui.remove.busy", { name }), { hint: "strom" });
111
+ const shared = ctx.settings.shared().value;
112
+ const others = ctx.knownTrees().map((k) => k.root).filter((r) => path.resolve(r) !== path.resolve(root));
113
+ const only = onlyItsFiles(root, others, shared);
114
+ const onlyBytes = only.reduce((s, f) => s + f.size, 0);
115
+ const mb = (b) => (b / 1e6 < 10 ? (b / 1e6).toFixed(1) : Math.round(b / 1e6).toString());
116
+ ctx.io.stdout(ui(lang, "ui.remove.what", { name, root: ctx.display(root), persons: tree.list("person").length }) + "\n");
117
+ // a way back first
118
+ let backup;
119
+ const all = packPlan(tree, shared, true).media.reduce((s, m) => s + m.size, 0);
120
+ if (await ctx.confirm(ui(lang, "ui.remove.backup", { mb: mb(all) }), true)) {
121
+ const out = defaultPackFile(ctx, tree);
122
+ backup = packTree(tree, shared, out, true).file;
123
+ ctx.io.stdout(ui(lang, "ui.remove.backup.done", { file: ctx.display(backup) }) + "\n");
124
+ }
125
+ const images = only.length > 0 && (await ctx.confirm(ui(lang, "ui.remove.images", { n: only.length, mb: mb(onlyBytes) }), backup !== undefined));
126
+ const typed = (await ctx.ask(ui(lang, "ui.remove.type", { name }))).trim();
127
+ if (!typed || foldText(typed) !== foldText(name))
128
+ return { text: ui(lang, "ui.remove.cancelled"), data: { removed: false, ...(backup ? { backup } : {}) } };
129
+ if (atWork(root, { bridge: false }))
130
+ throw new UsageError(ui(lang, "ui.remove.busy", { name }), { hint: "strom" });
131
+ const followed = stopLive(root, "the family tree is taken off this computer");
132
+ moveToTrash(root, ctx.env, path.basename(root));
133
+ let moved = 0;
134
+ if (images) {
135
+ // the images together, one folder in the trash named after the tree
136
+ const folder = path.join(shared, `.${safeFolderName(name) || "tree"}-${Date.now()}`);
137
+ for (const f of only) {
138
+ const to = path.join(folder, ...f.file.split("/"));
139
+ fs.mkdirSync(path.dirname(to), { recursive: true });
140
+ fs.renameSync(path.join(shared, ...f.file.split("/")), to);
141
+ moved++;
142
+ }
143
+ moveToTrash(folder, ctx.env, ui(lang, "ui.remove.images.folder", { name }));
144
+ }
145
+ const cfg = ctx.settings.config;
146
+ if (cfg.extraTrees)
147
+ cfg.extraTrees = cfg.extraTrees.filter((r) => path.resolve(r) !== path.resolve(root));
148
+ // the tree worked on went: the next one known is worked on now, never a menu with none chosen
149
+ let next;
150
+ if (cfg.currentTree && path.resolve(cfg.currentTree) === path.resolve(root)) {
151
+ const left = ctx.knownTrees().filter((k) => path.resolve(k.root) !== path.resolve(root));
152
+ if (left.length) {
153
+ cfg.currentTree = left[0].root;
154
+ next = left[0].name;
155
+ }
156
+ else
157
+ delete cfg.currentTree;
158
+ }
159
+ ctx.settings.save();
160
+ const text = lines(ui(lang, "ui.remove.done", { name }), followed ? ui(lang, "ui.remove.live") : undefined, moved ? ui(lang, "ui.remove.images.done", { n: moved, mb: mb(onlyBytes) }) : undefined, backup ? ui(lang, "ui.remove.backup.back", { file: ctx.display(backup) }) : undefined, ui(lang, backup ? "ui.remove.trash" : "ui.remove.trash.alone"), next ? ui(lang, "ui.remove.next", { name: next }) : undefined);
161
+ return { text, data: { removed: true, root, images: moved, ...(backup ? { backup } : {}), ...(next ? { current: cfg.currentTree } : {}) } };
162
+ },
163
+ });
84
164
  register({
85
165
  path: ["status"],
86
166
  summary: "State of the current tree: researches, data, checks, recent changes",