@inerrata-corporation/errata 2.0.23-dev.4734 → 2.0.23-dev.4796

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/errata.mjs CHANGED
@@ -20645,7 +20645,8 @@ var init_node_select = __esm({
20645
20645
  "nodes_live_fold_candidate",
20646
20646
  "nodes_live_resolved",
20647
20647
  "nodes_live_fix_candidate",
20648
- "nodes_live_anchor_hint"
20648
+ "nodes_live_anchor_hint",
20649
+ "nodes_live_created_v2"
20649
20650
  ];
20650
20651
  EMBEDDING_DIM_SQL = "(CASE WHEN length(embedding_blob) >= 4 THEN length(embedding_blob) / 4 ELSE COALESCE(json_array_length(embedding_json), 0) END)";
20651
20652
  KEY = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
@@ -20761,10 +20762,15 @@ function sameSqlValue(a, b) {
20761
20762
  if (a instanceof Uint8Array && b instanceof Uint8Array) return Buffer.compare(a, b) === 0;
20762
20763
  return false;
20763
20764
  }
20765
+ function attrsJsonWithoutPositions(attrs) {
20766
+ const rest2 = { ...attrs };
20767
+ for (const k of POSITION_ATTRS) delete rest2[k];
20768
+ return JSON.stringify(rest2);
20769
+ }
20764
20770
  function openGraphStore(opts) {
20765
20771
  return new SqliteGraphStore(opts);
20766
20772
  }
20767
- var PAGE_RANK_SCALE_KEY, TEXT_SEARCH_COLUMNS, LOCAL_RULE_OVERRIDES, SCHEMA_VERSION, SESSION_REPAIR_KEY, EXPOSURE_SIDE_KEY, SESSION_EVIDENCE_PREDICATE, SCHEMA_SQL, TEMPORAL_INDEX_SQL, FINGERPRINT_INDEXED_LABELS, NODE_LABELS_CHUNK, NODE_UPSERT_COLUMNS, EDGE_UPSERT_COLUMNS, PRIOR_TAG_MARKER, SqliteGraphStore;
20773
+ var PAGE_RANK_SCALE_KEY, TEXT_SEARCH_COLUMNS, LOCAL_RULE_OVERRIDES, SCHEMA_VERSION, SESSION_REPAIR_KEY, EXPOSURE_SIDE_KEY, SESSION_EVIDENCE_PREDICATE, SCHEMA_SQL, TEMPORAL_INDEX_SQL, FINGERPRINT_INDEXED_LABELS, NODE_LABELS_CHUNK, NODE_UPSERT_COLUMNS, EDGE_UPSERT_COLUMNS, POSITION_ATTRS, PRIOR_TAG_MARKER, SqliteGraphStore;
20768
20774
  var init_store = __esm({
20769
20775
  "../../packages/local-graph/src/store.ts"() {
20770
20776
  "use strict";
@@ -20918,7 +20924,10 @@ CREATE UNIQUE INDEX IF NOT EXISTS edges_triple ON edges(from_id, type, to_id);
20918
20924
  `;
20919
20925
  TEMPORAL_INDEX_SQL = `
20920
20926
  CREATE INDEX IF NOT EXISTS nodes_live ON nodes(label, valid_to);
20921
- CREATE INDEX IF NOT EXISTS nodes_live_count ON nodes(valid_to);
20927
+ -- Dropped: a prefix of nodes_live_page_rank (valid_to, page_rank), which serves
20928
+ -- the live count and the "any live node?" check as a covering index too. It was
20929
+ -- re-keyed on every close and revive: ~11 KB of a symbol flip's ~80 KB.
20930
+ DROP INDEX IF EXISTS nodes_live_count;
20922
20931
  -- Problems by recency (the only label read newest-first). Over every live row,
20923
20932
  -- each write that stamps last_updated_at moved a code symbol's entry from a
20924
20933
  -- scattered leaf: ~19 KB per position-only symbol rewrite on a 1.1 GB store.
@@ -20930,7 +20939,13 @@ CREATE INDEX IF NOT EXISTS nodes_live_problem_recent ON nodes(label, last_update
20930
20939
  -- walks nodes_live and reads every live row of the label to test created_at:
20931
20940
  -- 56 MB per guard on a 1.1 GB store, to learn there was nothing to do. valid_to
20932
20941
  -- is a key column so the planner prefers this over nodes_live (one more term).
20933
- CREATE INDEX IF NOT EXISTS nodes_live_created ON nodes(label, valid_to, created_at) WHERE valid_to IS NULL;
20942
+ -- last_updated_at rides along for errata.recent on code labels, which names this
20943
+ -- index: the filter runs on index entries, not on every row of the label (79 ms
20944
+ -- \u2192 3 ms for Function on a 1.1 GB store). It costs no extra writes: a close or
20945
+ -- revive already re-keys this index, and mergeNodeIfChanged leaves a symbol's
20946
+ -- last_updated_at alone when only its position moved.
20947
+ DROP INDEX IF EXISTS nodes_live_created;
20948
+ CREATE INDEX IF NOT EXISTS nodes_live_created_v2 ON nodes(label, valid_to, created_at, last_updated_at) WHERE valid_to IS NULL;
20934
20949
  -- Community members (the skills pull reads them twice per session boundary):
20935
20950
  -- ~15% of semantic nodes carry a localCommunity, and without this every live
20936
20951
  -- semantic row was read to find them (168 MB \u2192 18 MB per read).
@@ -21053,6 +21068,7 @@ CREATE INDEX IF NOT EXISTS nodes_revisit_true ON nodes(valid_from) WHERE json_ty
21053
21068
  "attrs_json"
21054
21069
  ];
21055
21070
  EDGE_UPSERT_COLUMNS = ["confidence", "last_seen_at", "nav_successes", "nav_failures", "valid_to", "attrs_json"];
21071
+ POSITION_ATTRS = ["startByte", "endByte", "startRow"];
21056
21072
  PRIOR_TAG_MARKER = '"priorTag":true';
21057
21073
  SqliteGraphStore = class {
21058
21074
  db;
@@ -21153,7 +21169,7 @@ CREATE INDEX IF NOT EXISTS nodes_revisit_true ON nodes(valid_from) WHERE json_ty
21153
21169
  lastUpdatedOf: this.db.prepare("SELECT last_updated_at AS t, attrs_json FROM nodes WHERE id = ?"),
21154
21170
  labelOf: this.db.prepare("SELECT label FROM nodes WHERE id = ?"),
21155
21171
  nodeContent: this.db.prepare(
21156
- "SELECT label, description, extraction_confidence, extraction_source, stability, valid_to, attrs_json FROM nodes WHERE id = ?"
21172
+ "SELECT label, description, extraction_confidence, extraction_source, stability, valid_to, last_updated_at, attrs_json FROM nodes WHERE id = ?"
21157
21173
  ),
21158
21174
  edgeContent: this.db.prepare(
21159
21175
  "SELECT confidence, valid_to, attrs_json FROM edges WHERE from_id = ? AND type = ? AND to_id = ?"
@@ -21566,11 +21582,23 @@ CREATE INDEX IF NOT EXISTS nodes_revisit_true ON nodes(valid_from) WHERE json_ty
21566
21582
  * rank, community, landmark, surprise, hits, tier and the vector belong to
21567
21583
  * the passes that compute them, and a skipped write leaves them as they are
21568
21584
  * where a merge would have reset them to the producer's defaults. Returns
21569
- * whether it wrote. */
21585
+ * whether it wrote.
21586
+ *
21587
+ * A live row whose only change is its position keeps its lastUpdatedAt, so
21588
+ * that stamp means "this symbol's content changed": what errata.recent lists
21589
+ * and the fix-candidate ask reads, and what lets nodes_live_created_v2 carry
21590
+ * it without being re-keyed by every edit above a symbol. A closed row is
21591
+ * always stamped: reviveReobserved reopens what this pass stamped. */
21570
21592
  mergeNodeIfChanged(node2) {
21571
21593
  const row = this.stmts.nodeContent.get(node2.id);
21572
- if (row && row.valid_to === null && row.label === node2.label && row.description === node2.description && row.extraction_confidence === node2.extractionConfidence && row.stability === node2.stability && !EXPOSURE_ATTRS.some((k) => node2.attrs?.[k] !== void 0) && row.attrs_json === JSON.stringify(node2.attrs ?? {})) return false;
21573
- this.mergeNode(node2);
21594
+ if (!row || row.valid_to !== null || row.label !== node2.label || row.description !== node2.description || row.extraction_confidence !== node2.extractionConfidence || row.stability !== node2.stability || EXPOSURE_ATTRS.some((k) => node2.attrs?.[k] !== void 0)) {
21595
+ this.mergeNode(node2);
21596
+ return true;
21597
+ }
21598
+ const attrs = node2.attrs ?? {};
21599
+ if (row.attrs_json === JSON.stringify(attrs)) return false;
21600
+ const moved = attrsJsonWithoutPositions(JSON.parse(row.attrs_json)) === attrsJsonWithoutPositions(attrs);
21601
+ this.mergeNode(moved ? { ...node2, lastUpdatedAt: row.last_updated_at } : node2);
21574
21602
  return true;
21575
21603
  }
21576
21604
  updateNode(id, patch) {
@@ -21650,8 +21678,8 @@ CREATE INDEX IF NOT EXISTS nodes_revisit_true ON nodes(valid_from) WHERE json_ty
21650
21678
  stmt2 = this.db.prepare(
21651
21679
  // Ordered, not left to the planner: readers take "first row wins" (the
21652
21680
  // symbol lexicon's name winner and doc pick), and the order otherwise
21653
- // followed whichever partial index the planner chose. nodes_live_created
21654
- // serves this order without a sort.
21681
+ // followed whichever partial index the planner chose. nodes_live_created_v2
21682
+ // serves it, sorting rowids only within an equal created_at.
21655
21683
  `SELECT id, description, community, extraction_confidence${cols} FROM nodes WHERE label = ? AND valid_to IS NULL ORDER BY created_at, rowid`
21656
21684
  );
21657
21685
  this.fieldStmts.set(cacheKey2, stmt2);
@@ -23199,10 +23227,12 @@ function crystallize(store, opts = { ts: 0 }) {
23199
23227
  const s = support(c) + Math.max(0, sourcesCount(c) - 1);
23200
23228
  const f = contradictions;
23201
23229
  const confidence = (1 + s) / (2 + s + f);
23202
- store.updateNode(c.id, {
23203
- attrs: { ...c.attrs, confidence },
23204
- lastUpdatedAt: opts.ts
23205
- });
23230
+ if (c.attrs["confidence"] !== confidence) {
23231
+ store.updateNode(c.id, {
23232
+ attrs: { ...c.attrs, confidence },
23233
+ lastUpdatedAt: opts.ts
23234
+ });
23235
+ }
23206
23236
  const sup = support(c);
23207
23237
  let tier = c.memoryTier;
23208
23238
  if (sup >= PERSISTENT_SUPPORT && confidence >= PERSISTENT_CONFIDENCE) tier = "persistent";
@@ -23270,7 +23300,10 @@ function mergeTruthKind(a, b) {
23270
23300
  function markBoth(store, a, b, patch, ts) {
23271
23301
  for (const n of [a, b]) {
23272
23302
  const fresh = store.getNode(n.id);
23273
- store.updateNode(n.id, { attrs: { ...fresh.attrs, ...patch }, lastUpdatedAt: ts });
23303
+ const next = { ...patch };
23304
+ if (fresh.attrs["revisit"] === true && fresh.attrs["revisitReason"] === next["revisitReason"]) delete next["revisitSinceTs"];
23305
+ if (Object.entries(next).every(([k, v]) => fresh.attrs[k] === v)) continue;
23306
+ store.updateNode(n.id, { attrs: { ...fresh.attrs, ...next }, lastUpdatedAt: ts });
23274
23307
  }
23275
23308
  }
23276
23309
  var LONG_TERM_SUPPORT, PERSISTENT_SUPPORT, PERSISTENT_CONFIDENCE, SCOPE_AXES;
@@ -37854,26 +37887,26 @@ function buildFileLexicon(store) {
37854
37887
  for (const n of store.findNodeFieldsByLabel("File", ["relPath"])) {
37855
37888
  const relPath = typeof n.attrs["relPath"] === "string" && n.attrs["relPath"].length > 0 ? n.attrs["relPath"] : n.description;
37856
37889
  if (!relPath) continue;
37857
- const basename10 = relPath.split("/").pop();
37858
- if (!basename10.includes(".")) continue;
37859
- entries.push({ relPath, basename: basename10 });
37860
- basenameCount.set(basename10, (basenameCount.get(basename10) ?? 0) + 1);
37861
- const stem = basename10.slice(0, basename10.indexOf("."));
37890
+ const basename11 = relPath.split("/").pop();
37891
+ if (!basename11.includes(".")) continue;
37892
+ entries.push({ relPath, basename: basename11 });
37893
+ basenameCount.set(basename11, (basenameCount.get(basename11) ?? 0) + 1);
37894
+ const stem = basename11.slice(0, basename11.indexOf("."));
37862
37895
  if (isBareMatchableStem(stem)) {
37863
37896
  let s = stemRelPaths.get(stem);
37864
37897
  if (!s) stemRelPaths.set(stem, s = /* @__PURE__ */ new Set());
37865
37898
  s.add(relPath);
37866
37899
  }
37867
37900
  }
37868
- for (const { relPath, basename: basename10 } of entries) {
37901
+ for (const { relPath, basename: basename11 } of entries) {
37869
37902
  const entry = { relPath };
37870
- if (relPath !== basename10) byKey.set(relPath, entry);
37871
- const conventional = CONVENTION_FILENAMES.has(basename10) || (basenameCount.get(basename10) ?? 0) >= CONVENTION_BASENAME_MIN_FILES;
37872
- if (!conventional && !byKey.has(basename10)) byKey.set(basename10, entry);
37873
- const list = byBasename.get(basename10);
37903
+ if (relPath !== basename11) byKey.set(relPath, entry);
37904
+ const conventional = CONVENTION_FILENAMES.has(basename11) || (basenameCount.get(basename11) ?? 0) >= CONVENTION_BASENAME_MIN_FILES;
37905
+ if (!conventional && !byKey.has(basename11)) byKey.set(basename11, entry);
37906
+ const list = byBasename.get(basename11);
37874
37907
  if (list) list.push(entry);
37875
- else byBasename.set(basename10, [entry]);
37876
- const stem = basename10.slice(0, basename10.indexOf("."));
37908
+ else byBasename.set(basename11, [entry]);
37909
+ const stem = basename11.slice(0, basename11.indexOf("."));
37877
37910
  if (isBareMatchableStem(stem) && !CONVENTION_STEMS.has(stem) && !byStem.has(stem)) {
37878
37911
  const dirStems = new Set(
37879
37912
  [...stemRelPaths.get(stem) ?? []].map((p) => {
@@ -37890,8 +37923,8 @@ function resolveFileToken(token, lexicon) {
37890
37923
  const exact = lexicon.byKey.get(token);
37891
37924
  if (exact) return exact;
37892
37925
  if (!token.includes("/")) return null;
37893
- const basename10 = token.split("/").pop();
37894
- for (const entry of lexicon.byBasename.get(basename10) ?? []) {
37926
+ const basename11 = token.split("/").pop();
37927
+ for (const entry of lexicon.byBasename.get(basename11) ?? []) {
37895
37928
  if (entry.relPath === token || entry.relPath.endsWith(`/${token}`)) return entry;
37896
37929
  }
37897
37930
  return null;
@@ -37916,9 +37949,9 @@ function fileRolePhrase(relPath, level = 1) {
37916
37949
  return "a source file";
37917
37950
  }
37918
37951
  function isSourceFileToken(token) {
37919
- const basename10 = token.split(/[/\\]/).pop() ?? token;
37920
- if (CONVENTION_FILENAMES.has(basename10)) return false;
37921
- const ext = basename10.split(".").pop();
37952
+ const basename11 = token.split(/[/\\]/).pop() ?? token;
37953
+ if (CONVENTION_FILENAMES.has(basename11)) return false;
37954
+ const ext = basename11.split(".").pop();
37922
37955
  return ext !== void 0 && SOURCE_EXTENSIONS.has(ext.toLowerCase());
37923
37956
  }
37924
37957
  function generalizeFileRefs(text, fileLexicon, level = 1) {
@@ -49322,7 +49355,14 @@ var init_mcp = __esm({
49322
49355
  total += count;
49323
49356
  groups[label] = {
49324
49357
  count,
49325
- items: store.selectNodes({ ...q, orderBy: { column: col, desc: true }, limit: sliceEnd(limit) }).map((n) => ({ id: n.id, description: n.description.slice(0, 160), at: col === "last_updated_at" ? n.lastUpdatedAt : n.createdAt }))
49358
+ items: store.selectNodes({
49359
+ ...q,
49360
+ orderBy: { column: col, desc: true },
49361
+ limit: sliceEnd(limit),
49362
+ // Unnamed, the planner reads every row of a code label through
49363
+ // nodes_live to test last_updated_at; this index carries it.
49364
+ ...col === "last_updated_at" ? { indexedBy: "nodes_live_created_v2" } : {}
49365
+ }).map((n) => ({ id: n.id, description: n.description.slice(0, 160), at: col === "last_updated_at" ? n.lastUpdatedAt : n.createdAt }))
49326
49366
  };
49327
49367
  }
49328
49368
  }
@@ -57689,6 +57729,7 @@ var init_message_accumulator = __esm({
57689
57729
  var webui_exports = {};
57690
57730
  __export(webui_exports, {
57691
57731
  ContextBaselines: () => ContextBaselines,
57732
+ FOCUS_NOTICE_FIELD: () => FOCUS_NOTICE_FIELD,
57692
57733
  SUBAGENT_REFRESH_MIN_MS: () => SUBAGENT_REFRESH_MIN_MS,
57693
57734
  asFailedPostToolUse: () => asFailedPostToolUse,
57694
57735
  buildWebUi: () => buildWebUi,
@@ -58398,7 +58439,9 @@ ${delta}${cue}`;
58398
58439
  block = own ?? readContextFileWithHash(deps.paths.root);
58399
58440
  storedCard = own ? own.card : block ? readContextCard(deps.paths.root, block.hash) : null;
58400
58441
  }
58401
- const decision = decideContextInject({
58442
+ const focusNotice = typeof body2[FOCUS_NOTICE_FIELD] === "string" ? body2[FOCUS_NOTICE_FIELD] : null;
58443
+ if (focusNotice && contextKey2) lastInjectedHash.reset(contextKey2, "focus");
58444
+ const decided = decideContextInject({
58402
58445
  event: hookEvent,
58403
58446
  source: String(body2["source"] ?? ""),
58404
58447
  sessionId: contextKey2,
@@ -58408,6 +58451,9 @@ ${delta}${cue}`;
58408
58451
  // A subagent's injections land in its own transcript, not the one posted.
58409
58452
  ...subagent === null && typeof body2["transcript_path"] === "string" && body2["transcript_path"] ? { transcriptPath: body2["transcript_path"] } : {}
58410
58453
  });
58454
+ const decision = focusNotice ? { hookEventName: decided?.hookEventName ?? hookEvent, additionalContext: decided ? `${focusNotice}
58455
+
58456
+ ${decided.additionalContext}` : focusNotice } : decided;
58411
58457
  if (decision && subagent !== null) lastSubagentInjectAt.set(contextKey2, Date.now());
58412
58458
  let systemMessage = null;
58413
58459
  const delivered = decision !== null && decision.additionalContext !== PROMPT_RECUE;
@@ -58606,7 +58652,7 @@ function proposalToIngest(p, profile, daemonVersion) {
58606
58652
  payloadDigest: p.id
58607
58653
  };
58608
58654
  }
58609
- var messageAccumulator, GENERIC_ERROR_WORDS, wsPackagesCache, WS_PACKAGES_TTL_MS, loggedToolSessions, SUBAGENT_REFRESH_MIN_MS, WINDOW_RESET_SOURCES, FULL_BLOCK_PREFIX, META_LABELS;
58655
+ var messageAccumulator, GENERIC_ERROR_WORDS, wsPackagesCache, WS_PACKAGES_TTL_MS, loggedToolSessions, SUBAGENT_REFRESH_MIN_MS, WINDOW_RESET_SOURCES, FULL_BLOCK_PREFIX, FOCUS_NOTICE_FIELD, META_LABELS;
58610
58656
  var init_webui = __esm({
58611
58657
  "src/webui.ts"() {
58612
58658
  "use strict";
@@ -58673,6 +58719,7 @@ var init_webui = __esm({
58673
58719
  SUBAGENT_REFRESH_MIN_MS = 6e4;
58674
58720
  WINDOW_RESET_SOURCES = /* @__PURE__ */ new Set(["compact", "clear", "resume"]);
58675
58721
  FULL_BLOCK_PREFIX = "The errata daemon has updated your live workspace context. The block below REPLACES any earlier errata context block in this conversation \u2014 treat it as the current source of truth:\n\n";
58722
+ FOCUS_NOTICE_FIELD = "errata_focus_notice";
58676
58723
  META_LABELS = /* @__PURE__ */ new Set([
58677
58724
  "Pattern",
58678
58725
  "Technique",
@@ -78818,10 +78865,10 @@ var ReaddirpStream = class extends Readable2 {
78818
78865
  }
78819
78866
  async _formatEntry(dirent, path3) {
78820
78867
  let entry;
78821
- const basename10 = this._isDirent ? dirent.name : dirent;
78868
+ const basename11 = this._isDirent ? dirent.name : dirent;
78822
78869
  try {
78823
- const fullPath = presolve(pjoin(path3, basename10));
78824
- entry = { path: prelative(this._root, fullPath), fullPath, basename: basename10 };
78870
+ const fullPath = presolve(pjoin(path3, basename11));
78871
+ entry = { path: prelative(this._root, fullPath), fullPath, basename: basename11 };
78825
78872
  entry[this._statsProp] = this._isDirent ? dirent : await this._stat(fullPath);
78826
78873
  } catch (err2) {
78827
78874
  this._onError(err2);
@@ -79360,9 +79407,9 @@ var NodeFsHandler = class {
79360
79407
  _watchWithNodeFs(path3, listener) {
79361
79408
  const opts = this.fsw.options;
79362
79409
  const directory = sysPath.dirname(path3);
79363
- const basename10 = sysPath.basename(path3);
79410
+ const basename11 = sysPath.basename(path3);
79364
79411
  const parent = this.fsw._getWatchedDir(directory);
79365
- parent.add(basename10);
79412
+ parent.add(basename11);
79366
79413
  const absolutePath = sysPath.resolve(path3);
79367
79414
  const options = {
79368
79415
  persistent: opts.persistent
@@ -79372,7 +79419,7 @@ var NodeFsHandler = class {
79372
79419
  let closer;
79373
79420
  if (opts.usePolling) {
79374
79421
  const enableBin = opts.interval !== opts.binaryInterval;
79375
- options.interval = enableBin && isBinaryPath(basename10) ? opts.binaryInterval : opts.interval;
79422
+ options.interval = enableBin && isBinaryPath(basename11) ? opts.binaryInterval : opts.interval;
79376
79423
  closer = setFsWatchFileListener(path3, absolutePath, options, {
79377
79424
  listener,
79378
79425
  rawEmitter: this.fsw._emitRaw
@@ -79395,10 +79442,10 @@ var NodeFsHandler = class {
79395
79442
  return;
79396
79443
  }
79397
79444
  const dirname21 = sysPath.dirname(file2);
79398
- const basename10 = sysPath.basename(file2);
79445
+ const basename11 = sysPath.basename(file2);
79399
79446
  const parent = this.fsw._getWatchedDir(dirname21);
79400
79447
  let prevStats = stats;
79401
- if (parent.has(basename10))
79448
+ if (parent.has(basename11))
79402
79449
  return;
79403
79450
  const listener = async (path3, newStats) => {
79404
79451
  if (!this.fsw._throttle(THROTTLE_MODE_WATCH, file2, 5))
@@ -79423,9 +79470,9 @@ var NodeFsHandler = class {
79423
79470
  prevStats = newStats2;
79424
79471
  }
79425
79472
  } catch (error48) {
79426
- this.fsw._remove(dirname21, basename10);
79473
+ this.fsw._remove(dirname21, basename11);
79427
79474
  }
79428
- } else if (parent.has(basename10)) {
79475
+ } else if (parent.has(basename11)) {
79429
79476
  const at = newStats.atimeMs;
79430
79477
  const mt = newStats.mtimeMs;
79431
79478
  if (!at || at <= mt || mt !== prevStats.mtimeMs) {
@@ -85606,7 +85653,7 @@ var OntologyValidationError = class extends Error {
85606
85653
  }
85607
85654
  };
85608
85655
  function phrase(text, article) {
85609
- const capitalized = text.length === 0 ? text : text.charAt(0).toUpperCase() + text.slice(1);
85656
+ const capitalized = firstAlphaCharUpper(text);
85610
85657
  return { text, article, textCapitalized: capitalized };
85611
85658
  }
85612
85659
  var DEFAULT_ONTOLOGY2 = {
@@ -85729,7 +85776,7 @@ function expectedArticleKind(text) {
85729
85776
  return "bare";
85730
85777
  }
85731
85778
  function firstAlphaCharUpper(s) {
85732
- return s.length === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1);
85779
+ return s.charAt(0).toUpperCase() + s.slice(1);
85733
85780
  }
85734
85781
  function phraseTriggersLayer1(text) {
85735
85782
  for (const pat of ALL_PATTERNS) {
@@ -87480,7 +87527,7 @@ function createWatchBreaker(deps) {
87480
87527
  }
87481
87528
 
87482
87529
  // src/engine.ts
87483
- var DAEMON_VERSION = true ? "2.0.23-dev.4734" : "2.0.0-alpha.0";
87530
+ var DAEMON_VERSION = true ? "2.0.23-dev.4796" : "2.0.0-alpha.0";
87484
87531
  var IGNORED_PATH = /[\\/](?:\.git|node_modules|\.errata|\.claude|\.codex|\.cursor|\.turbo|\.next|dist|coverage|test-results|playwright-report|__pycache__)(?:[\\/]|$)/;
87485
87532
  var IGNORED_NOISE = /castalia\.db|eventlog\.sqlite|turn-cursors/;
87486
87533
  var GIT_OP_MUTE_MS = 4e3;
@@ -87523,15 +87570,43 @@ function loadTurnOffsets(path3) {
87523
87570
  return /* @__PURE__ */ new Map();
87524
87571
  }
87525
87572
  }
87526
- function saveTurnCursors(path3, cursors, offsets) {
87573
+ function loadTurnMoves(path3) {
87574
+ try {
87575
+ const raw2 = JSON.parse(readFileSync33(path3, "utf8"));
87576
+ const out2 = /* @__PURE__ */ new Map();
87577
+ for (const [k, v] of Object.entries(raw2)) {
87578
+ const to = typeof v === "object" && v !== null ? v.movedTo : void 0;
87579
+ if (typeof to === "string" && to) out2.set(k, to);
87580
+ }
87581
+ return out2;
87582
+ } catch {
87583
+ return /* @__PURE__ */ new Map();
87584
+ }
87585
+ }
87586
+ function saveTurnCursors(path3, cursors, offsets, moves = /* @__PURE__ */ new Map()) {
87527
87587
  try {
87528
87588
  const merged = {};
87529
87589
  for (const [k, uuid3] of cursors) merged[k] = { uuid: uuid3, offset: offsets.get(k) ?? 0 };
87530
87590
  for (const [k, offset] of offsets) if (!merged[k]) merged[k] = { uuid: "", offset };
87591
+ for (const [k, movedTo] of moves) merged[k] = { ...merged[k] ?? { uuid: "", offset: 0 }, movedTo };
87531
87592
  writeFileSync24(path3, JSON.stringify(merged), "utf8");
87532
87593
  } catch {
87533
87594
  }
87534
87595
  }
87596
+ var turnCursorFile = (configDir) => join37(configDir, "turn-cursors.json");
87597
+ function turnCursorOnDisk(configDir, sessionId) {
87598
+ const path3 = turnCursorFile(configDir);
87599
+ const offset = loadTurnOffsets(path3).get(sessionId);
87600
+ const movedTo = loadTurnMoves(path3).get(sessionId);
87601
+ if (offset === void 0 && movedTo === void 0) return null;
87602
+ return { uuid: loadTurnCursors(path3).get(sessionId) ?? "", offset: offset ?? 0, ...movedTo ? { movedTo } : {} };
87603
+ }
87604
+ function releaseTurnCursorOnDisk(configDir, sessionId, to) {
87605
+ const path3 = turnCursorFile(configDir);
87606
+ const moves = loadTurnMoves(path3);
87607
+ moves.set(sessionId, to);
87608
+ saveTurnCursors(path3, loadTurnCursors(path3), loadTurnOffsets(path3), moves);
87609
+ }
87535
87610
  function gitSourceWatchTargets(root) {
87536
87611
  const SOURCE = /\.(ts|tsx|js|jsx|mjs|cjs|py)$/i;
87537
87612
  try {
@@ -88538,9 +88613,10 @@ function createWorkspaceEngine(opts) {
88538
88613
  resultSummary: { exitCode: e.exitCode ?? 1, errorTokens: e.errorTokens }
88539
88614
  });
88540
88615
  };
88541
- const turnCursorPath = join37(paths.configDir, "turn-cursors.json");
88616
+ const turnCursorPath = turnCursorFile(paths.configDir);
88542
88617
  const lastTurnUuid = loadTurnCursors(turnCursorPath);
88543
88618
  const turnOffset = loadTurnOffsets(turnCursorPath);
88619
+ const turnMovedTo = loadTurnMoves(turnCursorPath);
88544
88620
  const readLosses = { parseFailures: 0, ioFailures: 0, bytesUnreachable: 0, sliceGuardHits: 0 };
88545
88621
  const sessionLastProblem = /* @__PURE__ */ new Map();
88546
88622
  const sessionThreads = /* @__PURE__ */ new Map();
@@ -89548,7 +89624,7 @@ function createWorkspaceEngine(opts) {
89548
89624
  lastTurnUuid.set(sessionId, turns[turns.length - 1].uuid);
89549
89625
  await harvestTexts(sessionId, fresh);
89550
89626
  }
89551
- saveTurnCursors(turnCursorPath, lastTurnUuid, turnOffset);
89627
+ saveTurnCursors(turnCursorPath, lastTurnUuid, turnOffset, turnMovedTo);
89552
89628
  if (slice >= 256) {
89553
89629
  readLosses.sliceGuardHits++;
89554
89630
  console.warn("[errata] harvest slice guard hit \u2014 backlog resumes next tick");
@@ -89565,7 +89641,7 @@ function createWorkspaceEngine(opts) {
89565
89641
  if (next.losses.ioFailed) readLosses.ioFailures++;
89566
89642
  if (next.turns.length === 0 && !next.more) {
89567
89643
  turnOffset.set(sessionId, next.nextOffset);
89568
- saveTurnCursors(turnCursorPath, lastTurnUuid, turnOffset);
89644
+ saveTurnCursors(turnCursorPath, lastTurnUuid, turnOffset, turnMovedTo);
89569
89645
  break;
89570
89646
  }
89571
89647
  turns = next.turns;
@@ -89619,6 +89695,7 @@ function createWorkspaceEngine(opts) {
89619
89695
  }
89620
89696
  };
89621
89697
  const harvestSession = async (sessionId, transcriptPath) => {
89698
+ if (turnMovedTo.has(sessionId)) return;
89622
89699
  await harvestTurns(sessionId, transcriptPath);
89623
89700
  for (const sub of subagentTranscripts(transcriptPath, sessionId)) {
89624
89701
  await yieldToLoop();
@@ -89854,6 +89931,28 @@ B: ${b}`,
89854
89931
  storeReconciled: () => storeReconciled,
89855
89932
  lastGitEventAt: () => lastGitEventTs,
89856
89933
  quarantinedSessions: () => sessions.originSessions(),
89934
+ turnCursor(sessionId) {
89935
+ const offset = turnOffset.get(sessionId);
89936
+ const movedTo = turnMovedTo.get(sessionId);
89937
+ if (offset === void 0 && movedTo === void 0) return null;
89938
+ return { uuid: lastTurnUuid.get(sessionId) ?? "", offset: offset ?? 0, ...movedTo ? { movedTo } : {} };
89939
+ },
89940
+ releaseTurnCursor(sessionId, to) {
89941
+ turnMovedTo.set(sessionId, to);
89942
+ saveTurnCursors(turnCursorPath, lastTurnUuid, turnOffset, turnMovedTo);
89943
+ },
89944
+ adoptTurnCursor(sessionId, cursor) {
89945
+ turnMovedTo.delete(sessionId);
89946
+ if (cursor && cursor.offset > (turnOffset.get(sessionId) ?? -1)) {
89947
+ turnOffset.set(sessionId, cursor.offset);
89948
+ if (cursor.uuid) lastTurnUuid.set(sessionId, cursor.uuid);
89949
+ }
89950
+ saveTurnCursors(turnCursorPath, lastTurnUuid, turnOffset, turnMovedTo);
89951
+ },
89952
+ async settleTurns(sessionId) {
89953
+ await (harvestInFlight.get(sessionId) ?? Promise.resolve()).catch(() => {
89954
+ });
89955
+ },
89857
89956
  markContextDirty() {
89858
89957
  contextDirty = true;
89859
89958
  refreshContextNow();
@@ -90903,7 +91002,7 @@ function invalidateInstanceReceipt(current, destination, failedId, mirrorLegacy)
90903
91002
  init_dist();
90904
91003
  init_src5();
90905
91004
  import { existsSync as existsSync34, readFileSync as readFileSync40, writeFileSync as writeFileSync29 } from "node:fs";
90906
- import { dirname as dirname17, join as join47, resolve as pathResolve } from "node:path";
91005
+ import { basename as basename10, dirname as dirname17, join as join47, resolve as pathResolve } from "node:path";
90907
91006
 
90908
91007
  // src/principle-sync.ts
90909
91008
  init_src5();
@@ -95385,6 +95484,15 @@ function absolutePathsInCommand(command, platform = process.platform) {
95385
95484
  }
95386
95485
  return out2;
95387
95486
  }
95487
+ function leadingCdTarget(command, platform = process.platform) {
95488
+ if (typeof command !== "string") return void 0;
95489
+ const m = /^\s*(?:cd|pushd|Set-Location)\s+("[^"]+"|'[^']+'|[^\s;&|]+)\s*(?:&&|;|\|\||$)/.exec(command);
95490
+ if (!m) return void 0;
95491
+ const target = m[1].replace(/^["']|["']$/g, "");
95492
+ if (/^[a-zA-Z]:[\\/]/.test(target)) return target;
95493
+ if (platform === "win32") return /^\/[a-zA-Z](\/|$)/.test(target) ? msysToWindowsPath(target, platform) : void 0;
95494
+ return target.startsWith("/") ? target : void 0;
95495
+ }
95388
95496
  function hookPathCandidates(body2, platform = process.platform) {
95389
95497
  const input = body2["tool_input"] ?? {};
95390
95498
  const str = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
@@ -95430,6 +95538,205 @@ var SessionAffinity = class {
95430
95538
  if (typeof sessionId === "string") this.bySession.delete(sessionId);
95431
95539
  }
95432
95540
  };
95541
+ var FOCUS_WINDOW = 5;
95542
+ var FOCUS_ROOT_HEADER = "x-errata-focus-root";
95543
+ var FOCUS_QUORUM = 3;
95544
+ var FOCUS_TTL_MS = 6 * 60 * 6e4;
95545
+ var SessionFocus = class {
95546
+ constructor(ttlMs = FOCUS_TTL_MS, maxSessions = 500) {
95547
+ this.ttlMs = ttlMs;
95548
+ this.maxSessions = maxSessions;
95549
+ }
95550
+ bySession = /* @__PURE__ */ new Map();
95551
+ live(sessionId, now) {
95552
+ if (typeof sessionId !== "string" || sessionId.length === 0) return null;
95553
+ const s = this.bySession.get(sessionId);
95554
+ if (!s) return null;
95555
+ if (now - s.at > this.ttlMs) {
95556
+ this.bySession.delete(sessionId);
95557
+ return null;
95558
+ }
95559
+ return s;
95560
+ }
95561
+ touch(sessionId, s, now) {
95562
+ s.at = now;
95563
+ this.bySession.delete(sessionId);
95564
+ this.bySession.set(sessionId, s);
95565
+ while (this.bySession.size > this.maxSessions) {
95566
+ const oldest = this.bySession.keys().next().value;
95567
+ if (oldest === void 0) break;
95568
+ this.bySession.delete(oldest);
95569
+ }
95570
+ }
95571
+ /** The workspace this session's hooks should go to instead of its cwd's, if any. */
95572
+ focusFor(sessionId, now = Date.now()) {
95573
+ return this.live(sessionId, now)?.focus ?? null;
95574
+ }
95575
+ pinnedFor(sessionId, now = Date.now()) {
95576
+ return this.live(sessionId, now)?.pinned ?? false;
95577
+ }
95578
+ /** A tool hook's locator reached `root`; `cwdRoot` is where the session's cwd routes. */
95579
+ observe(sessionId, root, cwdRoot, now = Date.now()) {
95580
+ if (typeof sessionId !== "string" || sessionId.length === 0) return null;
95581
+ const s = this.live(sessionId, now) ?? { recent: [], focus: null, pinned: false, at: now, reached: /* @__PURE__ */ new Set() };
95582
+ s.reached.add(root);
95583
+ s.recent.push(root);
95584
+ if (s.recent.length > FOCUS_WINDOW) s.recent.shift();
95585
+ this.touch(sessionId, s, now);
95586
+ if (s.pinned) return null;
95587
+ const current = s.focus ?? cwdRoot;
95588
+ if (root === current) return null;
95589
+ if (s.recent.filter((r) => r === root).length < FOCUS_QUORUM) return null;
95590
+ s.focus = root === cwdRoot ? null : root;
95591
+ return { from: current, to: root, reason: "inferred" };
95592
+ }
95593
+ pin(sessionId, root, cwdRoot, now = Date.now()) {
95594
+ if (typeof sessionId !== "string" || sessionId.length === 0) return null;
95595
+ const s = this.live(sessionId, now) ?? { recent: [], focus: null, pinned: false, at: now, reached: /* @__PURE__ */ new Set() };
95596
+ const current = s.focus ?? cwdRoot;
95597
+ s.pinned = true;
95598
+ s.focus = root;
95599
+ s.recent = [];
95600
+ this.touch(sessionId, s, now);
95601
+ return root === current ? null : { from: current, to: root, reason: "pinned" };
95602
+ }
95603
+ /** Drop the pin and any inferred focus: the session follows its cwd again. */
95604
+ unpin(sessionId, cwdRoot, now = Date.now()) {
95605
+ const s = this.live(sessionId, now);
95606
+ if (!s) return null;
95607
+ const current = s.focus ?? cwdRoot;
95608
+ s.pinned = false;
95609
+ s.focus = null;
95610
+ s.recent = [];
95611
+ this.touch(sessionId, s, now);
95612
+ return cwdRoot && current !== cwdRoot ? { from: current, to: cwdRoot, reason: "unpinned" } : null;
95613
+ }
95614
+ /** A tool ran in the session's cwd without naming a path (a shell command, a search). */
95615
+ ranIn(sessionId, root, now = Date.now()) {
95616
+ if (typeof sessionId !== "string" || sessionId.length === 0) return;
95617
+ const s = this.live(sessionId, now) ?? { recent: [], focus: null, pinned: false, at: now, reached: /* @__PURE__ */ new Set() };
95618
+ s.reached.add(root);
95619
+ this.touch(sessionId, s, now);
95620
+ }
95621
+ /** Whether the session's tools ever worked in `root`; undefined for a session this daemon has not watched. */
95622
+ reached(sessionId, root, now = Date.now()) {
95623
+ return this.live(sessionId, now)?.reached.has(root);
95624
+ }
95625
+ forget(sessionId) {
95626
+ if (typeof sessionId === "string") this.bySession.delete(sessionId);
95627
+ }
95628
+ /** Every live focus, for `errata focus` to confirm a pin landed. */
95629
+ entries(now = Date.now()) {
95630
+ const out2 = [];
95631
+ for (const [sessionId, s] of this.bySession) {
95632
+ if (now - s.at > this.ttlMs || !s.focus) continue;
95633
+ out2.push({ sessionId, root: s.focus, pinned: s.pinned, at: s.at });
95634
+ }
95635
+ return out2;
95636
+ }
95637
+ };
95638
+ function misfiledTarget(roots, home) {
95639
+ if (roots.includes(home)) return null;
95640
+ const tally = /* @__PURE__ */ new Map();
95641
+ for (const r of roots) tally.set(r, (tally.get(r) ?? 0) + 1);
95642
+ const ranked = [...tally].sort((a, b) => b[1] - a[1]);
95643
+ const [top, next] = ranked;
95644
+ if (!top || top[1] < FOCUS_QUORUM || next && next[1] === top[1]) return null;
95645
+ return top[0];
95646
+ }
95647
+ function focusCommandIn(command) {
95648
+ if (typeof command !== "string") return void 0;
95649
+ const m = /(?:^|[\s;&|(])errata(?:\.cmd|\.ps1)?\s+focus(?:\s+("[^"]+"|'[^']+'|[^\s;&|)]+))?/.exec(command);
95650
+ if (!m) return void 0;
95651
+ const arg = m[1]?.replace(/^["']|["']$/g, "");
95652
+ if (!arg) return void 0;
95653
+ if (arg === "--clear" || arg === "clear" || arg === "off") return { ref: null };
95654
+ return arg.startsWith("-") ? void 0 : { ref: arg };
95655
+ }
95656
+ function resolveWorkspaceRef(ref, entries, ownerRoot) {
95657
+ const byName = entries.filter((e) => e.name.toLowerCase() === ref.toLowerCase());
95658
+ if (byName.length === 1) return byName[0];
95659
+ if (byName.length > 1) return null;
95660
+ const root = ownerRoot(entries.map((e) => e.path), msysToWindowsPath(ref));
95661
+ return root ? entries.find((e) => e.path === root) ?? null : null;
95662
+ }
95663
+
95664
+ // src/rehome.ts
95665
+ init_src2();
95666
+ import { isAbsolute as isAbsolute6 } from "node:path";
95667
+ function sourcesOf(n) {
95668
+ const s = n.attrs["sources"];
95669
+ return Array.isArray(s) ? s.filter((x) => typeof x === "string") : [];
95670
+ }
95671
+ function rehomeSessionNodes(from, to, sessionIds, opts) {
95672
+ const sessions = new Set(sessionIds);
95673
+ const now = opts.now ?? Date.now();
95674
+ const report = { moved: [], copied: [], vocabulary: [], edgesCarried: 0, edgesDropped: 0, cloudContributed: [] };
95675
+ const picked = /* @__PURE__ */ new Map();
95676
+ for (const row of from.selectNodes({ labels: SEMANTIC_NODE_LABELS, attrs: ["sources"] })) {
95677
+ const sources = sourcesOf(row);
95678
+ if (!sources.some((s) => sessions.has(s))) continue;
95679
+ const node2 = from.getNode(row.id);
95680
+ if (!node2) continue;
95681
+ picked.set(node2.id, node2);
95682
+ (sources.every((s) => sessions.has(s)) ? report.moved : report.copied).push(node2.id);
95683
+ if (node2.attrs["cloudNodeId"]) report.cloudContributed.push(node2.id);
95684
+ }
95685
+ const edges = /* @__PURE__ */ new Map();
95686
+ for (const id of picked.keys()) {
95687
+ for (const e of [...from.outEdges(id), ...from.inEdges(id)]) edges.set(e.id, e);
95688
+ }
95689
+ const vocabulary = /* @__PURE__ */ new Map();
95690
+ const carried = [];
95691
+ for (const e of edges.values()) {
95692
+ const other = picked.has(e.from) ? e.to : e.from;
95693
+ if (picked.has(other)) {
95694
+ carried.push(e);
95695
+ continue;
95696
+ }
95697
+ const node2 = from.getNode(other);
95698
+ if (!node2 || isCodeLabel(node2.label)) {
95699
+ report.edgesDropped++;
95700
+ continue;
95701
+ }
95702
+ if (!to.getNode(other)) vocabulary.set(other, node2);
95703
+ carried.push(e);
95704
+ }
95705
+ report.vocabulary = [...vocabulary.keys()];
95706
+ report.edgesCarried = carried.length;
95707
+ if (!opts.apply) return report;
95708
+ to.transaction(() => {
95709
+ for (const n of [...vocabulary.values(), ...picked.values()]) to.mergeNode(n);
95710
+ for (const e of carried) to.mergeEdge(e);
95711
+ });
95712
+ from.transaction(() => {
95713
+ for (const id of report.moved) {
95714
+ const n = picked.get(id);
95715
+ from.updateNode(id, { attrs: { ...n.attrs, rehomedAt: now } });
95716
+ from.closeNode(id, now);
95717
+ }
95718
+ });
95719
+ return report;
95720
+ }
95721
+ function captureWorkPaths(store, home) {
95722
+ const out2 = /* @__PURE__ */ new Map();
95723
+ for (const row of store.selectNodes({ labels: ["Problem"], attrs: ["sources", "anchorHint"] })) {
95724
+ const sources = sourcesOf(row);
95725
+ if (sources.length === 0) continue;
95726
+ let where = null;
95727
+ if (store.outEdges(row.id, ["ANCHORED_AT"]).length > 0) where = home;
95728
+ else {
95729
+ const hint = row.attrs["anchorHint"];
95730
+ if (typeof hint?.path === "string" && hint.path.length > 0) where = isAbsolute6(hint.path) ? hint.path : home;
95731
+ }
95732
+ for (const s of sources) {
95733
+ const list = out2.get(s) ?? [];
95734
+ if (where) list.push(where);
95735
+ out2.set(s, list);
95736
+ }
95737
+ }
95738
+ return out2;
95739
+ }
95433
95740
 
95434
95741
  // src/multi.ts
95435
95742
  init_checkout_root();
@@ -95487,6 +95794,14 @@ function pickOwnerRoot(roots, absPath) {
95487
95794
  }
95488
95795
  return best;
95489
95796
  }
95797
+ function withAdditionalContext(response, hookEventName, extra) {
95798
+ const out2 = response["hookSpecificOutput"] ?? {};
95799
+ const prior = typeof out2.additionalContext === "string" && out2.additionalContext ? `${out2.additionalContext}
95800
+
95801
+ ` : "";
95802
+ const { ok: _ok, event: _event, tool: _tool, recognizedFile: _file2, ...rest2 } = response;
95803
+ return { ...rest2, hookSpecificOutput: { ...out2, hookEventName, additionalContext: `${prior}${extra}` } };
95804
+ }
95490
95805
  function ownerOf(records, absPath) {
95491
95806
  const root = pickOwnerRoot(records.map((r) => r.root), absPath);
95492
95807
  return root == null ? null : records.find((r) => r.root === root) ?? null;
@@ -95893,6 +96208,8 @@ async function startMultiDaemon(opts = {}) {
95893
96208
  const app = new Hono2();
95894
96209
  const enqueueWakeIndex = serialQueue();
95895
96210
  const attaching = /* @__PURE__ */ new Map();
96211
+ let afterAttach = () => {
96212
+ };
95896
96213
  const attachWorkspace = (root) => {
95897
96214
  const norm = normPath(root);
95898
96215
  const pending = attaching.get(norm);
@@ -95942,6 +96259,7 @@ async function startMultiDaemon(opts = {}) {
95942
96259
  });
95943
96260
  }
95944
96261
  console.log(`[errata] hot-registered ${rec.entry.name} (${rec.id}) \u2014 now watching live`);
96262
+ afterAttach(rec);
95945
96263
  return { attached: true, id: rec.id };
95946
96264
  };
95947
96265
  app.post("/api/attach", async (c) => {
@@ -96066,6 +96384,105 @@ async function startMultiDaemon(opts = {}) {
96066
96384
  })
96067
96385
  );
96068
96386
  const sessionAffinity = new SessionAffinity();
96387
+ const sessionFocus = new SessionFocus();
96388
+ const pendingSwitch = /* @__PURE__ */ new Map();
96389
+ const turnHome = /* @__PURE__ */ new Map();
96390
+ const registryRoots = () => listWorkspaceEntries().map((e) => e.path);
96391
+ const rootOfPath = (p, roots) => p ? ownerOf(records, p)?.root ?? pickOwnerRoot((roots ?? registryRoots)(), p) : null;
96392
+ const workspaceName = (root) => root ? listWorkspaceEntries().find((e) => e.path === root)?.name ?? basename10(root) : "its working directory";
96393
+ const recordFor = async (root) => {
96394
+ const running = records.find((r) => r.root === root);
96395
+ if (running) return running;
96396
+ return (await attachWorkspace(root)).attached ? records.find((r) => r.root === root) ?? null : null;
96397
+ };
96398
+ const focusNotice = (change, cwdRoot) => {
96399
+ const to = workspaceName(change.to);
96400
+ const from = workspaceName(change.from);
96401
+ const where = `Captures from here on go to ${to}'s graph, and the block below is ${to}'s context: it replaces any earlier errata block.`;
96402
+ if (change.reason === "pinned") return `errata: this session is now pinned to ${to} (\`errata focus\`). ${where} \`errata focus off\` returns it to ${workspaceName(cwdRoot)}.`;
96403
+ if (change.reason === "unpinned") return `errata: focus cleared; this session follows its working directory, ${to}, again. ${where}`;
96404
+ return `errata: this session's work moved from ${from} to ${to}: ${FOCUS_QUORUM} of the last ${FOCUS_WINDOW} paths your tools touched are in ${to}. ${where} Wrong workspace? Run \`errata focus ${from === "its working directory" ? "off" : from}\`.`;
96405
+ };
96406
+ const noteFocus = (sid, change) => {
96407
+ if (!change) return;
96408
+ pendingSwitch.set(sid, change);
96409
+ console.log(`[errata] session ${sid.slice(0, 8)} focus ${workspaceName(change.from)} \u2192 ${workspaceName(change.to)} (${change.reason})`);
96410
+ };
96411
+ const observeFocus = (sid, body2) => {
96412
+ const event = String(body2["hook_event_name"] ?? "");
96413
+ const cwd = typeof body2["cwd"] === "string" ? msysToWindowsPath(body2["cwd"]) : void 0;
96414
+ const cwdRoot = rootOfPath(cwd);
96415
+ const input = body2["tool_input"] ?? {};
96416
+ if (event === "PreToolUse") {
96417
+ const pin = focusCommandIn(input["command"]);
96418
+ if (!pin) return;
96419
+ if (pin.ref === null) return noteFocus(sid, sessionFocus.unpin(sid, cwdRoot));
96420
+ const entry = resolveWorkspaceRef(pin.ref, listWorkspaceEntries(), pickOwnerRoot);
96421
+ if (entry) noteFocus(sid, sessionFocus.pin(sid, entry.path, cwdRoot));
96422
+ return;
96423
+ }
96424
+ if (event !== "PostToolUse" && event !== "PostToolUseFailure") return;
96425
+ let registered = null;
96426
+ const roots = () => registered ??= registryRoots();
96427
+ const located = hookPathCandidates(body2).filter((p) => p !== cwd).map((p) => rootOfPath(p, roots)).find((r) => r != null);
96428
+ if (located) noteFocus(sid, sessionFocus.observe(sid, located, cwdRoot));
96429
+ else if (cwdRoot && (typeof input["command"] === "string" || ["Grep", "Glob", "LS"].includes(String(body2["tool_name"])))) {
96430
+ sessionFocus.ranIn(sid, cwdRoot);
96431
+ }
96432
+ };
96433
+ const repositoryOf = (root) => {
96434
+ const gitRoot = nearestGitRoot(root);
96435
+ return normPath((gitRoot ? mainCheckoutOfWorktree(gitRoot) : null) ?? root);
96436
+ };
96437
+ const evidenceWorkspace = (home) => {
96438
+ let registered = null;
96439
+ const known = () => registered ??= registryRoots();
96440
+ return (p) => {
96441
+ if (p === home) return p;
96442
+ const direct = rootOfPath(p, known);
96443
+ if (direct) return direct;
96444
+ const gitRoot = nearestGitRoot(p);
96445
+ const main2 = gitRoot ? mainCheckoutOfWorktree(gitRoot) : null;
96446
+ return main2 ? rootOfPath(main2, known) : null;
96447
+ };
96448
+ };
96449
+ const rehomeMisfiled = async (sid, from, to, why) => {
96450
+ if (repositoryOf(from.root) === repositoryOf(to.root)) return;
96451
+ await from.engine.settleTurns(sid);
96452
+ const workspaceOf = evidenceWorkspace(from.root);
96453
+ if ((captureWorkPaths(from.engine.store, from.root).get(sid) ?? []).some((p) => workspaceOf(p) === from.root)) return;
96454
+ const report = rehomeSessionNodes(from.engine.store, to.engine.store, [sid], { apply: true });
96455
+ if (report.moved.length + report.copied.length === 0) return;
96456
+ from.engine.markContextDirty();
96457
+ to.engine.markContextDirty();
96458
+ console.log(
96459
+ `[errata] session ${sid.slice(0, 8)} captures ${from.entry.name} \u2192 ${to.entry.name} (${why}): ${report.moved.length} moved, ${report.copied.length} copied, ${report.edgesDropped} code edges dropped` + (report.cloudContributed.length > 0 ? `, ${report.cloudContributed.length} already in the cloud under ${from.entry.name}` : "")
96460
+ );
96461
+ };
96462
+ const handOffTurns = (sid, owner, cwd) => {
96463
+ const mine = owner.engine.turnCursor(sid);
96464
+ let fromRoot = mine?.movedTo ?? turnHome.get(sid) ?? null;
96465
+ if (!fromRoot && !mine) fromRoot = rootOfPath(cwd);
96466
+ if (fromRoot && fromRoot !== owner.root) {
96467
+ const from = records.find((r) => r.root === fromRoot);
96468
+ const configDir = workspacePaths(fromRoot).configDir;
96469
+ const cursor = from ? from.engine.turnCursor(sid) : turnCursorOnDisk(configDir, sid);
96470
+ if (from) from.engine.releaseTurnCursor(sid, owner.root);
96471
+ else releaseTurnCursorOnDisk(configDir, sid, owner.root);
96472
+ owner.engine.adoptTurnCursor(sid, cursor?.movedTo ? null : cursor);
96473
+ console.log(`[errata] session ${sid.slice(0, 8)} turns: ${workspaceName(fromRoot)} \u2192 ${owner.entry.name}`);
96474
+ if (from && sessionFocus.focusFor(sid) === owner.root && sessionFocus.reached(sid, fromRoot) === false) {
96475
+ void rehomeMisfiled(sid, from, owner, "its tools never worked there").catch(
96476
+ (err2) => console.warn(`[errata] rehome of session ${sid.slice(0, 8)} failed: ${err2 instanceof Error ? err2.message : err2}`)
96477
+ );
96478
+ }
96479
+ } else if (mine?.movedTo) {
96480
+ owner.engine.adoptTurnCursor(sid, null);
96481
+ }
96482
+ turnHome.delete(sid);
96483
+ turnHome.set(sid, owner.root);
96484
+ if (turnHome.size > 500) turnHome.delete(turnHome.keys().next().value);
96485
+ };
96069
96486
  const forward = async (c, pathInBody, opts2 = {}) => {
96070
96487
  const raw2 = await c.req.text();
96071
96488
  let body2 = {};
@@ -96075,9 +96492,13 @@ async function startMultiDaemon(opts = {}) {
96075
96492
  return c.json({ error: "bad-json" }, 400);
96076
96493
  }
96077
96494
  lastHook = { at: Date.now(), event: typeof body2["hook_event_name"] === "string" ? body2["hook_event_name"] : c.req.path };
96495
+ const sid = typeof body2["session_id"] === "string" ? body2["session_id"] : "";
96496
+ if (sid && opts2.affinity === "note") observeFocus(sid, body2);
96497
+ const focusRoot = sid && opts2.affinity === "follow" ? sessionFocus.focusFor(sid) : null;
96078
96498
  const primary = pathInBody(body2);
96079
96499
  const others = (opts2.extraPaths?.(body2) ?? []).filter((p) => p !== primary);
96080
- let owner = ownerOf(records, primary);
96500
+ let owner = focusRoot ? await recordFor(focusRoot) : null;
96501
+ if (!owner) owner = ownerOf(records, primary);
96081
96502
  for (const p of others) {
96082
96503
  if (owner) break;
96083
96504
  owner = ownerOf(records, p);
@@ -96096,8 +96517,8 @@ async function startMultiDaemon(opts = {}) {
96096
96517
  }
96097
96518
  }
96098
96519
  if (!owner && others.length > 0) {
96099
- const registryRoots = listWorkspaceEntries().map((e) => e.path);
96100
- const registered = others.map((p) => pickOwnerRoot(registryRoots, p)).find((r) => r != null);
96520
+ const registryRoots2 = listWorkspaceEntries().map((e) => e.path);
96521
+ const registered = others.map((p) => pickOwnerRoot(registryRoots2, p)).find((r) => r != null);
96101
96522
  if (registered && (await attachWorkspace(registered)).attached) {
96102
96523
  owner = records.find((r) => r.root === registered) ?? null;
96103
96524
  if (owner) console.log(`[errata] woke workspace ${owner.entry.name}`);
@@ -96126,32 +96547,109 @@ async function startMultiDaemon(opts = {}) {
96126
96547
  if (opts2.affinity === "note") sessionAffinity.note(body2["session_id"], owner.root);
96127
96548
  owner.lastHookAt = Date.now();
96128
96549
  const sub = c.req.path;
96550
+ const cwd = typeof body2["cwd"] === "string" ? msysToWindowsPath(body2["cwd"]) : void 0;
96551
+ if (sid && (sub === "/api/turn" || sub === "/api/session-end")) handOffTurns(sid, owner, cwd);
96552
+ const subagentHook = typeof body2["agent_id"] === "string" && body2["agent_id"] !== "";
96553
+ const pending = sid && !subagentHook ? pendingSwitch.get(sid) : void 0;
96554
+ let raw22 = raw2;
96555
+ if (pending && sub === "/api/context-inject" && pending.to === owner.root) {
96556
+ raw22 = JSON.stringify({ ...body2, [FOCUS_NOTICE_FIELD]: focusNotice(pending, rootOfPath(cwd)) });
96557
+ pendingSwitch.delete(sid);
96558
+ }
96129
96559
  const tag = (v) => typeof v === "string" ? ` ${v.slice(0, 40)}` : "";
96130
96560
  const done = markPass(`${sub}:${owner.entry.name}${tag(body2["hook_event_name"])}${tag(body2["tool_name"])}`);
96561
+ const post = async (target, path3, payload) => await target.webApp.fetch(
96562
+ new Request(`http://local${path3}`, {
96563
+ method: "POST",
96564
+ // The origin header (harness/fixture quarantine, session-registry.ts)
96565
+ // is the one caller header the sub-app needs; nothing else crosses.
96566
+ headers: {
96567
+ "content-type": "application/json",
96568
+ ...c.req.header(ORIGIN_HEADER) ? { [ORIGIN_HEADER]: c.req.header(ORIGIN_HEADER) } : {}
96569
+ },
96570
+ body: payload
96571
+ })
96572
+ );
96573
+ const withFocusHeader = (res) => {
96574
+ const focusedRoot = sid ? sessionFocus.focusFor(sid) : null;
96575
+ if (!focusedRoot) return res;
96576
+ const headers = new Headers(res.headers);
96577
+ headers.set(FOCUS_ROOT_HEADER, focusedRoot);
96578
+ return new Response(res.body, { status: res.status, headers });
96579
+ };
96131
96580
  try {
96132
- return await owner.webApp.fetch(
96133
- new Request(`http://local${sub}`, {
96134
- method: "POST",
96135
- // The origin header (harness/fixture quarantine, session-registry.ts)
96136
- // is the one caller header the sub-app needs; nothing else crosses.
96137
- headers: {
96138
- "content-type": "application/json",
96139
- ...c.req.header(ORIGIN_HEADER) ? { [ORIGIN_HEADER]: c.req.header(ORIGIN_HEADER) } : {}
96140
- },
96141
- body: raw2
96142
- })
96143
- );
96581
+ const res = await post(owner, sub, raw22);
96582
+ if (sub === "/api/session-end" && sid) {
96583
+ sessionFocus.forget(sid);
96584
+ pendingSwitch.delete(sid);
96585
+ }
96586
+ const hookEvent = String(body2["hook_event_name"] ?? "");
96587
+ if (pending && pendingSwitch.has(sid) && sub === "/api/hook" && ["PreToolUse", "PostToolUse", "PostToolUseFailure"].includes(hookEvent)) {
96588
+ const target = await recordFor(pending.to);
96589
+ if (target) {
96590
+ pendingSwitch.delete(sid);
96591
+ const inject = await post(target, "/api/context-inject", JSON.stringify({
96592
+ session_id: sid,
96593
+ hook_event_name: hookEvent,
96594
+ cwd: target.root,
96595
+ ...typeof body2["transcript_path"] === "string" ? { transcript_path: body2["transcript_path"] } : {},
96596
+ [FOCUS_NOTICE_FIELD]: focusNotice(pending, rootOfPath(cwd))
96597
+ }));
96598
+ const extra = (await inject.json().catch(() => ({}))).hookSpecificOutput?.additionalContext;
96599
+ if (extra) {
96600
+ const base = res.status === 204 ? {} : await res.json().catch(() => ({}));
96601
+ return withFocusHeader(c.json(withAdditionalContext(base, hookEvent, extra)));
96602
+ }
96603
+ }
96604
+ }
96605
+ return withFocusHeader(res);
96144
96606
  } finally {
96145
96607
  done();
96146
96608
  }
96147
96609
  };
96610
+ const MISFILED_SWEEP_DELAY_MS = 2 * 6e4;
96611
+ const sweepMisfiled = async (rec) => {
96612
+ const checkedPath = join47(workspacePaths(rec.root).configDir, "rehome-checked.json");
96613
+ let checked;
96614
+ try {
96615
+ checked = new Set(JSON.parse(readFileSync40(checkedPath, "utf8")));
96616
+ } catch {
96617
+ checked = /* @__PURE__ */ new Set();
96618
+ }
96619
+ const workspaceOf = evidenceWorkspace(rec.root);
96620
+ let changed = false;
96621
+ for (const [sid, paths] of captureWorkPaths(rec.engine.store, rec.root)) {
96622
+ if (stopping) break;
96623
+ if (checked.has(sid) || turnHome.has(sid) || sessionFocus.reached(sid, rec.root) !== void 0) continue;
96624
+ checked.add(sid);
96625
+ changed = true;
96626
+ const target = misfiledTarget(paths.map(workspaceOf).filter((r) => r != null), rec.root);
96627
+ const to = target ? await recordFor(target) : null;
96628
+ if (to) await rehomeMisfiled(sid, rec, to, "its captures were raised in its files");
96629
+ await new Promise((r) => setImmediate(r));
96630
+ }
96631
+ if (changed) writeFileSync29(checkedPath, JSON.stringify([...checked]), "utf8");
96632
+ };
96633
+ afterAttach = (rec) => {
96634
+ const t = setTimeout(() => {
96635
+ void sweepMisfiled(rec).catch(
96636
+ (err2) => console.warn(`[errata] misfiled-session check of ${rec.entry.name} failed: ${err2 instanceof Error ? err2.message : err2}`)
96637
+ );
96638
+ }, MISFILED_SWEEP_DELAY_MS);
96639
+ t.unref?.();
96640
+ };
96641
+ for (const rec of records) afterAttach(rec);
96642
+ app.get(
96643
+ "/api/focus",
96644
+ (c) => c.json({ sessions: sessionFocus.entries().map((f) => ({ ...f, name: workspaceName(f.root) })) })
96645
+ );
96148
96646
  app.post(
96149
96647
  "/api/hook",
96150
96648
  (c) => forward(c, (b) => {
96151
96649
  const input = b["tool_input"] ?? {};
96152
96650
  const fp = input["file_path"];
96153
96651
  const cwd = b["cwd"];
96154
- return (typeof fp === "string" ? fp : void 0) ?? (typeof cwd === "string" ? cwd : void 0);
96652
+ return (typeof fp === "string" ? fp : void 0) ?? leadingCdTarget(input["command"]) ?? (typeof cwd === "string" ? cwd : void 0);
96155
96653
  }, { extraPaths: (b) => hookPathCandidates(b), affinity: "note" })
96156
96654
  );
96157
96655
  app.post(
@@ -98328,6 +98826,9 @@ try {
98328
98826
  // a changed snapshot can go out as only what changed (the daemon renders it).
98329
98827
  const bodyPath = statePath && statePath.replace(/\.json$/, '.body.md');
98330
98828
  let emittedBody = null;
98829
+ // Set when the daemon routes this session to another workspace (errata focus):
98830
+ // that workspace's block is the current one.
98831
+ let focusRoot = null;
98331
98832
  const post = async (endpoint, relay = false) => {
98332
98833
  try {
98333
98834
  const response = await fetch('http://127.0.0.1:' + port + '/api/' + endpoint, {
@@ -98335,6 +98836,8 @@ try {
98335
98836
  body: JSON.stringify(payload), signal: AbortSignal.timeout(2000),
98336
98837
  });
98337
98838
  if (!response.ok) return false;
98839
+ const focused = response.headers.get('x-errata-focus-root');
98840
+ if (focused) focusRoot = focused;
98338
98841
  if (!relay) return true;
98339
98842
  const result = await response.json();
98340
98843
  const context = result.hookSpecificOutput?.additionalContext;
@@ -98355,17 +98858,20 @@ try {
98355
98858
  }
98356
98859
  if (workspaceRoot && ['SessionStart', 'UserPromptSubmit', 'PostToolUse'].includes(event)) {
98357
98860
  let body = '';
98358
- try { body = boundedRead(join(workspaceRoot, '.errata', 'context.md'), 128 * 1024).trim(); } catch {}
98861
+ const blockRoot = focusRoot || workspaceRoot;
98862
+ try { body = boundedRead(join(blockRoot, '.errata', 'context.md'), 128 * 1024).trim(); } catch {}
98359
98863
  if (body) {
98360
98864
  const digest = hash(body.replace(/\r\n?/g, '\n')).slice(0, 16);
98865
+ // A different workspace's block than the one last sent: a delta between them is noise.
98866
+ const switched = (previous.root || workspaceRoot) !== blockRoot;
98361
98867
  // Receipt is local to this graph + session, survives daemon restarts, and
98362
98868
  // records stdout emission, not a server's intention to send a response.
98363
- if (event === 'SessionStart' || previous.hash !== digest) {
98869
+ if (event === 'SessionStart' || switched || previous.hash !== digest) {
98364
98870
  const normalized = body.replace(/\r\n?/g, '\n');
98365
98871
  // Every SessionStart (startup, resume, compact, clear) sends the whole
98366
98872
  // block: the window may no longer hold the last one.
98367
98873
  let delta = null;
98368
- if (event !== 'SessionStart' && previous.hash && bodyPath) {
98874
+ if (event !== 'SessionStart' && !switched && previous.hash && bodyPath) {
98369
98875
  let prior = '';
98370
98876
  try { prior = boundedRead(bodyPath, 512 * 1024); } catch {}
98371
98877
  if (prior && hash(prior).slice(0, 16) === previous.hash) {
@@ -98383,7 +98889,7 @@ try {
98383
98889
  // Only bookkeeping moved: nothing to send, but this snapshot is now what the window holds.
98384
98890
  try {
98385
98891
  mkdirSync(stateDir, { recursive: true });
98386
- writeFileSync(statePath, JSON.stringify({ hash: digest, at: Date.now() }), { mode: 0o600 });
98892
+ writeFileSync(statePath, JSON.stringify({ hash: digest, at: Date.now(), root: blockRoot }), { mode: 0o600 });
98387
98893
  writeFileSync(bodyPath, normalized, { mode: 0o600 });
98388
98894
  } catch { /* the next change just goes whole */ }
98389
98895
  } else {
@@ -98392,11 +98898,11 @@ try {
98392
98898
  : 'Errata loaded the maintained local workspace context. This block REPLACES any earlier errata context block in this conversation. It is the latest saved snapshot; the daemon maintains it as work proceeds.\n\n' + body);
98393
98899
  if (!/^(0|off|false|no)$/i.test((process.env.ERRATA_RECALL_CARD || '').trim())) {
98394
98900
  try {
98395
- const card = JSON.parse(boundedRead(join(workspaceRoot, '.errata', 'context-card.json'), 128 * 1024));
98901
+ const card = JSON.parse(boundedRead(join(blockRoot, '.errata', 'context-card.json'), 128 * 1024));
98396
98902
  if (card.hash === digest && typeof card.line === 'string' && card.line) messages.push(card.line);
98397
98903
  } catch { /* Never show a card for a different snapshot. */ }
98398
98904
  }
98399
- receipt = { hash: digest, at: Date.now() };
98905
+ receipt = { hash: digest, at: Date.now(), root: blockRoot };
98400
98906
  emittedBody = normalized;
98401
98907
  }
98402
98908
  }
@@ -99922,6 +100428,10 @@ async function main() {
99922
100428
  return cmdUpdate(rest);
99923
100429
  case "projects":
99924
100430
  return cmdProjects();
100431
+ case "focus":
100432
+ return cmdFocus(rest);
100433
+ case "rehome":
100434
+ return cmdRehome(rest);
99925
100435
  case "dash":
99926
100436
  return cmdDash(rest);
99927
100437
  case "notify-test":
@@ -99945,6 +100455,14 @@ function printHelp() {
99945
100455
  Commands:
99946
100456
  init Auto-detect this workspace's profile, write .errata/workspace.json
99947
100457
  projects List every initialized project + whether its daemon is running
100458
+ focus <workspace|off>
100459
+ Run from an agent's shell tool: that session's captures and
100460
+ context follow the named workspace (name or path). Sessions
100461
+ also follow on their own once most of the paths their tools
100462
+ touch are in another workspace; this pins it.
100463
+ rehome --session <id> --from <ws> --to <ws> [--apply]
100464
+ Move what a session captured into the workspace its work was
100465
+ about (a session started in the wrong repo). Dry run by default.
99948
100466
  dash [--port N] [--no-reindex] [--embed]
99949
100467
  One daemon watching ALL initialized projects, routing hooks
99950
100468
  to each project's graph (default port 7891). JSON endpoints
@@ -103006,6 +103524,82 @@ async function cmdProjects() {
103006
103524
  console.log(` ${w.path}`);
103007
103525
  }
103008
103526
  }
103527
+ async function cmdFocus(args2) {
103528
+ const entries = listWorkspaceEntries();
103529
+ const ref = args2.find((a) => !a.startsWith("-"));
103530
+ const clear = args2.includes("--clear") || ref === "off" || ref === "clear";
103531
+ const port = resolveHookPort();
103532
+ const focused = async () => {
103533
+ try {
103534
+ const r = await fetch(`http://127.0.0.1:${port}/api/focus`, { signal: AbortSignal.timeout(2e3) });
103535
+ return r.ok ? (await r.json()).sessions : [];
103536
+ } catch {
103537
+ return [];
103538
+ }
103539
+ };
103540
+ if (!ref && !clear) {
103541
+ const now = await focused();
103542
+ console.log("usage: errata focus <workspace name or path> | errata focus off");
103543
+ console.log("Run it from an agent's shell tool: the session that runs it follows that workspace.\n");
103544
+ if (now.length === 0) console.log("No session is focused away from its working directory.");
103545
+ for (const f of now) console.log(` ${f.sessionId.slice(0, 8)} ${f.pinned ? "pinned to" : "following"} ${f.name} (${f.root})`);
103546
+ return;
103547
+ }
103548
+ if (clear) {
103549
+ console.log("focus cleared for the agent session that ran this: it follows its working directory again.");
103550
+ return;
103551
+ }
103552
+ const entry = resolveWorkspaceRef(ref, entries, pickOwnerRoot);
103553
+ if (!entry) {
103554
+ const same = entries.filter((e) => e.name.toLowerCase() === ref.toLowerCase());
103555
+ console.error(same.length > 1 ? `"${ref}" names ${same.length} workspaces; use a path instead:
103556
+ ${same.map((e) => ` ${e.path}`).join("\n")}` : `no workspace named or containing "${ref}". Known workspaces:
103557
+ ${entries.map((e) => ` ${e.name.padEnd(24)} ${e.path}`).join("\n")}`);
103558
+ process.exit(2);
103559
+ }
103560
+ const pinned = (await focused()).filter((f) => f.pinned && f.root === entry.path && Date.now() - f.at < 3e4);
103561
+ if (pinned.length === 0) {
103562
+ console.error(`${entry.name} (${entry.path}) is a workspace, but no agent session was pinned to it.`);
103563
+ console.error("Run this from an agent's shell tool, with the errata daemon running: the daemon pins the session whose hook reports the command.");
103564
+ process.exit(1);
103565
+ }
103566
+ console.log(`focus: ${entry.name} (${entry.path}), pinned for session ${pinned.map((f) => f.sessionId.slice(0, 8)).join(", ")}.`);
103567
+ console.log("Captures and context now come from this workspace. `errata focus off` returns the session to its working directory.");
103568
+ }
103569
+ async function cmdRehome(args2) {
103570
+ const values = (flag) => args2.flatMap((a, i2) => a === flag && args2[i2 + 1] ? [args2[i2 + 1]] : []);
103571
+ const sessions = values("--session");
103572
+ const [fromRef] = values("--from");
103573
+ const [toRef] = values("--to");
103574
+ const apply = args2.includes("--apply");
103575
+ if (sessions.length === 0 || !fromRef || !toRef) {
103576
+ console.error("usage: errata rehome --session <id> [--session <id> \u2026] --from <workspace> --to <workspace> [--apply]");
103577
+ process.exit(2);
103578
+ }
103579
+ const entries = listWorkspaceEntries();
103580
+ const from = resolveWorkspaceRef(fromRef, entries, pickOwnerRoot);
103581
+ const to = resolveWorkspaceRef(toRef, entries, pickOwnerRoot);
103582
+ if (!from || !to || from.path === to.path) {
103583
+ console.error(`--from and --to must name two different workspaces (got ${from?.name ?? fromRef} \u2192 ${to?.name ?? toRef}).`);
103584
+ process.exit(2);
103585
+ }
103586
+ const { openGraphStore: openGraphStore2 } = await Promise.resolve().then(() => (init_src5(), src_exports2));
103587
+ const src = openGraphStore2({ path: workspacePaths(from.path).castalia });
103588
+ const dst = openGraphStore2({ path: workspacePaths(to.path).castalia });
103589
+ try {
103590
+ const r = rehomeSessionNodes(src, dst, sessions, { apply });
103591
+ const verb = apply ? "" : "would ";
103592
+ console.log(`${from.name} \u2192 ${to.name}${apply ? "" : " (dry run; --apply to write)"}`);
103593
+ console.log(` ${verb}move ${r.moved.length} node(s) (closed in ${from.name}), ${verb}copy ${r.copied.length} shared with other sessions`);
103594
+ console.log(` ${verb}carry ${r.edgesCarried} edge(s), ${verb}add ${r.vocabulary.length} vocabulary node(s) to ${to.name}; ${r.edgesDropped} edge(s) into ${from.name}'s code dropped`);
103595
+ if (r.cloudContributed.length > 0) {
103596
+ console.log(` ${r.cloudContributed.length} of them were already contributed to the cloud under ${from.name}'s project; those cloud copies are not moved.`);
103597
+ }
103598
+ } finally {
103599
+ src.close();
103600
+ dst.close();
103601
+ }
103602
+ }
103009
103603
  async function cmdNotifications(state) {
103010
103604
  const s = state.toLowerCase();
103011
103605
  if (s !== "on" && s !== "off") {