strom-research 1.22.0 → 1.22.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.
@@ -29,6 +29,8 @@ const MEDIA_UNNAMED_SAID = 500 * 1024 * 1024;
29
29
  import { Tree, VERSION } from "../core/tree.js";
30
30
  import { timed } from "../core/timing.js";
31
31
  import { checked, installUpdate, isNewer, knownNewerVersion, lastChannelOf, latestRelease, newerNode, newerVersion, setInstallChannel, updateChannel, whatsNewUrl } from "../core/update.js";
32
+ import { privateCopies, writesPrivately } from "../core/packaged.js";
33
+ import { moveToTrash } from "../core/trash.js";
32
34
  import { configDir, defaultHome, desktopDir, isolated, noLinks, ownCommand } from "../core/paths.js";
33
35
  import { backupBefore, backupSaid, lastBackupLine } from "../cli/backups.js";
34
36
  import { BACKUP_SAID_DAYS, lastBackup } from "../core/backup.js";
@@ -41,7 +43,7 @@ import { offerLinks, shortcutName } from "../cli/wizard.js";
41
43
  import { linkHandlerState, linkScheme, linkStateText } from "../core/links.js";
42
44
  import { globalTargets, installGlobal } from "../agents/global.js";
43
45
  import { placeholders, ui, UI } from "../cli/ui.js";
44
- import { foundTrees } from "../cli/seal.js";
46
+ import { adoptSeal, foundTrees } from "../cli/seal.js";
45
47
  import { PERMISSION_LEVELS } from "../core/config.js";
46
48
  import { PROFILES } from "../agents/profiles.js";
47
49
  import { agentSettingsSaid, EFFORTS, hasEffort, ownEffort, raisesEffort, RECOMMENDED_EFFORT } from "../agents/effort.js";
@@ -58,7 +60,7 @@ import { isArchive } from "../core/mode.js";
58
60
  import { fixEnv, fixesDoctorLine } from "./fixchecks.js";
59
61
  import { placeOf, sampleFiles } from "../core/clouddrive.js";
60
62
  import { check } from "../core/check.js";
61
- import { assertIntact, verifyFull } from "../core/integrity.js";
63
+ import { assertIntact, OTHER_KEY, verifyFull } from "../core/integrity.js";
62
64
  import { ensurePluginsDir } from "../core/connector.js";
63
65
  import { checkGate, ensureGatesDir } from "../core/gate.js";
64
66
  import { ensureHooksDir } from "../core/hooks.js";
@@ -277,6 +279,10 @@ function diagnoseInto(ctx, checks, t, add, progress) {
277
279
  add("program", "ok", `${t("ui.doc.innode", { version: VERSION, node: process.version })}${beta}`);
278
280
  else
279
281
  add("program", "fail", t("ui.doc.oldnode", { node: process.version }), "https://nodejs.org");
282
+ // an update an app packaged for Windows installed for itself alone (core/packaged.ts): that app runs another strom
283
+ const copies = installation().kind === "installed" ? privateCopies(ctx.env, installation().root) : [];
284
+ if (copies.length)
285
+ add("packaged", "warn", t("ui.doc.packaged.copy", { folders: copies.map((c) => ctx.display(c)).join(", ") }), "strom doctor --fix", "packaged");
280
286
  // The backup before another channel or an older version: one that could not be made (nothing switches until it is),
281
287
  // else where the last one is, for a month
282
288
  const backup = lastBackup(ctx.env, BACKUP_SAID_DAYS);
@@ -518,9 +524,13 @@ function diagnoseInto(ctx, checks, t, add, progress) {
518
524
  else if (ctx.hasTree()) {
519
525
  const tr = ctx.tree();
520
526
  progress("tree", tr.config.name);
521
- const errs = timed("doctor.check", () => check(tr).filter((f) => f.level === "error").length) + timed("doctor.verify", () => verifyFull(tr).findings.filter((f) => f.level === "error").length);
527
+ const sealErrs = timed("doctor.verify", () => verifyFull(tr).findings.filter((f) => f.level === "error"));
528
+ const errs = timed("doctor.check", () => check(tr).filter((f) => f.level === "error").length) + sealErrs.length;
522
529
  if (errs === 0)
523
530
  add("tree", "ok", t("ui.doc.tree.ok", { name: tr.config.name }));
531
+ // two seal keys of this computer (strom of an app packaged for Windows and of a terminal): united, never repaired
532
+ else if (sealErrs.length === errs && sealErrs.every((f) => f.code === OTHER_KEY))
533
+ add("tree", "fail", t("ui.doc.tree.twokeys", { name: tr.config.name }), "strom seal adopt", "unite");
524
534
  else
525
535
  add("tree", "fail", t("ui.doc.tree.bad", { name: tr.config.name, n: errs }), "strom verify ; strom check");
526
536
  // can it be written — its locks (.strom) and history (.git) tried: found on Windows, an agent's sandbox refused
@@ -637,6 +647,20 @@ async function repair(ctx, checks, out) {
637
647
  }
638
648
  if (todo.has("links") && person)
639
649
  await offerLinks(ctx, lang);
650
+ // two seal keys of this computer united: as strom seal adopt, the person's yes at a terminal (never an agent)
651
+ if (todo.has("unite") && person && ctx.hasTree()) {
652
+ const r = await adoptSeal(ctx, ctx.tree(), lang);
653
+ if (r.adopted)
654
+ out(ui(lang, "ui.seal.united", { name: ctx.tree().config.name }));
655
+ }
656
+ // the private copies of the program into the trash, on the person's yes in a terminal of their own (from the packaged
657
+ // app itself the copy is what it reads: nothing done there)
658
+ if (todo.has("packaged") && person && !writesPrivately(ctx.env, installation().root) && (await ctx.confirm(ui(lang, "ui.fix.packaged"), true))) {
659
+ for (const c of privateCopies(ctx.env, installation().root)) {
660
+ moveToTrash(c, ctx.env, "Strom (copy of an app)");
661
+ out(ui(lang, "ui.fix.packaged.done", { folder: ctx.display(c) }));
662
+ }
663
+ }
640
664
  // a change of strom's that made the reading less sure: returned on the person's yes (a window when an agent asks)
641
665
  if (todo.has("tune"))
642
666
  await fixTuneWorse(ctx, out);
@@ -737,6 +761,8 @@ register({
737
761
  return { text: inst.kind === "npm" ? t("ui.update.npm", { version: newest, tag: channel === "beta" ? "beta" : "latest" }) : t("ui.update.source", { version: newest }), data: { current: VERSION, latest: newest, newer: true, npm: inst.kind === "npm" } };
738
762
  // the research where the update replaces the program: nothing asked, backed up or installed
739
763
  refuseResearchInProgram(ctx, inst.root);
764
+ // started by a packaged app (Claude's desktop app): the update would be that app's alone (core/packaged.ts)
765
+ refusePackaged(ctx, inst.root);
740
766
  const older = newer && isNewer(VERSION, newest);
741
767
  const question = switching
742
768
  ? `${t(`ui.update.channel.${to}.sure`, { version: newest, current: VERSION })} ${older ? t("ui.update.older", { version: newest, current: VERSION }) : t("ui.update.stays")}`
@@ -807,6 +833,15 @@ function refuseResearchInProgram(ctx, root) {
807
833
  details: { folder: hit, entry },
808
834
  });
809
835
  }
836
+ function refusePackaged(ctx, root) {
837
+ if (!writesPrivately(ctx.env, root))
838
+ return;
839
+ throw new StromError(`this strom was started by an app packaged for Windows (MSIX): what it installed into ${ctx.display(root)} only that app would see, the terminal would stay at ${VERSION}; nothing was installed`, {
840
+ hint: "the user runs strom update in their own terminal (PowerShell); the app takes the new version from there",
841
+ code: "update.packaged",
842
+ params: { folder: ctx.display(root), current: VERSION },
843
+ });
844
+ }
810
845
  /**
811
846
  * Before strom update installs another channel or an older version: the backup the next run of that version would
812
847
  * otherwise make (the same change: from what ran here last, its channel, to the version and channel installed). Made
@@ -15,7 +15,7 @@ import { eventName, humanDate, humanPlace, humanTask } from "../cli/human.js";
15
15
  import { UI, ui } from "../cli/ui.js";
16
16
  import { actsAsArchive } from "./state.js";
17
17
  /** Operations nobody needs to read: what another line says already, or bookkeeping. */
18
- const QUIET = new Set(["session.metrics", "session.readers", "task.start", "task.continue", "session.note", "tree.settle", "seal.adopt"]);
18
+ const QUIET = new Set(["session.metrics", "session.readers", "task.start", "task.continue", "session.note", "tree.settle", "seal.adopt", "seal.unite"]);
19
19
  /** The one-line form of an operation, by its kind: {who} a person or family, {name} the record. */
20
20
  /** What strom mode logs (core/mode.ts), as the history says it. */
21
21
  const MODE_SAID = { "the research is an archive": "log.mode.archive", "the research works with an agent again": "log.mode.research" };
package/dist/core/git.js CHANGED
@@ -276,7 +276,7 @@ function commitTree(dir, tree, parent, message) {
276
276
  * fewer processes, no hooks, no editor. Returns the hash, or undefined if
277
277
  * nothing changed.
278
278
  */
279
- export function commitAll(dir, message, pathspec = ["."], seal) {
279
+ export function commitAll(dir, message, pathspec = ["."], seal, always = false) {
280
280
  // Paths that no longer exist are fine for `add -A` only if git knows them;
281
281
  // filter missing, untracked-never-seen paths out to keep `add` from failing.
282
282
  const missing = pathspec.filter((p) => p !== "." && !fs.existsSync(path.join(dir, p)));
@@ -286,7 +286,7 @@ export function commitAll(dir, message, pathspec = ["."], seal) {
286
286
  git(dir, ["add", "-A", "--", ...specs]);
287
287
  const tree = git(dir, ["write-tree"]).trim();
288
288
  const parent = headInfo(dir);
289
- if (parent && parent.tree === tree)
289
+ if (parent && parent.tree === tree && !always)
290
290
  return undefined;
291
291
  const full = seal ? `${message}\n\n${seal(tree)}\n` : `${message}\n`;
292
292
  const r = commitTree(dir, tree, parent?.hash, full);
@@ -16,8 +16,9 @@ import fs from "node:fs";
16
16
  import path from "node:path";
17
17
  import * as git from "./git.js";
18
18
  import { RECORD_TYPES } from "./model.js";
19
- import { fileSha, fingerprint, verifyCommitSeal, verifyOp } from "./seal.js";
20
- import { StromError } from "./errors.js";
19
+ import { fileSha, fingerprint, otherKeys, readCommitSeal, verifyCommitSeal, verifyOp } from "./seal.js";
20
+ import { settingsHidden } from "./oldsettings.js";
21
+ import { NeedsConsentError, StromError } from "./errors.js";
21
22
  import { counted } from "../cli/format.js";
22
23
  /**
23
24
  * What strom alone writes in a tree, each change through a sealed operation: the records and their history (data/), the
@@ -56,8 +57,29 @@ function parseOps(text, file, findings, startLine = 0) {
56
57
  });
57
58
  return out;
58
59
  }
60
+ /**
61
+ * A research sealed with two keys of this computer: the settings' own and another in an old settings folder (strom of
62
+ * an app packaged for Windows and of a terminal each had their own: core/oldsettings.ts). Its writes are the person's,
63
+ * never "outside strom" — united by the person in their terminal (strom seal adopt), never undone by strom repair.
64
+ */
65
+ export const OTHER_KEY = "other-key";
66
+ const OTHER_KEY_HINT = "the user runs in their terminal: strom seal adopt (unites the keys — never strom repair, which would undo that work)";
67
+ function keysOf(tree, trust = [], united = new Map()) {
68
+ let others;
69
+ let hidden;
70
+ return {
71
+ hidden: () => (hidden ??= settingsHidden(tree.env)),
72
+ trusted: new Map(trust.map((k) => [fingerprint(k), k])),
73
+ united,
74
+ others: () => (others ??= new Map(tree.key ? otherKeys(tree.env, tree.config.id, tree.key).map((o) => [fingerprint(o.key), o.key]) : [])),
75
+ };
76
+ }
77
+ /** Is an operation shaped as strom signs one (a key's fingerprint and a signature), whatever key it was? */
78
+ function strommade(op) {
79
+ return typeof op.k === "string" && /^[0-9a-f]{12}$/u.test(op.k) && typeof op.sig === "string" && /^[0-9a-f]{32}$/u.test(op.sig);
80
+ }
59
81
  /** Check chain and signatures of a run of operations. */
60
- function checkOps(tree, ops, prevSig, file, findings) {
82
+ function checkOps(tree, ops, prevSig, file, findings, keys = keysOf(tree)) {
61
83
  const key = tree.key;
62
84
  const fp = key ? fingerprint(key) : undefined;
63
85
  let prev = prevSig;
@@ -70,16 +92,27 @@ function checkOps(tree, ops, prevSig, file, findings) {
70
92
  adopted = true;
71
93
  if (!key)
72
94
  continue;
73
- if (op.k === fp) {
74
- if (!verifyOp(op, key))
95
+ const trusted = keys.trusted.get(op.k);
96
+ if (op.k === fp || trusted) {
97
+ if (!verifyOp(op, trusted ?? key))
75
98
  findings.push({ level: "error", code: "forged-op", file, message: `operation ${op.op} at ${op.at} has an invalid signature (written outside strom)` });
76
99
  }
100
+ else if ((keys.united.get(op.k) ?? "") >= op.at) {
101
+ // another key of this computer, its operations until then checked and taken when the person united the keys
102
+ }
77
103
  else if (adopted) {
78
- findings.push({ level: "error", code: "forged-op", file, message: `operation ${op.op} at ${op.at} is signed with an unknown key after the tree was adopted` });
104
+ const other = keys.others().get(op.k);
105
+ if (other && verifyOp(op, other))
106
+ findings.push({ level: "error", code: OTHER_KEY, file, message: `operation ${op.op} at ${op.at} is signed with another seal key of strom on this computer (a second copy of strom's settings)`, hint: OTHER_KEY_HINT });
107
+ // a key out of sight only for what strom signs (a fingerprint and a signature): anything else was written outside
108
+ else if (keys.hidden() && strommade(op))
109
+ findings.push({ level: "error", code: OTHER_KEY, file, message: `operation ${op.op} at ${op.at} is signed with a key this strom does not see (an app packaged for Windows started it)`, hint: OTHER_KEY_HINT });
110
+ else
111
+ findings.push({ level: "error", code: "forged-op", file, message: `operation ${op.op} at ${op.at} is signed with an unknown key after the tree was adopted` });
79
112
  }
80
113
  }
81
114
  }
82
- function headSeal(tree, findings) {
115
+ function headSeal(tree, findings, keys = keysOf(tree)) {
83
116
  const head = git.headInfo(tree.root);
84
117
  if (!head)
85
118
  return;
@@ -93,7 +126,27 @@ function headSeal(tree, findings) {
93
126
  });
94
127
  return;
95
128
  }
96
- if (!verifyCommitSeal(key, head.tree, head.message))
129
+ const sealedWith = (k) => verifyCommitSeal(k, head.tree, head.message);
130
+ if (sealedWith(key) || [...keys.trusted.values()].some(sealedWith))
131
+ return;
132
+ if ([...keys.others().values()].some(sealedWith))
133
+ findings.push({
134
+ level: "error",
135
+ code: OTHER_KEY,
136
+ message: "the last commit was sealed with another seal key of strom on this computer (a second copy of strom's settings)",
137
+ hint: OTHER_KEY_HINT,
138
+ });
139
+ // started by an app packaged for Windows: the terminal's key is out of its sight — the terminal unites them (where
140
+ // seal adopt checks every seal again: a commit of nobody's stays refused). Only a commit with strom's seal: one
141
+ // without (git used directly) is said as it always was, never sent to uniting it cannot do
142
+ else if (keys.hidden() && readCommitSeal(head.message) !== undefined)
143
+ findings.push({
144
+ level: "error",
145
+ code: OTHER_KEY,
146
+ message: "the last commit was not sealed with a key this strom sees — an app packaged for Windows started it, the key of the terminal's strom is out of its sight",
147
+ hint: OTHER_KEY_HINT,
148
+ });
149
+ else
97
150
  findings.push({
98
151
  level: "error",
99
152
  code: "foreign-commit",
@@ -295,17 +348,44 @@ export function verifyFast(tree, snap = snapshot(tree)) {
295
348
  findings.unshift(notDownloadedFinding([...cloud].sort()));
296
349
  return { findings, tampered: [...tampered] };
297
350
  }
298
- /** Full check: every operation and every record. */
299
- export function verifyFull(tree) {
351
+ /**
352
+ * The other keys of this computer a seal.unite of the tree's own key took, each with when (its operations until then
353
+ * are the person's).
354
+ */
355
+ function unitedKeys(tree, logs) {
356
+ const united = new Map();
357
+ const key = tree.key;
358
+ if (!key)
359
+ return united;
360
+ const fp = fingerprint(key);
361
+ for (const { ops } of logs)
362
+ for (const op of ops)
363
+ if (op.op === "seal.unite" && op.k === fp && verifyOp(op, key))
364
+ for (const k of op.keys ?? [])
365
+ if ((united.get(k) ?? "") < op.at)
366
+ united.set(k, op.at);
367
+ return united;
368
+ }
369
+ /**
370
+ * Full check: every operation and every record. `trust`: other keys of this computer whose operations and commits are
371
+ * checked with them as the tree's own (strom seal adopt, before it unites them).
372
+ */
373
+ export function verifyFull(tree, o = {}) {
300
374
  const findings = [];
301
375
  const tampered = new Set();
302
- headSeal(tree, findings);
303
376
  const latest = new Map();
304
377
  const all = [];
305
- for (const file of opsLogs(tree.dataDir)) {
378
+ const logs = opsLogs(tree.dataDir).map((file) => {
306
379
  const rel = path.relative(tree.root, file).split(path.sep).join("/");
307
- const ops = parseOps(fs.readFileSync(file, "utf8"), rel, findings);
308
- checkOps(tree, ops, "", rel, findings);
380
+ return { rel, ops: parseOps(fs.readFileSync(file, "utf8"), rel, findings) };
381
+ });
382
+ const keys = keysOf(tree, o.trust, unitedKeys(tree, logs));
383
+ // the last commit said first, as ever
384
+ const headFindings = [];
385
+ headSeal(tree, headFindings, keys);
386
+ findings.unshift(...headFindings);
387
+ for (const { rel, ops } of logs) {
388
+ checkOps(tree, ops, "", rel, findings, keys);
309
389
  all.push(...ops);
310
390
  }
311
391
  // Logs are per session: the last word on a file is the newest operation across all of them.
@@ -387,6 +467,9 @@ export function assertIntact(tree) {
387
467
  tree.requireKey();
388
468
  return;
389
469
  }
470
+ // the last commit sealed with another key of this computer: the person unites them (never strom repair)
471
+ if (errors.every((f) => f.code === OTHER_KEY))
472
+ throw otherKeyError(tree.config.name);
390
473
  // files only in iCloud: downloaded first — what else is wrong is judged then
391
474
  if (errors.some((f) => f.code === NOT_DOWNLOADED))
392
475
  throw notDownloadedError(notDownloaded(tree.root));
@@ -410,3 +493,10 @@ export function notDownloadedError(files) {
410
493
  details: files.slice(0, 50),
411
494
  });
412
495
  }
496
+ /**
497
+ * Writing refused while the last commit carries the seal of another key of this computer: a consent of the person in
498
+ * their terminal (strom seal adopt unites the keys), said in their language (ui.error.seal.other-key).
499
+ */
500
+ export function otherKeyError(name) {
501
+ return new NeedsConsentError([{ key: "seal", kind: "consent", question: "This tree was sealed with two keys of strom on this computer. Unite them?", set: "strom seal adopt" }], undefined, undefined, { code: "seal.other-key", params: { name, set: "strom seal adopt" } });
502
+ }
package/dist/core/live.js CHANGED
@@ -246,6 +246,7 @@ const CODES = {
246
246
  "ui.media.large": "media.large",
247
247
  "ui.media.full": "media.full",
248
248
  "ui.seal.bridge": SEAL_FOREIGN,
249
+ "ui.seal.bridge.twokeys": "seal.other-key",
249
250
  "ui.error.tree.not-downloaded": "tree.not-downloaded",
250
251
  "ui.experiments.link": "experiments.link",
251
252
  "ui.history.place.link": "history.link",
@@ -2240,6 +2241,9 @@ export function serveLive(root, env) {
2240
2241
  // sealed on another computer since it was asked (its key gone meanwhile): said as the bridge says it before
2241
2242
  if (data.code === SEAL_FOREIGN)
2242
2243
  reply(409, { ...sealRefusal(), intake: sent.intake });
2244
+ // sealed here with two keys of this computer (an app packaged for Windows and a terminal): united in a terminal
2245
+ else if (data.code === "seal.other-key")
2246
+ reply(409, { ...said(lang, "ui.seal.bridge.twokeys", { name: String(data.params?.name ?? ""), set: "strom seal adopt" }), intake: sent.intake });
2243
2247
  // no room on the disk: said as everywhere (507 disk.full), never the system's words with strom's inner path
2244
2248
  else if (data.code === DISK_FULL || isDiskFull(why))
2245
2249
  reply(507, { ...diskFullSaid(lang, fullWhere(data, root)), reason: why, intake: sent.intake });
@@ -0,0 +1,101 @@
1
+ // strom's settings on Windows before 1.23: %APPDATA%\strom. An app packaged as MSIX (Claude's desktop app) gets every
2
+ // file it creates in AppData as a private copy of its own (%LOCALAPPDATA%\Packages\<app>\LocalCache\Roaming\strom),
3
+ // read before the real one and seen by no other program — a strom it started and a strom of a terminal each had their
4
+ // own seal key for one research, each side's writes "outside strom" to the other (found 2026-10-30). Microsoft: "all
5
+ // newly created files and folders in the user's AppData folder are written to a private per-user, per-app location";
6
+ // "apart from AppData, the app can write to any location where the user has write access, including other parts of
7
+ // %userprofile%" (learn.microsoft.com/windows/msix/desktop/flexible-virtualization). The settings are outside AppData
8
+ // now (paths.ts configDir); these folders are read, taken over once, and never moved or deleted: an older strom reads
9
+ // them still (a switch back to an older version).
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { configDir, userHome } from "./paths.js";
13
+ import { writesPrivately } from "./packaged.js";
14
+ import { installation } from "./self.js";
15
+ /**
16
+ * The folders strom kept its settings in before, as this process sees them: %APPDATA%\strom first (in a packaged app
17
+ * its private copy where there is one, else the real one), then each packaged app's copy, read by its path. None with
18
+ * settings of their own (STROM_CONFIG_DIR) and none off Windows. STROM_TEST_OLD_SETTINGS (the sources only): the folders
19
+ * standing for them, separated as PATH is.
20
+ */
21
+ export function oldSettingsDirs(env, platform = process.platform, inst = installation()) {
22
+ if (inst.kind === "source" && env.STROM_TEST_OLD_SETTINGS !== undefined)
23
+ return env.STROM_TEST_OLD_SETTINGS.split(path.delimiter).filter(Boolean);
24
+ if (env.STROM_CONFIG_DIR || platform !== "win32")
25
+ return [];
26
+ const home = env.USERPROFILE || userHome(env);
27
+ const roaming = path.join(env.APPDATA || path.join(home, "AppData", "Roaming"), "strom");
28
+ const packages = path.join(env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "Packages");
29
+ let apps = [];
30
+ try {
31
+ apps = fs.readdirSync(packages);
32
+ }
33
+ catch {
34
+ // no packaged apps
35
+ }
36
+ const copies = apps.map((a) => path.join(packages, a, "LocalCache", "Roaming", "strom")).filter((d) => fs.existsSync(d));
37
+ return [roaming, ...copies];
38
+ }
39
+ /**
40
+ * Every packaged app's copy of the old settings as one pattern, those made later too:
41
+ * `%LOCALAPPDATA%\Packages\*\LocalCache\Roaming\strom` — kept from the agents whole (a key an agent put into a copy
42
+ * made after the rules were written would make a commit of nobody's another key of this computer, offered to unite).
43
+ * None where oldSettingsDirs has none. STROM_TEST_PACKAGES (the sources only): the folder standing for Packages.
44
+ */
45
+ export function packagedSettingsPattern(env, platform = process.platform, inst = installation()) {
46
+ if (inst.kind === "source" && env.STROM_TEST_PACKAGES !== undefined)
47
+ return path.join(env.STROM_TEST_PACKAGES, "*", "LocalCache", "Roaming", "strom");
48
+ if (env.STROM_CONFIG_DIR || platform !== "win32")
49
+ return undefined;
50
+ const home = env.USERPROFILE || userHome(env);
51
+ return path.join(env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "Packages", "*", "LocalCache", "Roaming", "strom");
52
+ }
53
+ /** What is never copied over: a lock or a file half written of a strom running meanwhile, and the keys (seal.ts). */
54
+ function copied(from, file) {
55
+ if (/\.(lock|tmp)$/u.test(file))
56
+ return false;
57
+ return path.relative(from, file).split(path.sep)[0] !== "keys";
58
+ }
59
+ /**
60
+ * The settings taken over into the folder of today once, before anything reads them (no config.json there yet): every
61
+ * old folder with settings copied in, the first one's word kept where two say something. The seal keys are taken one by
62
+ * one when a research needs its own (seal.ts settleKey). The folder taken from, or undefined.
63
+ */
64
+ export function takeOverOldSettings(env, platform = process.platform, inst = installation()) {
65
+ const dir = configDir(env, platform);
66
+ if (fs.existsSync(path.join(dir, "config.json")))
67
+ return undefined;
68
+ const old = oldSettingsDirs(env, platform, inst).filter((d) => fs.existsSync(path.join(d, "config.json")));
69
+ if (!old.length)
70
+ return undefined;
71
+ fs.mkdirSync(dir, { recursive: true });
72
+ for (const from of old)
73
+ fs.cpSync(from, dir, { recursive: true, force: false, errorOnExist: false, filter: (file) => copied(from, file) });
74
+ return old[0];
75
+ }
76
+ /** A research's seal key in the old folders, each different one once with where it was (the first seen first). */
77
+ export function oldKeys(env, treeId, inst = installation()) {
78
+ const out = [];
79
+ for (const dir of oldSettingsDirs(env, process.platform, inst)) {
80
+ const file = path.join(dir, "keys", `${treeId}.key`);
81
+ let key;
82
+ try {
83
+ key = Buffer.from(fs.readFileSync(file, "utf8").trim(), "hex");
84
+ }
85
+ catch {
86
+ continue;
87
+ }
88
+ if (key.length && !out.some((o) => o.key.equals(key)))
89
+ out.push({ file, key });
90
+ }
91
+ return out;
92
+ }
93
+ /**
94
+ * Whether this strom was started by an app packaged for Windows that keeps its own copy of the old settings: the key a
95
+ * strom of the terminal keeps there it cannot see (its own copy reads first) — what is sealed with it is another key of
96
+ * this computer, not git used directly. Tried (core/packaged.ts), only when a seal does not match.
97
+ */
98
+ export function settingsHidden(env, inst = installation()) {
99
+ const dir = oldSettingsDirs(env, process.platform, inst)[0];
100
+ return !!dir && fs.existsSync(dir) && writesPrivately(env, dir, process.platform, inst);
101
+ }
@@ -0,0 +1,69 @@
1
+ // A strom started by an app packaged as MSIX (Claude's desktop app on Windows): every file it creates under AppData goes
2
+ // to a private copy of the app's (%LOCALAPPDATA%\Packages\<app>\LocalCache\Local\…), read before the real one and seen
3
+ // by no other program (learn.microsoft.com/windows/msix/desktop/desktop-to-uwp-behind-the-scenes, "AppData operations
4
+ // on Windows 10, version 1903 and later"). strom's program is in %LOCALAPPDATA%\Programs\Strom: an update installed
5
+ // from there would be that app's alone — the app on the new version, the terminal on the old one for good (found
6
+ // 2026-10-30). Whether this process writes so is tried, not guessed: a file made in the folder and looked for in the
7
+ // private copies.
8
+ import crypto from "node:crypto";
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { userHome } from "./paths.js";
12
+ import { installation } from "./self.js";
13
+ /** The AppData folders a packaged app's new files are taken from, each with its folder in the private copy. */
14
+ function appData(env) {
15
+ const home = env.USERPROFILE || userHome(env);
16
+ return [
17
+ { real: env.LOCALAPPDATA || path.join(home, "AppData", "Local"), copy: "Local" },
18
+ { real: env.APPDATA || path.join(home, "AppData", "Roaming"), copy: "Roaming" },
19
+ ];
20
+ }
21
+ /** Where the packaged apps keep their private copies of `dir`, whether they are there or not (none: dir outside AppData). */
22
+ function copiesOf(env, dir) {
23
+ const packages = path.join(appData(env)[0].real, "Packages");
24
+ for (const { real, copy } of appData(env)) {
25
+ const rel = path.relative(real, dir);
26
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel))
27
+ continue;
28
+ let apps = [];
29
+ try {
30
+ apps = fs.readdirSync(packages);
31
+ }
32
+ catch {
33
+ // no packaged apps
34
+ }
35
+ return apps.map((a) => path.join(packages, a, "LocalCache", copy, rel));
36
+ }
37
+ return [];
38
+ }
39
+ /** The private copies of `dir` some packaged app made (from the terminal: the update it installed only for itself). */
40
+ export function privateCopies(env, dir, platform = process.platform) {
41
+ if (platform !== "win32")
42
+ return [];
43
+ return copiesOf(env, dir).filter((d) => fs.existsSync(d));
44
+ }
45
+ /**
46
+ * Whether a file this process makes in `dir` lands in a packaged app's private copy instead. STROM_TEST_PACKAGED=1 (the
47
+ * sources only) stands for it. `make` writes the file (a test makes it where Windows would).
48
+ */
49
+ export function writesPrivately(env, dir, platform = process.platform, inst = installation(), make = (file) => fs.writeFileSync(file, "", { flag: "wx" })) {
50
+ if (inst.kind === "source" && env.STROM_TEST_PACKAGED !== undefined)
51
+ return env.STROM_TEST_PACKAGED === "1";
52
+ if (platform !== "win32")
53
+ return false;
54
+ const copies = copiesOf(env, dir);
55
+ if (!copies.length)
56
+ return false;
57
+ const name = `.strom-probe-${crypto.randomBytes(6).toString("hex")}`;
58
+ try {
59
+ make(path.join(dir, name));
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ // the private copy says so; the real folder could have it as well — what counts is the copy
65
+ const hit = copies.some((c) => fs.existsSync(path.join(c, name)));
66
+ for (const at of [dir, ...copies])
67
+ fs.rmSync(path.join(at, name), { force: true });
68
+ return hit;
69
+ }
@@ -44,14 +44,18 @@ export function noLinks(env) {
44
44
  export function userHome(env) {
45
45
  return env.HOME || env.USERPROFILE || os.homedir();
46
46
  }
47
- /** Directory holding the small user config (the pointer to the Strom home). */
47
+ /**
48
+ * Directory holding the small user config (the pointer to the Strom home) and the seal keys. On Windows outside AppData
49
+ * (%USERPROFILE%\.config\strom, as ~/.config/strom elsewhere): an app packaged as MSIX (Claude's desktop app) gets
50
+ * every new file of AppData as a private copy of its own, which no strom of a terminal sees — a research had two seal
51
+ * keys, each side's writes "outside strom" to the other (found 2026-10-30). The folder before: core/oldsettings.ts.
52
+ */
48
53
  export function configDir(env, platform = process.platform) {
49
54
  if (env.STROM_CONFIG_DIR)
50
55
  return path.resolve(env.STROM_CONFIG_DIR);
51
- if (platform === "win32") {
52
- const appData = env.APPDATA || path.join(userHome(env), "AppData", "Roaming");
53
- return path.join(appData, "strom");
54
- }
56
+ // the profile's own folder (HOME is Git Bash's, which may name another)
57
+ if (platform === "win32")
58
+ return path.join(env.USERPROFILE || userHome(env), ".config", "strom");
55
59
  const xdg = env.XDG_CONFIG_HOME || path.join(userHome(env), ".config");
56
60
  return path.join(xdg, "strom");
57
61
  }
@@ -218,6 +218,39 @@ export function regionEventsAt(tree, shared, id, at = factsByRecord(tree).get(id
218
218
  }
219
219
  return [...out.values()].sort((a, b) => a.event.from - b.event.from || a.event.id.localeCompare(b.event.id));
220
220
  }
221
+ /**
222
+ * The own events of the tree's places that belong to a region in the years of an event (their facts' places mapped to
223
+ * it): the mirror of regionEventsAt, for a place looked for before its region (spec 020 K7.1a). Not taken back.
224
+ */
225
+ export function placeEventsIn(tree, shared, region, e) {
226
+ if (!shared)
227
+ return [];
228
+ const maps = readPlaces(tree);
229
+ const folded = new Map(Object.entries(maps).map(([k, v]) => [foldText(k), v]));
230
+ const out = [];
231
+ for (const [id, at] of factsByRecord(tree)) {
232
+ const inIt = at.keys.some((key) => ((maps[key] ?? folded.get(foldText(key)))?.regions ?? []).some((g) => g.id === region && inRegion(e, g)));
233
+ const p = inIt ? placeRecord(tree, id) : undefined;
234
+ if (!p)
235
+ continue;
236
+ for (const x of readPlaceCatalog(shared, placeKeyOf(tree, p))?.events ?? [])
237
+ if (!x.retracted)
238
+ out.push({ event: x, place: p });
239
+ }
240
+ return out.sort((a, b) => a.event.from - b.event.from || a.event.id.localeCompare(b.event.id));
241
+ }
242
+ /** An event of one of the region's places of the same kind and years: the region's history never writes it again. */
243
+ export function refusePlaceSame(tree, shared, region, e) {
244
+ const same = placeEventsIn(tree, shared, region, e).filter((x) => x.event.kind === e.kind && x.event.from <= e.to && e.from <= x.event.to);
245
+ if (!same.length)
246
+ return;
247
+ const ids = same.map((x) => `${x.event.id} (${x.place.id})`);
248
+ throw new UsageError(`an event of a place of ${region} of the same kind and years is there already: ${ids.join(", ")} — nothing written`, {
249
+ code: "history.same.place",
250
+ params: { ids: same.map((x) => x.event.id).join(", ") },
251
+ hint: `the place's event shows there already — leave it out; an event of the whole region (it touched its other places too): --distinct "<why it is another>"`,
252
+ });
253
+ }
221
254
  // ── the person's asks ──────────────────────────────────────────────────────
222
255
  export function readPlaceAsks(tree) {
223
256
  try {
package/dist/core/seal.js CHANGED
@@ -16,6 +16,7 @@ import fs from "node:fs";
16
16
  import path from "node:path";
17
17
  import { configDir } from "./paths.js";
18
18
  import { canonicalize } from "./json.js";
19
+ import { oldKeys, oldSettingsDirs } from "./oldsettings.js";
19
20
  export function sha256(content) {
20
21
  return crypto.createHash("sha256").update(content).digest("hex");
21
22
  }
@@ -30,20 +31,67 @@ export function fileSha(file) {
30
31
  export function keyFile(env, treeId) {
31
32
  return path.join(configDir(env), "keys", `${treeId}.key`);
32
33
  }
33
- export function loadKey(env, treeId) {
34
+ function readKey(file) {
34
35
  try {
35
- return Buffer.from(fs.readFileSync(keyFile(env, treeId), "utf8").trim(), "hex");
36
+ const key = Buffer.from(fs.readFileSync(file, "utf8").trim(), "hex");
37
+ return key.length ? key : undefined;
36
38
  }
37
39
  catch {
38
40
  return undefined;
39
41
  }
40
42
  }
41
- /** Create the key for a tree on this computer (0600). Fails if it exists. */
43
+ /** The tree's key on this computer: the settings' own, else one of the old settings folders (core/oldsettings.ts). */
44
+ export function loadKey(env, treeId) {
45
+ return readKey(keyFile(env, treeId)) ?? oldKeys(env, treeId)[0]?.key;
46
+ }
47
+ /**
48
+ * The tree's key, the one in an old settings folder taken over into the settings of today where they have none: of two
49
+ * different ones (strom of a packaged app and of a terminal, each its own) the one `prefer` says (the one that sealed the
50
+ * last commit, the side that could still write), else the first.
51
+ */
52
+ export function settleKey(env, treeId, prefer) {
53
+ const own = readKey(keyFile(env, treeId));
54
+ if (own)
55
+ return own;
56
+ const old = oldKeys(env, treeId);
57
+ if (!old.length)
58
+ return undefined;
59
+ const key = (old.length > 1 ? old.find((o) => prefer(o.key)) : undefined)?.key ?? old[0].key;
60
+ try {
61
+ const file = keyFile(env, treeId);
62
+ fs.mkdirSync(path.dirname(file), { recursive: true });
63
+ fs.writeFileSync(file, key.toString("hex") + "\n", { mode: 0o600, flag: "wx" });
64
+ }
65
+ catch {
66
+ // taken over next time
67
+ }
68
+ return key;
69
+ }
70
+ /** The tree's other keys on this computer: those of the old settings folders that are not `key` (strom seal adopt unites them). */
71
+ export function otherKeys(env, treeId, key) {
72
+ return oldKeys(env, treeId).filter((o) => !o.key.equals(key));
73
+ }
74
+ /**
75
+ * Create the key for a tree on this computer (0600). Fails if it exists. Also into the old settings folder (Windows),
76
+ * made if need be: an older strom, after a switch back, reads it there.
77
+ */
42
78
  export function createKey(env, treeId) {
43
79
  const file = keyFile(env, treeId);
44
80
  fs.mkdirSync(path.dirname(file), { recursive: true });
45
81
  const key = crypto.randomBytes(32);
46
82
  fs.writeFileSync(file, key.toString("hex") + "\n", { mode: 0o600, flag: "wx" });
83
+ // also where an older strom looks (Windows: %APPDATA%\strom), made if need be — back on an older version, the research
84
+ // is still sealed here
85
+ const old = oldSettingsDirs(env)[0];
86
+ if (old) {
87
+ try {
88
+ fs.mkdirSync(path.join(old, "keys"), { recursive: true });
89
+ fs.writeFileSync(path.join(old, "keys", `${treeId}.key`), key.toString("hex") + "\n", { mode: 0o600, flag: "wx" });
90
+ }
91
+ catch {
92
+ // a key of its own there already: the older strom keeps it
93
+ }
94
+ }
47
95
  return key;
48
96
  }
49
97
  /** Short public fingerprint of a key, stored on each operation. */