amalgm 0.1.261 → 0.1.263

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.
Files changed (24) hide show
  1. package/lib/cli.js +9 -3
  2. package/package.json +1 -1
  3. package/runtime/scripts/amalgm-mcp/lib/prepared.js +27 -0
  4. package/runtime/scripts/amalgm-mcp/observer/README.md +4 -1
  5. package/runtime/scripts/amalgm-mcp/observer/coverage.js +52 -0
  6. package/runtime/scripts/amalgm-mcp/observer/index.js +186 -44
  7. package/runtime/scripts/amalgm-mcp/observer/store.js +41 -27
  8. package/runtime/scripts/amalgm-mcp/observer/watch.js +6 -2
  9. package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +54 -19
  10. package/runtime/scripts/amalgm-mcp/registration/index.js +58 -12
  11. package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +43 -4
  12. package/runtime/scripts/amalgm-mcp/registration/repo-states.js +15 -5
  13. package/runtime/scripts/amalgm-mcp/registration/service.js +121 -16
  14. package/runtime/scripts/amalgm-mcp/registration/tree.js +104 -20
  15. package/runtime/scripts/amalgm-mcp/registry/evidence.js +34 -13
  16. package/runtime/scripts/amalgm-mcp/registry/store.js +30 -26
  17. package/runtime/scripts/amalgm-mcp/repocard/capture.js +45 -3
  18. package/runtime/scripts/amalgm-mcp/repocard/follow.js +65 -10
  19. package/runtime/scripts/amalgm-mcp/tests/entity-cloud.test.js +90 -0
  20. package/runtime/scripts/amalgm-mcp/tests/entity-materialization.test.js +236 -0
  21. package/runtime/scripts/amalgm-mcp/tests/observer.coverage.test.js +197 -0
  22. package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +30 -3
  23. package/runtime/scripts/amalgm-mcp/tests/registration.test.js +10 -6
  24. package/runtime/scripts/amalgm-mcp/tests/repocard.scope.test.js +182 -0
package/lib/cli.js CHANGED
@@ -1215,10 +1215,16 @@ function selectEntity(status, selector) {
1215
1215
  const uuid = selector && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(selector)
1216
1216
  ? selector.toLowerCase()
1217
1217
  : null;
1218
+ // A UUID is an entity's globally unique address. Do not let rows without
1219
+ // an optional display path turn that exact address into a fuzzy selector.
1220
+ if (uuid) {
1221
+ const match = entities.find((entity) => entity.uuid === uuid);
1222
+ if (!match) throw new Error(`No cloud-backed entity matches ${selector}.`);
1223
+ return match;
1224
+ }
1218
1225
  const localPath = selector && !uuid ? path.resolve(selector) : null;
1219
- const cloudPath = uuid ? null : String(selector).replace(/^\/+|\/+$/g, '');
1220
- const matches = entities.filter((entity) => entity.uuid === uuid
1221
- || entity.localPath === localPath
1226
+ const cloudPath = String(selector).replace(/^\/+|\/+$/g, '');
1227
+ const matches = entities.filter((entity) => entity.localPath === localPath
1222
1228
  || entity.cloudPath === cloudPath
1223
1229
  || entity.name === selector);
1224
1230
  if (matches.length === 0) throw new Error(`No cloud-backed entity matches ${selector}.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.261",
3
+ "version": "0.1.263",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Memoized statement preparation for one database handle.
5
+ *
6
+ * better-sqlite3 compiles the SQL text on EVERY `db.prepare()` call — it
7
+ * keeps no statement cache of its own. The stores' row-level verbs run
8
+ * thousands of times per scan (a first census inserts one row per file),
9
+ * so recompiling identical SQL each call was a measured double-digit
10
+ * share of a large registration. Each store builds one preparer over its
11
+ * injected handle and asks it instead; the SQL string is the whole key,
12
+ * so a handful of table-templated queries memoize to a handful of
13
+ * statements, never an unbounded set.
14
+ */
15
+ function preparer(db) {
16
+ const statements = new Map();
17
+ return (sql) => {
18
+ let statement = statements.get(sql);
19
+ if (!statement) {
20
+ statement = db.prepare(sql);
21
+ statements.set(sql, statement);
22
+ }
23
+ return statement;
24
+ };
25
+ }
26
+
27
+ module.exports = { preparer };
@@ -80,7 +80,10 @@ never merges, never decides truth — it emits facts for the layers above.
80
80
  never traversed — an explicitly enrolled symlink enrolls as the
81
81
  pointer itself.
82
82
  2. **The doorbell is a hint, never a fact — but a hint only ever raises
83
- scrutiny.** OS watchers are lossy by contract. Truth is always:
83
+ scrutiny.** One OS watcher per outermost tree (coverage.js — see
84
+ docs/watchers.md): enclosed roots hold no handles; the encloser
85
+ forwards their dings, rebased, synchronously. OS watchers are lossy
86
+ by contract. Truth is always:
84
87
  settle → walk → compare → hash. Stats may excuse a file from hashing
85
88
  only when all three hold: nothing named it, its stats are identical,
86
89
  and its mtime is strictly older than the moment we last read its bytes
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The coverage law: one OS watcher per outermost tree.
5
+ *
6
+ * A recursive watcher on a directory hears every event beneath it, at any
7
+ * depth — so a root enclosed by watched ground already has its content
8
+ * observed, exactly as its ADDRESS is already observed (the enclosed()
9
+ * rule that predates this module). Opening a second watcher over enclosed
10
+ * ground buys nothing on any platform and, on Linux — where a recursive
11
+ * watch is silently one inotify slot PER DIRECTORY against a small kernel
12
+ * budget — it is what exhausted the T3 machine. Doorbells are shared;
13
+ * what a root DOES with its dings (files channels, repo Card+Checkpoint)
14
+ * stays its own type's business, routed by ring().
15
+ *
16
+ * Pure functions over root rows — no handles, no state. index.js owns
17
+ * opening and closing; this module only answers "who guards whom".
18
+ */
19
+
20
+ const path = require('path');
21
+
22
+ /** The ground a root's doorbell must cover: a directory root guards
23
+ * itself; a single-file root's bell lives on its parent directory (the
24
+ * file's own inode goes deaf on atomic saves). */
25
+ function doorbellGround(root) {
26
+ return root.kind === 'file' ? path.dirname(root.path) : root.path;
27
+ }
28
+
29
+ /**
30
+ * The root whose OS watcher covers `root`'s doorbell ground, or null when
31
+ * `root` guards itself. The OUTERMOST enclosing directory root wins: with
32
+ * A ⊃ B ⊃ C only A holds a handle, and every deeper root — B, C, nested
33
+ * repos, single files — rides it. A file root can never cover anything.
34
+ */
35
+ function watchOwnerOf(root, roots) {
36
+ const ground = doorbellGround(root);
37
+ let owner = null;
38
+ for (const other of roots) {
39
+ if (other.rootId === root.rootId || other.kind === 'file') continue;
40
+ if (ground !== other.path && !ground.startsWith(`${other.path}/`)) continue;
41
+ if (!owner || other.path.length < owner.path.length) owner = other;
42
+ }
43
+ return owner;
44
+ }
45
+
46
+ /** Every live root covered by `owner` (owner excluded). */
47
+ function coveredRoots(owner, roots) {
48
+ return roots.filter((root) => root.rootId !== owner.rootId
49
+ && watchOwnerOf(root, roots)?.rootId === owner.rootId);
50
+ }
51
+
52
+ module.exports = { coveredRoots, doorbellGround, watchOwnerOf };
@@ -21,10 +21,12 @@
21
21
  * Nested repositories are territories too: a repo inside a repo is simply
22
22
  * another repo. A census at lifecycle moments (enrollment, conversion,
23
23
  * catch-up scans) plus doorbells live make each nested boundary its own
24
- * root with its own watcher; the parent stays silent for a child's ground.
25
- * A doorbell's filename never proves anything — it only says where to
26
- * look, and disk decides what it means (a repo moved in whole rings only
27
- * the directory that landed, never a path spelling ".git").
24
+ * root — but never its own OS watcher: one watcher per outermost tree
25
+ * (coverage.js), and the encloser FORWARDS a child territory's dings,
26
+ * rebased, synchronous, still raw. A doorbell's filename never proves
27
+ * anything — it only says where to look, and disk decides what it means
28
+ * (a repo moved in whole rings only the directory that landed, never a
29
+ * path spelling ".git").
28
30
  *
29
31
  * Enrollment is amalgm's OWN policy (`shouldEnroll`), applied uniformly to
30
32
  * files territory and repo boundaries alike. Git's ignore rules never
@@ -42,6 +44,7 @@ const { createContinuity } = require('./continuity');
42
44
  const { createStore } = require('./store');
43
45
  const { censusBoundaries, censusLinks, diffDirState, diffLinkState, diffRootState, gitDirTruth, listDir, walkRoot } = require('./scan');
44
46
  const { createVerify } = require('./verify');
47
+ const { coveredRoots, watchOwnerOf } = require('./coverage');
45
48
  const { watchDir, watchRecursive } = require('./watch');
46
49
  const { adapterFor } = require('../adapters');
47
50
  const { lookFailed } = require('../adapters/truth');
@@ -80,7 +83,8 @@ function createObserver(options) {
80
83
  if (typeof emit !== 'function') throw new Error('createObserver requires { emit }');
81
84
 
82
85
  const store = createStore(db);
83
- // rootId -> { kind, watcher, settleTimer, dirtyPaths, checkAll, territories }
86
+ // rootId -> { kind, watcher, addressWatcher, settleTimer, dirtyPaths,
87
+ // checkAll, territories, watchEnabled, coveredBy, held }
84
88
  const live = new Map();
85
89
  let started = false;
86
90
 
@@ -338,7 +342,7 @@ function createObserver(options) {
338
342
  detach(root.rootId);
339
343
  store.removeRoot(root.rootId);
340
344
  live.get(parent.rootId)?.territories.delete(root.path.slice(parent.path.length + 1));
341
- publish({ type: 'repo.changed', rootId: parent.rootId, areas: ['worktree'] });
345
+ publish({ type: 'repo.changed', rootId: parent.rootId, areas: ['worktree'], hint: null });
342
346
  return;
343
347
  }
344
348
  if (parent) {
@@ -407,8 +411,10 @@ function createObserver(options) {
407
411
  || linkDiff.renames.length || linkDiff.creates.length || linkDiff.deletes.length
408
412
  || fileDiff.renames.length || fileDiff.creates.length || fileDiff.deletes.length;
409
413
  if (structural) return false;
410
- applyLinkDiff(root, linkDiff); // changes and retags: payload-local facts
411
- applyDiff(root, fileDiff); // checks only: content truth on existing channels
414
+ store.transaction(() => {
415
+ applyLinkDiff(root, linkDiff); // changes and retags: payload-local facts
416
+ applyDiff(root, fileDiff); // checks only: content truth on existing channels
417
+ });
412
418
  return true;
413
419
  }
414
420
 
@@ -448,11 +454,17 @@ function createObserver(options) {
448
454
  : { ...diff, deletes: diff.deletes.filter((row) => !underUnsettled(row.relPath)) });
449
455
  if (unsettled.length > 0 && state) armRetry(root, state);
450
456
 
451
- applyDirDiff(root, concluded(diffDirState(store.dirsForRoot(root.rootId), dirs)));
452
- applyLinkDiff(root, concluded(diffLinkState(store.linksForRoot(root.rootId), links)));
453
- const rows = store.filesForRoot(root.rootId);
454
- const mustCheck = checkAll ? new Set(entries.map((entry) => entry.relPath)) : rung;
455
- applyDiff(root, concluded(diffRootState(rows, entries, mustCheck)));
457
+ // One walk's conclusions are one durable transition: a census over
458
+ // thousands of channels commits once, not once per row. Events stay
459
+ // hints — a consumer told early about a rolled-back row re-reads
460
+ // store truth and finds the old state, which concludes nothing.
461
+ store.transaction(() => {
462
+ applyDirDiff(root, concluded(diffDirState(store.dirsForRoot(root.rootId), dirs)));
463
+ applyLinkDiff(root, concluded(diffLinkState(store.linksForRoot(root.rootId), links)));
464
+ const rows = store.filesForRoot(root.rootId);
465
+ const mustCheck = checkAll ? new Set(entries.map((entry) => entry.relPath)) : rung;
466
+ applyDiff(root, concluded(diffRootState(rows, entries, mustCheck)));
467
+ });
456
468
  }
457
469
 
458
470
  /**
@@ -580,8 +592,9 @@ function createObserver(options) {
580
592
  censusRepoRoot(root);
581
593
  // Catch-up semantics: the observer cannot know what happened to a
582
594
  // repo while nobody watched; the follower's ids turn "maybe" into
583
- // silence, so an unconditional doorbell is the honest answer.
584
- publish({ type: 'repo.changed', rootId: root.rootId, areas: ['git', 'worktree'] });
595
+ // silence, so an unconditional doorbell is the honest answer —
596
+ // and a null path, because anything could have changed.
597
+ publish({ type: 'repo.changed', rootId: root.rootId, areas: ['git', 'worktree'], hint: null });
585
598
  }
586
599
  }
587
600
  // A scan that leaves no look pending resets the backoff: the
@@ -605,6 +618,10 @@ function createObserver(options) {
605
618
  function ring(root, relPath) {
606
619
  const state = live.get(root.rootId);
607
620
  if (!state) return;
621
+ // Held doorbells: the watcher is proven alive but registration has not
622
+ // committed. Dings conclude nothing — start(rootId)'s initial scan and
623
+ // census are the catch-up for everything heard during the hold.
624
+ if (state.held) return;
608
625
  state.retryMs = settleMs; // new evidence resets a backoff
609
626
 
610
627
  if (state.kind === 'file') {
@@ -653,6 +670,7 @@ function createObserver(options) {
653
670
  // Only absence buries — and an unsettled look keeps the news
654
671
  // pending: the retry looks again, no second doorbell needed.
655
672
  if (childTruth === 'unsettled') armRetry(root, state);
673
+ else forwardToTerritory(root, territory, relPath);
656
674
  return;
657
675
  }
658
676
  const known = store.rootByPath(childPath);
@@ -736,17 +754,22 @@ function createObserver(options) {
736
754
  }
737
755
  }
738
756
  // Forwarded raw and instantly: quieting is the follower's job, and a
739
- // delayed doorbell would blind its torn-read guard.
757
+ // delayed doorbell would blind its torn-read guard. The named path
758
+ // travels as a HINT — scope for the checkpoint to patch one file
759
+ // instead of re-reading the world; null means "anywhere". A hint is
760
+ // never a conviction: the grammar's voice (path/from/to) stays
761
+ // wording-independent, exactly as the wording grid ratifies.
740
762
  const areas = relPath === null
741
763
  ? ['git', 'worktree']
742
764
  : relPath === '.git' || relPath.startsWith('.git/') ? ['git'] : ['worktree'];
743
- publish({ type: 'repo.changed', rootId: root.rootId, areas });
765
+ publish({ type: 'repo.changed', rootId: root.rootId, areas, hint: relPath });
744
766
  return;
745
767
  }
746
768
 
747
- // A child repo's own doorbell covers its territory — while the boundary
748
- // is alive on disk. A dead one is territory news: fall through and let
749
- // the scan's reconcile bury it and reclaim the ground.
769
+ // A child territory's dings arrive on THIS root's watcher (one watcher
770
+ // per outermost tree) and are forwarded to the child — while the
771
+ // boundary is alive on disk. A dead one is territory news: fall through
772
+ // and let the scan's reconcile bury it and reclaim the ground.
750
773
  const filesTerritory = relPath === null ? null : coveringTerritory(state, relPath);
751
774
  if (filesTerritory !== null) {
752
775
  const territoryTruth = gitDirTruth(path.join(root.path, filesTerritory));
@@ -754,7 +777,10 @@ function createObserver(options) {
754
777
  armRetry(root, state); // no conclusion from a failed look — the retry decides
755
778
  return;
756
779
  }
757
- if (territoryTruth === 'present') return;
780
+ if (territoryTruth === 'present') {
781
+ forwardToTerritory(root, filesTerritory, relPath);
782
+ return;
783
+ }
758
784
  }
759
785
  // Enrollment policy applies to doorbells too — except .git itself: it is
760
786
  // never enrollable, but its appearance is territory news and must scan.
@@ -771,6 +797,19 @@ function createObserver(options) {
771
797
  armSettle(root, state);
772
798
  }
773
799
 
800
+ /**
801
+ * A covered child's dings arrive on its encloser's watcher; the encloser
802
+ * forwards them — synchronously, so repo doorbells stay raw and instant —
803
+ * rebased to the child's own ground. A ding naming the territory itself
804
+ * forwards as null (anything in there could have changed). Ringing the
805
+ * child recurses naturally through deeper territories.
806
+ */
807
+ function forwardToTerritory(root, territory, relPath) {
808
+ const child = store.rootByPath(path.join(root.path, territory));
809
+ if (!child) return; // racing a burial: the next reconcile owns it
810
+ ring(child, relPath === territory ? null : relPath.slice(territory.length + 1));
811
+ }
812
+
774
813
  function openWatcher(root) {
775
814
  // A single-file root's doorbell lives on its parent directory: a
776
815
  // watcher on the file itself would follow the inode and go deaf on
@@ -801,6 +840,34 @@ function createObserver(options) {
801
840
  );
802
841
  }
803
842
 
843
+ /**
844
+ * One root's watch health, coverage-aware: a covered root is watching
845
+ * exactly when its OWNER's watcher is live — it holds no handle of its
846
+ * own, by law, and pretending otherwise would hide a dead doorbell.
847
+ * Self-owned roots report their own handle, as always.
848
+ */
849
+ function statusOf(root) {
850
+ const state = live.get(root.rootId);
851
+ const ownerState = state?.coveredBy ? live.get(state.coveredBy) : null;
852
+ const watching = state?.coveredBy
853
+ ? Boolean(ownerState?.watcher) && !ownerState.watcher.degraded
854
+ : Boolean(state?.watcher) && !state.watcher.degraded;
855
+ const degraded = state?.coveredBy
856
+ ? Boolean(ownerState?.watcher?.degraded)
857
+ : Boolean(state?.watcher?.degraded);
858
+ return {
859
+ ...root,
860
+ watching,
861
+ degraded,
862
+ degradedReason: (state?.coveredBy ? ownerState : state)?.watcher?.reason ?? null,
863
+ coveredBy: state?.coveredBy ?? null,
864
+ addressRequired: root.kind !== 'file' && !enclosed(root),
865
+ addressWatching: Boolean(state?.addressWatcher) && !state.addressWatcher.degraded,
866
+ addressDegraded: Boolean(state?.addressWatcher?.degraded),
867
+ files: root.kind === 'repo' ? null : store.filesForRoot(root.rootId).length,
868
+ };
869
+ }
870
+
804
871
  /** A root enclosed by watched ground has its address observed already. */
805
872
  function enclosed(root) {
806
873
  return store.listRoots().some((other) =>
@@ -917,29 +984,81 @@ function createObserver(options) {
917
984
  const state = live.get(root.rootId);
918
985
  if (!state || !started) return;
919
986
  state.watcher = openWatcher(root);
920
- try {
921
- scanNow(root.rootId);
922
- } catch (error) {
923
- console.warn(`[Observer] post-revive scan failed for "${root.path}":`, error?.message || error);
987
+ // Only owners hold watchers, so this death silenced every covered
988
+ // root too: the gap reconcile belongs to the whole family.
989
+ const family = [root, ...coveredRoots(root, watchCandidates())];
990
+ for (const member of family) {
991
+ try {
992
+ scanNow(member.rootId);
993
+ } catch (error) {
994
+ console.warn(`[Observer] post-revive scan failed for "${member.path}":`, error?.message || error);
995
+ }
924
996
  }
925
997
  }
926
998
 
927
- function startWatching(root, state) {
999
+ /** The roots that participate in watch coverage: attached, and asked to
1000
+ * watch (a registration-phase address-only root is neither an owner nor
1001
+ * covered — it is invisible to coverage until its watch begins). */
1002
+ function watchCandidates() {
1003
+ return store.listRoots().filter((root) => live.get(root.rootId)?.watchEnabled);
1004
+ }
1005
+
1006
+ function ensureOwnWatcher(root, state) {
928
1007
  if (!state.watcher || state.watcher.degraded) {
929
1008
  state.watcher?.close();
930
1009
  state.watcher = openWatcher(root);
931
1010
  }
932
- if ((!state.addressWatcher || state.addressWatcher.degraded)
933
- && root.kind !== 'file' && !enclosed(root)) {
1011
+ }
1012
+
1013
+ function ensureAddressWatcher(root, state) {
1014
+ if (root.kind === 'file') return; // its main doorbell IS the parent
1015
+ if (enclosed(root)) {
1016
+ // The encloser's walk is its address evidence — and adoption can
1017
+ // make this true of a root that once held one: close it.
1018
+ state.addressWatcher?.close();
1019
+ state.addressWatcher = null;
1020
+ return;
1021
+ }
1022
+ if (!state.addressWatcher || state.addressWatcher.degraded) {
934
1023
  state.addressWatcher?.close();
935
1024
  state.addressWatcher = openAddressWatcher(root);
936
1025
  }
937
1026
  }
938
1027
 
1028
+ /**
1029
+ * THE one decision about who holds OS handles, re-made whenever a root
1030
+ * joins, leaves, or moves: each watch-enabled root is either self-owned
1031
+ * (outermost — opens its own recursive doorbell) or covered (rides its
1032
+ * owner's; ring() forwards). Owners open FIRST, covered close after, so
1033
+ * an adoption never leaves a gap with no watcher over the ground.
1034
+ * Idempotent — settled roots are untouched.
1035
+ */
1036
+ function resolveCoverage() {
1037
+ if (!started) return;
1038
+ const candidates = watchCandidates();
1039
+ const owned = [];
1040
+ const covered = [];
1041
+ for (const root of candidates) {
1042
+ const state = live.get(root.rootId);
1043
+ const owner = watchOwnerOf(root, candidates);
1044
+ state.coveredBy = owner?.rootId ?? null;
1045
+ (owner ? covered : owned).push([root, state]);
1046
+ }
1047
+ for (const [root, state] of owned) ensureOwnWatcher(root, state);
1048
+ for (const [, state] of covered) {
1049
+ state.watcher?.close();
1050
+ state.watcher = null;
1051
+ }
1052
+ for (const [root, state] of [...owned, ...covered]) ensureAddressWatcher(root, state);
1053
+ }
1054
+
939
1055
  function attach(root, { watch = true } = {}) {
940
1056
  const existing = live.get(root.rootId);
941
1057
  if (existing) {
942
- if (started && watch) startWatching(root, existing);
1058
+ if (started && watch) {
1059
+ existing.watchEnabled = true;
1060
+ resolveCoverage();
1061
+ }
943
1062
  return existing;
944
1063
  }
945
1064
  const state = {
@@ -952,12 +1071,18 @@ function createObserver(options) {
952
1071
  dirtyPaths: new Set(),
953
1072
  checkAll: false,
954
1073
  territories: new Set(),
1074
+ watchEnabled: false,
1075
+ coveredBy: null,
1076
+ held: false,
955
1077
  };
956
1078
  for (const child of childRepoRoots(root)) {
957
1079
  state.territories.add(child.path.slice(root.path.length + 1));
958
1080
  }
959
1081
  live.set(root.rootId, state);
960
- if (started && watch) startWatching(root, state);
1082
+ if (started && watch) {
1083
+ state.watchEnabled = true;
1084
+ resolveCoverage();
1085
+ }
961
1086
  return state;
962
1087
  }
963
1088
 
@@ -968,6 +1093,9 @@ function createObserver(options) {
968
1093
  state.addressWatcher?.close();
969
1094
  if (state.settleTimer) clearTimeout(state.settleTimer);
970
1095
  live.delete(rootId);
1096
+ // A departed owner promotes whoever it covered (stop() sets
1097
+ // started=false first, so teardown never reopens handles).
1098
+ resolveCoverage();
971
1099
  }
972
1100
 
973
1101
  return {
@@ -1164,6 +1292,27 @@ function createObserver(options) {
1164
1292
  }
1165
1293
  },
1166
1294
 
1295
+ /**
1296
+ * Open and prove a root's doorbells WITHOUT scanning — the
1297
+ * registration boundary's "watcher first" step. The watcher is born
1298
+ * (or refuses to be, visibly), coverage is resolved, and its dings
1299
+ * are HELD: nothing is scanned, published, or recorded until
1300
+ * start(rootId) releases the hold — whose initial scan and census
1301
+ * are the catch-up for anything heard meanwhile. Returns the root's
1302
+ * coverage-aware status row so the caller can refuse BEFORE any
1303
+ * identity commits.
1304
+ */
1305
+ watchNow(rootId) {
1306
+ started = true; // watching has begun for this runtime
1307
+ const root = store.listRoots().find((row) => row.rootId === rootId);
1308
+ if (!root) throw new Error(`watchNow requires a live root: ${rootId}`);
1309
+ const state = attach(root, { watch: false });
1310
+ state.held = true;
1311
+ state.watchEnabled = true;
1312
+ resolveCoverage();
1313
+ return statusOf(root);
1314
+ },
1315
+
1167
1316
  start(rootId = null) {
1168
1317
  const firstStart = !started;
1169
1318
  if (firstStart) started = true;
@@ -1173,8 +1322,12 @@ function createObserver(options) {
1173
1322
  : store.listRoots().filter((root) => root.rootId === rootId);
1174
1323
  // Watch begins after registration. The initial scan makes the fresh
1175
1324
  // structural tree concrete; changes during registration are outside
1176
- // this operation's contract and are not reconstructed here.
1177
- for (const root of roots) attach(root, { watch: true });
1325
+ // this operation's contract and are not reconstructed here — held
1326
+ // doorbells (watchNow) release into that same catch-up.
1327
+ for (const root of roots) {
1328
+ const state = attach(root, { watch: true });
1329
+ state.held = false;
1330
+ }
1178
1331
  try {
1179
1332
  for (const root of roots) scanNow(root.rootId);
1180
1333
  } catch (error) {
@@ -1188,18 +1341,7 @@ function createObserver(options) {
1188
1341
  },
1189
1342
 
1190
1343
  status() {
1191
- return store.listRoots().map((root) => {
1192
- const state = live.get(root.rootId);
1193
- return {
1194
- ...root,
1195
- watching: Boolean(state?.watcher) && !state.watcher.degraded,
1196
- degraded: Boolean(state?.watcher?.degraded),
1197
- addressRequired: root.kind !== 'file' && !enclosed(root),
1198
- addressWatching: Boolean(state?.addressWatcher) && !state.addressWatcher.degraded,
1199
- addressDegraded: Boolean(state?.addressWatcher?.degraded),
1200
- files: root.kind === 'repo' ? null : store.filesForRoot(root.rootId).length,
1201
- };
1202
- });
1344
+ return store.listRoots().map((root) => statusOf(root));
1203
1345
  },
1204
1346
  };
1205
1347
  }