strom-research 1.13.1 → 1.13.2-beta.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.
@@ -345,6 +345,8 @@
345
345
  "sync.verify.done": "každý záznam dohledaný a přečtený nezávisle (snímek zaregistrovaný, strom read M… --blind): čtení se shodují → poznámka u pramene a údaje zůstávají; liší se → rozpor s oběma čteními",
346
346
  "sync.reading.note": "Uživatel přečetl tento záznam v aplikaci Strom jinak ({source}); čtení výzkumu platí, dokud se záznam nepřečte znovu:",
347
347
  "sync.reading.page": "strana",
348
+ "conflict.app.decided": "rozhodnutí uživatele v aplikaci Strom",
349
+ "conflict.app.noted": "{decided}: {note}",
348
350
  "sync.reading.text": "přepis",
349
351
  "archive.held": "odloženo: výzkum je archiv (data přicházejí z aplikace Strom); do fronty se vrátí, až se zapne práce s agentem",
350
352
  "archive.removed": "odebráno v aplikaci Strom",
@@ -1066,6 +1068,9 @@
1066
1068
  "ui.link.conflict.decided": "Tento rozpor už je rozhodnutý: {resolution}",
1067
1069
  "ui.link.what.conflict.decide": "rozhodnout rozpor: {title}",
1068
1070
  "ui.link.what.conflict.agent": "nechat rozpor na agentovi ({agent}): {title}",
1071
+ "ui.link.what.conflict.user": "rozhodnout rozpor: {title} — ponechat hodnotu z aplikace: {value}",
1072
+ "ui.link.what.conflict.research": "rozhodnout rozpor: {title} — převzít hodnotu výzkumu: {value}",
1073
+ "ui.link.conflict.empty": "(prázdné)",
1069
1074
  "ui.link.conflict.pick": "Co platí?",
1070
1075
  "ui.link.conflict.other": "Něco jiného (napíšu sám)",
1071
1076
  "ui.link.conflict.own": "Co platí?",
@@ -1264,6 +1269,8 @@
1264
1269
  "log.lesson.add": "Poznatek: {name}",
1265
1270
  "log.hypothesis.add": "Hypotéza: {name}",
1266
1271
  "log.hypothesis.argue": "Hypotéza zvážena: {name}",
1272
+ "log.hypothesis.link": "Co by verze hypotézy spojila: {name}",
1273
+ "log.hypothesis.variant": "Další možnost hypotézy: {name}",
1267
1274
  "log.hypothesis.decide": "Hypotéza rozhodnuta: {name}",
1268
1275
  "log.conflict.add": "Záznamy si odporují: {name}",
1269
1276
  "log.conflict.resolve": "Rozpor vyřešen: {name}",
@@ -345,6 +345,8 @@
345
345
  "sync.verify.done": "jeder Eintrag gefunden und unabhängig gelesen (Bild registriert, strom read M… --blind): die Lesungen stimmen überein → Notiz an der Quelle, die Angaben bleiben; sie weichen ab → ein Widerspruch mit beiden Lesungen",
346
346
  "sync.reading.note": "Der Benutzer hat diesen Eintrag in der Strom-App anders gelesen ({source}); die Lesung der Forschung gilt, bis er erneut gelesen ist:",
347
347
  "sync.reading.page": "Seite",
348
+ "conflict.app.decided": "Entscheidung des Nutzers in der Strom-App",
349
+ "conflict.app.noted": "{decided}: {note}",
348
350
  "sync.reading.text": "Abschrift",
349
351
  "archive.held": "zurückgestellt: die Forschung ist ein Archiv (die Daten kommen aus der Strom-App); zurück in der Warteschlange, wenn die Arbeit mit einem Agenten eingeschaltet wird",
350
352
  "archive.removed": "in der Strom-App entfernt",
@@ -1066,6 +1068,9 @@
1066
1068
  "ui.link.conflict.decided": "Dieser Widerspruch ist bereits entschieden: {resolution}",
1067
1069
  "ui.link.what.conflict.decide": "den Widerspruch entscheiden: {title}",
1068
1070
  "ui.link.what.conflict.agent": "den Widerspruch dem Agenten überlassen ({agent}): {title}",
1071
+ "ui.link.what.conflict.user": "den Widerspruch entscheiden: {title} — den Wert aus der App behalten: {value}",
1072
+ "ui.link.what.conflict.research": "den Widerspruch entscheiden: {title} — den Wert der Forschung übernehmen: {value}",
1073
+ "ui.link.conflict.empty": "(leer)",
1069
1074
  "ui.link.conflict.pick": "Was stimmt?",
1070
1075
  "ui.link.conflict.other": "Etwas anderes (ich schreibe es)",
1071
1076
  "ui.link.conflict.own": "Was stimmt?",
@@ -1264,6 +1269,8 @@
1264
1269
  "log.lesson.add": "Gelernt: {name}",
1265
1270
  "log.hypothesis.add": "Eine Hypothese: {name}",
1266
1271
  "log.hypothesis.argue": "Hypothese abgewogen: {name}",
1272
+ "log.hypothesis.link": "Was eine Variante der Hypothese verbinden würde: {name}",
1273
+ "log.hypothesis.variant": "Eine weitere Möglichkeit der Hypothese: {name}",
1267
1274
  "log.hypothesis.decide": "Hypothese entschieden: {name}",
1268
1275
  "log.conflict.add": "Die Einträge widersprechen sich: {name}",
1269
1276
  "log.conflict.resolve": "Widerspruch geklärt: {name}",
@@ -48,6 +48,19 @@ conclusion so the next researcher can follow it.
48
48
  queue holds back every other task about people off the tree
49
49
  (`strom task list --off-tree`); they come back by themselves once the link
50
50
  is recorded.
51
+ - **Say what a hypothesis would connect.** A hypothesis about a connection —
52
+ whose child, the same person, a couple, siblings — gets each variant's links
53
+ when you record or argue it: `strom hypothesis link H… B --child P… --of F…`
54
+ (or `--parents P… [P…]`), `--same P… P…`, `--partners P… P…`,
55
+ `--siblings P… P…`. The Strom app shows them for a look and where the tree
56
+ ends; nothing is linked until the hypothesis is decided and the link
57
+ recorded. A variant whose people are not in the research has none: its
58
+ words only. An older open hypothesis you work on that has none gets them
59
+ then (`strom hypothesis list --unlinked`). A variant's letter stays its
60
+ claim's: a claim that would connect other people is a new variant
61
+ (`strom hypothesis variant H… "<claim>"`). Deciding one whose variants have
62
+ links names the variant it is decided for:
63
+ `strom hypothesis decide H… --variant B --decision "…"`.
51
64
  - **Stay within the task.** New questions become new tasks (`strom task add`,
52
65
  with where and done-when), not detours. A new task whose images are not here
53
66
  gets them now: through the archive's connector (built first when it has
@@ -67,7 +67,10 @@ without --dry-run. `#name` labels what a line creates, `@name` uses it later.
67
67
  person with a conflict only once something else proves it. What would decide
68
68
  it, if not at hand, is a task about it: `strom task add … --about X…` (or H…).
69
69
  `strom conflict resolve X… --resolution "…" --reasoning "…"` when the
70
- evidence decides it. A decided
70
+ evidence decides it. What the user decided (in the Strom app, or from its
71
+ link) stands: an agent decides it again — or opens a conflict of that fact
72
+ again — only on a source the decision did not weigh (`--source S…`) and with
73
+ `--reason "…"`; the same record read again changes nothing. A decided
71
74
  hypothesis that a new record overturns is decided again (the earlier
72
75
  decision is kept): `strom hypothesis decide H… --decision "…" --reason "…"`.
73
76
  People named wrongly by the weaker record keep that name as a variant
@@ -28,6 +28,7 @@ import { isValidLang } from "../core/lang.js";
28
28
  import { agentsHere, suggestedWay, waysHere } from "../core/apps.js";
29
29
  import { chooseWay } from "./ways.js";
30
30
  import { claimText } from "../core/people.js";
31
+ import { sideOf, sideText, sidesOf } from "../core/conflicts.js";
31
32
  import { PROFILES } from "../agents/profiles.js";
32
33
  import { foldText, safeFolderName } from "../core/text.js";
33
34
  import { StromError } from "../core/errors.js";
@@ -126,8 +127,11 @@ export function sessionsView(ctx, lang, root) {
126
127
  if (all.length > shown.length)
127
128
  out(ctx, ` ${t("ui.link.sessions.more", { n: all.length - shown.length })}`);
128
129
  }
129
- /** A conflict the person decides: which claim holds (or their own words), and why. */
130
- export async function decideConflict(ctx, run, lang, root, id) {
130
+ /**
131
+ * A conflict the person decides: which claim holds (or their own words), and why. The side picked in the Strom app
132
+ * already (take): only confirmed, and why — that side taken (a conflict not decided by side: the claims offered).
133
+ */
134
+ export async function decideConflict(ctx, run, lang, root, id, take) {
131
135
  const t = translator(lang);
132
136
  const tree = Tree.open(root, ctx.env);
133
137
  const c = tree.get(id);
@@ -135,6 +139,22 @@ export async function decideConflict(ctx, run, lang, root, id) {
135
139
  return stop(ctx, t("ui.link.conflict.none", { id }));
136
140
  if (c.state === "resolved")
137
141
  return stop(ctx, t("ui.link.conflict.decided", { resolution: c.resolution ?? "" }));
142
+ const sides = take ? sidesOf(c) : undefined;
143
+ if (take && sides) {
144
+ const value = sideText(tree, c, sides[take]) || t("ui.link.conflict.empty");
145
+ if (!(await asks(ctx, lang, root, t(take === "user" ? "ui.link.what.conflict.user" : "ui.link.what.conflict.research", { title: c.title, value }))))
146
+ return false;
147
+ let why = "";
148
+ while (!why) {
149
+ why = (await ctx.ask(t("ui.link.conflict.why"))).trim();
150
+ if (why === "0" || outOfAnswers(ctx))
151
+ return false;
152
+ }
153
+ if ((await run(["conflict", "resolve", id, `--take=${take}`, `--reasoning=${t("ui.link.conflict.by", { why })}`], true)) !== 0)
154
+ return false;
155
+ out(ctx, t("ui.link.conflict.done"));
156
+ return true;
157
+ }
138
158
  if (!(await asks(ctx, lang, root, t("ui.link.what.conflict.decide", { title: c.title }))))
139
159
  return false;
140
160
  const claim = (x) => `${claimText(tree, c, x)}${x.source ? ` — ${tree.get(x.source)?.title ?? x.source}` : ""}`;
@@ -153,7 +173,10 @@ export async function decideConflict(ctx, run, lang, root, id) {
153
173
  if (why === "0" || outOfAnswers(ctx))
154
174
  return false;
155
175
  }
156
- if ((await run(["conflict", "resolve", id, `--resolution=${resolution}`, `--reasoning=${t("ui.link.conflict.by", { why })}`], true)) !== 0)
176
+ // a side of a conflict of the user's edit picked: that side taken — the user's value written into the fact (found: the
177
+ // value with its source named neither side, the conflict closed and nothing written)
178
+ const side = i < c.claims.length ? sideOf(c, c.claims[i]) : undefined;
179
+ if ((await run(["conflict", "resolve", id, `--resolution=${resolution}`, ...(side ? [`--take=${side}`] : []), `--reasoning=${t("ui.link.conflict.by", { why })}`], true)) !== 0)
157
180
  return false;
158
181
  out(ctx, t("ui.link.conflict.done"));
159
182
  return true;
package/dist/cli/ui.js CHANGED
@@ -982,6 +982,9 @@ export const UI = {
982
982
  "ui.link.conflict.decided": "This conflict is decided already: {resolution}",
983
983
  "ui.link.what.conflict.decide": "decide the conflict: {title}",
984
984
  "ui.link.what.conflict.agent": "leave the conflict to the agent ({agent}): {title}",
985
+ "ui.link.what.conflict.user": "decide the conflict: {title} — keep the value from the app: {value}",
986
+ "ui.link.what.conflict.research": "decide the conflict: {title} — take the research's value: {value}",
987
+ "ui.link.conflict.empty": "(empty)",
985
988
  "ui.link.conflict.pick": "Which is right?",
986
989
  "ui.link.conflict.other": "Something else (I write it)",
987
990
  "ui.link.conflict.own": "What is right?",
@@ -3,7 +3,7 @@ import { register } from "../cli/registry.js";
3
3
  import { ui } from "../cli/ui.js";
4
4
  import { lines, moreLine, paginate, table, truncate } from "../cli/format.js";
5
5
  import { UsageError } from "../core/errors.js";
6
- import { LESSON_MAX, LESSON_SCOPES, RECORD_TYPES, SEARCH_METHODS, SEARCH_RESULTS, } from "../core/model.js";
6
+ import { LESSON_MAX, LESSON_SCOPES, RECORD_TYPES, SEARCH_METHODS, SEARCH_RESULTS, VARIANT_LINK_KINDS, } from "../core/model.js";
7
7
  import { create, csvOpt, listOpt, normId, requireRecord, update } from "../core/records.js";
8
8
  import { ancestorGenerations, birthEvent, claimText, conflictTitle, deathEvent, displayName, familiesAsPartner, lifespan, parentsOf, primaryName, resolvePerson } from "../core/people.js";
9
9
  import { changeLines } from "../core/changelog.js";
@@ -11,6 +11,7 @@ import { isAgent } from "../core/which.js";
11
11
  import { foldText } from "../core/text.js";
12
12
  import { makeNote } from "../core/actions.js";
13
13
  import { takeSide } from "../core/sync.js";
14
+ import { againAllowed, claimOf, isEditConflict, weighedSources } from "../core/conflicts.js";
14
15
  import { currentSession } from "../core/session.js";
15
16
  import { typeOfId } from "../core/tree.js";
16
17
  import { resolveResearch } from "./research.js";
@@ -81,6 +82,138 @@ function stillOpen(tree, id) {
81
82
  const open = tasksAbout(tree, id).filter((t) => !["done", "dropped"].includes(t.state));
82
83
  return open.length ? ui(tree.lang, "ui.conflict.tasks-open", { id, tasks: open.map((t) => t.id).join(", "), first: open[0].id }) : undefined;
83
84
  }
85
+ // ── what a variant would connect (strom hypothesis link) ───────────────────
86
+ /** The next letter of a hypothesis's variants: after the last one used (A, B → C), never one before it. */
87
+ function nextLabel(labels) {
88
+ const letters = labels.filter((l) => /^[A-Z]$/.test(l)).map((l) => l.charCodeAt(0));
89
+ const next = letters.length ? Math.max(...letters) + 1 : 65;
90
+ if (next <= 90)
91
+ return String.fromCharCode(next);
92
+ for (let n = labels.length + 1;; n++)
93
+ if (!labels.includes(String(n)))
94
+ return String(n);
95
+ }
96
+ /** IDs given to a link: repeated, or several in one value (commas or spaces). */
97
+ function idsOpt(v) {
98
+ return listOpt(v).flatMap((x) => x.split(/[\s,]+/)).filter(Boolean);
99
+ }
100
+ /** A person a link may name: there, not retracted, not merged. */
101
+ function linkPerson(tree, ref) {
102
+ const id = normId(ref, "person");
103
+ if (typeOfId(id) !== "person")
104
+ throw new UsageError(`"${ref}" is not a person ID: a link names people by ID (P0001)`, { hint: "strom person list" });
105
+ const p = tree.get(id);
106
+ if (!p)
107
+ throw new UsageError(`no person ${id}`, { hint: "strom person list", code: "record.none", params: { kind: "person", id } });
108
+ if (p.mergedInto)
109
+ throw new UsageError(`${id} was merged into ${p.mergedInto}`, { hint: `use ${p.mergedInto}`, code: "record.merged", params: { id, into: p.mergedInto } });
110
+ if (p.retracted)
111
+ throw new UsageError(`${id} is retracted`, { hint: `strom person show ${id}` });
112
+ return id;
113
+ }
114
+ function distinct(ids, what) {
115
+ const set = [...new Set(ids)];
116
+ if (set.length !== ids.length)
117
+ throw new UsageError(`${what}: the same person twice (${ids.join(" ")})`);
118
+ return set;
119
+ }
120
+ /** The family whose partners are exactly these people. */
121
+ function familyOf(tree, partners) {
122
+ return tree.list("family").find((f) => !f.retracted && f.partners.length === partners.length && partners.every((p) => f.partners.includes(p)));
123
+ }
124
+ /** The link the options name (undefined: none — --remove alone), checked against the tree. */
125
+ function variantLink(tree, opts, extra) {
126
+ const more = extra.flatMap((x) => x.split(/[\s,]+/)).filter(Boolean);
127
+ const kinds = VARIANT_LINK_KINDS.filter((k) => opts[k] !== undefined);
128
+ const how = "--child P… --of F… | --child P… --parents P… [P…] | --same P… P… | --partners P… P… | --siblings P… P… [P…]";
129
+ if (kinds.length > 1)
130
+ throw new UsageError(`one link at a time: ${kinds.map((k) => `--${k}`).join(", ")} given`, { hint: how });
131
+ const kind = kinds[0];
132
+ if (!kind) {
133
+ if (!opts.remove)
134
+ throw new UsageError("say what the variant would connect", { hint: how });
135
+ if (more.length || opts.of !== undefined || opts.parents !== undefined)
136
+ throw new UsageError("--of and --parents go with --child", { hint: how });
137
+ return undefined;
138
+ }
139
+ if (kind === "child") {
140
+ const children = idsOpt(opts.child);
141
+ if (children.length !== 1)
142
+ throw new UsageError("--child takes one person: the child", { hint: how });
143
+ const child = linkPerson(tree, children[0]);
144
+ const parents = [...idsOpt(opts.parents), ...more];
145
+ if (opts.of !== undefined && parents.length)
146
+ throw new UsageError("a family (--of) or parents (--parents), not both", { hint: how });
147
+ if (opts.of !== undefined) {
148
+ const famId = normId(String(opts.of), "family");
149
+ if (typeOfId(famId) !== "family")
150
+ throw new UsageError(`"${String(opts.of)}" is not a family ID (F0001)`, { hint: "strom family list" });
151
+ const f = tree.get(famId);
152
+ if (!f)
153
+ throw new UsageError(`no family ${famId}`, { hint: "strom family list", code: "record.none", params: { kind: "family", id: famId } });
154
+ if (f.mergedInto)
155
+ throw new UsageError(`${famId} was merged into ${f.mergedInto}`, { hint: `use ${f.mergedInto}`, code: "record.merged", params: { id: famId, into: f.mergedInto } });
156
+ if (f.retracted)
157
+ throw new UsageError(`${famId} is retracted`);
158
+ if (f.children.some((c) => c.person === child))
159
+ throw new UsageError(`${child} is a child of ${famId} already: nothing uncertain to show`, { hint: "decide the hypothesis: strom hypothesis decide H… --decision \"…\"" });
160
+ if (f.partners.includes(child))
161
+ throw new UsageError(`${child} is a partner of ${famId}`);
162
+ return { kind, person: child, family: famId };
163
+ }
164
+ if (!parents.length)
165
+ throw new UsageError("whose child: --of F… (a family of the tree) or --parents P… [P…]", { hint: how });
166
+ if (parents.length > 2)
167
+ throw new UsageError("one or two parents", { hint: how });
168
+ const ps = distinct(parents.map((p) => linkPerson(tree, p)), "--parents");
169
+ if (ps.includes(child))
170
+ throw new UsageError(`${child} cannot be their own parent`);
171
+ const fam = familyOf(tree, ps);
172
+ if (fam?.children.some((c) => c.person === child))
173
+ throw new UsageError(`${child} is a child of ${fam.id} already: nothing uncertain to show`);
174
+ if (fam)
175
+ throw new UsageError(`${ps.join(" and ")} are a family of the tree: ${fam.id}`, { hint: `strom hypothesis link H… <variant> --child ${child} --of ${fam.id}` });
176
+ return { kind, person: child, parents: ps };
177
+ }
178
+ if (opts.of !== undefined || opts.parents !== undefined)
179
+ throw new UsageError("--of and --parents go with --child", { hint: how });
180
+ const persons = distinct([...idsOpt(opts[kind]), ...more].map((p) => linkPerson(tree, p)), `--${kind}`);
181
+ if (kind === "siblings" ? persons.length < 2 : persons.length !== 2)
182
+ throw new UsageError(kind === "siblings" ? "--siblings takes two or more people" : `--${kind} takes two people`, { hint: `--${kind} P… P…${kind === "siblings" ? " [P…]" : ""}` });
183
+ const together = kind === "partners" ? familyOf(tree, persons) : undefined;
184
+ if (together)
185
+ throw new UsageError(`${persons.join(" and ")} are partners of ${together.id} already: nothing uncertain to show`);
186
+ return { kind, persons };
187
+ }
188
+ /** One link as a key: the same link given again is the same, whatever the order of its people. */
189
+ function linkKey(l) {
190
+ return l.kind === "child" ? `child ${l.person} ${l.family ?? [...(l.parents ?? [])].sort().join("+")}` : `${l.kind} ${[...l.persons].sort().join("+")}`;
191
+ }
192
+ /** One link in a few words: "child P0006 of F0001", "same P0005 = P0003". */
193
+ export function linkText(l) {
194
+ if (l.kind === "child")
195
+ return `child ${l.person} of ${l.family ?? (l.parents ?? []).join(" + ")}`;
196
+ return `${l.kind} ${l.persons.join(l.kind === "same" ? " = " : l.kind === "partners" ? " + " : ", ")}`;
197
+ }
198
+ /**
199
+ * What a new link would say that the variant did not (its letter is one claim's: the Strom app remembers "H0022,
200
+ * variant B"): links again after all were taken off, naming anyone they did not; a child it named, given other
201
+ * parents. Adding to what it says is fine.
202
+ */
203
+ function otherPeople(v, l) {
204
+ const before = new Set(v.linked ?? []);
205
+ if (!before.size)
206
+ return [];
207
+ const ids = linkIds(l);
208
+ if (!v.links?.length)
209
+ return ids.filter((x) => !before.has(x));
210
+ if (l.kind === "child" && before.has(l.person))
211
+ return ids.filter((x) => x !== l.person && !before.has(x));
212
+ return [];
213
+ }
214
+ function linkIds(l) {
215
+ return l.kind === "child" ? [l.person, ...(l.family ? [l.family] : []), ...(l.parents ?? [])] : l.persons;
216
+ }
84
217
  // ── searches ───────────────────────────────────────────────────────────────
85
218
  function yearsRange(y) {
86
219
  const m = y ? /^(\d{3,4})(?:-(\d{3,4}))?$/.exec(y) : null;
@@ -320,6 +453,21 @@ register({
320
453
  const fact = typeof opts.fact === "string" ? opts.fact.trim().toUpperCase() : undefined;
321
454
  if (fact !== undefined && !/^(?:[A-Z]{3,4}|_[A-Z]{2,6})$/.test(fact))
322
455
  throw new UsageError(`--fact is a GEDCOM tag (BIRT, DEAT, NAME, SEX…), not "${opts.fact}"`);
456
+ // the user decided this fact (in the Strom app, at a terminal): an agent opens it again only on a source that
457
+ // decision did not weigh, with a reason — never because it reads the same record again
458
+ if (fact && tree.actor !== "user") {
459
+ const reason = typeof opts.reason === "string" && opts.reason.trim() ? opts.reason.trim() : undefined;
460
+ for (const d of tree.list("conflict").filter((c) => c.state === "resolved" && c.decidedBy === "user" && c.fact === fact && c.subject.some((s) => subject.includes(s)))) {
461
+ const weighed = weighedSources(tree, d);
462
+ const fresh = claims.map((c) => c.source).filter((s) => !!s && !weighed.has(s));
463
+ if (!fresh.length || !reason)
464
+ throw new UsageError(`the user decided ${fact} of ${d.subject.join(", ")} in ${d.id}: "${truncate(d.resolution ?? "", 80)}" — an agent opens it again only on a source that decision did not weigh, with --reason`, {
465
+ hint: `strom conflict show ${d.id}; a new record: strom conflict add … --claim "S…: …" --reason "<what it shows>" — or ask the user`,
466
+ code: "conflict.user-decided",
467
+ params: { id: d.id, resolution: d.resolution ?? "" },
468
+ });
469
+ }
470
+ }
323
471
  const x = create(tree, "conflict", { title: args[0].trim(), ...(fact ? { fact } : {}), subject, claims, state: "open", note: opts.note }, (id) => `+${id} conflict "${truncate(args[0], 60)}"`, subject);
324
472
  return { text: written(tree), data: { conflict: x } };
325
473
  },
@@ -329,11 +477,15 @@ register({
329
477
  group: "analysis",
330
478
  tree: true,
331
479
  writes: true,
480
+ description: "A decided conflict is decided again only with --reason (the earlier decision is kept in a note). One the user decided " +
481
+ "(in the Strom app, or at a terminal) stands: an agent decides it again only on a source the decision did not weigh " +
482
+ "(--source S…) and with --reason; a side once taken is not taken again — change the facts with the fact commands.",
332
483
  args: [{ name: "conflict", description: "conflict ID (X0001)", required: true }],
333
484
  options: [
334
485
  { name: "resolution", type: "string", value: "<text>", description: "the conclusion (with --take: the value taken, by default)" },
335
486
  { name: "reasoning", type: "string", value: "<text>", description: "why — which evidence outweighs which" },
336
487
  { name: "take", type: "string", value: "<user|research>", description: "a conflict of the user's edit in the Strom app: whose value the fact keeps — the user's is written into it" },
488
+ { name: "source", type: "string", multiple: true, value: "<S…>", description: "deciding again what the user decided: the new source it rests on (one none of its claims names)" },
337
489
  ],
338
490
  run(ctx, { args, opts }) {
339
491
  const tree = ctx.tree();
@@ -343,14 +495,16 @@ register({
343
495
  if (take !== undefined && take !== "user" && take !== "research")
344
496
  throw new UsageError(`--take is user or research, not "${opts.take}"`, { code: "conflict.take", params: { take: String(opts.take) } });
345
497
  // a conflict of the user's edit in the Strom app: a fact's, a child's parents, a name's or a sex's
346
- const ofEdit = Boolean(c0.edit || c0.parents || (["NAME", "SEX", "NPFX", "NSFX"].includes(c0.fact ?? "") && c0.claims.some((c) => c.note === "the user's edit")));
498
+ const ofEdit = isEditConflict(c0);
347
499
  if (take && !ofEdit)
348
500
  throw new UsageError(`${id} is no conflict of an edit in the Strom app: write what you conclude with the commands that change facts, then resolve it with --resolution`, { hint: `strom conflict show ${id}`, code: "conflict.no-edit", params: { id } });
349
501
  // the conclusion naming one side's value exactly: that side taken (found on Mac: "tesař" resolved, the fact left
350
502
  // "kovář", the app then showing kovář in silence)
351
- const user = c0.claims.find((c) => c.note === "the user's edit");
352
- const research = c0.claims.find((c) => c.note?.startsWith("the research"));
353
- const said = opts.resolution ? foldText(String(opts.resolution)).trim() : undefined;
503
+ const user = claimOf(c0, "user");
504
+ const research = claimOf(c0, "research");
505
+ // …with the source it rests on after it, as the decide link of the Strom app writes it: "4 MAR 1885 (S0004)"
506
+ // (found: the user's value picked there closed the conflict and wrote nothing)
507
+ const said = opts.resolution ? foldText(String(opts.resolution).replace(/\s*\((?:[Ss]\d+(?:\s*,\s*[Ss]\d+)*)\)\s*$/u, "")).trim() : undefined;
354
508
  // (as strom compares it, or in words as the person reads it)
355
509
  const names = (c) => (c ? [c.value, claimText(tree, c0, c)].map((v) => foldText(v).trim()) : []);
356
510
  const side = take ?? (ofEdit && said ? (names(user).includes(said) ? "user" : names(research).includes(said) ? "research" : undefined) : undefined);
@@ -363,13 +517,31 @@ register({
363
517
  throw new UsageError("--reasoning is required", { hint: `strom conflict resolve ${id} … --reasoning "<why>"`, code: "conflict.needs-reasoning", params: { id } });
364
518
  if (!resolution)
365
519
  throw new UsageError("--resolution is required", { ...(ofEdit ? { hint: `or take a side: strom conflict resolve ${id} --take user|research` } : {}), code: "conflict.needs-resolution", params: { id } });
520
+ const reason = typeof opts.reason === "string" && opts.reason.trim() ? opts.reason.trim() : undefined;
521
+ const sources = listOpt(opts.source).map((s) => requireRecord(tree, s, "source").id);
522
+ // who decides: the user (in the Strom app through the bridge, or at a terminal), else an agent
523
+ const by = tree.actor === "user" ? "user" : "agent";
524
+ const inApp = ctx.env.STROM_FOR_APP === "1";
366
525
  let did;
526
+ let again;
367
527
  const x = tree.withTreeLock(() => {
528
+ // read afresh under the lock: decided meanwhile (another strom, the app) — never decided over in silence
529
+ const now = tree.get(id);
530
+ if (now.state === "resolved") {
531
+ againAllowed(tree, now, { reason, sources, by, side });
532
+ again = now;
533
+ }
368
534
  if (side)
369
- did = takeSide(tree, tree.get(id), side, String(opts.reasoning));
370
- return update(tree, id, "conflict", (c) => ({ ...c, state: "resolved", resolution, reasoning: String(opts.reasoning) }), {
535
+ did = takeSide(tree, now, side, String(opts.reasoning));
536
+ const earlier = again ? [makeNote(tree, truncate(`earlier decided${again.decidedBy ? ` by the ${again.decidedBy}` : ""}${again.taken ? ` (the ${again.taken === "user" ? "user's" : "research's"} value)` : ""}: ${again.resolution ?? ""} — ${again.reasoning ?? ""}`, 480))] : [];
537
+ const newer = again && sources.length ? [makeNote(tree, truncate(`decided again on ${sources.join(", ")}: ${reason}`, 480))] : [];
538
+ return update(tree, id, "conflict", (c) => {
539
+ const { taken: _t, decidedIn: _i, ...rest } = c;
540
+ return { ...rest, state: "resolved", resolution, reasoning: String(opts.reasoning), decidedBy: by, ...(inApp ? { decidedIn: "app" } : {}), decidedAt: new Date().toISOString(), ...(side ? { taken: side } : {}), notes: [...c.notes, ...earlier, ...newer] };
541
+ }, {
371
542
  op: "conflict.resolve",
372
- summary: `${id} resolved${side ? ` — the ${side === "user" ? "user's edit" : "research's"} taken` : ""}`,
543
+ summary: `${id} ${again ? "decided again" : "resolved"}${side ? ` — the ${side === "user" ? "user's edit" : "research's"} taken` : ""}${inApp ? " in the Strom app" : ""}`,
544
+ ...(reason ? { reason } : {}),
373
545
  });
374
546
  });
375
547
  // what was saved, in the research's language — the user's decision, whoever typed it (found on Mac: "X0001
@@ -465,6 +637,124 @@ register({
465
637
  }, { op: "hypothesis.argue", summary: `${id} ${args[1]} ${opts.for ? "+for" : "+against"}` });
466
638
  return { text: written(tree), data: { hypothesis: h } };
467
639
  },
640
+ }, {
641
+ path: ["hypothesis", "variant"],
642
+ summary: "Add a variant to an open hypothesis — a new letter, never one used before",
643
+ group: "analysis",
644
+ tree: true,
645
+ writes: true,
646
+ description: "A variant's letter stays one claim's (the Strom app remembers it): a claim that changes whom it would connect is a new variant.",
647
+ args: [
648
+ { name: "hypothesis", description: "hypothesis ID (H0001)", required: true },
649
+ { name: "claim", description: 'the claim, e.g. "the son of Josef from No. 12" (or "C: …" with its letter)', required: true },
650
+ ],
651
+ examples: ['strom hypothesis variant H0001 "syn Josefa z čp. 12"'],
652
+ run(ctx, { args }) {
653
+ const tree = ctx.tree();
654
+ const id = normId(args[0], "hypothesis");
655
+ const m = /^([\p{L}\p{N}]{1,3})\s*:\s*(.+)$/u.exec(args[1].trim());
656
+ const claim = (m ? m[2] : args[1]).trim();
657
+ if (!claim)
658
+ throw new UsageError("the claim is empty");
659
+ // (a writing command holds the tree's lock from its first read: the letter picked here is the one written)
660
+ const now = requireRecord(tree, id, "hypothesis");
661
+ if (now.state !== "open")
662
+ throw new UsageError(`${id} is ${now.state}: a variant is added to an open hypothesis`, { hint: `strom hypothesis show ${id}` });
663
+ if (m && now.variants.some((v) => foldText(v.label) === foldText(m[1])))
664
+ throw new UsageError(`${id} has a variant ${m[1]} already: a letter is never used twice`, { hint: "leave the letter out: strom picks the next one" });
665
+ const label = m ? m[1] : nextLabel(now.variants.map((v) => v.label));
666
+ const h = update(tree, id, "hypothesis", (h) => ({ ...h, variants: [...h.variants, { label, claim, support: [], against: [] }] }), {
667
+ op: "hypothesis.variant",
668
+ summary: `${id} +${label}: ${truncate(claim, 60)}`,
669
+ });
670
+ return { text: written(tree), data: { hypothesis: h, variant: label } };
671
+ },
672
+ }, {
673
+ path: ["hypothesis", "link"],
674
+ summary: "Say what a variant would connect — whose child, the same person, a couple, siblings — never a link of the tree",
675
+ group: "analysis",
676
+ tree: true,
677
+ writes: true,
678
+ description: [
679
+ "A variant about a connection names its people by ID: the Strom app shows what it would connect (drawn apart, only",
680
+ "for a look) and where the tree ends, the parents named but not linked. Nothing is linked in the tree until the",
681
+ "hypothesis is decided and the link recorded (family add / family child). One link per call; a variant may have several.",
682
+ " --child P… --of F… the child of a family of the tree",
683
+ " --child P… --parents P… [P…] the child of one or two parents who have no family together",
684
+ " --same P… P… two records of one person",
685
+ " --partners P… P… a couple",
686
+ " --siblings P… P… [P…] siblings, their parents unknown",
687
+ "--remove takes that link off again; --remove alone, every link of the variant.",
688
+ "A variant's letter stays its claim's (the Strom app remembers it): links may be added to it, never links naming",
689
+ "other people once it had some — that is a new variant (strom hypothesis variant). Deciding: decide --variant <label>.",
690
+ ].join("\n"),
691
+ args: [
692
+ { name: "hypothesis", description: "hypothesis ID (H0001)", required: true },
693
+ { name: "variant", description: "variant label, e.g. B", required: true },
694
+ { name: "people", description: "the second (and further) person of --same, --partners, --siblings or --parents", variadic: true },
695
+ ],
696
+ options: [
697
+ { name: "child", type: "string", value: "<P…>", description: "the child whose parents the variant names" },
698
+ { name: "of", type: "string", value: "<F…>", description: "…the family of the tree it would be a child of" },
699
+ { name: "parents", type: "string", multiple: true, value: "<P…>", description: "…or its one or two parents (no family of theirs)" },
700
+ { name: "same", type: "string", multiple: true, value: "<P…>", description: "two records of one person" },
701
+ { name: "partners", type: "string", multiple: true, value: "<P…>", description: "a couple" },
702
+ { name: "siblings", type: "string", multiple: true, value: "<P…>", description: "two or more siblings" },
703
+ { name: "remove", type: "boolean", description: "take the link off (alone: every link of the variant)" },
704
+ ],
705
+ examples: [
706
+ "strom hypothesis link H0001 B --child P0006 --of F0001",
707
+ "strom hypothesis link H0001 B --child P0006 --parents P0002 P0003",
708
+ "strom hypothesis link H0001 B --same P0005 P0003",
709
+ "strom hypothesis link H0001 A --remove",
710
+ ],
711
+ run(ctx, { args, opts }) {
712
+ const tree = ctx.tree();
713
+ const id = normId(args[0], "hypothesis");
714
+ const link = variantLink(tree, opts, args.slice(2));
715
+ const said = link ? linkText(link) : "every link";
716
+ const h = update(tree, id, "hypothesis", (h) => {
717
+ if (h.state !== "open")
718
+ throw new UsageError(`${id} is ${h.state}: what a variant would connect is for an open hypothesis`, { hint: `strom hypothesis show ${id}` });
719
+ const v = h.variants.find((x) => foldText(x.label) === foldText(args[1]));
720
+ if (!v)
721
+ throw new UsageError(`no variant ${args[1]} in ${id}`, { hint: h.variants.map((x) => x.label).join(", ") });
722
+ const had = v.links ?? [];
723
+ const at = link ? had.findIndex((l) => linkKey(l) === linkKey(link)) : -1;
724
+ if (opts.remove) {
725
+ if (!had.length)
726
+ throw new UsageError(`${id} ${v.label} has no links`, { hint: `strom hypothesis show ${id}` });
727
+ if (link && at < 0)
728
+ throw new UsageError(`${id} ${v.label} has no link ${linkText(link)}`, { hint: had.map(linkText).join(" · ") });
729
+ const rest = link ? had.filter((_, i) => i !== at) : [];
730
+ if (rest.length)
731
+ v.links = rest;
732
+ else
733
+ delete v.links;
734
+ }
735
+ else {
736
+ if (at >= 0)
737
+ throw new UsageError(`${id} ${v.label} has that link already: ${linkText(link)}`, { hint: `strom hypothesis show ${id}` });
738
+ const other = otherPeople(v, link);
739
+ if (other.length)
740
+ throw new UsageError(`${id} ${v.label} said other people before (${(v.linked ?? []).join(" ")}): a variant's letter stays one claim's — ${other.join(" ")} is a new variant`, {
741
+ hint: `strom hypothesis variant ${id} "<the claim>", then strom hypothesis link ${id} <its letter> …`,
742
+ });
743
+ v.links = [...had, link];
744
+ v.linked = [...new Set([...(v.linked ?? []), ...linkIds(link)])];
745
+ }
746
+ return h;
747
+ }, {
748
+ op: "hypothesis.link",
749
+ summary: `${id} ${args[1]}: ${opts.remove ? `${said} taken off` : said}`,
750
+ targets: link ? linkIds(link) : [],
751
+ });
752
+ const v = h.variants.find((x) => foldText(x.label) === foldText(args[1]));
753
+ return {
754
+ text: lines(written(tree), ...(v.links ?? []).map((l) => ` ${v.label} ⇢ ${linkText(l)}`)),
755
+ data: { hypothesis: h, variant: v.label, links: v.links ?? [] },
756
+ };
757
+ },
468
758
  }, {
469
759
  path: ["hypothesis", "decide"],
470
760
  summary: "Decide a hypothesis (or abandon it) — again, with a reason, when a new record overturns the decision",
@@ -475,6 +765,7 @@ register({
475
765
  args: [{ name: "hypothesis", description: "hypothesis ID", required: true }],
476
766
  options: [
477
767
  { name: "decision", type: "string", value: "<text>", description: "which variant and why" },
768
+ { name: "variant", type: "string", value: "<label>", description: "the variant it is decided for (always when its variants have links)" },
478
769
  { name: "abandon", type: "boolean", description: "none of the variants can be decided" },
479
770
  ],
480
771
  run(ctx, { args, opts }) {
@@ -483,14 +774,20 @@ register({
483
774
  throw new UsageError("--decision is required");
484
775
  const id = normId(args[0], "hypothesis");
485
776
  const reason = typeof opts.reason === "string" && opts.reason.trim() ? opts.reason.trim() : undefined;
777
+ if (opts.variant !== undefined && opts.abandon)
778
+ throw new UsageError("--variant names the variant it is decided for: not with --abandon");
486
779
  const h = update(tree, id, "hypothesis", (h) => {
487
780
  // A decision is overturned in the open: the earlier one stays readable.
488
781
  const earlier = h.state !== "open" && h.decision ? h.decision : undefined;
489
782
  if (earlier && !reason)
490
783
  throw new UsageError(`${id} is already ${h.state}: "${truncate(earlier, 80)}"`, { hint: 'deciding again needs --reason (e.g. "the marriage entry of 1839 overturns it")' });
491
784
  const notes = earlier ? [...h.notes, makeNote(tree, truncate(`earlier ${h.state}: ${earlier}`, 480))] : h.notes;
492
- return { ...h, state: opts.abandon ? "abandoned" : "decided", decision: String(opts.decision), notes };
493
- }, { op: "hypothesis.decide", summary: `${id} ${opts.abandon ? "abandoned" : "decided"}${reason ? " again" : ""}`, reason });
785
+ const chosen = opts.variant === undefined ? undefined : h.variants.find((x) => foldText(x.label) === foldText(String(opts.variant)));
786
+ if (opts.variant !== undefined && !chosen)
787
+ throw new UsageError(`no variant ${String(opts.variant)} in ${id}`, { hint: h.variants.map((x) => x.label).join(", ") });
788
+ const { chosen: _was, ...rest } = h;
789
+ return { ...rest, state: opts.abandon ? "abandoned" : "decided", decision: String(opts.decision), ...(chosen ? { chosen: chosen.label } : {}), notes };
790
+ }, { op: "hypothesis.decide", summary: `${id} ${opts.abandon ? "abandoned" : "decided"}${opts.variant !== undefined ? ` for ${String(opts.variant)}` : ""}${reason ? " again" : ""}`, reason });
494
791
  // decided: the sources it names that its people's facts do not cite yet — what they say goes on the people now
495
792
  const unrecorded = opts.abandon ? [] : sourcesNotOnPeople(tree, h);
496
793
  const say = unrecorded.map((u) => `${u.person}: ${u.sources.join(", ")} — named by ${id}, cited by none of ${u.person}'s facts: what they say of ${u.person} goes on ${u.person} now (event add ${u.person} … --cite ${u.sources[0]}); a note or the diary does not reach the tree`);
@@ -501,11 +798,24 @@ register({
501
798
  summary: "Open hypotheses",
502
799
  group: "analysis",
503
800
  tree: true,
504
- options: [{ name: "all", type: "boolean", description: "include decided and abandoned" }],
801
+ options: [
802
+ { name: "all", type: "boolean", description: "include decided and abandoned" },
803
+ { name: "unlinked", type: "boolean", description: "only those no variant of which says what it would connect (strom hypothesis link)" },
804
+ ],
805
+ examples: ["strom hypothesis list", "strom hypothesis list --unlinked"],
505
806
  run(ctx, { opts }) {
506
- const all = ctx.tree().list("hypothesis").filter((h) => opts.all || h.state === "open");
807
+ const linked = (h) => h.variants.some((v) => v.links?.length);
808
+ const all = ctx
809
+ .tree()
810
+ .list("hypothesis")
811
+ .filter((h) => (opts.all || h.state === "open") && !(opts.unlinked && linked(h)));
812
+ const links = (h) => h.variants.reduce((n, v) => n + (v.links?.length ?? 0), 0);
507
813
  return {
508
- text: all.length ? table(all.map((h) => [h.id, h.state, truncate(h.question, 60), h.variants.map((v) => v.label).join("/")])) : "no open hypotheses",
814
+ text: all.length
815
+ ? table(all.map((h) => [h.id, h.state, truncate(h.question, 60), h.variants.map((v) => v.label).join("/"), links(h) ? `${links(h)} link${links(h) === 1 ? "" : "s"}` : ""]))
816
+ : opts.unlinked
817
+ ? "no open hypothesis without links"
818
+ : "no open hypotheses",
509
819
  data: { hypotheses: all },
510
820
  };
511
821
  },
@@ -519,7 +829,7 @@ register({
519
829
  const tree = ctx.tree();
520
830
  const h = requireRecord(tree, args[0], "hypothesis");
521
831
  return {
522
- text: lines(`${h.id} ${h.question} [${h.state}]`, `about ${h.subject.join(" ")}`, taskLines(tree, h.id), ...h.variants.flatMap((v) => [`\n${v.label}: ${v.claim}`, ...v.support.map((s) => ` + ${s}`), ...v.against.map((s) => ` − ${s}`)]), h.decision ? `\ndecision ${h.decision}` : undefined),
832
+ text: lines(`${h.id} ${h.question} [${h.state}]`, `about ${h.subject.join(" ")}`, taskLines(tree, h.id), ...h.variants.flatMap((v) => [`\n${v.label}: ${v.claim}`, ...v.support.map((s) => ` + ${s}`), ...v.against.map((s) => ` − ${s}`), ...(v.links ?? []).map((l) => ` ⇢ ${linkText(l)}`)]), h.decision ? `\ndecision${h.chosen ? ` (for ${h.chosen})` : ""} ${h.decision}` : undefined),
523
833
  data: { hypothesis: h },
524
834
  };
525
835
  },
@@ -160,7 +160,7 @@ async function inTerminal(ctx, link) {
160
160
  case "conflict": {
161
161
  if (link.do === "decide") {
162
162
  const { decideConflict } = await import("../cli/menu-links.js");
163
- return end(await decideConflict(ctx, run, treeLang, root, link.id));
163
+ return end(await decideConflict(ctx, run, treeLang, root, link.id, link.take));
164
164
  }
165
165
  // left to the agent: it costs — said and asked first, then the conversation about it
166
166
  const say = aboutConflict(root, ctx, link.id);
@@ -323,7 +323,8 @@ register({
323
323
  "strom-research://review?tree=<id>&person=P0012&scope=person|family|line — the person reviewed (strom review).\n" +
324
324
  "strom-research://research?tree=<id>&person=P0012&direction=ancestors|descendants — a new direction (research new).\n" +
325
325
  "strom-research://task?…&task=T0007&do=park|drop|wake — the task put aside, given up, back in the queue (the reason asked).\n" +
326
- "strom-research://conflict?tree=<id>&id=X0007&do=decide|agent — decided by the user (which claim, why) or left to the agent.\n" +
326
+ "strom-research://conflict?tree=<id>&id=X0007&do=decide|agent — decided by the user (which claim, why) or left to the agent;\n" +
327
+ " &take=user|research — the side picked in the app: only confirmed, and why.\n" +
327
328
  "strom-research://story?tree=<id>&person=P0012[&partner=P0013]&do=final|keep — the story approved (strom story approve), of a couple with partner=.\n" +
328
329
  "strom-research://sync-undo?tree=<id>&intake=I0042 — a sending from the app taken back (strom sync undo).\n" +
329
330
  "strom-research://sync?tree=<id>&do=show[&intake=R…] — a tree the app sent that waits: shown, then written or thrown away.\n" +