strom-research 1.4.0 → 1.5.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.
package/dist/cli/ui.js CHANGED
@@ -241,6 +241,18 @@ export const UI = {
241
241
  "ui.review.pick": "Which one?",
242
242
  "ui.review.family": "With their spouse and children?",
243
243
  "ui.review.run": "Let the agent work on it now, on its own? ({n} tasks)",
244
+ "ui.menu.unproven": "People without a record of their own ({n})",
245
+ "ui.unproven.title": "No record of their own proves these people yet:",
246
+ "ui.unproven.named": "named in a record",
247
+ "ui.unproven.leads": "only leads",
248
+ "ui.unproven.none": "no record",
249
+ "ui.unproven.tasks": "work on them is under way",
250
+ "ui.unproven.living": "Most likely living (born less than {years} years ago) — the family's to tell, no tasks: {names}",
251
+ "ui.unproven.how": "What should the agent do?",
252
+ "ui.unproven.all": "All of them, step by step ({n} at a time)",
253
+ "ui.unproven.pick": "Only the ones I pick",
254
+ "ui.unproven.numbers": "Their numbers from the list (e.g. 1 3 5-7, or their IDs; Enter or 0 goes back)",
255
+ "ui.unproven.bad": "Not in the list: {x}",
244
256
  "ui.o.reread": "second reading",
245
257
  "ui.o.reread.offer": "{n} facts rest only on readings by another model ({models}); the user reads with {model} now — offer it once, in a sentence: strom review <whom> --reread (a task per person; the user decides)",
246
258
  "ui.o.stories": "stories",
@@ -278,6 +290,7 @@ export const UI = {
278
290
  "ui.update.downloading": "Downloading strom {version}…",
279
291
  "ui.update.failed": "The update did not work: {detail}. strom stays as it was.",
280
292
  "ui.update.done": "✓ strom is updated: {previous} → {version}.",
293
+ "ui.update.restart": "strom is updated — this menu still runs the old version. Start strom again (or the shortcut \"{shortcut}\"), and the new version is at work.",
281
294
  "ui.o.update": "update",
282
295
  "ui.o.update.new": "{version} is out (this is {current}) — tell the user; strom update installs it with their yes",
283
296
  "ui.app.where.installed": "the installed app",
@@ -380,6 +393,60 @@ export const UI = {
380
393
  "ui.browse.plan": "What the agent will do next",
381
394
  "ui.review.new": "{name} — {n} new task(s):",
382
395
  "ui.review.nothing": "{name} — nothing new to review",
396
+ "ui.sync.title": "{file}: {n} change(s) against the research",
397
+ "ui.sync.base": "Compared with what the research gave the Strom app ({head}): only what was changed since counts as your edit.",
398
+ "ui.sync.nobase": "The file does not say which state of the research it came from: additions are taken, differences only shown (pick them with --only).",
399
+ "ui.sync.headmissing": "The research's history does not have the state {head} the file names: additions are taken, differences only shown (pick them with --only).",
400
+ "ui.sync.partial": "The file has far fewer people than it was given: nothing is taken as removed.",
401
+ "ui.sync.strangers": "These IDs are not the research's people of that name — taken as new people: {ids}",
402
+ "ui.sync.nothing": "{file}: nothing new — the research has everything in it.",
403
+ "ui.sync.person.new": "{n}. new person: {name}",
404
+ "ui.sync.fact.new": "{n}. {who}: new — {fact}",
405
+ "ui.sync.fact.changed": "{n}. {who}: {kind} changed — {was} → {now}",
406
+ "ui.sync.fact.differs": "{n}. {who}: {kind} differs — the research {was}, the file {now}",
407
+ "ui.sync.fact.gone": "{n}. {who}: no longer in it — {fact}",
408
+ "ui.sync.name.new": "{n}. {who}: another name — {text}",
409
+ "ui.sync.sex.changed": "{n}. {who}: sex {text}",
410
+ "ui.sync.note.new": "{n}. {who}: a note — “{text}”",
411
+ "ui.sync.family.new": "{n}. a new family: {who}{kids}",
412
+ "ui.sync.child.new": "{n}. {who}: a child — {child}",
413
+ "ui.sync.child.gone": "{n}. {who}: no longer their child — {child}",
414
+ "ui.sync.person.gone": "{n}. no longer in it: {name}",
415
+ "ui.sync.do.add": "added as a lead",
416
+ "ui.sync.do.correct": "corrects the research's lead",
417
+ "ui.sync.do.conflict": "a conflict for you to decide (the research has it from a record)",
418
+ "ui.sync.do.user": "your edit wins (the record's fact withdrawn with the reason)",
419
+ "ui.sync.do.pick": "only when you pick it (--only)",
420
+ "ui.sync.do.report": "nothing changes — tell the agent what you meant",
421
+ "ui.sync.edits": "Your edits to facts a record proves: {mode} (strom config set sync.edits conflict|user)",
422
+ "ui.sync.mode.conflict": "a conflict for you to decide",
423
+ "ui.sync.mode.user": "your edit wins",
424
+ "ui.sync.next": "To write it: strom sync {file} --apply · only some: --apply --only 1,3",
425
+ "ui.sync.done": "{n} change(s) from {file} written ({input}) — to take them back: strom sync undo {input}",
426
+ "ui.sync.undone": "Sync {input} taken back: {n} step(s).",
427
+ "ui.sync.wasundone": "Sync {input} was taken back already.",
428
+ "ui.sync.bridge.large": "the tree is too large to send — export it as GEDCOM and load it in the research (strom sync <file>)",
429
+ "ui.sync.bridge.other": "this tree is of another research",
430
+ "ui.sync.bridge.foreign": "this does not look like the research's family tree — export it and load it in the research as another family tree",
431
+ "ui.sync.bridge.empty": "the tree came empty or unreadable",
432
+ "ui.sync.wait": "The Strom app opens with this research — choose which tree to send, and confirm there. Waiting for it (at most {min} min; Ctrl+C stops) …",
433
+ "ui.sync.waited": "The Strom app did not send the tree within {min} min — try again: strom sync --app, or export it as GEDCOM: strom sync <file>",
434
+ "ui.sync.got": "The Strom app sent the tree.",
435
+ "ui.sync.nothing.unchanged": "The Strom app sends nothing: the tree has not changed since it was last loaded from the research.",
436
+ "ui.sync.nothing.cancelled": "Nothing was sent from the Strom app (cancelled there).",
437
+ "ui.sync.nothing.no-tree": "The Strom app has no tree of this research — open it there first (the Strom app item of the menu), or export the tree and load the file.",
438
+ "ui.sync.nosend": "This copy of the Strom app cannot send the tree yet (it comes with a newer version) — export it in the app as GEDCOM and load it: strom sync <file>",
439
+ "ui.sync.from": "From where?",
440
+ "ui.sync.from.app": "Straight from the Strom app (it sends the tree itself)",
441
+ "ui.sync.from.file": "From a file (.ged, .json)",
442
+ "ui.sync.again": "The research took this file in already ({input}) — once more: --again",
443
+ "ui.sync.source.app": "Edits in the Strom app ({file}, {day})",
444
+ "ui.sync.source.file": "Family tree {file} ({day})",
445
+ "ui.review.living": "{who}: most likely alive (born less than {years} years ago, no death recorded). The family knows more than the registers, and those of recent years are mostly closed. Review all the same?",
446
+ "ui.review.living.no": "Nothing added.",
447
+ "ui.review.unproven.none": "Everyone in the tree rests on a record of their own — or strom has nothing more to propose for those who do not (strom person list --unproven).",
448
+ "ui.review.unproven.left": "{n} more without a record of their own: strom review --unproven again takes the next ones once these are done",
449
+ "ui.review.unproven.left.human": "{n} more people wait without a record of their own — the next ones come when these are done.",
383
450
  "ui.review.would": " would add: {level} · {what}",
384
451
  "ui.review.dry": "(dry run — nothing written)",
385
452
  "ui.review.covered": " {name}: already covered by {task} ({state}) — {what}",
@@ -439,10 +506,19 @@ export const UI = {
439
506
  "ui.consent.remote": "Start the Claude Code sessions strom opens with Remote Control, so that you can follow and steer them from claude.ai or the Claude app on your phone?",
440
507
  "ui.consent.hook.on": "Let the hook “{name}” be told of everything saved into your researches? It is a program on this computer and does with it what it was written to do.",
441
508
  "ui.consent.hook.off": "Stop telling the hook “{name}” what is saved?",
442
- "ui.menu.more": "Add to the research: material, a new direction, one person reviewed",
509
+ "ui.menu.more": "Add to the research: material, a new direction, one person reviewed, another family tree",
510
+ "ui.menu.more.app": "Add to the research: material, a new direction, one person reviewed, edits from the Strom app",
443
511
  "ui.more.title": "Add to the research",
444
512
  "ui.more.intake": "Material from the family: documents, photos, a family tree from another program, what you know",
445
513
  "ui.more.research": "A new direction: the ancestors or descendants of someone else, or one question",
514
+ "ui.more.sync": "Take in edits from the Strom app or another family tree (.ged, .json)",
515
+ "ui.more.sync.noapp": "Take in another family tree's changes (.ged, .json)",
516
+ "ui.sync.path.noapp": "Drag the family tree file (.ged or .json) here, or type its path (Enter or 0 goes back)",
517
+ "ui.sync.path": "Drag the file exported from the Strom app (.ged or .json) here, or type its path (Enter or 0 goes back)",
518
+ "ui.sync.how": "Write it into the research?",
519
+ "ui.sync.all": "Yes, everything as shown",
520
+ "ui.sync.some": "Only the ones I pick",
521
+ "ui.sync.app": "Open the research in the Strom app again (the Strom app item of the menu): it asks whether to update the tree — then it has your edits as the research took them.",
446
522
  "ui.intake.what": "What do you bring?",
447
523
  "ui.intake.files": "Files or a folder from this computer",
448
524
  "ui.intake.text": "What you know — names, dates, places, family memories",
@@ -14,8 +14,9 @@ import { lacksImages } from "../core/queue.js";
14
14
  import { rankedQueue, waitingForUser } from "./tasks.js";
15
15
  /** Most rows a section of `recent` lists; the rest is counted. */
16
16
  const RECENT_ROWS = 15;
17
+ /** A person as the user reads them, with the ID to name them by later ("strom review P0012", the agent). */
17
18
  function whoText(w) {
18
- return w.years ? `${w.name} (${w.years})` : w.name;
19
+ return `${w.years ? `${w.name} (${w.years})` : w.name} [${w.id}]`;
19
20
  }
20
21
  /** The person asked for, else the main person of the tree. */
21
22
  function personOrMain(tree, ref) {
@@ -72,7 +73,7 @@ export function humanTask(tree, text, lang) {
72
73
  if (r.type === "session")
73
74
  return ui(lang, "ui.plan.session", { day: humanDay(r.started, lang) });
74
75
  if (r.type === "person")
75
- return displayName(r);
76
+ return `${displayName(r)} [${r.id}]`;
76
77
  if (r.type === "family")
77
78
  return r.partners.map((x) => tree.get(x)).filter((x) => !!x).map(displayName).join(" & ") || id;
78
79
  return r.title;
@@ -192,7 +193,7 @@ register({
192
193
  ` ${t.state === "doing" ? ui(lang, "ui.plan.now") : `${++n}.`}`,
193
194
  `${truncate(humanTask(tree, t.what, lang), 110)}${lacksImages(tree, t) ? ui(lang, "ui.plan.images") : ""}`,
194
195
  ]);
195
- const storyNames = [...new Set(stories.map((t) => tree.get(t.subject[0] ?? "")).filter((p) => !!p).map(displayName))];
196
+ const storyNames = [...new Set(stories.map((t) => tree.get(t.subject[0] ?? "")).filter((p) => !!p).map((p) => `${displayName(p)} [${p.id}]`))];
196
197
  const out = [ui(lang, "ui.plan.title", { name: tree.config.name })];
197
198
  if (!queue.length)
198
199
  out.push(` ${ui(lang, "ui.plan.none")}`);
@@ -5,6 +5,7 @@ import { register } from "../cli/registry.js";
5
5
  import { lines, table } from "../cli/format.js";
6
6
  import { check } from "../core/check.js";
7
7
  import { guard } from "../core/guard.js";
8
+ import { unprovenPeople } from "../core/review.js";
8
9
  import { verifyFast, verifyFull } from "../core/integrity.js";
9
10
  import { createKey, verifyCommitSeal } from "../core/seal.js";
10
11
  import { NeedsConsentError, StromError } from "../core/errors.js";
@@ -23,7 +24,12 @@ register({
23
24
  tree: true,
24
25
  run(ctx) {
25
26
  const tree = ctx.tree();
26
- return report([...verifyFull(tree).findings, ...check(tree), ...guard(tree)], "ok — data consistent, sealed, nothing lost");
27
+ const out = report([...verifyFull(tree).findings, ...check(tree), ...guard(tree)], "ok — data consistent, sealed, nothing lost");
28
+ // not an error — research to do: who rests on no record of their own
29
+ const unproven = unprovenPeople(tree).filter((u) => !u.living).length;
30
+ if (!unproven)
31
+ return out;
32
+ return { ...out, text: lines(out.text, `note: ${unproven} people rest on no record of their own → strom person list --unproven`), data: { ...out.data, unproven } };
27
33
  },
28
34
  }, {
29
35
  path: ["verify"],
@@ -32,6 +32,12 @@ HARD RULES
32
32
  the research needs has none yet: build one first (strom connector new,
33
33
  its DISCOVERY.md) and tell the user in a sentence. The user saves images
34
34
  by hand only where the archive does not allow automation.
35
+ 8. Old handwriting is read by your strongest model — you, or a subagent on
36
+ that same model; never a faster, cheaper or "quick" pass. A name or place
37
+ the next search depends on is read again on a crop at full resolution
38
+ (strom media view … --crop) before it is recorded; unsure stays unsure.
39
+ 9. A find is told to the user only once it is recorded (the source with its
40
+ transcript, the facts citing it), and told from strom: strom person card.
35
41
  ${language}
36
42
 
37
43
  GETTING STARTED (once)
@@ -42,14 +48,20 @@ GETTING STARTED (once)
42
48
  strom intake --text "<what the user told you, in their words>"
43
49
  strom intake <file or folder> documents, photos, a GEDCOM or Strom tree
44
50
 
45
- EVERY SESSION
46
- strom session start the brief: task, what is known, method
47
- … work; record every finding at once …
51
+ THE WORK, STEP BY STEP (every agent — also one strom did not start: a
52
+ conversation opened anywhere, a bot on its own server)
53
+ strom where things stand and the next step — run it before each step
54
+ strom session start the next task of the queue (or: strom session start T0003, the one
55
+ the user chose): its brief — the task, what is known, the method
56
+ … work on that task only; record every finding at once …
48
57
  strom task done T0001 --result "…" a complete negative search is a result
49
- strom session close --summary "…" --next "…" (not finished: --continue)
50
- in a conversation, the next task is best begun in a fresh context (Claude Code: /clear) —
51
- suggest it to the user; many tasks waiting: the agent working on its own (strom run, the
52
- user starts it from strom's menu), every task in a fresh session
58
+ strom session close --summary "…" --next "…" (not finished: --continue) — it proposes the next tasks
59
+ then tell the user what was found (strom person card …) and what comes next (strom plan), and ask
60
+ before the next session. The next step is strom's queue: what the user wants instead becomes a
61
+ task first (strom task add …, or strom research new …) — never search outside a session and its task.
62
+ In a conversation the next task is best begun in a fresh context (Claude Code: /clear) — suggest
63
+ it to the user; many tasks waiting: the agent working on its own (strom run, the user starts it
64
+ from strom's menu), every task in a fresh session
53
65
 
54
66
  RECORDING
55
67
  A record (register entry, certificate) — the source first, then its facts:
@@ -124,6 +136,15 @@ ONE PERSON, LOOKED AT AGAIN (the user asks you to check or complete someone; a n
124
136
  diary say of them outside their data, entries whose images are here but were not read whole, facts
125
137
  resting on one reading, conflicts left open (--reread: a second reading with the model the user reads
126
138
  with now). Each task's brief lists its items. Tell the user how many and what they cost; they decide.
139
+ Several at once: strom review P… P… (one review). The people no record of their own proves (strom check
140
+ counts them): strom person list --unproven (how each stands; the living apart — no tasks for them);
141
+ strom review --unproven proposes their work a batch at a time — again for the next batch, nothing twice.
142
+ THE STROM APP'S EDITS, ANOTHER FAMILY TREE (the user changed the tree in the Strom app, or brings a .ged/.json)
143
+ strom sync <file> shows what it would take: what the file adds, what the user changed since the research
144
+ gave it (_STROM_HEAD), what is no longer in it. Show the user the list; write it on their yes (--apply, some:
145
+ --only 1,3). Additions become leads; a change to a fact a record proves becomes a conflict (sync.edits: the
146
+ user's choice). A file of another research, or of few of our people, is refused; strom sync undo I… takes a
147
+ sync back. After it: the user opens the research in the Strom app again to have the tree updated.
127
148
  STORIES OF THE ANCESTORS (the setting stories — on by default; strom shows it)
128
149
  Once records tell a person's life (a baptism and more facts from records), strom proposes a narrate
129
150
  task: write the story for the family book — plain words, the research language, every statement on a
@@ -4,6 +4,7 @@ import "./start.js";
4
4
  import "./trees.js";
5
5
  import "./research.js";
6
6
  import "./intake.js";
7
+ import "./sync.js";
7
8
  import "./tasks.js";
8
9
  import "./people.js";
9
10
  import "./batch.js";
@@ -149,6 +149,9 @@ register({
149
149
  report.push(` ${input.id} tree: ${imported.persons} persons, ${imported.families} families, ${imported.events} facts as leads${imported.matched ? `, ${imported.matched} matched to existing` : ""}${imported.problems.length ? ` (${imported.problems.length} unreadable lines)` : ""}`);
150
150
  if (imported.extended.length)
151
151
  report.push(` added to what we have (leads — check them): ${imported.extended.slice(0, 6).join(" · ")}${imported.extended.length > 6 ? " …" : ""}`);
152
+ // the research's own tree coming back: sync sees what was changed in it too, not only what was added
153
+ if (imported.matched && imported.matched >= imported.persons)
154
+ report.push(` it is this research's tree coming back: strom sync "${ctx.display(file)}" shows what was changed in it too`);
152
155
  const system = `gedcom:${sha.slice(0, 12)}`;
153
156
  const dupes = likelyDuplicates(tree, tree.list("person").filter((p) => p.refs?.some((r) => r.system === system)).map((p) => p.id));
154
157
  if (dupes.length)
@@ -9,6 +9,7 @@ import { INFORMATION } from "../core/evidence.js";
9
9
  import { EVENT_KINDS, NAME_KINDS } from "../core/model.js";
10
10
  import { ancestorGenerations, displayName, familiesAsChild, familiesAsPartner, findPersons, formatName, label, lifespan, relationTo, resolvePerson, } from "../core/people.js";
11
11
  import { resolveResearch } from "./research.js";
12
+ import { DEATH_AFTER_YEARS, unprovenPeople } from "../core/review.js";
12
13
  import { foldText } from "../core/text.js";
13
14
  function list(v) {
14
15
  if (v === undefined)
@@ -158,9 +159,10 @@ register({
158
159
  options: [
159
160
  { name: "research", type: "string", value: "<G…>", description: "only people in scope of this research, with generations" },
160
161
  { name: "generation", type: "string", value: "<n>", description: "only this generation (with --research)" },
162
+ { name: "unproven", type: "boolean", description: "only people no record of their own proves: how each stands, the records naming them, open tasks" },
161
163
  { name: "full", type: "boolean", description: "--json: whole records instead of one row each" },
162
164
  ],
163
- examples: ["strom person list", "strom person list visek", "strom person list --research G0001 --generation 3"],
165
+ examples: ["strom person list", "strom person list visek", "strom person list --research G0001 --generation 3", "strom person list --unproven"],
164
166
  run(ctx, { args, opts }) {
165
167
  const tree = ctx.tree();
166
168
  let people = tree.list("person").filter((p) => !p.retracted);
@@ -175,6 +177,8 @@ register({
175
177
  people = people.filter((p) => gens.get(p.id) === Number(opts.generation));
176
178
  people.sort((a, b) => gens.get(a.id) - gens.get(b.id) || a.id.localeCompare(b.id));
177
179
  }
180
+ if (opts.unproven)
181
+ return unprovenList(ctx, tree, people, gens);
178
182
  const page = paginate(people, ctx.limit, ctx.page);
179
183
  const rows = page.items.map((p) => [
180
184
  p.id,
@@ -662,3 +666,56 @@ register({
662
666
  return { text: written(tree), data: { event: e } };
663
667
  },
664
668
  });
669
+ const UNPROVEN_KIND = { named: "named in a record", leads: "leads only", none: "no record" };
670
+ /** The people no record of their own proves: how each stands, the records that name them, the tasks already on them. */
671
+ function unprovenList(ctx, tree, people, gens) {
672
+ const among = new Set(people.map((p) => p.id));
673
+ const found = unprovenPeople(tree).filter((u) => among.has(u.person.id));
674
+ // the living come apart: the family's to tell, no research of the registers
675
+ const all = found.filter((u) => !u.living);
676
+ const living = found.filter((u) => u.living);
677
+ const open = tree.list("task").filter((t) => ["open", "doing", "parked", "waiting"].includes(t.state));
678
+ const tasksOf = (id) => open.filter((t) => t.subject.includes(id)).map((t) => t.id);
679
+ const counts = { named: 0, leads: 0, none: 0 };
680
+ for (const u of all)
681
+ counts[u.kind]++;
682
+ const page = paginate(all, ctx.limit, ctx.page);
683
+ const rows = page.items.map((u) => {
684
+ const tasks = tasksOf(u.person.id);
685
+ return [
686
+ u.person.id,
687
+ displayName(u.person),
688
+ lifespan(u.person),
689
+ gens ? `G${gens.get(u.person.id)}` : "",
690
+ UNPROVEN_KIND[u.kind] + (u.sources.length ? `: ${u.sources.slice(0, 3).join(" ")}${u.sources.length > 3 ? " …" : ""}` : ""),
691
+ u.kind === "none" && !u.person.notes.length ? "no note where from" : "",
692
+ tasks.length ? `tasks ${tasks.slice(0, 2).join(" ")}${tasks.length > 2 ? " …" : ""}` : "",
693
+ ];
694
+ });
695
+ const livingLine = living.length
696
+ ? `living, most likely (born less than ${DEATH_AFTER_YEARS} years ago, no death recorded) — not counted, no tasks: ${living.map((u) => `${u.person.id} ${displayName(u.person)}`).join(" · ")}`
697
+ : undefined;
698
+ const text = !all.length
699
+ ? lines("everyone rests on a record of their own", livingLine)
700
+ : lines(`${all.length} without a record of their own — named in a record: ${counts.named} · leads only: ${counts.leads} · no record: ${counts.none}`, table(rows), moreLine(page, "strom person list --unproven"), livingLine, "", "tasks for them: strom review --unproven (a batch at a time) · strom review P… P… (these) · strom review P… (one)");
701
+ return {
702
+ text,
703
+ data: {
704
+ total: page.total,
705
+ page: page.page,
706
+ pages: page.pages,
707
+ counts,
708
+ living: living.map((u) => u.person.id),
709
+ persons: page.items.map((u) => ({
710
+ id: u.person.id,
711
+ name: displayName(u.person),
712
+ lifespan: lifespan(u.person),
713
+ kind: u.kind,
714
+ sources: u.sources,
715
+ notes: u.person.notes.length,
716
+ tasks: tasksOf(u.person.id),
717
+ ...(gens ? { generation: gens.get(u.person.id) } : {}),
718
+ })),
719
+ },
720
+ };
721
+ }
@@ -4,6 +4,7 @@ import { lines, table } from "../cli/format.js";
4
4
  import { addPerson, addResearch } from "../core/actions.js";
5
5
  import { applyFrontier, researchProposals } from "./session.js";
6
6
  import { frontier } from "../core/frontier.js";
7
+ import { DEATH_AFTER_YEARS, livingBorn, unprovenPeople } from "../core/review.js";
7
8
  import { parkedWhy } from "./tasks.js";
8
9
  import { ui } from "../cli/ui.js";
9
10
  import { phrase } from "../core/phrases.js";
@@ -145,31 +146,57 @@ register({
145
146
  };
146
147
  },
147
148
  });
149
+ /** People without a record in one review at a time. */
150
+ export const UNPROVEN_BATCH = 10;
148
151
  register({
149
152
  path: ["review"],
150
- summary: "Review one person: what the tree already says of them elsewhere, entries to read whole, facts to check — as tasks",
153
+ summary: "Review a person, several, or those no record proves: what the tree says of them elsewhere, entries to read, facts to check — as tasks",
151
154
  group: "research",
152
155
  tree: true,
153
156
  writes: true,
154
- description: "Sends the research straight to one person. strom looks at what is recorded and proposes the work, at\n" +
155
- "most one task of each kind: what records, notes and the diary say of them outside their data; entries whose\n" +
156
- "images are here but were not read whole (transcript, godparents, witnesses, house); facts resting on one\n" +
157
- "reading; conflicts left open; with --reread, a second reading of what only another model read — after the\n" +
158
- "agent or the model changed. Birth, parents and the story come as in any research. Run again later: what a\n" +
159
- "task took up is not proposed again. Nothing is deleted; corrections go through strom with their reasons.",
160
- args: [{ name: "person", description: "whom (ID or name)", required: true }],
157
+ description: "Sends the research straight to one person — or to several named at once (one review for them all), or with\n" +
158
+ "--unproven to the people no record of their own proves (strom person list --unproven), a batch at a time: run\n" +
159
+ "it again for the next batch; who is proved leaves the review. strom looks at what is recorded and proposes\n" +
160
+ "the work, at most one task of each kind for a person: what records, notes and the diary say of them outside\n" +
161
+ "their data; entries whose images are here but were not read whole (transcript, godparents, witnesses,\n" +
162
+ "house); facts resting on one reading; conflicts left open; with --reread, a second reading of what only\n" +
163
+ "another model read — after the agent or the model changed. Birth, parents and the story come as in any\n" +
164
+ "research. Run again later: what a task took up, or a task open elsewhere covers, is not proposed again.\n" +
165
+ "Nothing is deleted; corrections go through strom with their reasons.",
166
+ args: [{ name: "person", description: "whom (ID or name); several: one review for them all; none with --unproven", variadic: true }],
161
167
  options: [
162
- { name: "scope", type: "string", value: "<scope>", description: "person (default), family (with partners and children), line (with the ancestors)" },
168
+ { name: "scope", type: "string", value: "<scope>", description: "one person: person (default), family (with partners and children), line (with the ancestors)" },
169
+ { name: "unproven", type: "boolean", description: "the people no record of their own proves (strom person list --unproven), a batch at a time" },
170
+ { name: "max", type: "string", value: "<n>", description: `with --unproven: at most so many people in the batch (default ${UNPROVEN_BATCH})` },
171
+ { name: "living", type: "boolean", description: "review someone most likely alive (born less than 100 years ago) all the same — the user asked for it" },
163
172
  { name: "reread", type: "boolean", description: "also a second reading of what only another model read (the model you read with now: model.vision)" },
164
173
  { name: "model", type: "string", value: "<model>", description: "with --reread: this model instead of model.vision" },
165
174
  ],
166
- examples: ["strom review P0001", 'strom review "Josef Novák" --scope family', "strom review P0001 --reread", "strom review P0001 --dry-run"],
167
- run(ctx, { args, opts }) {
175
+ examples: [
176
+ "strom review P0001",
177
+ 'strom review "Josef Novák" --scope family',
178
+ "strom review P0001 P0002",
179
+ "strom review --unproven",
180
+ "strom review P0001 --reread",
181
+ "strom review P0001 --dry-run",
182
+ ],
183
+ async run(ctx, { args, opts }) {
168
184
  const tree = ctx.tree();
169
- const p = resolvePerson(tree, args[0]);
185
+ const unproven = !!opts.unproven;
186
+ if (unproven && args.length)
187
+ throw new UsageError("--unproven reviews the people without a record — no person with it", { hint: "strom review --unproven · or strom review <person>" });
188
+ if (!unproven && !args.length)
189
+ throw new UsageError("whom? a person, several, or --unproven", { hint: "strom review P0001 · strom review P0001 P0002 · strom review --unproven" });
190
+ if (!unproven && opts.max !== undefined)
191
+ throw new UsageError("--max goes with --unproven");
192
+ if ((unproven || args.length > 1) && opts.scope !== undefined)
193
+ throw new UsageError("--scope is for one person's review");
170
194
  const scope = opts.scope ?? "person";
171
195
  if (!REVIEW_SCOPES.includes(scope))
172
196
  throw new UsageError(`--scope must be one of ${REVIEW_SCOPES.join(", ")}`);
197
+ const max = int(opts.max, "max") ?? UNPROVEN_BATCH;
198
+ if (max < 1)
199
+ throw new UsageError("--max must be 1 or more");
173
200
  let reread;
174
201
  if (opts.reread) {
175
202
  reread = opts.model ?? ctx.settings.models(ctx.settings.agent(tree.config).value, tree.config).vision;
@@ -178,26 +205,61 @@ register({
178
205
  }
179
206
  else if (opts.model)
180
207
  throw new UsageError("--model goes with --reread");
181
- const review = { scope: scope, ...(reread ? { reread } : {}) };
182
208
  const lang = tree.lang;
183
- // one review research per person: again later, it goes on where it was
184
- const existing = tree.list("research").find((r) => r.direction === "person" && r.focus === p.id && r.review);
209
+ const chosen = [...new Map(args.map((a) => resolvePerson(tree, a)).map((x) => [x.id, x])).values()];
210
+ // the living are the family's to tell: reviewed only when the person says so
211
+ const living = chosen.map((x) => ({ x, born: livingBorn(tree, x) })).filter((l) => l.born !== undefined);
212
+ if (living.length && !opts.living && !ctx.yes && !tree.dryRun) {
213
+ const who = living.map((l) => `${label(l.x)}`).join(", ");
214
+ if (ctx.interactive && !isAgent(ctx.env)) {
215
+ if (!(await ctx.confirm(ui(lang, "ui.review.living", { who, years: DEATH_AFTER_YEARS }), false)))
216
+ return { text: ui(lang, "ui.review.living.no"), data: { research: null, created: [], planned: [], open: [], living: living.map((l) => l.x.id) } };
217
+ }
218
+ else
219
+ throw new UsageError(`most likely alive (born less than ${DEATH_AFTER_YEARS} years ago, no death recorded): ${who}`, {
220
+ hint: `ask the user whether to review them all the same; if yes: strom review ${args.join(" ")} --living`,
221
+ });
222
+ }
223
+ const p = chosen.length === 1 ? chosen[0] : undefined;
224
+ const several = chosen.length > 1;
225
+ const sameSet = (ids) => !!ids && ids.length === chosen.length && chosen.every((x) => ids.includes(x.id));
226
+ // one review research per person, per set of people, one of the people without a record: again later, it goes on where it was
227
+ const existing = tree
228
+ .list("research")
229
+ .find((r) => r.direction === "person" &&
230
+ r.review &&
231
+ (unproven ? !!r.review.unproven : several ? !r.review.unproven && sameSet(r.review.people) : !r.review.people && r.focus === p.id));
232
+ let review = { scope: scope, ...(reread ? { reread } : {}) };
233
+ let left = 0;
234
+ if (unproven) {
235
+ const batch = unprovenBatch(tree, existing, max, review);
236
+ if (!batch.people.length)
237
+ return { text: ui(lang, "ui.review.unproven.none"), data: { research: existing ?? null, created: [], planned: [], open: [] } };
238
+ review = { ...review, people: batch.people, unproven: true };
239
+ left = batch.left;
240
+ }
241
+ else if (several)
242
+ review = { ...review, people: chosen.map((x) => x.id) };
243
+ const names = chosen.map((x) => displayName(x));
244
+ const name = unproven ? phrase(lang, "review.unproven.research") : names.length > 3 ? `${names.slice(0, 3).join(", ")} +${names.length - 3}` : names.join(", ");
245
+ const focus = p?.id ?? review.people[0];
185
246
  // What it would propose, asked before anything is written: with nothing to do, no research is made for it.
186
247
  const draft = existing
187
- ? { ...existing, state: "active", review }
188
- : { id: "G?", type: "research", name: phrase(lang, "review.research", { name: displayName(p) }), focus: p.id, direction: "person", state: "active", review, notes: [] };
248
+ ? { ...existing, focus, state: "active", review }
249
+ : { id: "G?", type: "research", name: unproven ? name : phrase(lang, "review.research", { name }), focus, direction: "person", state: "active", review, notes: [] };
189
250
  const planned = researchProposals(tree, draft).map((x) => x.proposal);
190
251
  let research = draft;
191
252
  let tasks = [];
253
+ const what = unproven ? `${review.people.length} without a record of their own` : several ? review.people.join(" ") : scope;
192
254
  if (planned.length && !tree.dryRun) {
193
255
  if (existing)
194
- research = update(tree, existing.id, "research", (r) => ({ ...r, state: "active", review }), {
256
+ research = update(tree, existing.id, "research", (r) => ({ ...r, focus, state: "active", review }), {
195
257
  op: "research.edit",
196
- summary: `${existing.id} review again: ${scope}${reread ? `, second reading with ${reread}` : ""}`,
258
+ summary: `${existing.id} review again: ${what}${reread ? `, second reading with ${reread}` : ""}`,
197
259
  });
198
260
  else {
199
- const r = addResearch(tree, { name: draft.name, focus: p.id, direction: "person" });
200
- research = update(tree, r.id, "research", (x) => ({ ...x, review }), { op: "research.edit", summary: `${r.id} review: ${scope}${reread ? `, second reading with ${reread}` : ""}` });
261
+ const r = addResearch(tree, { name: draft.name, focus, direction: "person" });
262
+ research = update(tree, r.id, "research", (x) => ({ ...x, review }), { op: "research.edit", summary: `${r.id} review: ${what}${reread ? `, second reading with ${reread}` : ""}` });
201
263
  }
202
264
  tasks = applyFrontier(tree, research)
203
265
  .map((id) => tree.get(id))
@@ -219,12 +281,39 @@ register({
219
281
  // Nothing new: why — the work that covers them already (a parked task says how to go on), a search done in vain.
220
282
  const why = list.length ? [] : coveredLines(tree, draft, lang, human);
221
283
  const inReview = existing ?? (planned.length && !tree.dryRun ? research : undefined);
222
- const text = lines(...(human ? [] : tree.written.map((o) => o.summary)), tree.dryRun ? ui(lang, "ui.review.dry") : undefined, (tree.written.length && !human) || tree.dryRun ? "" : undefined, list.length ? ui(lang, "ui.review.new", { name: label(p), n: list.length }) : ui(lang, "ui.review.nothing", { name: label(p) }), ...list, ...why, open.length || tasks.length
284
+ const text = lines(...(human ? [] : tree.written.map((o) => o.summary)), tree.dryRun ? ui(lang, "ui.review.dry") : undefined, (tree.written.length && !human) || tree.dryRun ? "" : undefined, list.length ? ui(lang, "ui.review.new", { name: p ? label(p) : name, n: list.length }) : ui(lang, "ui.review.nothing", { name: p ? label(p) : name }), ...list, ...why, open.length || tasks.length
223
285
  ? ui(lang, "ui.review.open", { n: open.length || tasks.length, research: inReview.id }) + (avg !== undefined ? ui(lang, "ui.review.cost", { cost: avg.toFixed(2) }) : "")
224
- : undefined, (open.length || tasks.length) && inReview ? ui(lang, human ? "ui.review.next.human" : "ui.review.next", { research: inReview.id }) : undefined);
225
- return { text, data: { research: inReview ?? null, created: tasks, planned, open: open.map((t) => t.id), ...(why.length ? { why } : {}), ...(avg !== undefined ? { avgCostUsd: avg } : {}) } };
286
+ : undefined, (open.length || tasks.length) && inReview ? ui(lang, human ? "ui.review.next.human" : "ui.review.next", { research: inReview.id }) : undefined, left ? ui(lang, human ? "ui.review.unproven.left.human" : "ui.review.unproven.left", { n: left }) : undefined);
287
+ return {
288
+ text,
289
+ data: { research: inReview ?? null, created: tasks, planned, open: open.map((t) => t.id), ...(unproven ? { people: review.people, left } : {}), ...(why.length ? { why } : {}), ...(avg !== undefined ? { avgCostUsd: avg } : {}) },
290
+ };
226
291
  },
227
292
  });
293
+ const OPEN_TASK = new Set(["open", "doing", "parked", "waiting"]);
294
+ /**
295
+ * The next batch of people without a record of their own: those whose tasks of the review are still open stay;
296
+ * then the ones strom has work for — a record naming them first (the quickest to prove), then leads, then
297
+ * nothing — those never in a batch before those tried already. How many more wait besides.
298
+ */
299
+ function unprovenBatch(tree, existing, max, review) {
300
+ // the living are the family's to tell: no research of the registers for them
301
+ const all = unprovenPeople(tree).filter((u) => !u.living);
302
+ const tasks = existing ? tree.list("task").filter((t) => t.research === existing.id) : [];
303
+ const busy = (id) => tasks.some((t) => t.subject.includes(id) && OPEN_TASK.has(t.state));
304
+ const keep = all.filter((u) => busy(u.person.id)).map((u) => u.person.id);
305
+ const before = new Set(existing?.review?.people ?? []);
306
+ const order = { named: 0, leads: 1, none: 2 };
307
+ const rest = all
308
+ .filter((u) => !keep.includes(u.person.id))
309
+ .sort((a, b) => Number(before.has(a.person.id)) - Number(before.has(b.person.id)) || order[a.kind] - order[b.kind] || a.person.id.localeCompare(b.person.id));
310
+ // only those strom has work for: what it would propose for all of them, asked once
311
+ const draft = { ...(existing ?? { id: "G?", type: "research", name: "", direction: "person", state: "active", notes: [] }), review: { ...review, people: rest.map((u) => u.person.id), unproven: true } };
312
+ const planned = researchProposals(tree, { ...draft, focus: rest[0]?.person.id ?? "" }).map((x) => x.proposal);
313
+ const workable = rest.filter((u) => planned.some((x) => x.subject.includes(u.person.id))).map((u) => u.person.id);
314
+ const people = [...keep, ...workable].slice(0, Math.max(max, keep.length));
315
+ return { people, left: all.length - people.length };
316
+ }
228
317
  /** Why a review found nothing new, person by person: the work that covers them already, or a search done in vain. */
229
318
  function coveredLines(tree, research, lang, human = false) {
230
319
  const out = [];