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.
@@ -13,7 +13,7 @@ import { UsageError } from "../core/errors.js";
13
13
  import { excerptSettings, planExcerpts } from "../core/excerpt.js";
14
14
  import { runGit, tracked } from "../core/git.js";
15
15
  import { Settings } from "../core/config.js";
16
- import { appKnowsArchive, appKnowsNoCouple, appOpensLinks, appReadsTitles, appShowsCoupleEvents, appShowsEdges, appShowsSourceReads, appShowsFactStatus, appShowsStoryDrafts, appTurnsExcerpts } from "../core/stromapp.js";
16
+ import { appKnowsArchive, appKnowsNoCouple, appShowsHypothesisLinks, appDecidesConflicts, appOpensLinks, appReadsTitles, appShowsCoupleEvents, appShowsEdges, appShowsSourceReads, appShowsFactStatus, appShowsStoryDrafts, appTurnsExcerpts } from "../core/stromapp.js";
17
17
  import { EXCERPT_QUALITIES, EXCERPT_SCOPES } from "../core/model.js";
18
18
  function findingRows(f) {
19
19
  return table(f.slice(0, 30).map((x) => [x.level === "error" ? "ERROR" : "warn", `line ${x.line}`, x.message]));
@@ -85,7 +85,11 @@ export function writeTreeGed(tree, opts) {
85
85
  const noCouple = opts.for === "strom" && appKnowsNoCouple(new Settings(tree.env, {}), opts.stromVersion);
86
86
  const archive = opts.for === "strom" && appKnowsArchive(new Settings(tree.env, {}), opts.stromVersion);
87
87
  const titles = opts.for === "strom" && appReadsTitles(new Settings(tree.env, {}), opts.stromVersion);
88
- const result = exportGedcom(tree, { for: opts.for, stromVersion: opts.stromVersion, ...(persons ? { persons } : {}), ...(images ? { excerpts: images.of } : {}), ...(clips ? { clips } : {}), ...(research ? { research } : {}), ...(edges ? { edges } : {}), ...(storyDrafts ? { storyDrafts } : {}), ...(coupleResi ? { coupleResi } : {}), ...(sourceReads ? { sourceReads } : {}), ...(factStatus ? { factStatus } : {}), ...(archive ? { archive } : {}), ...(turnsExcerpts ? { turnsExcerpts } : {}), ...(noCouple ? { noCouple } : {}), ...(titles ? { titles } : {}) });
88
+ // …and what each variant of a hypothesis would connect, for an app that shows it
89
+ const hypothesisLinks = opts.for === "strom" && appShowsHypothesisLinks(new Settings(tree.env, {}), opts.stromVersion);
90
+ // …and the sides of a conflict of the user's edit, for an app that decides it
91
+ const conflictSides = opts.for === "strom" && appDecidesConflicts(new Settings(tree.env, {}), opts.stromVersion);
92
+ const result = exportGedcom(tree, { for: opts.for, stromVersion: opts.stromVersion, ...(persons ? { persons } : {}), ...(images ? { excerpts: images.of } : {}), ...(clips ? { clips } : {}), ...(research ? { research } : {}), ...(edges ? { edges } : {}), ...(storyDrafts ? { storyDrafts } : {}), ...(coupleResi ? { coupleResi } : {}), ...(sourceReads ? { sourceReads } : {}), ...(factStatus ? { factStatus } : {}), ...(archive ? { archive } : {}), ...(turnsExcerpts ? { turnsExcerpts } : {}), ...(noCouple ? { noCouple } : {}), ...(titles ? { titles } : {}), ...(hypothesisLinks ? { hypothesisLinks } : {}), ...(conflictSides ? { conflictSides } : {}) });
89
93
  const findings = validateGedcom(result.text, opts.for === "standard" ? { strict: true } : {});
90
94
  const file = opts.out ?? gedFile(tree, opts.for, opts.research);
91
95
  if (!hasGedErrors(findings) && !tree.dryRun) {
@@ -362,6 +362,8 @@ function edgeNext(e) {
362
362
  const join = hyp ? ` · ${hyp.id} ${hyp.island ? `joins ${hyp.joins.join(" ")} (${hyp.island.people} people off the tree)` : `would join through ${hyp.joins.join(" ")}`}` : "";
363
363
  if (t)
364
364
  return `${t.id}${t.position ? ` #${t.position}` : ""}${t.held ? ` (${t.held})` : ""}${e.tasks.length > 1 ? ` +${e.tasks.length - 1}` : ""}${join}`;
365
+ if (e.next === "decide" && e.end === "named")
366
+ return `${e.hypotheses.filter((h) => !h.tests.length).map((h) => h.id).join(" ")} names the parents, no task tests it — the user decides${join}`;
365
367
  if (e.next === "decide")
366
368
  return `tried ${e.tried.join(" ")} — ask the user${join}`;
367
369
  return join.replace(/^ · /, "");
@@ -383,6 +385,7 @@ const SCOPE_WORDS = {
383
385
  };
384
386
  const END_WORDS = {
385
387
  unnamed: "the baptism names no father (born out of wedlock)",
388
+ named: "a variant of an open hypothesis names the parents — not linked until it is decided",
386
389
  lost: "the records of those years are lost",
387
390
  "before-records": "born before the known birth records of the place begin",
388
391
  gap: "the known records have a gap in those years",
@@ -416,10 +419,10 @@ function edgeLines(tree, e, who) {
416
419
  e.books.length ? ` books: ${e.books.map((b) => `${b.id}${b.years ? ` (${b.years})` : ""}${b.access.startsWith("online") ? "" : ` ${b.access}`}`).join(", ")}` : undefined,
417
420
  e.covered.length ? ` searched in vain: ${e.covered.map(y).join(", ")}` : undefined,
418
421
  e.noRecords.length ? ` no records known: ${e.noRecords.map(y).join(", ")}` : undefined,
419
- ` next: ${NEXT_WORDS[e.next]}`,
422
+ ` next: ${e.next === "decide" && e.end === "named" ? "the hypothesis that names the parents waits for a decision — the user decides (or a task tests it)" : NEXT_WORDS[e.next]}`,
420
423
  ...e.tasks.map((t) => ` ${t.id} ${t.level} ${t.state}${t.position ? ` #${t.position}` : ""}${t.held ? ` (waits: ${t.held}${t.until ? ` until ${t.until}` : ""})` : ""}${t.on ? ` — ${truncate(t.on, 60)}` : ""}: ${truncate(t.what, 90)}`),
421
424
  e.tried.length ? ` tried: ${e.tried.join(" ")}` : undefined,
422
- ...e.hypotheses.map((h) => ` ${h.id} ${truncate(h.question, 90)}${!h.joins.length ? "" : h.island ? ` — would join ${h.joins.map(who).join(", ")} of a family off the tree (${h.island.people} people, ${h.island.held} tasks wait for it)` : ` — would join them to the tree through ${h.joins.map(who).join(", ")}`}${h.tests.length ? `; tested by ${h.tests.join(" ")}` : "; no task tests it"}`),
425
+ ...e.hypotheses.map((h) => ` ${h.id} ${truncate(h.question, 90)}${h.variants?.length ? ` (variant ${h.variants.join(", ")})` : ""}${!h.joins.length ? "" : h.island ? ` — would join ${h.joins.map(who).join(", ")} of a family off the tree (${h.island.people} people, ${h.island.held} tasks wait for it)` : ` — would join them to the tree through ${h.joins.map(who).join(", ")}`}${h.tests.length ? `; tested by ${h.tests.join(" ")}` : "; no task tests it"}`),
423
426
  e.conflicts.length ? ` open conflicts: ${e.conflicts.join(" ")}` : undefined,
424
427
  e.searches || e.sessions ? ` work so far: ${e.searches} search${e.searches === 1 ? "" : "es"}, ${e.sessions} session${e.sessions === 1 ? "" : "s"}${e.cost !== undefined ? `, $${e.cost.toFixed(2)}${e.costPartial ? "+" : ""}` : ""}${e.last ? `; last ${e.last}` : ""}` : undefined,
425
428
  ].filter((x) => x !== undefined);
@@ -41,6 +41,8 @@ const SAID = {
41
41
  "lesson.add": "log.lesson.add",
42
42
  "hypothesis.add": "log.hypothesis.add",
43
43
  "hypothesis.argue": "log.hypothesis.argue",
44
+ "hypothesis.link": "log.hypothesis.link",
45
+ "hypothesis.variant": "log.hypothesis.variant",
44
46
  "hypothesis.decide": "log.hypothesis.decide",
45
47
  "conflict.add": "log.conflict.add",
46
48
  "conflict.resolve": "log.conflict.resolve",
@@ -0,0 +1,105 @@
1
+ // A conflict of the user's edit in the Strom app with what the research holds: its two sides, the user's and the
2
+ // research's — what `strom conflict resolve --take user|research` decides by.
3
+ import { UsageError } from "./errors.js";
4
+ import { claimText } from "./people.js";
5
+ /** The note on the user's claim of a conflict of their edit (core/sync.ts conflictOfEdit). */
6
+ export const USER_EDIT = "the user's edit";
7
+ /** A conflict of the user's edit in the Strom app: a fact's, a child's parents, a name's, a title's or a sex's. */
8
+ export function isEditConflict(c) {
9
+ return Boolean(c.edit || c.parents || (["NAME", "SEX", "NPFX", "NSFX"].includes(c.fact ?? "") && c.claims.some((x) => x.note === USER_EDIT)));
10
+ }
11
+ /** Which side a claim of a conflict of the user's edit is: the user's, the research's; none in any other conflict. */
12
+ export function sideOf(c, claim) {
13
+ if (!isEditConflict(c))
14
+ return undefined;
15
+ if (claim.note === USER_EDIT)
16
+ return "user";
17
+ if (claim.note?.startsWith("the research"))
18
+ return "research";
19
+ return undefined;
20
+ }
21
+ /** The claim of a side of a conflict of the user's edit. */
22
+ export function claimOf(c, side) {
23
+ return c.claims.find((x) => sideOf(c, x) === side);
24
+ }
25
+ /** The kinds of fact a conflict of the user's edit is decided by side from the Strom app (phase 1: not a child's parents). */
26
+ const BY_SIDE = new Set(["NAME", "SEX", "NPFX", "NSFX"]);
27
+ /**
28
+ * The two sides of a conflict of the user's edit the Strom app decides by side (conflict.decide): a fact's value (date,
29
+ * place, value — not its details or the people it names, which carry no edit), a name, a title before or after it, a
30
+ * sex. None for a child's parents (FAMC), a conflict between sources, or one without both sides.
31
+ */
32
+ export function sidesOf(c) {
33
+ if (!isEditConflict(c) || c.parents || c.fact === "FAMC")
34
+ return undefined;
35
+ if (!c.edit && !BY_SIDE.has(c.fact ?? ""))
36
+ return undefined;
37
+ const user = claimOf(c, "user");
38
+ const research = claimOf(c, "research");
39
+ return user && research ? { user, research } : undefined;
40
+ }
41
+ /**
42
+ * A side's value as the Strom app is given it (the Strom file's VAL, the bridge's sides): in the research's language,
43
+ * an empty value empty (a title the user took off) — never a dash.
44
+ */
45
+ export function sideText(tree, c, claim) {
46
+ return claim.value.trim() === "" ? "" : claimText(tree, c, claim);
47
+ }
48
+ /** What the app is told of a conflict it decides by side: both sides' values, a sex also as M, F or U. */
49
+ export function appSides(tree, c) {
50
+ const s = c.state === "open" ? sidesOf(c) : undefined;
51
+ if (!s)
52
+ return undefined;
53
+ return {
54
+ take: true,
55
+ sides: { user: sideText(tree, c, s.user), research: sideText(tree, c, s.research) },
56
+ ...(c.fact === "SEX" ? { raw: { user: sexRaw(s.user.value), research: sexRaw(s.research.value) } } : {}),
57
+ };
58
+ }
59
+ /** A sex as a program reads it: M, F, else U. */
60
+ export function sexRaw(v) {
61
+ return v === "M" || v === "F" ? v : "U";
62
+ }
63
+ /** The sources a conflict's decision weighed: those of its claims, and those of the fact of the user's edit. */
64
+ export function weighedSources(tree, c) {
65
+ const out = new Set(c.claims.map((x) => x.source).filter((s) => !!s));
66
+ if (c.edit)
67
+ for (const owner of c.subject) {
68
+ const rec = tree.get(owner);
69
+ const e = rec && (rec.type === "person" || rec.type === "family") ? rec.events.find((x) => x.id === c.edit.event) : undefined;
70
+ for (const cite of e?.citations ?? [])
71
+ out.add(cite.source);
72
+ }
73
+ return out;
74
+ }
75
+ /**
76
+ * Whether a decided conflict may be decided again (strom conflict resolve), else why not: only with a reason; a side
77
+ * once taken is not taken again (its fact is written: changed with the fact commands); what the user decided, an agent
78
+ * decides again only on a source the decision did not weigh.
79
+ */
80
+ export function againAllowed(tree, c, how) {
81
+ const was = c.resolution ?? "";
82
+ const params = { id: c.id, resolution: was, ...(c.taken ? { take: c.taken } : {}), ...(c.decidedBy ? { by: c.decidedBy } : {}) };
83
+ if (!how.reason)
84
+ throw new UsageError(`${c.id} is decided already: "${was.slice(0, 120)}"${c.decidedBy === "user" ? " — the user's decision" : ""}`, {
85
+ hint: c.decidedBy === "user" ? `the user's decision stands; deciding it again needs a new source and a reason: strom conflict resolve ${c.id} … --source S… --reason "<what the new record shows>"` : `deciding again needs --reason (e.g. "the marriage entry of 1839 overturns it")`,
86
+ code: "conflict.decided",
87
+ params,
88
+ });
89
+ if (how.side && c.taken)
90
+ throw new UsageError(`${c.id} took the ${c.taken === "user" ? "user's" : "research's"} value already: its fact is written — change the fact with the fact commands (event edit, event retract), then decide it again with --resolution`, {
91
+ hint: `strom conflict show ${c.id}`,
92
+ code: "conflict.taken",
93
+ params,
94
+ });
95
+ if (c.decidedBy === "user" && how.by === "agent") {
96
+ const weighed = weighedSources(tree, c);
97
+ const fresh = how.sources.filter((s) => !weighed.has(s));
98
+ if (!fresh.length)
99
+ throw new UsageError(`${c.id} is the user's decision: an agent decides it again only on a source it did not weigh (${[...weighed].join(", ") || "none"})`, {
100
+ hint: `strom conflict resolve ${c.id} … --source S… --reason "<what the new record shows>" — or ask the user`,
101
+ code: "conflict.user-decided",
102
+ params,
103
+ });
104
+ }
105
+ }
@@ -20,19 +20,40 @@ export function namesId(text, id) {
20
20
  }
21
21
  const PERSON_ID = /(?<![\p{L}\p{M}\p{N}])P\d{4,}(?![\p{L}\p{M}\p{N}])/gu;
22
22
  /**
23
- * The people a hypothesis is about: those of its subject, and the people of the tree its question and its variants'
24
- * claims name by ID ("the son of P0012 and P0013") — a variant that names a family nothing links to the tree would
25
- * join it, though the subject names only the person of the tree. What each variant cites for or against it is
26
- * evidence, not who it is about.
23
+ * The people a hypothesis is about: those of its subject, and the people its variants' links would connect (strom
24
+ * hypothesis link). A hypothesis no variant of which says what it would connect yet: the people of the tree its
25
+ * question and its variants' claims name by ID instead ("the son of P0012 and P0013") — a variant that names a family
26
+ * nothing links to the tree would join it, though the subject names only the person of the tree. Once links say it,
27
+ * an ID in a claim is context (a wife, a witness), not who it is about. What each variant cites for or against it is
28
+ * evidence, never who it is about.
27
29
  */
28
30
  export function hypothesisPeople(tree, h) {
29
- const named = [h.question, ...h.variants.map((v) => v.claim)].flatMap((text) => text.match(PERSON_ID) ?? []);
31
+ const linked = h.variants.some((v) => v.links?.length);
32
+ const named = linked
33
+ ? h.variants.flatMap((v) => (v.links ?? []).flatMap((l) => linkPeople(tree, l)))
34
+ : [h.question, ...h.variants.map((v) => v.claim)].flatMap((text) => text.match(PERSON_ID) ?? []);
30
35
  const people = named.filter((id) => {
31
36
  const p = tree.get(id);
32
37
  return p && !p.retracted;
33
38
  });
34
39
  return [...new Set([...aboutPeople(tree, h.subject), ...people])];
35
40
  }
41
+ /** The people one variant names: those its links would connect and those its claim names by ID. */
42
+ export function variantPeople(tree, v) {
43
+ return [...new Set([...(v.links ?? []).flatMap((l) => linkPeople(tree, l)), ...(v.claim.match(PERSON_ID) ?? [])])];
44
+ }
45
+ /** The variants that would join these people (any of them): by their links, or their claim naming them by ID. */
46
+ export function joiningVariants(tree, h, people) {
47
+ const set = new Set(people);
48
+ return h.variants.filter((v) => variantPeople(tree, v).some((x) => set.has(x))).map((v) => v.label);
49
+ }
50
+ /** The people a variant's link would connect: the child and its parents (the partners of its family), or its persons. */
51
+ export function linkPeople(tree, l) {
52
+ if (l.kind !== "child")
53
+ return l.persons;
54
+ const family = l.family ? tree.get(l.family) : undefined;
55
+ return [l.person, ...(l.parents ?? []), ...(family && !family.retracted ? family.partners : [])];
56
+ }
36
57
  /** The direction a task belongs to: its own, else the narrowest one its people are in; none for a task about nobody. */
37
58
  export function directionOf(tree, t, all) {
38
59
  if (t.research)
package/dist/core/edge.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // tree ends (_STROM_EDGE, _STROM_ISLAND) and what `strom edge` says.
7
7
  import { now } from "./tree.js";
8
8
  import { Settings } from "./config.js";
9
- import { aboutPeople, directionOf, hypothesisPeople, namesId, scopes } from "./directions.js";
9
+ import { aboutPeople, directionOf, hypothesisPeople, joiningVariants, namesId, scopes } from "./directions.js";
10
10
  import { birthEstimate, birthRecordProven, birthWindow, frontier, FRONTIER_LEVELS, RECORD_KINDS, recordsetsCovering } from "./frontier.js";
11
11
  import { kinship, offTree } from "./kin.js";
12
12
  import { familiesAsChild, familiesAsPartner, parentsOf, primaryName } from "./people.js";
@@ -23,12 +23,13 @@ export const EDGE_MISSING = ["parents", "father", "mother", "proof"];
23
23
  */
24
24
  export const EDGE_SCOPES = ["in", "limit", "paused", "done", "living", "outside", "off-tree"];
25
25
  /**
26
- * What the records say so far, from the most final: the baptism names no father (born out of wedlock); every year of the
26
+ * What the records say so far, from the most final: the baptism names no father (born out of wedlock); a variant of an
27
+ * open hypothesis names the parents, nobody linked them (strom hypothesis link --child: the user decides); every year of the
27
28
  * birth is searched in vain or has no records — lost, before the known records begin, a gap in them; searched in
28
29
  * vain everywhere strom knew to look; the books left are only in the archive; searched in part; not searched yet;
29
30
  * no book of the birthplace known; the birthplace unknown; nothing to go on (no year, no place).
30
31
  */
31
- export const EDGE_ENDS = ["unnamed", "lost", "before-records", "gap", "not-found", "offline", "partly", "unsearched", "no-books", "no-place", "no-clue"];
32
+ export const EDGE_ENDS = ["unnamed", "named", "lost", "before-records", "gap", "not-found", "offline", "partly", "unsearched", "no-books", "no-place", "no-clue"];
32
33
  /**
33
34
  * What comes next: an agent works on it now, it waits for the user, it is in the queue, it waits out of the queue
34
35
  * (a direction paused, people off the tree, put aside), strom proposes it with the next session, everything strom
@@ -83,7 +84,11 @@ function islands(tree, kin, tasks, hypotheses) {
83
84
  hypotheses: hypotheses
84
85
  .map((h) => ({ h, about: hypothesisPeople(tree, h) }))
85
86
  .filter(({ about }) => about.some((x) => people.has(x)) && about.some((x) => kin.has(x)))
86
- .map(({ h, about }) => ({ id: h.id, joins: about.filter((x) => kin.has(x)) })),
87
+ .map(({ h, about }) => {
88
+ // the variants that would join this family: they name its people
89
+ const variants = joiningVariants(tree, h, people);
90
+ return { id: h.id, joins: about.filter((x) => kin.has(x)), ...(variants.length ? { variants } : {}) };
91
+ }),
87
92
  held: tasks.filter((t) => LIVE.has(t.state) && off(t) && aboutPeople(tree, t.subject).some((x) => people.has(x))).length,
88
93
  };
89
94
  for (const id of people)
@@ -91,8 +96,34 @@ function islands(tree, kin, tasks, hypotheses) {
91
96
  }
92
97
  return out;
93
98
  }
94
- /** Every edge of the tree with something the research knows of it, and the families nothing links to the tree. */
95
- export function treeEdges(tree) {
99
+ /**
100
+ * Whether a variant of the hypothesis would make the person a child of parents of the tree (strom hypothesis link
101
+ * --child): a family there, or parents there — not already (as the Strom file says it: core/gedcom export).
102
+ */
103
+ function namesParents(tree, h, person) {
104
+ const there = (id) => {
105
+ const p = tree.get(id);
106
+ return !!p && !p.retracted;
107
+ };
108
+ return h.variants.some((v) => (v.links ?? []).some((l) => {
109
+ if (l.kind !== "child" || l.person !== person)
110
+ return false;
111
+ const parents = [...new Set(l.parents ?? [])];
112
+ // parents of no family together — none since, with the child in it
113
+ if (!l.family)
114
+ return (parents.length > 0 &&
115
+ parents.every(there) &&
116
+ !familiesAsChild(tree, person).some((f) => !f.retracted && f.partners.length === parents.length && parents.every((x) => f.partners.includes(x))));
117
+ const f = tree.get(l.family);
118
+ return !!f && !f.retracted && !f.children.some((c) => c.person === person);
119
+ }));
120
+ }
121
+ /**
122
+ * Every edge of the tree with something the research knows of it, and the families nothing links to the tree.
123
+ * `named`: an edge whose parents a variant names (the end named) — off for a Strom app that does not know it yet.
124
+ */
125
+ export function treeEdges(tree, opts = {}) {
126
+ const named = opts.named ?? true;
96
127
  const lang = tree.lang;
97
128
  const today = now().slice(0, 10);
98
129
  const kin = kinship(tree);
@@ -220,10 +251,14 @@ export function treeEdges(tree) {
220
251
  open.push({ year: y, online: kept.some((b) => b.access === "online-free" || b.access === "online-login") });
221
252
  }
222
253
  const earliest = Math.min(...placeBooks.map((b) => yearsOf(b.years)?.from ?? Infinity));
254
+ // the parents a variant of an open hypothesis names, not linked: the user decides it (the hypothesis, its tests)
255
+ const naming = named && missing !== "proof" ? hyps.filter(({ h }) => namesParents(tree, h, p.id)).map(({ h }) => h.id) : [];
223
256
  let end;
224
257
  // the baptism is read and names the mother alone (a child born out of wedlock); a mother missing is one not recorded yet
225
258
  if (missing === "father" && proven)
226
259
  end = "unnamed";
260
+ else if (naming.length)
261
+ end = "named";
227
262
  else if (!est.place)
228
263
  end = est.year ? "no-place" : "no-clue";
229
264
  else if (!placeBooks.length)
@@ -251,15 +286,21 @@ export function treeEdges(tree) {
251
286
  const joining = kin.size > 0 && about.some((x) => kin.has(x)) && about.some((x) => !kin.has(x));
252
287
  const joins = joining ? about.filter((x) => kin.has(x) !== onTree) : [];
253
288
  const isle = joins.length && onTree ? isles.get(joins[0]) : undefined;
289
+ // the variants that would make the join: those that name the people off the tree
290
+ const variants = joins.length ? joiningVariants(tree, h, onTree ? joins : (isles.get(p.id)?.people ?? [p.id])) : [];
254
291
  return {
255
292
  id: h.id,
256
293
  question: h.question,
257
294
  joins,
258
295
  ...(isle ? { island: { people: isle.people.length, held: isle.held } } : {}),
296
+ ...(variants.length ? { variants } : {}),
259
297
  // a task about it, or one whose words name it ("the baptism … (H0007 B)")
260
298
  tests: tasks.filter((t) => LIVE.has(t.state) && (t.subject.includes(h.id) || namesId(t.what, h.id))).map((t) => t.id),
261
299
  };
262
300
  });
301
+ // named, and no task tests the hypotheses that name them: what comes next is the user's decision
302
+ const untested = naming.length > 0 && edgeHyps.filter((h) => naming.includes(h.id)).every((h) => !h.tests.length);
303
+ const nextNow = end === "named" && untested && (next === "none" || next === "proposed") ? "decide" : next;
263
304
  const edgeConflicts = conflicts
264
305
  .filter((c) => subjectPeople(tree, c.subject).includes(p.id) && (!c.fact || EDGE_FACTS.has(c.fact)))
265
306
  .map((c) => c.id);
@@ -276,7 +317,7 @@ export function treeEdges(tree) {
276
317
  scope,
277
318
  ...(through ? { research: through.research.id, generation: through.people.get(p.id) } : {}),
278
319
  end,
279
- next,
320
+ next: nextNow,
280
321
  ...(est.year || est.place ? { estimate: { ...(est.year ? { year: est.year } : {}), ...(est.place ? { place: est.place } : {}), ...(est.basis ? { basis: est.basis } : {}) } } : {}),
281
322
  ...(window ? { window } : {}),
282
323
  ...(Number.isFinite(earliest) && (end === "before-records" || noRecords.some((y) => y < earliest)) ? { recordsFrom: earliest } : {}),
@@ -62,6 +62,8 @@ export const LINK_DIRECTION_DOS = ["pause", "done", "resume"];
62
62
  export const LINK_SYNC_DOS = ["show"];
63
63
  /** What a link does with a conflict: the user decides it, or leaves it to the agent. */
64
64
  export const LINK_CONFLICT_DOS = ["decide", "agent"];
65
+ /** The side a conflict of the user's edit is decided for, picked in the app: the terminal asks only to confirm and why. */
66
+ export const LINK_CONFLICT_TAKES = ["user", "research"];
65
67
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
66
68
  const SOURCE = /^S\d{1,9}$/;
67
69
  const PERSON = /^P\d{1,9}$/;
@@ -227,8 +229,12 @@ export function parseLink(text, scheme = LINK_SCHEME) {
227
229
  const partner = idOf(q, "partner", PERSON, false);
228
230
  return { action, tree: id, person, ...(partner ? { partner } : {}), do: oneOf(q, "do", LINK_STORY_DOS) };
229
231
  }
230
- case "conflict":
231
- return { action, tree: id, id: idOf(q, "id", CONFLICT, true), do: oneOf(q, "do", LINK_CONFLICT_DOS, "decide") };
232
+ case "conflict": {
233
+ const how = oneOf(q, "do", LINK_CONFLICT_DOS, "decide");
234
+ // the side, only of a decision, only one of its fixed values
235
+ const take = how === "decide" && q.has("take") ? oneOf(q, "take", LINK_CONFLICT_TAKES) : undefined;
236
+ return { action, tree: id, id: idOf(q, "id", CONFLICT, true), do: how, ...(take ? { take } : {}) };
237
+ }
232
238
  case "sync-undo":
233
239
  return { action, tree: id, intake: idOf(q, "intake", INPUT, true) };
234
240
  case "sync": {
package/dist/core/live.js CHANGED
@@ -26,6 +26,8 @@
26
26
  // X-Strom-Batch, -Path (and -Zip: 1, a ZIP unpacked here): one file of a batch
27
27
  // POST <token>/batch/<id>/done the batch is whole ({name, files, person, note}): its files become sorting tasks
28
28
  // (a batch nobody closes is closed a day after its last file)
29
+ // POST <token>/conflict/<X…> the person decides a conflict of their edit in the app by side ({"do": "decide",
30
+ // "take": "user" | "research", "note"?}): strom conflict resolve --take, written as the user's
29
31
  // GET <token>/adopt a new research waits for a tree of the app: its mark, name, until when; transfer: true when
30
32
  // the tree comes from a browser the app cannot reach strom from; existing: true when it goes
31
33
  // into a research made before, its tree never came (POST /adopt hands it over)
@@ -62,7 +64,7 @@ import path from "node:path";
62
64
  import crypto from "node:crypto";
63
65
  import { spawn, spawnSync } from "node:child_process";
64
66
  import { withoutAgentMarks } from "./which.js";
65
- import { newerTree, Tree, TREE_FILE, VERSION } from "./tree.js";
67
+ import { newerTree, Tree, TREE_FILE, typeOfId, VERSION } from "./tree.js";
66
68
  import { readJsonIfExists } from "./json.js";
67
69
  import { changeLines } from "./changelog.js";
68
70
  import { directionOf, scopes } from "./directions.js";
@@ -87,11 +89,13 @@ import { rankTasks } from "./queue.js";
87
89
  import { humanTask } from "../cli/human.js";
88
90
  import { knownNewerVersion, updateChannel } from "./update.js";
89
91
  import { gitProgram, runGit } from "./git.js";
90
- import { appKnowsArchive, appKnowsNoCouple, appOpensLinks, appReadsTitles, appShowsCoupleEvents, appShowsEdges, appShowsFactStatus, appShowsSourceReads, appShowsStoryDrafts, appTurnsExcerpts, appUrlSetting, isAppVersion, isStromAppOrigin } from "./stromapp.js";
92
+ import { appKnowsArchive, appKnowsNoCouple, appShowsHypothesisLinks, appDecidesConflicts, appOpensLinks, appReadsTitles, appShowsCoupleEvents, appShowsEdges, appShowsFactStatus, appShowsSourceReads, appShowsStoryDrafts, appTurnsExcerpts, appUrlSetting, isAppVersion, isStromAppOrigin } from "./stromapp.js";
91
93
  import { Settings } from "./config.js";
92
94
  import { LINK_SCHEME, linkActions, linkHandlerState, linkHandlerStateLater, linkScheme } from "./links.js";
93
95
  import { autoTidy } from "./tidy.js";
94
96
  import { diskVersion, stromLauncher } from "./self.js";
97
+ import { sidesOf } from "./conflicts.js";
98
+ import { normId } from "./records.js";
95
99
  import { foldText } from "./text.js";
96
100
  import { storiesToApprove } from "./stories.js";
97
101
  /**
@@ -145,6 +149,15 @@ function said(lang, key, params = {}) {
145
149
  * - `batch.closed` 409 {batch} — the batch is closed already: send the rest as a new batch
146
150
  * - `batch.full-files` 413 {batch, files}; `batch.full-bytes` 413 {batch, gb} — the most a batch takes
147
151
  * - `batch.none` 404 {batch} — POST /batch/<id>/done: no such batch here; `batch.bad-body` 400 — its body no JSON
152
+ *
153
+ * A decision of the person (POST /conflict/<X…>): 200 {decided, take, head, written, person?, family?}, else
154
+ * - `app.only` 403; `decide.bad-body` 400 — no JSON {"do": "decide", …}; `conflict.bad-take` 400 — take not user|research
155
+ * - `conflict.none` 404 {id} — no such conflict; `conflict.no-edit` 422 {id} — not one the app decides by side
156
+ * - `conflict.decided` 409 {id} (and resolution, take, at, by, in) — decided already (a terminal, an agent, the app)
157
+ * - `research.busy` 503 (and Retry-After, `retry`) — a send or an adoption is being written, another decision, another
158
+ * strom holding the research: ask again in a moment
159
+ * - `locked` 423 (and reasonCode, reasonParams) — a newer strom wrote the research: this one never writes it
160
+ * - `decide.failed` 500 — went wrong (the English sentence says why)
148
161
  */
149
162
  function refusal(error, code, params = {}) {
150
163
  return { error, code, text: error, ...(Object.keys(params).length ? { params } : {}) };
@@ -593,7 +606,7 @@ function idsOf(applied, known) {
593
606
  return Object.keys(ids.persons).length || Object.keys(ids.sources).length ? { ids } : {};
594
607
  }
595
608
  /** What the bridge does that an app may ask about (each added once, never taken away). */
596
- export const BRIDGE_FEATURES = ["sync.again", "sync.undoneSince", "sync.takenBack", "sync.conflictEdit", "sync.since", "sync.ids", "family.noCouple", "family.alone", "adopt.transfer", "adopt.empty", "material.list", "person.titles", "media.codes"];
609
+ export const BRIDGE_FEATURES = ["sync.again", "sync.undoneSince", "sync.takenBack", "sync.conflictEdit", "sync.since", "sync.ids", "family.noCouple", "family.alone", "adopt.transfer", "adopt.empty", "material.list", "person.titles", "media.codes", "hypothesis.links", "conflict.decide"];
597
610
  export function history(root, tree, range = [`-n${LOG_MAX}`]) {
598
611
  const r = runGit(root, ["log", ...range, "--format=%x1e%H%x1f%cI%x1f%s%x1f%b%x1f", "--name-only"]);
599
612
  if (r.status !== 0)
@@ -1050,7 +1063,7 @@ export function serveLive(root, env) {
1050
1063
  catch {
1051
1064
  // not JSON: the status alone
1052
1065
  }
1053
- const marks = ["code", "reasonCode", "intake", "input", "changes", "applied", "pending", "keptAs", "known", "batch", "sha"]
1066
+ const marks = ["code", "reasonCode", "decided", "take", "intake", "input", "changes", "applied", "pending", "keptAs", "known", "batch", "sha"]
1054
1067
  .filter((k) => said[k] !== undefined && said[k] !== null && typeof said[k] !== "object")
1055
1068
  .map((k) => `${k} ${String(said[k]).slice(0, 80)}`);
1056
1069
  if (Array.isArray(said.conflicts) && said.conflicts.length)
@@ -1153,6 +1166,10 @@ export function serveLive(root, env) {
1153
1166
  batchDone(req, res, origin, decodeURIComponent(sub ?? ""));
1154
1167
  return;
1155
1168
  }
1169
+ if (req.method === "POST" && t === token && what === "conflict" && sub && !act) {
1170
+ decideFor(req, res, origin, "conflict", decodeURIComponent(sub), conflictDecision);
1171
+ return;
1172
+ }
1156
1173
  if (req.method !== "GET" || t !== token) {
1157
1174
  res.writeHead(404).end();
1158
1175
  return;
@@ -1251,12 +1268,14 @@ export function serveLive(root, env) {
1251
1268
  const turnsExcerpts = appTurnsExcerpts(new Settings(env, {}), version);
1252
1269
  const noCouple = appKnowsNoCouple(new Settings(env, {}), version);
1253
1270
  const titles = appReadsTitles(new Settings(env, {}), version);
1271
+ const hypothesisLinks = appShowsHypothesisLinks(new Settings(env, {}), version);
1272
+ const conflictSides = appDecidesConflicts(new Settings(env, {}), version);
1254
1273
  const offered = opens ? links(env) : [];
1255
1274
  ged = {
1256
1275
  head: h,
1257
1276
  links: offered.join(" "),
1258
1277
  version: version ?? "",
1259
- text: exportGedcom(tree, { for: "strom", ...(h ? { head: h } : {}), ...(images ? { excerpts: images.of } : {}), ...(opens ? { clips: true, research: true } : {}), ...(edges ? { edges } : {}), ...(storyDrafts ? { storyDrafts } : {}), ...(coupleResi ? { coupleResi } : {}), ...(sourceReads ? { sourceReads } : {}), ...(factStatus ? { factStatus } : {}), ...(archive ? { archive } : {}), ...(turnsExcerpts ? { turnsExcerpts } : {}), ...(noCouple ? { noCouple } : {}), ...(titles ? { titles } : {}), ...(offered.length ? { links: offered, ...(linkScheme(env) !== LINK_SCHEME ? { linkScheme: linkScheme(env) } : {}) } : {}) }).text,
1278
+ text: exportGedcom(tree, { for: "strom", ...(h ? { head: h } : {}), ...(images ? { excerpts: images.of } : {}), ...(opens ? { clips: true, research: true } : {}), ...(edges ? { edges } : {}), ...(storyDrafts ? { storyDrafts } : {}), ...(coupleResi ? { coupleResi } : {}), ...(sourceReads ? { sourceReads } : {}), ...(factStatus ? { factStatus } : {}), ...(archive ? { archive } : {}), ...(turnsExcerpts ? { turnsExcerpts } : {}), ...(noCouple ? { noCouple } : {}), ...(titles ? { titles } : {}), ...(hypothesisLinks ? { hypothesisLinks } : {}), ...(conflictSides ? { conflictSides } : {}), ...(offered.length ? { links: offered, ...(linkScheme(env) !== LINK_SCHEME ? { linkScheme: linkScheme(env) } : {}) } : {}) }).text,
1260
1279
  };
1261
1280
  }
1262
1281
  res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "X-Strom-Head": h }).end(ged.text);
@@ -1925,6 +1944,150 @@ export function serveLive(root, env) {
1925
1944
  closeBatchNow(id, extra, reply);
1926
1945
  }));
1927
1946
  };
1947
+ /**
1948
+ * A decision of the person in the app (POST /conflict/<X…>; one route of each kind — a hypothesis's later shares
1949
+ * this): checked here, then written by a strom of its own as the user's (forApp: the command the terminal runs, its
1950
+ * lock, its log and commit), one at a time and never while a send or an adoption is written — then busy (503 +
1951
+ * Retry-After, research.busy: what the bridge says of an original or a batch the research cannot take now). Every
1952
+ * kind answers alike: 200 {decided, head, written, …}; its refusals by their codes (`<kind>.none` 404, `<kind>.decided`
1953
+ * 409, …), the research a newer strom wrote 423 `locked`, not from the app's pages 403 `app.only`.
1954
+ */
1955
+ let deciding;
1956
+ const DECIDE_RETRY_S = 5;
1957
+ const decideFor = (req, res, origin, kind, raw, how) => {
1958
+ const reply = (code, body) => {
1959
+ if (!res.headersSent)
1960
+ res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", ...(code === 503 ? { "Retry-After": String(body.retry ?? DECIDE_RETRY_S) } : {}) }).end(JSON.stringify(body));
1961
+ };
1962
+ if (!origin) {
1963
+ req.resume();
1964
+ return reply(403, refusal("only the Strom app may decide here", "app.only"));
1965
+ }
1966
+ let text = "";
1967
+ let over = false;
1968
+ req.on("data", (c) => {
1969
+ if (text.length + c.length > 8192)
1970
+ over = true;
1971
+ else
1972
+ text += c.toString("utf8");
1973
+ });
1974
+ req.on("end", () => safely(req, res, () => {
1975
+ // a research a newer strom wrote: never opened, never written (as /status says it: locked)
1976
+ const newer = newerTree(root, env);
1977
+ if (newer)
1978
+ return reply(423, { ...refusal(newer.message, "locked"), reasonCode: newer.code, ...(newer.params ? { reasonParams: newer.params } : {}) });
1979
+ let body = {};
1980
+ try {
1981
+ const parsed = text.trim() ? JSON.parse(text) : {};
1982
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
1983
+ throw new Error("not an object");
1984
+ body = parsed;
1985
+ }
1986
+ catch {
1987
+ over = true;
1988
+ }
1989
+ if (over || body.do !== "decide")
1990
+ return reply(400, refusal(`the body: JSON {"do": "decide", …}`, "decide.bad-body"));
1991
+ const id = /^[A-Za-z]\d{1,9}$/.test(raw) ? normId(raw) : "";
1992
+ const tree = Tree.open(root, env);
1993
+ const rec = id && typeOfId(id) === kind ? tree.get(id) : undefined;
1994
+ if (!rec || rec.type !== kind)
1995
+ return reply(404, refusal(`no ${kind} ${raw.slice(0, 32)} in this research`, `${kind}.none`, { id: raw.slice(0, 32) }));
1996
+ const asked = how.ask(tree, rec, body);
1997
+ if ("code" in asked)
1998
+ return reply(asked.code, asked.body);
1999
+ // a send or an adoption being written, another decision: asked again in a moment
2000
+ if (writing || adopting || deciding)
2001
+ return reply(503, { ...refusal("the research is busy — ask again in a moment", "research.busy"), retry: DECIDE_RETRY_S });
2002
+ deciding = id;
2003
+ const { command, args } = stromLauncher();
2004
+ const child = spawn(command, [...args, ...asked.args, "--json"], { cwd: root, env: forApp(env, root), stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
2005
+ track(child);
2006
+ let out = "";
2007
+ let err = "";
2008
+ child.stdout.on("data", (d) => (out += d.toString("utf8")));
2009
+ child.stderr.on("data", (d) => (err += d.toString("utf8")));
2010
+ child.on("error", (e) => {
2011
+ deciding = undefined;
2012
+ reply(500, refusal(errorText(e), "decide.failed"));
2013
+ });
2014
+ child.on("close", (code) => {
2015
+ deciding = undefined;
2016
+ if (code === null)
2017
+ return;
2018
+ let data = {};
2019
+ try {
2020
+ data = JSON.parse(out);
2021
+ }
2022
+ catch {
2023
+ // said by the exit code
2024
+ }
2025
+ const now = Tree.open(root, env);
2026
+ if (code === 0)
2027
+ return reply(200, how.done(now, id, data, head(root)));
2028
+ const why = String(data.message ?? err.trim().split("\n").find((l) => l.startsWith("error:"))?.slice(6).trim() ?? `exit ${code}`);
2029
+ noteLive(root, `${id} was not decided (exit ${code}): ${why}`);
2030
+ // the research busy (another strom holding it longer than a writer waits)
2031
+ if (code === EXIT.locked)
2032
+ return reply(503, { ...refusal(why, "research.busy"), retry: DECIDE_RETRY_S });
2033
+ const known = typeof data.code === "string" ? how.refused(now, id, data.code, why) : undefined;
2034
+ reply(known?.code ?? 500, known?.body ?? { ...refusal(why, "decide.failed"), ...(typeof data.code === "string" ? { reasonCode: data.code } : {}) });
2035
+ });
2036
+ }));
2037
+ };
2038
+ /** Whose a conflict is, as the app finds it: its person (a couple's: its first partner) and its family. */
2039
+ const conflictOwners = (tree, c) => {
2040
+ const person = c.subject.find((s) => s.startsWith("P"));
2041
+ const family = c.subject.find((s) => s.startsWith("F"));
2042
+ const partner = person ?? (family ? tree.get(family)?.partners[0] : undefined);
2043
+ return { ...(partner ? { person: partner } : {}), ...(family ? { family } : {}) };
2044
+ };
2045
+ /** A conflict decided already: what was decided, which side, when and by whom. */
2046
+ const decidedAnswer = (c) => ({
2047
+ ...refusal(`${c.id} is decided already: ${c.resolution ?? ""}`, "conflict.decided", { id: c.id }),
2048
+ resolution: c.resolution ?? "",
2049
+ ...(c.taken ? { take: c.taken } : {}),
2050
+ ...(c.decidedAt ? { at: c.decidedAt } : {}),
2051
+ ...(c.decidedBy ? { by: c.decidedBy } : {}),
2052
+ ...(c.decidedIn ? { in: c.decidedIn } : {}),
2053
+ });
2054
+ /** POST /conflict/<X…> {"do": "decide", "take": "user" | "research", "note"?}: strom conflict resolve --take, the user's. */
2055
+ const conflictDecision = {
2056
+ ask(tree, rec, body) {
2057
+ const c = rec;
2058
+ if (c.state !== "open")
2059
+ return { code: 409, body: decidedAnswer(c) };
2060
+ if (!sidesOf(c))
2061
+ return { code: 422, body: refusal(`${c.id} is no conflict of an edit in the Strom app the app decides by side`, "conflict.no-edit", { id: c.id }) };
2062
+ if (body.take !== "user" && body.take !== "research")
2063
+ return { code: 400, body: refusal(`"take" is "user" or "research"`, "conflict.bad-take") };
2064
+ if (body.note !== undefined && typeof body.note !== "string")
2065
+ return { code: 400, body: refusal(`"note" is text`, "decide.bad-body") };
2066
+ // the person's note: one line of text, 200 characters at most (the rest cut)
2067
+ const note = [...String(body.note ?? "").replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim()].slice(0, 200).join("").trim();
2068
+ const decided = phrase(tree.lang, "conflict.app.decided");
2069
+ const reasoning = note ? phrase(tree.lang, "conflict.app.noted", { decided, note }) : decided;
2070
+ return { args: ["conflict", "resolve", c.id, `--take=${body.take}`, `--reasoning=${reasoning}`] };
2071
+ },
2072
+ done(tree, id, data) {
2073
+ const c = tree.get(id);
2074
+ return {
2075
+ decided: id,
2076
+ take: c.taken ?? data.taken,
2077
+ head: head(root),
2078
+ written: typeof data.written === "string" ? data.written : `${id} decided: the research's value kept`,
2079
+ ...conflictOwners(tree, c),
2080
+ };
2081
+ },
2082
+ refused(tree, id, code, why) {
2083
+ const c = tree.get(id);
2084
+ if (c && ["conflict.decided", "conflict.taken", "conflict.user-decided"].includes(code))
2085
+ return { code: 409, body: decidedAnswer(c) };
2086
+ if (code === "conflict.no-edit")
2087
+ return { code: 422, body: refusal(why, "conflict.no-edit", { id }) };
2088
+ return undefined;
2089
+ },
2090
+ };
1928
2091
  /** The app sends nothing (unchanged, the user said no, no tree of this research): strom sync --app stops waiting. */
1929
2092
  const nothing = (req, res, origin) => {
1930
2093
  const reply = (code, body) => res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(JSON.stringify(body));
@@ -50,6 +50,13 @@ export const SEARCH_RESULTS = ["found", "negative", "partial", "inconclusive"];
50
50
  export const STRATEGIES = ["balanced", "depth", "priority"];
51
51
  export const TASK_LEVELS = ["intake", "locate", "link", "verify", "enrich", "request", "narrate"];
52
52
  export const TASK_STATES = ["open", "doing", "done", "parked", "waiting", "dropped"];
53
+ /**
54
+ * What a variant of a hypothesis would connect, were it true (strom hypothesis link): whose child a person is — a
55
+ * family of the tree, or one or two parents who have no family together —, two records of one person, a couple, or
56
+ * siblings whose parents are unknown. Nothing of it is a link of the tree: the variant's claim, said so a program can
57
+ * show it (the Strom app: _VAR / _LINK under _STROM_HYPO).
58
+ */
59
+ export const VARIANT_LINK_KINDS = ["child", "same", "partners", "siblings"];
53
60
  export const LESSON_SCOPES = ["method", "repository", "recordset", "place", "project"];
54
61
  export const LESSON_MAX = 200;
55
62
  export const INPUT_KINDS = ["document", "photo", "tree", "text", "other"];