strom-research 1.0.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/LICENSE +373 -0
- package/README.md +142 -0
- package/assets/lang/cs.json +302 -0
- package/assets/lang/de.json +302 -0
- package/assets/method/core.md +43 -0
- package/assets/method/enrich.md +11 -0
- package/assets/method/intake.md +30 -0
- package/assets/method/link.md +28 -0
- package/assets/method/locate.md +28 -0
- package/assets/method/narrate.md +13 -0
- package/assets/method/reading.md +62 -0
- package/assets/method/recording.md +59 -0
- package/assets/method/request.md +10 -0
- package/assets/method/verify.md +17 -0
- package/assets/plugins/README.md +23 -0
- package/assets/plugins/connectors/DISCOVERY.md +159 -0
- package/assets/plugins/connectors/README.md +376 -0
- package/assets/plugins/connectors/sdk.ts +168 -0
- package/assets/plugins/connectors/template.ts +38 -0
- package/assets/plugins/gitignore +4 -0
- package/dist/agents/files.js +313 -0
- package/dist/agents/global.js +257 -0
- package/dist/agents/launch.js +36 -0
- package/dist/agents/profiles.js +95 -0
- package/dist/brief/brief.js +345 -0
- package/dist/cli/commit.js +44 -0
- package/dist/cli/context.js +311 -0
- package/dist/cli/execute.js +154 -0
- package/dist/cli/fixes.js +78 -0
- package/dist/cli/format.js +53 -0
- package/dist/cli/help.js +59 -0
- package/dist/cli/main.js +152 -0
- package/dist/cli/menu.js +212 -0
- package/dist/cli/registry.js +96 -0
- package/dist/cli/ui.js +266 -0
- package/dist/cli/wizard.js +142 -0
- package/dist/cli.js +14 -0
- package/dist/commands/analysis.js +622 -0
- package/dist/commands/batch.js +181 -0
- package/dist/commands/checks.js +153 -0
- package/dist/commands/connectors.js +1377 -0
- package/dist/commands/guide.js +160 -0
- package/dist/commands/index.js +19 -0
- package/dist/commands/intake.js +234 -0
- package/dist/commands/media.js +406 -0
- package/dist/commands/meta.js +195 -0
- package/dist/commands/output.js +117 -0
- package/dist/commands/people.js +664 -0
- package/dist/commands/read.js +199 -0
- package/dist/commands/research.js +139 -0
- package/dist/commands/session.js +605 -0
- package/dist/commands/setup.js +465 -0
- package/dist/commands/sources.js +634 -0
- package/dist/commands/start.js +383 -0
- package/dist/commands/story.js +75 -0
- package/dist/commands/tasks.js +436 -0
- package/dist/commands/trees.js +128 -0
- package/dist/core/actions.js +852 -0
- package/dist/core/age.js +95 -0
- package/dist/core/apps.js +74 -0
- package/dist/core/assets.js +34 -0
- package/dist/core/awake.js +33 -0
- package/dist/core/browser.js +281 -0
- package/dist/core/calibration.js +48 -0
- package/dist/core/check.js +112 -0
- package/dist/core/chromium.js +88 -0
- package/dist/core/config.js +348 -0
- package/dist/core/connector.js +811 -0
- package/dist/core/deps.js +73 -0
- package/dist/core/dialog.js +61 -0
- package/dist/core/errors.js +89 -0
- package/dist/core/evidence.js +58 -0
- package/dist/core/frontier.js +219 -0
- package/dist/core/gdate.js +77 -0
- package/dist/core/git.js +300 -0
- package/dist/core/guard.js +124 -0
- package/dist/core/http2.js +76 -0
- package/dist/core/import.js +541 -0
- package/dist/core/install.js +28 -0
- package/dist/core/integrity.js +219 -0
- package/dist/core/json.js +87 -0
- package/dist/core/lang.js +70 -0
- package/dist/core/live.js +244 -0
- package/dist/core/lock.js +112 -0
- package/dist/core/logins.js +67 -0
- package/dist/core/media.js +223 -0
- package/dist/core/model.js +101 -0
- package/dist/core/net.js +366 -0
- package/dist/core/open.js +29 -0
- package/dist/core/paths.js +84 -0
- package/dist/core/people.js +283 -0
- package/dist/core/phrases.js +85 -0
- package/dist/core/queue.js +113 -0
- package/dist/core/reader.js +76 -0
- package/dist/core/records.js +105 -0
- package/dist/core/roles.js +30 -0
- package/dist/core/schema.js +261 -0
- package/dist/core/seal.js +77 -0
- package/dist/core/self.js +40 -0
- package/dist/core/session.js +155 -0
- package/dist/core/shortcut.js +90 -0
- package/dist/core/stories.js +61 -0
- package/dist/core/stromapp.js +138 -0
- package/dist/core/text.js +104 -0
- package/dist/core/tree.js +507 -0
- package/dist/core/uninstall.js +128 -0
- package/dist/core/update.js +193 -0
- package/dist/core/validate.js +260 -0
- package/dist/core/views.js +164 -0
- package/dist/core/which.js +51 -0
- package/dist/core/workers.js +42 -0
- package/dist/gedcom/export.js +454 -0
- package/dist/gedcom/labels.js +103 -0
- package/dist/gedcom/lines.js +91 -0
- package/dist/gedcom/parse.js +53 -0
- package/dist/gedcom/validate.js +183 -0
- package/dist/image/image.js +223 -0
- package/dist/image/index.js +114 -0
- package/dist/image/jpeg-decode.js +552 -0
- package/dist/image/jpeg-encode.js +254 -0
- package/dist/image/png.js +241 -0
- package/dist/runners/antigravity.js +70 -0
- package/dist/runners/claude.js +179 -0
- package/dist/runners/codex.js +45 -0
- package/dist/runners/index.js +13 -0
- package/dist/runners/jsonl.js +86 -0
- package/dist/runners/opencode.js +50 -0
- package/dist/runners/runner.js +63 -0
- package/dist/runners/script.js +58 -0
- package/package.json +44 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
// task add · next · list · show · edit · start · done · park · wake · wait · drop
|
|
2
|
+
//
|
|
3
|
+
// A task is a contract: WHAT to find, WHERE to look (record sets or a clear
|
|
4
|
+
// description), WHY it matters, and WHEN it is done. A task without "where"
|
|
5
|
+
// is a wish; strom refuses it.
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { ui } from "../cli/ui.js";
|
|
9
|
+
import { register } from "../cli/registry.js";
|
|
10
|
+
import { lines, moreLine, paginate, runs, table, truncate } from "../cli/format.js";
|
|
11
|
+
import { UsageError } from "../core/errors.js";
|
|
12
|
+
import { TASK_LEVELS } from "../core/model.js";
|
|
13
|
+
import { create, csvOpt, listOpt, normId, requireRecord, update } from "../core/records.js";
|
|
14
|
+
import { resolvePerson } from "../core/people.js";
|
|
15
|
+
import { now, typeOfId } from "../core/tree.js";
|
|
16
|
+
import { resolveResearch } from "./research.js";
|
|
17
|
+
import { makeNote } from "../core/actions.js";
|
|
18
|
+
import { lacksImages, rankTasks } from "../core/queue.js";
|
|
19
|
+
import { inboxFolderFor, parseImageList } from "../core/media.js";
|
|
20
|
+
function written(tree) {
|
|
21
|
+
return lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined);
|
|
22
|
+
}
|
|
23
|
+
/** Resolve subject references: IDs of any type, or person names. */
|
|
24
|
+
function subjects(tree, refs) {
|
|
25
|
+
return refs.map((r) => {
|
|
26
|
+
const id = normId(r);
|
|
27
|
+
if (typeOfId(id)) {
|
|
28
|
+
if (!tree.get(id))
|
|
29
|
+
throw new UsageError(`no record ${id}`);
|
|
30
|
+
return id;
|
|
31
|
+
}
|
|
32
|
+
return resolvePerson(tree, r).id;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function defaultResearch(tree, ref) {
|
|
36
|
+
if (typeof ref === "string")
|
|
37
|
+
return resolveResearch(tree, ref).id;
|
|
38
|
+
const active = tree.list("research").filter((r) => r.state === "active");
|
|
39
|
+
return active.length === 1 ? active[0].id : undefined;
|
|
40
|
+
}
|
|
41
|
+
/** What the research waits for from outside — mostly from the user: scans to download, a book to find, a reply. */
|
|
42
|
+
export function waitingForUser(tree) {
|
|
43
|
+
return tree
|
|
44
|
+
.list("task")
|
|
45
|
+
.filter((t) => t.state === "waiting")
|
|
46
|
+
.map((t) => ({ id: t.id, what: t.what, on: t.waitingOn ?? "" }));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The lines that tell the user what the research waits for. Images they save by hand come with
|
|
50
|
+
* the book's link and the folder they go into: facts, while the request itself is in their language.
|
|
51
|
+
*/
|
|
52
|
+
export function waitingLines(tree, where = {}, max = 5) {
|
|
53
|
+
const w = tree.list("task").filter((t) => t.state === "waiting");
|
|
54
|
+
if (!w.length)
|
|
55
|
+
return undefined;
|
|
56
|
+
const show = where.display ?? ((p) => p);
|
|
57
|
+
const lang = tree.lang;
|
|
58
|
+
const images = (t) => {
|
|
59
|
+
if (!t.awaits)
|
|
60
|
+
return [];
|
|
61
|
+
const b = tree.get(t.awaits.recordset);
|
|
62
|
+
const dir = where.shared ? show(path.join(where.shared, "inbox", t.awaits.folder)) : `inbox/${t.awaits.folder}`;
|
|
63
|
+
return [
|
|
64
|
+
` ${ui(lang, "ui.wait.images", { images: t.awaits.images, book: `${t.awaits.recordset}${b ? ` ${b.title}` : ""}` })}${b?.url ? ` · ${b.url}` : ""}`,
|
|
65
|
+
` ${ui(lang, "ui.wait.save", { dir: `${dir}${path.sep}` })}`,
|
|
66
|
+
];
|
|
67
|
+
};
|
|
68
|
+
return lines(ui(lang, "ui.wait.title", { n: w.length }), ...w.slice(0, max).flatMap((t) => [` ${t.id} ${truncate(t.waitingOn || t.what, 150)}`, ...images(t)]), w.length > max ? ` … strom task list --state waiting` : undefined, ` ${ui(lang, "ui.wait.how1")}`, ` ${ui(lang, "ui.wait.how2")}`);
|
|
69
|
+
}
|
|
70
|
+
/** The open tasks of the queue in the order to work on them (see core/queue.ts). */
|
|
71
|
+
export function taskQueue(tree, filter = {}) {
|
|
72
|
+
return rankedQueue(tree, filter).map((r) => r.task);
|
|
73
|
+
}
|
|
74
|
+
export function rankedQueue(tree, filter = {}) {
|
|
75
|
+
const tasks = tree
|
|
76
|
+
.list("task")
|
|
77
|
+
.filter((t) => !filter.research || !t.research || t.research === filter.research)
|
|
78
|
+
.filter((t) => !filter.level || t.level === filter.level);
|
|
79
|
+
return rankTasks(tree, tasks, filter.strategy);
|
|
80
|
+
}
|
|
81
|
+
function taskLine(t) {
|
|
82
|
+
return [t.id, `p${t.priority}`, t.level, t.state === "open" ? "" : `[${t.state}]`, truncate(t.what, 70)];
|
|
83
|
+
}
|
|
84
|
+
function taskDetail(tree, t) {
|
|
85
|
+
const where = t.where.map((w) => {
|
|
86
|
+
const b = /^B\d{4,}$/.test(w) ? tree.get(w) : undefined;
|
|
87
|
+
return b ? `${b.id} ${b.title}${b.url ? ` · ${b.url}` : ""}` : w;
|
|
88
|
+
});
|
|
89
|
+
const searches = tree.list("search").filter((s) => s.task === t.id || s.recordsets.some((b) => t.where.includes(b)));
|
|
90
|
+
const lessons = tree.list("lesson").filter((l) => l.target && t.where.includes(l.target));
|
|
91
|
+
return lines(`${t.id} ${t.what}`, `level ${t.level} · priority ${t.priority} · ${t.state}${t.research ? ` · research ${t.research}` : ""}${t.parkedUntil ? ` · parked until ${t.parkedUntil}` : ""}${t.waitingOn ? ` · waiting on ${t.waitingOn}` : ""}`, `where ${where.join("\n ")}`, `why ${t.why}`, `done ${t.doneWhen}`, t.subject.length ? `about ${t.subject.join(" ")}` : undefined, t.result ? `result ${t.result}${t.produced?.length ? ` → ${t.produced.join(" ")}` : ""}` : undefined, searches.length ? "\nalready searched there\n" + table(searches.map((s) => [` ${s.id}`, s.result, truncate(s.question, 60), s.scope.years ?? ""])) : undefined, lessons.length ? "\nlessons for these record sets\n" + lessons.map((l) => ` ${l.id} ${l.rule}`).join("\n") : undefined, ...t.notes.map((n) => `note ${n.text}`));
|
|
92
|
+
}
|
|
93
|
+
function stateChange(tree, ref, state, fields, reason, verb) {
|
|
94
|
+
const id = normId(ref, "task");
|
|
95
|
+
return update(tree, id, "task", (t) => {
|
|
96
|
+
if (t.state === "done" && state !== "open")
|
|
97
|
+
throw new UsageError(`${id} is already done`);
|
|
98
|
+
const next = { ...t, state, ...fields };
|
|
99
|
+
if (state !== "parked")
|
|
100
|
+
delete next.parkedUntil;
|
|
101
|
+
if (state !== "waiting") {
|
|
102
|
+
delete next.waitingOn;
|
|
103
|
+
delete next.awaits;
|
|
104
|
+
}
|
|
105
|
+
else if (!fields.awaits)
|
|
106
|
+
delete next.awaits;
|
|
107
|
+
return next;
|
|
108
|
+
}, { op: `task.${verb}`, summary: `${id} ${verb}${fields.result ? `: ${truncate(fields.result, 60)}` : ""}`, reason });
|
|
109
|
+
}
|
|
110
|
+
register({
|
|
111
|
+
path: ["task", "add"],
|
|
112
|
+
summary: "Add a task: what, where, why, done-when (all required)",
|
|
113
|
+
group: "tasks",
|
|
114
|
+
tree: true,
|
|
115
|
+
writes: true,
|
|
116
|
+
description: "Levels: intake (process an input), locate (find where records are), link (prove a parent/marriage),\n" +
|
|
117
|
+
"verify (independent second reading), enrich (details of a placed person), request (ask an archive),\n" +
|
|
118
|
+
"narrate (write the story). Priority 1 (low) – 5 (urgent), default 3.",
|
|
119
|
+
args: [{ name: "what", description: 'what to find, one line: "Baptism of Jan Novák (~1905)"', required: true }],
|
|
120
|
+
options: [
|
|
121
|
+
{ name: "level", type: "string", value: "<level>", description: TASK_LEVELS.join(", ") },
|
|
122
|
+
{ name: "where", type: "string", multiple: true, value: "<B…|text>", description: "record set ID or a precise description (repeatable)" },
|
|
123
|
+
{ name: "why", type: "string", value: "<text>", description: "why it matters for the research" },
|
|
124
|
+
{ name: "done-when", type: "string", value: "<text>", description: "what counts as done (found, or searched completely)" },
|
|
125
|
+
{ name: "priority", type: "string", value: "1-5", description: "default 3" },
|
|
126
|
+
{ name: "about", type: "string", multiple: true, value: "<who>", description: "person/record the task is about, also a conflict X… or hypothesis H… it decides (repeatable)" },
|
|
127
|
+
{ name: "research", type: "string", value: "<G…>", description: "research it belongs to (default: the only active one)" },
|
|
128
|
+
{ name: "note", type: "string", value: "<text>", description: "short note" },
|
|
129
|
+
],
|
|
130
|
+
examples: [
|
|
131
|
+
'strom task add "Křest Jana Nováka (~1905)" --level link --where B0001 --why "potvrdí otce Josefa" --done-when "zápis nalezen, nebo ročníky 1903–1907 prohledány celé" --about P0001 --priority 4',
|
|
132
|
+
],
|
|
133
|
+
run(ctx, { args, opts }) {
|
|
134
|
+
const tree = ctx.tree();
|
|
135
|
+
const missing = [!opts.level && "--level", !listOpt(opts.where).length && "--where", !opts.why && "--why", !opts["done-when"] && "--done-when"].filter(Boolean);
|
|
136
|
+
if (missing.length)
|
|
137
|
+
throw new UsageError(`a task needs ${missing.join(", ")}`, { hint: "a task without where is a wish: name the record set (B…) or describe exactly where to look" });
|
|
138
|
+
if (!TASK_LEVELS.includes(opts.level))
|
|
139
|
+
throw new UsageError(`invalid --level "${opts.level}"`, { hint: TASK_LEVELS.join(", ") });
|
|
140
|
+
const priority = opts.priority === undefined ? 3 : Number(opts.priority);
|
|
141
|
+
if (!Number.isInteger(priority) || priority < 1 || priority > 5)
|
|
142
|
+
throw new UsageError("--priority must be 1..5");
|
|
143
|
+
const where = listOpt(opts.where).map((w) => (/^[Bb]\d+$/.test(w) ? requireRecord(tree, w, "recordset").id : w));
|
|
144
|
+
const t = create(tree, "task", {
|
|
145
|
+
level: opts.level,
|
|
146
|
+
priority,
|
|
147
|
+
what: args[0].trim(),
|
|
148
|
+
where,
|
|
149
|
+
why: String(opts.why).trim(),
|
|
150
|
+
doneWhen: String(opts["done-when"]).trim(),
|
|
151
|
+
subject: subjects(tree, listOpt(opts.about)),
|
|
152
|
+
research: defaultResearch(tree, opts.research),
|
|
153
|
+
state: "open",
|
|
154
|
+
origin: tree.actor,
|
|
155
|
+
note: opts.note,
|
|
156
|
+
}, (id) => `+${id} task "${truncate(args[0], 60)}"`);
|
|
157
|
+
// the same person, the same kind of work, the same books: most likely the same task
|
|
158
|
+
const books = new Set(where.filter((w) => /^B\d+$/.test(w)));
|
|
159
|
+
const similar = tree
|
|
160
|
+
.list("task")
|
|
161
|
+
.filter((x) => x.id !== t.id && ["open", "doing", "waiting", "parked"].includes(x.state) && x.level === t.level)
|
|
162
|
+
.filter((x) => x.subject.some((s) => t.subject.includes(s)) && (books.size === 0 || x.where.some((w) => books.has(w))));
|
|
163
|
+
// A link without images can only wait for the user: say now what to download, while
|
|
164
|
+
// it is known — not in a session spent finding out that there is nothing to read.
|
|
165
|
+
const noImages = lacksImages(tree, t);
|
|
166
|
+
return {
|
|
167
|
+
text: lines(written(tree), ...similar.map((x) => `⚠ similar ${x.state} task ${x.id} "${truncate(x.what, 60)}" — if it is the same, keep one: strom task drop ${t.id} --reason "duplicate of ${x.id}" and strom task edit ${x.id} --where … --note "…"`), noImages ? `· no images for it here yet — if the user has to download them, say which now: strom task wait ${t.id} --images B…:<numbers> --on "<the book, its link, which images>"` : undefined),
|
|
168
|
+
data: { task: t, similar: similar.map((x) => x.id), needsImages: noImages },
|
|
169
|
+
};
|
|
170
|
+
},
|
|
171
|
+
}, {
|
|
172
|
+
path: ["task", "next"],
|
|
173
|
+
summary: "The task to work on now (never asks — the queue is ordered)",
|
|
174
|
+
group: "tasks",
|
|
175
|
+
tree: true,
|
|
176
|
+
options: [
|
|
177
|
+
{ name: "research", type: "string", value: "<G…>", description: "only this research" },
|
|
178
|
+
{ name: "level", type: "string", value: "<level>", description: "only this level" },
|
|
179
|
+
],
|
|
180
|
+
run(ctx, { opts }) {
|
|
181
|
+
const tree = ctx.tree();
|
|
182
|
+
const research = typeof opts.research === "string" ? resolveResearch(tree, opts.research).id : undefined;
|
|
183
|
+
const strategy = ctx.settings.strategy(tree.config);
|
|
184
|
+
const queue = rankedQueue(tree, { ...(research ? { research } : {}), ...(opts.level ? { level: String(opts.level) } : {}), strategy });
|
|
185
|
+
const first = queue[0];
|
|
186
|
+
if (!first)
|
|
187
|
+
return { text: 'no open tasks → strom research show <G…> to see what is missing, then strom task add', data: { task: null } };
|
|
188
|
+
const t = first.task;
|
|
189
|
+
return {
|
|
190
|
+
text: lines(taskDetail(tree, t), "", `first because: ${first.why} (queue: ${strategy})`, `start it: strom task start ${t.id}`),
|
|
191
|
+
data: { task: t, why: first.why, strategy, queueLength: queue.length },
|
|
192
|
+
};
|
|
193
|
+
},
|
|
194
|
+
}, {
|
|
195
|
+
path: ["task", "list"],
|
|
196
|
+
summary: "The task queue in order (or all tasks with --state)",
|
|
197
|
+
description: "The order (setting queue.strategy): balanced — work that can be done now before work waiting for a download,\n" +
|
|
198
|
+
"the nearest ancestors first, a line that just had sessions waits for the others, a task tried again and again\n" +
|
|
199
|
+
"sinks; depth — stay on the line of the last sessions; priority — strict priority. --json says why for each.",
|
|
200
|
+
group: "tasks",
|
|
201
|
+
tree: true,
|
|
202
|
+
options: [
|
|
203
|
+
{ name: "state", type: "string", value: "<state>", description: "open (default queue), doing, done, parked, waiting, dropped, all" },
|
|
204
|
+
{ name: "research", type: "string", value: "<G…>", description: "only this research" },
|
|
205
|
+
{ name: "level", type: "string", value: "<level>", description: "only this level" },
|
|
206
|
+
{ name: "about", type: "string", value: "<who>", description: "only tasks about this person/record (also X…, H…)" },
|
|
207
|
+
{ name: "full", type: "boolean", description: "--json: whole records instead of one row each" },
|
|
208
|
+
],
|
|
209
|
+
run(ctx, { opts }) {
|
|
210
|
+
const tree = ctx.tree();
|
|
211
|
+
const research = typeof opts.research === "string" ? resolveResearch(tree, opts.research).id : undefined;
|
|
212
|
+
let all;
|
|
213
|
+
const why = new Map();
|
|
214
|
+
if (!opts.state) {
|
|
215
|
+
const ranked = rankedQueue(tree, { ...(research ? { research } : {}), ...(opts.level ? { level: String(opts.level) } : {}), strategy: ctx.settings.strategy(tree.config) });
|
|
216
|
+
for (const r of ranked)
|
|
217
|
+
why.set(r.task.id, r.why);
|
|
218
|
+
all = ranked.map((r) => r.task);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
all = tree.list("task").filter((t) => opts.state === "all" || t.state === opts.state);
|
|
222
|
+
if (research)
|
|
223
|
+
all = all.filter((t) => t.research === research);
|
|
224
|
+
if (opts.level)
|
|
225
|
+
all = all.filter((t) => t.level === opts.level);
|
|
226
|
+
}
|
|
227
|
+
if (opts.about) {
|
|
228
|
+
const id = subjects(tree, [String(opts.about)])[0];
|
|
229
|
+
all = all.filter((t) => t.subject.includes(id));
|
|
230
|
+
}
|
|
231
|
+
const page = paginate(all, ctx.limit, ctx.page);
|
|
232
|
+
return {
|
|
233
|
+
text: all.length ? lines(table(page.items.map(taskLine)), moreLine(page, "strom task list")) : "no tasks",
|
|
234
|
+
data: { total: page.total, tasks: opts.full ? page.items : page.items.map((t) => ({ id: t.id, priority: t.priority, level: t.level, state: t.state, what: t.what, subject: t.subject, ...(why.has(t.id) ? { why: why.get(t.id) } : {}) })) },
|
|
235
|
+
};
|
|
236
|
+
},
|
|
237
|
+
}, {
|
|
238
|
+
path: ["task", "show"],
|
|
239
|
+
summary: "One task with what was already searched there and lessons for those record sets",
|
|
240
|
+
group: "tasks",
|
|
241
|
+
tree: true,
|
|
242
|
+
args: [{ name: "task", description: "task ID (T0001)", required: true }],
|
|
243
|
+
run(ctx, { args }) {
|
|
244
|
+
const tree = ctx.tree();
|
|
245
|
+
const t = requireRecord(tree, args[0], "task");
|
|
246
|
+
return { text: taskDetail(tree, t), data: { task: t } };
|
|
247
|
+
},
|
|
248
|
+
}, {
|
|
249
|
+
path: ["task", "edit"],
|
|
250
|
+
summary: "Change a task: point it at record sets, sharpen what/why/done-when, priority, who it is about",
|
|
251
|
+
group: "tasks",
|
|
252
|
+
tree: true,
|
|
253
|
+
writes: true,
|
|
254
|
+
description: "--where and --about replace the old values (repeat them for several). A task written before its books were\nknown is pointed at them here — that is how a locate task hands over to a link task.",
|
|
255
|
+
args: [{ name: "task", description: "task ID", required: true }],
|
|
256
|
+
options: [
|
|
257
|
+
{ name: "what", type: "string", value: "<text>", description: "what to find" },
|
|
258
|
+
{ name: "where", type: "string", multiple: true, value: "<B…|text>", description: "record set ID or a precise description (repeatable; replaces)" },
|
|
259
|
+
{ name: "why", type: "string", value: "<text>", description: "why it matters" },
|
|
260
|
+
{ name: "done-when", type: "string", value: "<text>", description: "what counts as done" },
|
|
261
|
+
{ name: "priority", type: "string", value: "1-5", description: "priority" },
|
|
262
|
+
{ name: "about", type: "string", multiple: true, value: "<who>", description: "person/record the task is about, also a conflict X… or hypothesis H… it decides (repeatable; replaces)" },
|
|
263
|
+
{ name: "note", type: "string", value: "<text>", description: "add a short note (what a later finding means for this task)" },
|
|
264
|
+
],
|
|
265
|
+
examples: ["strom task edit T0001 --where B0001 --priority 4", 'strom task edit T0001 --note "the baptism of the son names the bride\'s father"'],
|
|
266
|
+
run(ctx, { args, opts }) {
|
|
267
|
+
const tree = ctx.tree();
|
|
268
|
+
const id = normId(args[0], "task");
|
|
269
|
+
const change = {};
|
|
270
|
+
if (typeof opts.what === "string" && opts.what.trim())
|
|
271
|
+
change.what = opts.what.trim();
|
|
272
|
+
if (listOpt(opts.where).length)
|
|
273
|
+
change.where = listOpt(opts.where).map((w) => (/^[Bb]\d+$/.test(w) ? requireRecord(tree, w, "recordset").id : w));
|
|
274
|
+
if (typeof opts.why === "string" && opts.why.trim())
|
|
275
|
+
change.why = opts.why.trim();
|
|
276
|
+
if (typeof opts["done-when"] === "string" && opts["done-when"].trim())
|
|
277
|
+
change.doneWhen = opts["done-when"].trim();
|
|
278
|
+
if (opts.priority !== undefined) {
|
|
279
|
+
const priority = Number(opts.priority);
|
|
280
|
+
if (!Number.isInteger(priority) || priority < 1 || priority > 5)
|
|
281
|
+
throw new UsageError("--priority must be 1..5");
|
|
282
|
+
change.priority = priority;
|
|
283
|
+
}
|
|
284
|
+
if (listOpt(opts.about).length)
|
|
285
|
+
change.subject = subjects(tree, listOpt(opts.about));
|
|
286
|
+
const note = typeof opts.note === "string" ? makeNote(tree, opts.note) : undefined;
|
|
287
|
+
const fields = [...Object.keys(change), ...(note ? ["note"] : [])];
|
|
288
|
+
if (!fields.length)
|
|
289
|
+
throw new UsageError("nothing to change", { hint: `e.g. strom task edit ${id} --where B0001` });
|
|
290
|
+
const t = update(tree, id, "task", (t) => ({ ...t, ...change, ...(note ? { notes: [...t.notes, note] } : {}) }), {
|
|
291
|
+
op: "task.edit",
|
|
292
|
+
summary: `${id} ${fields.map((f) => (f === "doneWhen" ? "done-when" : f === "subject" ? "about" : f)).join(", ")}${change.where ? ` → ${truncate(change.where.join("; "), 50)}` : ""}`,
|
|
293
|
+
reason: opts.reason,
|
|
294
|
+
});
|
|
295
|
+
return { text: written(tree), data: { task: t } };
|
|
296
|
+
},
|
|
297
|
+
}, {
|
|
298
|
+
path: ["task", "start"],
|
|
299
|
+
summary: "Mark a task as being worked on",
|
|
300
|
+
group: "tasks",
|
|
301
|
+
tree: true,
|
|
302
|
+
writes: true,
|
|
303
|
+
args: [{ name: "task", description: "task ID", required: true }],
|
|
304
|
+
run(ctx, { args }) {
|
|
305
|
+
const tree = ctx.tree();
|
|
306
|
+
const t = stateChange(tree, args[0], "doing", {}, undefined, "start");
|
|
307
|
+
return { text: lines(written(tree), `details: strom task show ${t.id}`), data: { task: t } };
|
|
308
|
+
},
|
|
309
|
+
}, {
|
|
310
|
+
path: ["task", "done"],
|
|
311
|
+
summary: "Close a task with its result (also a negative one)",
|
|
312
|
+
group: "tasks",
|
|
313
|
+
tree: true,
|
|
314
|
+
writes: true,
|
|
315
|
+
args: [{ name: "task", description: "task ID", required: true }],
|
|
316
|
+
options: [
|
|
317
|
+
{ name: "result", type: "string", value: "<text>", description: "what came out — 'not found in 1903–1907, searched completely' is a result" },
|
|
318
|
+
{ name: "produced", type: "string", multiple: true, value: "<ID>", description: "records created (sources, events, searches…)" },
|
|
319
|
+
],
|
|
320
|
+
run(ctx, { args, opts }) {
|
|
321
|
+
const tree = ctx.tree();
|
|
322
|
+
if (!opts.result)
|
|
323
|
+
throw new UsageError("--result is required", { hint: "say what was found — or where it was searched without success" });
|
|
324
|
+
const t = stateChange(tree, args[0], "done", { result: String(opts.result).trim(), produced: csvOpt(opts.produced).map((x) => normId(x)) }, undefined, "done");
|
|
325
|
+
// A search that is not recorded will be done again: nudge when a search task records none.
|
|
326
|
+
const noSearch = ["locate", "link", "verify", "enrich"].includes(t.level) && !tree.list("search").some((s) => s.task === t.id)
|
|
327
|
+
? `note: no search is recorded for ${t.id} — record what you looked at, found or not: strom search add "<what>" --task ${t.id} --method web|catalog|index|page-by-page --result found|negative`
|
|
328
|
+
: undefined;
|
|
329
|
+
// A locate task hands over: the link tasks that name no record set yet should now name one.
|
|
330
|
+
const unpointed = t.level === "locate"
|
|
331
|
+
? tree.list("task").filter((x) => x.level === "link" && ["open", "doing", "parked"].includes(x.state) && !x.where.some((w) => /^B\d{4,}$/.test(w)))
|
|
332
|
+
: [];
|
|
333
|
+
const handover = unpointed.length
|
|
334
|
+
? `note: ${unpointed.map((x) => x.id).join(", ")} name no record set yet — point them at the books found: strom task edit ${unpointed[0].id} --where B…`
|
|
335
|
+
: undefined;
|
|
336
|
+
// Finishing an intake task finishes its input: nothing is left dangling as "new".
|
|
337
|
+
if (t.level === "intake")
|
|
338
|
+
for (const id of new Set([...t.subject, ...t.where]))
|
|
339
|
+
if (typeOfId(id) === "input" && tree.get(id)?.state === "new")
|
|
340
|
+
update(tree, id, "input", (i) => ({ ...i, state: "processed" }), { op: "input.done", summary: `${id} processed (${t.id} done)` });
|
|
341
|
+
return { text: lines(written(tree), noSearch, handover), data: { task: t } };
|
|
342
|
+
},
|
|
343
|
+
}, {
|
|
344
|
+
path: ["task", "park"],
|
|
345
|
+
summary: "Put a task aside (it stops being offered) — optionally until a date",
|
|
346
|
+
group: "tasks",
|
|
347
|
+
tree: true,
|
|
348
|
+
writes: true,
|
|
349
|
+
args: [{ name: "task", description: "task ID", required: true }],
|
|
350
|
+
options: [{ name: "until", type: "string", value: "<YYYY-MM-DD>", description: "offer it again after this date" }],
|
|
351
|
+
run(ctx, { args, opts }) {
|
|
352
|
+
const tree = ctx.tree();
|
|
353
|
+
if (!opts.reason)
|
|
354
|
+
throw new UsageError("--reason is required", { hint: 'e.g. --reason "waiting until the register is digitised"' });
|
|
355
|
+
const until = opts.until ? String(opts.until) : undefined;
|
|
356
|
+
if (until && !/^\d{4}-\d{2}-\d{2}$/.test(until))
|
|
357
|
+
throw new UsageError("--until must be YYYY-MM-DD");
|
|
358
|
+
const t = stateChange(tree, args[0], "parked", until ? { parkedUntil: until } : {}, String(opts.reason), "park");
|
|
359
|
+
return { text: written(tree), data: { task: t } };
|
|
360
|
+
},
|
|
361
|
+
}, {
|
|
362
|
+
path: ["task", "wake"],
|
|
363
|
+
summary: "Bring a parked or waiting task back into the queue",
|
|
364
|
+
group: "tasks",
|
|
365
|
+
tree: true,
|
|
366
|
+
writes: true,
|
|
367
|
+
args: [{ name: "task", description: "task ID", required: true }],
|
|
368
|
+
run(ctx, { args }) {
|
|
369
|
+
const tree = ctx.tree();
|
|
370
|
+
const t = stateChange(tree, args[0], "open", {}, undefined, "wake");
|
|
371
|
+
return { text: written(tree), data: { task: t } };
|
|
372
|
+
},
|
|
373
|
+
}, {
|
|
374
|
+
path: ["task", "wait"],
|
|
375
|
+
summary: "The task waits for something outside (an archive's reply, the user) — images the user saves by hand: --images",
|
|
376
|
+
group: "tasks",
|
|
377
|
+
tree: true,
|
|
378
|
+
writes: true,
|
|
379
|
+
description: "--on says what it waits for, to the user, in their language. When it is images the user downloads by hand\n" +
|
|
380
|
+
"(no connector may fetch them), name them with --images B…:<numbers>: strom makes the folder of the shared\n" +
|
|
381
|
+
"inbox they go into and shows it to the user, checks the numbers of what arrives, and the task comes back\n" +
|
|
382
|
+
"by itself once they are registered.",
|
|
383
|
+
args: [{ name: "task", description: "task ID", required: true }],
|
|
384
|
+
options: [
|
|
385
|
+
{ name: "on", type: "string", value: "<text>", description: "what it waits for, for the user: which book, its link, which images" },
|
|
386
|
+
{ name: "images", type: "string", value: "<B…:n-m>", description: "the images the user saves by hand, numbered as the portal's viewer counts them (B0001:40-69, B0001:9,12)" },
|
|
387
|
+
],
|
|
388
|
+
examples: [
|
|
389
|
+
'strom task wait T0002 --on "reply of the archive to the request of 21 Sep"',
|
|
390
|
+
'strom task wait T0002 --images B0001:40-45 --on "images 40–45 of the baptisms of Týnec 1784–1820 (https://archive.example.org/book/5359)"',
|
|
391
|
+
],
|
|
392
|
+
run(ctx, { args, opts }) {
|
|
393
|
+
const tree = ctx.tree();
|
|
394
|
+
if (!opts.on)
|
|
395
|
+
throw new UsageError("--on is required", { hint: 'e.g. --on "reply of the archive to the request of 21 Sep"' });
|
|
396
|
+
let awaits;
|
|
397
|
+
let b;
|
|
398
|
+
if (opts.images !== undefined) {
|
|
399
|
+
const m = /^\s*([Bb]\d+)\s*:\s*(.+)$/.exec(String(opts.images));
|
|
400
|
+
const nums = m ? parseImageList(m[2]) : undefined;
|
|
401
|
+
if (!m || !nums)
|
|
402
|
+
throw new UsageError(`--images: a record set and its image numbers, e.g. B0001:40-45 or B0001:9,12 — not "${opts.images}"`);
|
|
403
|
+
b = requireRecord(tree, m[1], "recordset");
|
|
404
|
+
awaits = { recordset: b.id, images: runs(nums), folder: inboxFolderFor(b) };
|
|
405
|
+
}
|
|
406
|
+
const id = normId(args[0], "task");
|
|
407
|
+
const task = tree.get(id);
|
|
408
|
+
const where = b && task && !task.where.includes(b.id) ? { where: [...task.where, b.id] } : {};
|
|
409
|
+
const t = stateChange(tree, args[0], "waiting", { waitingOn: String(opts.on), ...(awaits ? { awaits } : {}), ...where }, undefined, "wait");
|
|
410
|
+
if (!awaits)
|
|
411
|
+
return { text: written(tree), data: { task: t } };
|
|
412
|
+
// the folder is there before the user looks for it
|
|
413
|
+
const s = ctx.settings.shared()?.value;
|
|
414
|
+
const dir = s ? path.join(s, "inbox", awaits.folder) : undefined;
|
|
415
|
+
if (dir && !tree.dryRun)
|
|
416
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
417
|
+
return {
|
|
418
|
+
text: lines(written(tree), `the user saves images ${awaits.images} of ${b.id} into ${dir ? ctx.display(dir) : `the inbox folder "${awaits.folder}"`}, each named by its number (${parseImageList(awaits.images)[0]}.jpg)`, "they see it in strom's overview; once the images are registered (strom media add --inbox), the task comes back by itself"),
|
|
419
|
+
data: { task: t, folder: dir },
|
|
420
|
+
};
|
|
421
|
+
},
|
|
422
|
+
}, {
|
|
423
|
+
path: ["task", "drop"],
|
|
424
|
+
summary: "Give up a task (kept, with the reason)",
|
|
425
|
+
group: "tasks",
|
|
426
|
+
tree: true,
|
|
427
|
+
writes: true,
|
|
428
|
+
args: [{ name: "task", description: "task ID", required: true }],
|
|
429
|
+
run(ctx, { args, opts }) {
|
|
430
|
+
const tree = ctx.tree();
|
|
431
|
+
if (!opts.reason)
|
|
432
|
+
throw new UsageError("--reason is required");
|
|
433
|
+
const t = stateChange(tree, args[0], "dropped", {}, String(opts.reason), "drop");
|
|
434
|
+
return { text: written(tree), data: { task: t } };
|
|
435
|
+
},
|
|
436
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// init · trees · status · lang — trees (one folder per family tree).
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { waitingLines } from "./tasks.js";
|
|
5
|
+
import { register } from "../cli/registry.js";
|
|
6
|
+
import { Context } from "../cli/context.js";
|
|
7
|
+
import { lines, table } from "../cli/format.js";
|
|
8
|
+
import { Tree } from "../core/tree.js";
|
|
9
|
+
import { safeFolderName } from "../core/text.js";
|
|
10
|
+
import { isValidLang, langName } from "../core/lang.js";
|
|
11
|
+
import { UsageError } from "../core/errors.js";
|
|
12
|
+
import { verifyFast } from "../core/integrity.js";
|
|
13
|
+
import * as git from "../core/git.js";
|
|
14
|
+
import { ensureShared, setTreeSetting } from "./setup.js";
|
|
15
|
+
import { syncAgentFiles } from "../agents/files.js";
|
|
16
|
+
register({
|
|
17
|
+
path: ["init"],
|
|
18
|
+
summary: "Create a family tree (a folder with its own history) to hold researches",
|
|
19
|
+
group: "research",
|
|
20
|
+
description: "The tree goes into the trees folder unless --dir is given. One tree = one family = one tree in the Strom app.",
|
|
21
|
+
args: [{ name: "name", description: 'tree name, e.g. "Novákovi"', required: true }],
|
|
22
|
+
options: [{ name: "dir", type: "string", value: "<folder>", description: "create the tree in this folder instead" }],
|
|
23
|
+
examples: ['strom init "Novákovi"', 'strom init "Novákovi" --lang cs', 'strom init "Dvořákovi" --dir "~/Rodokmeny/Dvořákovi"'],
|
|
24
|
+
run: async (ctx, { args, opts }) => {
|
|
25
|
+
const name = args[0].trim();
|
|
26
|
+
if (!name)
|
|
27
|
+
throw new UsageError("the tree needs a name");
|
|
28
|
+
await ctx.requireHome();
|
|
29
|
+
const treesDir = ctx.settings.trees().value;
|
|
30
|
+
const root = opts.dir ? ctx.resolvePath(opts.dir) : path.join(treesDir, safeFolderName(name));
|
|
31
|
+
const langR = ctx.settings.lang();
|
|
32
|
+
const lang = langR.value;
|
|
33
|
+
fs.mkdirSync(treesDir, { recursive: true });
|
|
34
|
+
ensureShared(ctx.settings.shared().value);
|
|
35
|
+
const tree = Tree.create(root, name, lang, ctx.env);
|
|
36
|
+
const agentFiles = syncAgentFiles(tree);
|
|
37
|
+
tree.withTreeLock(() => tree.commit(`Agent instructions: ${agentFiles.join(", ")}`, agentFiles));
|
|
38
|
+
if (path.dirname(root) !== treesDir) {
|
|
39
|
+
const extra = new Set(ctx.settings.config.extraTrees ?? []);
|
|
40
|
+
extra.add(root);
|
|
41
|
+
ctx.settings.config.extraTrees = [...extra];
|
|
42
|
+
ctx.settings.save();
|
|
43
|
+
}
|
|
44
|
+
ctx.adopt(tree);
|
|
45
|
+
const text = lines(`Created tree "${name}" in ${ctx.display(root)}`, `research language: ${langName(lang)} (${lang})${langR.source === "detected" ? " — detected from the system; if the user speaks another language: strom lang <code>" : ""}`, "", `next strom research new "<research name>" --new-person "<Given /Surname/>" --born "<date>" --born-place "<place>"`);
|
|
46
|
+
return { text, data: { name, root, lang } };
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
register({
|
|
50
|
+
path: ["trees"],
|
|
51
|
+
summary: "List the family trees Strom knows",
|
|
52
|
+
group: "research",
|
|
53
|
+
run(ctx) {
|
|
54
|
+
const trees = ctx.knownTrees().map((t) => {
|
|
55
|
+
const tree = Tree.open(t.root, ctx.env);
|
|
56
|
+
return {
|
|
57
|
+
...t,
|
|
58
|
+
persons: tree.count("person"),
|
|
59
|
+
researches: tree.list("research").length,
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
if (trees.length === 0)
|
|
63
|
+
return { text: 'no trees yet → strom init "<tree name>"', data: { trees } };
|
|
64
|
+
const text = table(trees.map((t) => [t.name, t.lang, `${t.persons} persons`, `${t.researches} researches`, ctx.display(t.root)]));
|
|
65
|
+
return { text, data: { trees } };
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
register({
|
|
69
|
+
path: ["trees", "use"],
|
|
70
|
+
summary: "Work on this tree from now on, wherever you are (remembered for you)",
|
|
71
|
+
group: "research",
|
|
72
|
+
description: "Commands find the tree by --tree, STROM_TREE, the folder you are in, this choice, or the only tree there is.",
|
|
73
|
+
args: [{ name: "tree", description: "tree name or folder", required: true }],
|
|
74
|
+
examples: ['strom trees use "Novákovi"'],
|
|
75
|
+
run(ctx, { args }) {
|
|
76
|
+
const probe = Context.fromOptions({ env: ctx.env, cwd: ctx.cwd, io: ctx.io, json: ctx.json, values: { tree: args[0] } });
|
|
77
|
+
const root = probe.locateTree();
|
|
78
|
+
ctx.settings.config.currentTree = root;
|
|
79
|
+
ctx.settings.save();
|
|
80
|
+
const tree = Tree.open(root, ctx.env);
|
|
81
|
+
return { text: `now working on "${tree.config.name}" (${ctx.display(root)})`, data: { name: tree.config.name, root } };
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
register({
|
|
85
|
+
path: ["status"],
|
|
86
|
+
summary: "State of the current tree: researches, data, checks, recent changes",
|
|
87
|
+
group: "start",
|
|
88
|
+
tree: true,
|
|
89
|
+
run(ctx) {
|
|
90
|
+
const tree = ctx.tree();
|
|
91
|
+
const researches = tree.list("research");
|
|
92
|
+
const findings = verifyFast(tree).findings;
|
|
93
|
+
const errors = findings.filter((f) => f.level === "error");
|
|
94
|
+
const recent = git.log(tree.root, 5);
|
|
95
|
+
const text = lines(`${tree.config.name} — ${langName(tree.config.lang)} (${tree.config.lang}) — ${ctx.display(tree.root)}`, `data ${tree.count("person")} persons · ${tree.count("family")} families · ${researches.length} researches`, `check ${errors.length === 0 ? "ok" : `${errors.length} errors → strom check`}${findings.length - errors.length ? ` · ${findings.length - errors.length} warnings` : ""}`, researches.length ? "research\n" + table(researches.map((r) => [` ${r.id}`, r.name, `[${r.state}]`])) : undefined, waitingLines(tree, { shared: ctx.settings.shared()?.value, display: (p) => ctx.display(p) }), recent.length ? "recent\n" + table(recent.map((c) => [` ${c.at.slice(0, 16).replace("T", " ")}`, c.subject])) : undefined);
|
|
96
|
+
return {
|
|
97
|
+
text,
|
|
98
|
+
data: {
|
|
99
|
+
tree: { name: tree.config.name, root: tree.root, lang: tree.config.lang },
|
|
100
|
+
counts: { persons: tree.count("person"), families: tree.count("family"), researches: researches.length },
|
|
101
|
+
researches: researches.map((r) => ({ id: r.id, name: r.name, state: r.state })),
|
|
102
|
+
findings,
|
|
103
|
+
recent,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
register({
|
|
109
|
+
path: ["lang"],
|
|
110
|
+
summary: "Show or change the research language of the current tree",
|
|
111
|
+
group: "research",
|
|
112
|
+
tree: true,
|
|
113
|
+
description: "The agent talks to the user and writes research texts in this language. CLI output stays English.",
|
|
114
|
+
args: [{ name: "code", description: "new language code, e.g. cs, en, de" }],
|
|
115
|
+
examples: ["strom lang", "strom lang de"],
|
|
116
|
+
run(ctx, { args }) {
|
|
117
|
+
const tree = ctx.tree();
|
|
118
|
+
if (args[0]) {
|
|
119
|
+
const code = args[0].toLowerCase();
|
|
120
|
+
if (!isValidLang(code))
|
|
121
|
+
throw new UsageError(`invalid language code "${args[0]}"`, { hint: "use a code like cs, en, de" });
|
|
122
|
+
// Same as `strom config set lang <code> --for-tree`: logged, committed, agent files follow.
|
|
123
|
+
setTreeSetting(ctx, "lang", code);
|
|
124
|
+
}
|
|
125
|
+
const lang = tree.config.lang;
|
|
126
|
+
return { text: `${langName(lang)} (${lang})`, data: { lang, name: langName(lang) } };
|
|
127
|
+
},
|
|
128
|
+
});
|