amalgm 0.1.258 → 0.1.260

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/lib/cli.js CHANGED
@@ -120,7 +120,10 @@ function usage() {
120
120
  ' amalgm stop',
121
121
  ' amalgm register [path ...] Register local ground (default: current directory)',
122
122
  ' amalgm add <entity-uuid> [directory] Add a cloud-backed tree to this machine',
123
+ ' amalgm ls [entity] List the verified cloud workspace',
124
+ ' amalgm cd <entity> Resolve a materialized cloud directory',
123
125
  ' amalgm status [path-or-uuid] Show cloud-backed entity and watcher state',
126
+ ' amalgm shell init zsh Enable `amalgm cd` in the current shell',
124
127
  ' amalgm doctor',
125
128
  ' amalgm service [install|start|stop|status|uninstall] [--mode auto|systemd|launchd|windows|portable]',
126
129
  ' amalgm update [--tag latest|canary]',
@@ -1177,12 +1180,10 @@ async function runtimeStatus() {
1177
1180
  }
1178
1181
  }
1179
1182
 
1180
- /** `amalgm status [path-or-uuid]` is a read-only view of entity truth. The
1181
- * runtime owns paths, bindings, and cloud freshness; the CLI only narrows and
1182
- * renders the returned facts. Daemon status remains available as
1183
- * `amalgm service status`. */
1184
- async function entityStatus(positionals) {
1185
- if (positionals.length > 1) throw new Error('Usage: amalgm status [path-or-uuid]');
1183
+ /** One runtime read for every navigation command. The endpoint owns facts;
1184
+ * these commands only select and render them, so navigating never becomes a
1185
+ * backdoor mutation or cloud refresh. */
1186
+ async function readEntityStatus() {
1186
1187
  const port = servicePorts().find(([name]) => name === 'amalgm-mcp')?.[1];
1187
1188
  let answer = null;
1188
1189
  if (port) {
@@ -1195,26 +1196,71 @@ async function entityStatus(positionals) {
1195
1196
  if (!answer) {
1196
1197
  console.error('Amalgm runtime is not running. Start it with `amalgm start`.');
1197
1198
  process.exitCode = 1;
1198
- return;
1199
+ return null;
1199
1200
  }
1200
1201
  if (answer.status !== 200 || !answer.payload) {
1201
1202
  console.error(`Error: ${answer.payload?.error || `runtime responded ${answer.status}`}`);
1202
1203
  process.exitCode = 1;
1203
- return;
1204
+ return null;
1204
1205
  }
1206
+ return answer.payload;
1207
+ }
1205
1208
 
1206
- const status = answer.payload;
1207
- const selector = positionals[0] || null;
1209
+ /** A selector is deliberately exact: full UUID, local address, canonical
1210
+ * cloud path, or an unambiguous entity name. A guessed fuzzy match would
1211
+ * make a cloud navigation command choose the wrong ground. */
1212
+ function selectEntity(status, selector) {
1213
+ const entities = status.entities || [];
1214
+ if (!selector) return null;
1208
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)
1209
1216
  ? selector.toLowerCase()
1210
1217
  : null;
1211
1218
  const localPath = selector && !uuid ? path.resolve(selector) : null;
1212
- const entities = (status.entities || []).filter((entity) => !selector
1213
- || entity.uuid === uuid || entity.localPath === localPath);
1214
- if (selector && entities.length === 0) {
1215
- console.error(`No cloud-backed entity matches ${selector}.`);
1216
- process.exitCode = 1;
1217
- return;
1219
+ const cloudPath = uuid ? null : String(selector).replace(/^\/+|\/+$/g, '');
1220
+ const matches = entities.filter((entity) => entity.uuid === uuid
1221
+ || entity.localPath === localPath
1222
+ || entity.cloudPath === cloudPath
1223
+ || entity.name === selector);
1224
+ if (matches.length === 0) throw new Error(`No cloud-backed entity matches ${selector}.`);
1225
+ if (matches.length > 1) {
1226
+ throw new Error(`Entity selector ${selector} is ambiguous: ${matches.map((entity) => entity.cloudPath || entity.uuid).join(', ')}. Use a UUID or canonical cloud path.`);
1227
+ }
1228
+ return matches[0];
1229
+ }
1230
+
1231
+ function requireCurrentCloudWorkspace(status) {
1232
+ const cloud = status.cloud || {};
1233
+ if (!cloud.replica) {
1234
+ throw new Error(cloud.identity?.enabled
1235
+ ? 'Cloud workspace is initializing; wait for the signed-in runtime to finish its handshake.'
1236
+ : 'Cloud workspace is unavailable because this machine is not signed in.');
1237
+ }
1238
+ if (!cloud.catalog?.current) {
1239
+ throw new Error('Cloud workspace is catching up; wait for the active verified cloud head before listing it.');
1240
+ }
1241
+ }
1242
+
1243
+ function printEntityLine(entity) {
1244
+ const where = entity.localPath ? 'local' : 'cloud only';
1245
+ console.log(` ${entity.name}\t${entity.type}\t${entity.status}\t${where}\t${entity.uuid}`);
1246
+ }
1247
+
1248
+ /** `amalgm status [path-or-uuid]` is a read-only health view. Daemon status
1249
+ * remains available as `amalgm service status`. */
1250
+ async function entityStatus(positionals) {
1251
+ if (positionals.length > 1) throw new Error('Usage: amalgm status [path-or-uuid]');
1252
+ const status = await readEntityStatus();
1253
+ if (!status) return;
1254
+ const selector = positionals[0] || null;
1255
+ let entities = status.entities || [];
1256
+ if (selector) {
1257
+ try {
1258
+ entities = [selectEntity(status, selector)];
1259
+ } catch (error) {
1260
+ console.error(error.message);
1261
+ process.exitCode = 1;
1262
+ return;
1263
+ }
1218
1264
  }
1219
1265
 
1220
1266
  const cloud = status.cloud || {};
@@ -1246,6 +1292,91 @@ async function entityStatus(positionals) {
1246
1292
  }
1247
1293
  }
1248
1294
 
1295
+ /** `amalgm ls [entity]` lists the canonical cloud graph, never this
1296
+ * machine's incidental directory. A stale catalog is useful in `status`,
1297
+ * but is never presented as a navigable workspace here. */
1298
+ async function entityList(positionals) {
1299
+ if (positionals.length > 1) throw new Error('Usage: amalgm ls [entity]');
1300
+ const status = await readEntityStatus();
1301
+ if (!status) return;
1302
+ try {
1303
+ requireCurrentCloudWorkspace(status);
1304
+ const selected = positionals[0] ? selectEntity(status, positionals[0]) : null;
1305
+ if (selected && !selected.catalogued) {
1306
+ throw new Error(`Entity ${selected.uuid} is not in the verified cloud workspace yet.`);
1307
+ }
1308
+ const entities = (status.entities || []).filter((entity) => entity.catalogued
1309
+ && entity.status === 'active'
1310
+ && (selected ? entity.parentUUID === selected.uuid : entity.parentUUID === null))
1311
+ .sort((left, right) => left.name.localeCompare(right.name));
1312
+ if (selected) console.log(`${selected.cloudPath || selected.uuid}:`);
1313
+ console.log('NAME\tTYPE\tSTATE\tLOCATION\tUUID');
1314
+ for (const entity of entities) printEntityLine(entity);
1315
+ } catch (error) {
1316
+ console.error(error.message);
1317
+ process.exitCode = 1;
1318
+ }
1319
+ }
1320
+
1321
+ async function resolveEntityDirectory(positionals) {
1322
+ if (positionals.length !== 1) throw new Error('Usage: amalgm cd <entity>');
1323
+ const status = await readEntityStatus();
1324
+ if (!status) return null;
1325
+ const entity = selectEntity(status, positionals[0]);
1326
+ if (!entity.localPath) {
1327
+ throw new Error(`Cloud entity ${entity.cloudPath || entity.uuid} is not materialized on this machine. Run \`amalgm add ${entity.uuid} <directory>\` first.`);
1328
+ }
1329
+ let stat;
1330
+ try { stat = fs.statSync(entity.localPath); } catch {
1331
+ throw new Error(`Local binding for ${entity.cloudPath || entity.uuid} is unavailable at ${entity.localPath}. Run \`amalgm status ${entity.uuid}\` for details.`);
1332
+ }
1333
+ if (!stat.isDirectory()) {
1334
+ throw new Error(`${entity.cloudPath || entity.uuid} is a ${entity.type}, not a directory.`);
1335
+ }
1336
+ return entity.localPath;
1337
+ }
1338
+
1339
+ /** A standalone process cannot change the invoking shell. This direct form
1340
+ * resolves a directory honestly and points to the opt-in shell integration;
1341
+ * the shell function below intercepts the same spelling and performs cd. */
1342
+ async function entityCd(positionals) {
1343
+ try {
1344
+ const target = await resolveEntityDirectory(positionals);
1345
+ if (!target) return;
1346
+ console.log(target);
1347
+ console.log('Run `eval "$(amalgm shell init zsh)"` once to make `amalgm cd <entity>` change this shell.');
1348
+ } catch (error) {
1349
+ console.error(error.message);
1350
+ process.exitCode = 1;
1351
+ }
1352
+ }
1353
+
1354
+ async function resolveEntityDirectoryMachine(positionals) {
1355
+ try {
1356
+ const target = await resolveEntityDirectory(positionals);
1357
+ if (target) console.log(target);
1358
+ } catch (error) {
1359
+ console.error(error.message);
1360
+ process.exitCode = 1;
1361
+ }
1362
+ }
1363
+
1364
+ function shellCommand(positionals) {
1365
+ if (positionals[0] !== 'init' || !['zsh', 'bash'].includes(positionals[1] || 'zsh') || positionals.length > 2) {
1366
+ throw new Error('Usage: amalgm shell init [zsh|bash]');
1367
+ }
1368
+ console.log(`amalgm() {
1369
+ if [ "$1" = cd ]; then
1370
+ shift
1371
+ local _amalgm_destination
1372
+ _amalgm_destination="$(command amalgm __resolve-directory "$@")" || return $?
1373
+ builtin cd "$_amalgm_destination"
1374
+ return $?
1375
+ fi
1376
+ command amalgm "$@"
1377
+ }`);
1378
+ }
1379
+
1249
1380
  async function doctor() {
1250
1381
  ensureBaseDirs();
1251
1382
 
@@ -1718,7 +1849,11 @@ async function main(argv) {
1718
1849
  if (command === 'stop') return stop();
1719
1850
  if (command === 'register') return register(positionals);
1720
1851
  if (command === 'add') return add(positionals);
1852
+ if (command === 'ls') return entityList(positionals);
1853
+ if (command === 'cd') return entityCd(positionals);
1721
1854
  if (command === 'status') return entityStatus(positionals);
1855
+ if (command === 'shell') return shellCommand(positionals);
1856
+ if (command === '__resolve-directory') return resolveEntityDirectoryMachine(positionals);
1722
1857
  if (command === 'doctor') return doctor();
1723
1858
  if (command === 'service') return serviceCommand(options, positionals);
1724
1859
  if (command === 'update') return update(options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.258",
3
+ "version": "0.1.260",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -40,7 +40,7 @@ const path = require('path');
40
40
  const { createApply } = require('./apply');
41
41
  const { createContinuity } = require('./continuity');
42
42
  const { createStore } = require('./store');
43
- const { censusBoundaries, censusLinks, diffDirState, diffLinkState, diffRootState, gitDirTruth, walkRoot } = require('./scan');
43
+ const { censusBoundaries, censusLinks, diffDirState, diffLinkState, diffRootState, gitDirTruth, listDir, walkRoot } = require('./scan');
44
44
  const { createVerify } = require('./verify');
45
45
  const { watchDir, watchRecursive } = require('./watch');
46
46
  const { adapterFor } = require('../adapters');
@@ -343,18 +343,75 @@ function createObserver(options) {
343
343
  }
344
344
  if (parent) {
345
345
  // Born from a files root, returned to it: the parent's next scan
346
- // reclaims the files as creates.
346
+ // reclaims the files as creates. No doorbell will ever name the
347
+ // reclaimed ground, so every file is suspect — the scan must walk.
347
348
  detach(root.rootId);
348
349
  store.removeRoot(root.rootId);
349
- live.get(parent.rootId)?.territories.delete(root.path.slice(parent.path.length + 1));
350
+ const parentState = live.get(parent.rootId);
351
+ parentState?.territories.delete(root.path.slice(parent.path.length + 1));
352
+ if (parentState) parentState.checkAll = true;
350
353
  scanFilesRoot(parent);
351
354
  return;
352
355
  }
353
356
  store.setRootKind(root.rootId, 'files');
354
- if (state) state.kind = 'files';
357
+ if (state) {
358
+ state.kind = 'files';
359
+ state.checkAll = true; // conversion ground has no doorbells: walk it
360
+ }
355
361
  scanFilesRoot({ ...root, kind: 'files' });
356
362
  }
357
363
 
364
+ /**
365
+ * The scoped scan a NAMED doorbell earns: read only the directories
366
+ * the rung paths live in, compare only those directories' rows, and
367
+ * conclude only what is channel-local — an existing file's bytes
368
+ * changed, an existing link's written target changed. Anything
369
+ * structural (a create, a delete, a rename, a retag, a `.git`, an
370
+ * unreadable or absent directory) may be half of a story another
371
+ * directory holds, so it concludes nothing here and escalates to the
372
+ * full walk, which owns those laws unchanged. Returns true when the
373
+ * scoped conclusion was complete; false means walk.
374
+ */
375
+ function scanRungDirs(root, state, rung) {
376
+ const scopeDirs = new Set();
377
+ for (const relPath of rung) {
378
+ scopeDirs.add(relPath.includes('/') ? relPath.slice(0, relPath.lastIndexOf('/')) : '');
379
+ // A rung directory is also looked at directly: its own children
380
+ // may be what actually changed (the OS may name only the folder).
381
+ if (isDirectory(path.join(root.path, relPath))) scopeDirs.add(relPath);
382
+ }
383
+ const entries = { files: [], dirs: [], links: [] };
384
+ for (const relDir of scopeDirs) {
385
+ if (relDir !== '' && underTerritory(state, relDir)) return false; // territory ground answers to its own root
386
+ const look = listDir(root.path, relDir, shouldEnroll);
387
+ if (look.absent || look.unsettled || look.hasGit) return false;
388
+ for (const dir of look.dirs) {
389
+ // A nested repo is territory, never a dir channel — walkRoot
390
+ // reports it to repoDirs; the scoped look just leaves it alone.
391
+ if (state.territories.has(dir.relPath)) continue;
392
+ entries.dirs.push(dir);
393
+ }
394
+ entries.files.push(...look.files);
395
+ entries.links.push(...look.links);
396
+ }
397
+ const dirs = [...scopeDirs];
398
+ const rows = {
399
+ files: dirs.flatMap((relDir) => store.filesInDir(root.rootId, relDir)),
400
+ dirs: dirs.flatMap((relDir) => store.dirsInDir(root.rootId, relDir)),
401
+ links: dirs.flatMap((relDir) => store.linksInDir(root.rootId, relDir)),
402
+ };
403
+ const dirDiff = diffDirState(rows.dirs, entries.dirs);
404
+ const linkDiff = diffLinkState(rows.links, entries.links);
405
+ const fileDiff = diffRootState(rows.files, entries.files, rung);
406
+ const structural = dirDiff.renames.length || dirDiff.retags.length || dirDiff.creates.length || dirDiff.deletes.length
407
+ || linkDiff.renames.length || linkDiff.creates.length || linkDiff.deletes.length
408
+ || fileDiff.renames.length || fileDiff.creates.length || fileDiff.deletes.length;
409
+ 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
412
+ return true;
413
+ }
414
+
358
415
  function scanFilesRoot(root, { allowRootRepository = false } = {}) {
359
416
  // Drain the doorbell's named hints first: anything that rings during the
360
417
  // walk below belongs to the next scan, not this one.
@@ -366,6 +423,10 @@ function createObserver(options) {
366
423
  state.checkAll = false;
367
424
  }
368
425
 
426
+ // A named doorbell earns a scoped look; only a nameless one (or a
427
+ // scoped look that met structural news) pays for the whole walk.
428
+ if (!checkAll && rung.size > 0 && state && scanRungDirs(root, state, rung)) return;
429
+
369
430
  const { files: entries, dirs, links, repoDirs, unsettled } = walkRoot(
370
431
  root.path,
371
432
  shouldEnroll,
@@ -14,15 +14,31 @@ const { lookFailed } = require('../adapters/truth');
14
14
  /**
15
15
  * Does a .git hold this ground? Three answers, like every look — and
16
16
  * only present or absent may conclude: territory is never born,
17
- * buried, or converted on a look that failed.
17
+ * buried, or converted on a look that failed. A directory is Git's own
18
+ * repository marker. A file is a linked-worktree/submodule pointer, so it
19
+ * claims territory only when the referenced gitdir still exists: a stale
20
+ * pointer is ordinary folder ground, never a broken repo boundary.
18
21
  */
19
22
  function gitDirTruth(absDir) {
23
+ let marker;
20
24
  try {
21
- fs.lstatSync(path.join(absDir, '.git'));
22
- return 'present';
25
+ marker = fs.lstatSync(path.join(absDir, '.git'));
23
26
  } catch (error) {
24
27
  return lookFailed(error).truth;
25
28
  }
29
+ if (marker.isDirectory()) return 'present';
30
+ if (!marker.isFile()) return 'absent';
31
+ try {
32
+ const source = fs.readFileSync(path.join(absDir, '.git'), 'utf8');
33
+ const match = /^gitdir:\s*(.+?)\s*$/im.exec(source);
34
+ if (!match) return 'absent';
35
+ const gitDir = path.resolve(absDir, match[1]);
36
+ if (!fs.lstatSync(gitDir).isDirectory()) return 'absent';
37
+ if (!fs.lstatSync(path.join(gitDir, 'HEAD')).isFile()) return 'absent';
38
+ return 'present';
39
+ } catch (error) {
40
+ return lookFailed(error).truth === 'unsettled' ? 'unsettled' : 'absent';
41
+ }
26
42
  }
27
43
 
28
44
  /**
@@ -72,9 +88,15 @@ function walkRoot(rootPath, shouldEnroll, { allowRootRepository = false } = {})
72
88
  vanished(error, relDir);
73
89
  continue;
74
90
  }
75
- // A .git claims its subtree: an embedded repository is repo territory,
76
- // never files territory. Its changes travel as one card, not as files.
77
- if (entries.some((entry) => entry.name === '.git') && !(allowRootRepository && relDir === '')) {
91
+ // A valid .git claims its subtree: an embedded repository is repo
92
+ // territory, never files territory. Its changes travel as one card, not
93
+ // as files. A stale linked-worktree pointer is plain folder ground.
94
+ const territory = gitDirTruth(absDir);
95
+ if (territory === 'unsettled') {
96
+ unsettled.push(relDir);
97
+ continue;
98
+ }
99
+ if (territory === 'present' && !(allowRootRepository && relDir === '')) {
78
100
  repoDirs.push(relDir);
79
101
  if (!relDir) return { files: [], dirs: [], links: [], repoDirs, unsettled }; // the whole root is repo territory
80
102
  continue;
@@ -126,6 +148,70 @@ function walkRoot(rootPath, shouldEnroll, { allowRootRepository = false } = {})
126
148
  return { files, dirs, links, repoDirs, unsettled };
127
149
  }
128
150
 
151
+ /**
152
+ * One directory's immediate truth — the scoped look a NAMED doorbell
153
+ * earns (walkRoot is the nameless doorbell's). Same laws, one level
154
+ * deep: entries classified exactly as walkRoot classifies them,
155
+ * symlinks read but never followed. The caller decides what a scoped
156
+ * look may conclude; this function only refuses to pretend it saw more
157
+ * than one directory: a `.git` here is territory news (`hasGit`), a
158
+ * listing that failed is `unsettled`, a directory that is gone is
159
+ * `absent` — all three send the caller to the full walk, which owns
160
+ * those conclusions.
161
+ */
162
+ function listDir(rootPath, relDir, shouldEnroll) {
163
+ const absDir = relDir ? path.join(rootPath, relDir) : rootPath;
164
+ const out = { files: [], dirs: [], links: [], hasGit: false, absent: false, unsettled: false };
165
+ let entries;
166
+ try {
167
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
168
+ } catch (error) {
169
+ if (lookFailed(error).truth === 'absent') out.absent = true;
170
+ else out.unsettled = true;
171
+ return out;
172
+ }
173
+ const territory = gitDirTruth(absDir);
174
+ if (territory === 'unsettled') {
175
+ out.unsettled = true;
176
+ return out;
177
+ }
178
+ if (territory === 'present') {
179
+ out.hasGit = true;
180
+ return out;
181
+ }
182
+ for (const entry of entries) {
183
+ const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
184
+ if (!shouldEnroll(relPath)) continue;
185
+ let stat;
186
+ try {
187
+ stat = fs.lstatSync(path.join(rootPath, relPath));
188
+ } catch (error) {
189
+ // Vanished mid-look is honest absence (simply not listed); a
190
+ // failed look concludes nothing — the full walk reads again.
191
+ if (lookFailed(error).truth === 'unsettled') out.unsettled = true;
192
+ continue;
193
+ }
194
+ if (stat.isDirectory()) {
195
+ out.dirs.push({ relPath, device: stat.dev, inode: stat.ino });
196
+ } else if (stat.isSymbolicLink()) {
197
+ try {
198
+ out.links.push({ relPath, device: stat.dev, inode: stat.ino, target: fs.readlinkSync(path.join(rootPath, relPath)) });
199
+ } catch (error) {
200
+ if (lookFailed(error).truth === 'unsettled') out.unsettled = true;
201
+ }
202
+ } else if (stat.isFile()) {
203
+ out.files.push({
204
+ relPath,
205
+ device: stat.dev,
206
+ inode: stat.ino,
207
+ size: stat.size,
208
+ mtimeMs: Math.floor(stat.mtimeMs),
209
+ });
210
+ }
211
+ }
212
+ return out;
213
+ }
214
+
129
215
  function defaultShouldEnroll(relPath) {
130
216
  return require('../workspace/tree/inclusion').shouldEnroll(relPath);
131
217
  }
@@ -159,7 +245,12 @@ function censusBoundaries(rootPath, shouldEnroll = defaultShouldEnroll) {
159
245
  if (lookFailed(error).truth === 'unsettled') unsettled.push(relDir);
160
246
  continue;
161
247
  }
162
- if (relDir && entries.some((entry) => entry.name === '.git')) {
248
+ const territory = gitDirTruth(absDir);
249
+ if (territory === 'unsettled') {
250
+ unsettled.push(relDir);
251
+ continue;
252
+ }
253
+ if (relDir && territory === 'present') {
163
254
  boundaries.push(relDir);
164
255
  continue;
165
256
  }
@@ -200,7 +291,12 @@ function censusLinks(rootPath, shouldEnroll = defaultShouldEnroll) {
200
291
  if (lookFailed(error).truth === 'unsettled') unsettled.push(relDir);
201
292
  continue;
202
293
  }
203
- if (relDir && entries.some((entry) => entry.name === '.git')) continue;
294
+ const territory = gitDirTruth(absDir);
295
+ if (territory === 'unsettled') {
296
+ unsettled.push(relDir);
297
+ continue;
298
+ }
299
+ if (relDir && territory === 'present') continue;
204
300
  for (const entry of entries) {
205
301
  if (entry.name === '.git') continue;
206
302
  const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
@@ -384,4 +480,4 @@ function diffRootState(rows, entries, mustCheck = null) {
384
480
  return actions;
385
481
  }
386
482
 
387
- module.exports = { censusBoundaries, censusLinks, diffDirState, diffLinkState, diffRootState, gitDirTruth, walkRoot };
483
+ module.exports = { censusBoundaries, censusLinks, diffDirState, diffLinkState, diffRootState, gitDirTruth, listDir, walkRoot };
@@ -78,6 +78,20 @@ function rowToFile(row) {
78
78
  };
79
79
  }
80
80
 
81
+ function rowToDir(row) {
82
+ return {
83
+ dirId: row.dir_id, rootId: row.root_id, relPath: row.rel_path,
84
+ device: row.device, inode: row.inode,
85
+ };
86
+ }
87
+
88
+ function rowToLink(row) {
89
+ return {
90
+ linkId: row.link_id, rootId: row.root_id, relPath: row.rel_path,
91
+ device: row.device, inode: row.inode, target: row.target,
92
+ };
93
+ }
94
+
81
95
  const TABLES = ['observer_roots', 'observer_files', 'observer_dirs', 'observer_links'];
82
96
 
83
97
  function rowToRoot(row) {
@@ -127,6 +141,24 @@ function createStore(db) {
127
141
  db.exec(SCHEMA);
128
142
  db.pragma(`user_version = ${GENERATION}`);
129
143
 
144
+ /**
145
+ * One directory's immediate child rows — the scoped scan's slice of
146
+ * memory, served by the (root_id, rel_path) unique index. '0' is the
147
+ * character after '/', so [`dir/`, `dir0`) is exactly the rel_path
148
+ * range under the directory; depth filters to immediate children
149
+ * because rel_path is flat text.
150
+ */
151
+ function immediateRows(table, rootId, relDir) {
152
+ if (relDir === '') {
153
+ return db.prepare(`SELECT * FROM ${table} WHERE root_id = ? AND instr(rel_path, '/') = 0 ORDER BY rel_path`)
154
+ .all(rootId);
155
+ }
156
+ const prefix = `${relDir}/`;
157
+ return db.prepare(`SELECT * FROM ${table} WHERE root_id = ? AND rel_path >= ? AND rel_path < ? ORDER BY rel_path`)
158
+ .all(rootId, prefix, `${relDir}0`)
159
+ .filter((row) => !row.rel_path.slice(prefix.length).includes('/'));
160
+ }
161
+
130
162
  return {
131
163
  addRoot({ rootId, path, kind, device = null, inode = null }) {
132
164
  db.prepare('INSERT INTO observer_roots (root_id, path, kind, device, inode) VALUES (?, ?, ?, ?, ?)')
@@ -177,6 +209,15 @@ function createStore(db) {
177
209
  return db.prepare('SELECT * FROM observer_files WHERE root_id = ? ORDER BY rel_path').all(rootId)
178
210
  .map(rowToFile);
179
211
  },
212
+ filesInDir(rootId, relDir) {
213
+ return immediateRows('observer_files', rootId, relDir).map(rowToFile);
214
+ },
215
+ dirsInDir(rootId, relDir) {
216
+ return immediateRows('observer_dirs', rootId, relDir).map(rowToDir);
217
+ },
218
+ linksInDir(rootId, relDir) {
219
+ return immediateRows('observer_links', rootId, relDir).map(rowToLink);
220
+ },
180
221
  fileByPath(rootId, relPath) {
181
222
  return rowToFile(
182
223
  db.prepare('SELECT * FROM observer_files WHERE root_id = ? AND rel_path = ?').get(rootId, relPath),
@@ -204,10 +245,7 @@ function createStore(db) {
204
245
 
205
246
  dirsForRoot(rootId) {
206
247
  return db.prepare('SELECT * FROM observer_dirs WHERE root_id = ? ORDER BY rel_path').all(rootId)
207
- .map((row) => ({
208
- dirId: row.dir_id, rootId: row.root_id, relPath: row.rel_path,
209
- device: row.device, inode: row.inode,
210
- }));
248
+ .map(rowToDir);
211
249
  },
212
250
  /**
213
251
  * One compare result is ONE truth transition. Movers may exchange
@@ -240,10 +278,7 @@ function createStore(db) {
240
278
  // per-row like files — every commit is a valid last-known truth.
241
279
  linksForRoot(rootId) {
242
280
  return db.prepare('SELECT * FROM observer_links WHERE root_id = ? ORDER BY rel_path').all(rootId)
243
- .map((row) => ({
244
- linkId: row.link_id, rootId: row.root_id, relPath: row.rel_path,
245
- device: row.device, inode: row.inode, target: row.target,
246
- }));
281
+ .map(rowToLink);
247
282
  },
248
283
  insertLink({ linkId, rootId, relPath, device, inode, target }) {
249
284
  db.prepare('INSERT INTO observer_links (link_id, root_id, rel_path, device, inode, target) VALUES (?, ?, ?, ?, ?, ?)')
@@ -818,6 +818,20 @@ function createRegistrationService({
818
818
  const localRecords = boundary.current.registry.syncRecords();
819
819
  const localByUuid = new Map(localRecords.map((record) => [record.uuid, record]));
820
820
  const cloudByUuid = new Map(cloud.catalog.records.map((record) => [record.uuid, record]));
821
+ function cloudPathOf(uuid) {
822
+ const names = [];
823
+ const seen = new Set();
824
+ let record = cloudByUuid.get(uuid) || null;
825
+ while (record) {
826
+ if (seen.has(record.uuid)) return null;
827
+ seen.add(record.uuid);
828
+ names.unshift(record.name);
829
+ if (record.parentUUID === null) break;
830
+ record = cloudByUuid.get(record.parentUUID) || null;
831
+ if (!record) return null;
832
+ }
833
+ return names.length > 0 ? names.join('/') : null;
834
+ }
821
835
  const roots = observer.status().map((root) => {
822
836
  const tree = boundary.current.tree(root.path);
823
837
  const record = tree ? tree.root() : null;
@@ -847,6 +861,7 @@ function createRegistrationService({
847
861
  // `cloudBacked`: the enclosing cloud summary says whether the
848
862
  // catalog is actually pinned to an authoritative current head.
849
863
  catalogued: cloudByUuid.has(uuid),
864
+ cloudPath: cloudByUuid.has(uuid) ? cloudPathOf(uuid) : null,
850
865
  localPath,
851
866
  root: rootByUuid.get(uuid) || null,
852
867
  };
@@ -29,6 +29,7 @@
29
29
  const fs = require('fs');
30
30
  const path = require('path');
31
31
  const crypto = require('crypto');
32
+ const { gitDirTruth } = require('../observer/scan');
32
33
 
33
34
  function rootType(rootPath, kind) {
34
35
  if (kind === 'repo') return 'repo.git';
@@ -37,18 +38,10 @@ function rootType(rootPath, kind) {
37
38
  }
38
39
 
39
40
  function isRepository(dirPath) {
40
- try {
41
- fs.lstatSync(path.join(dirPath, '.git'));
42
- return true;
43
- } catch (error) {
44
- // A failed look cannot prove a repo boundary. Register the readable
45
- // shape below it; observer territory detection retries the boundary
46
- // look and performs the UUID-preserving folder → repo.git transition
47
- // once Git becomes knowable.
48
- if (error.code === 'ENOENT' || error.code === 'ENOTDIR'
49
- || error.code === 'EACCES' || error.code === 'EPERM') return false;
50
- throw error;
51
- }
41
+ // This is the observer's exact territory primitive. In particular, a
42
+ // stale linked-worktree pointer must be ordinary folder ground for both
43
+ // structural registration and later Watch/Detect reconciliation.
44
+ return gitDirTruth(dirPath) === 'present';
52
45
  }
53
46
 
54
47
  /**
@@ -70,15 +70,23 @@ function untrackedFiles(repoPath) {
70
70
  // its own card; the parent's checkpoint must not contain it.
71
71
  if (rel.endsWith('/')) return [];
72
72
  const abs = path.join(repoPath, rel);
73
- const stat = fs.lstatSync(abs);
74
- if (stat.isSymbolicLink()) {
75
- const content = Buffer.from(fs.readlinkSync(abs));
76
- return [{ path: rel, mode: '120000', content, sha256: sha256(content) }];
73
+ try {
74
+ const stat = fs.lstatSync(abs);
75
+ if (stat.isSymbolicLink()) {
76
+ const content = Buffer.from(fs.readlinkSync(abs));
77
+ return [{ path: rel, mode: '120000', content, sha256: sha256(content) }];
78
+ }
79
+ if (stat.isDirectory()) return []; // same boundary, unmarked: never read a directory as a file
80
+ const content = fs.readFileSync(abs);
81
+ const mode = stat.mode & 0o111 ? '100755' : '100644';
82
+ return [{ path: rel, mode, content, sha256: sha256(content) }];
83
+ } catch (error) {
84
+ // Git's file listing is necessarily a point-in-time observation. A file
85
+ // may be removed or renamed before it can be read; the next watch event
86
+ // captures its replacement, while this capture truthfully omits it.
87
+ if (error && error.code === 'ENOENT') return [];
88
+ throw error;
77
89
  }
78
- if (stat.isDirectory()) return []; // same boundary, unmarked: never read a directory as a file
79
- const content = fs.readFileSync(abs);
80
- const mode = stat.mode & 0o111 ? '100755' : '100644';
81
- return [{ path: rel, mode, content, sha256: sha256(content) }];
82
90
  });
83
91
  }
84
92
 
@@ -63,6 +63,21 @@ test('a .git claims its subtree: embedded repos are never files territory', () =
63
63
  ));
64
64
  });
65
65
 
66
+ test('a stale linked-worktree pointer is ordinary folder ground, not a broken repository', () => {
67
+ const dir = makeDir('stale-gitfile');
68
+ const child = path.join(dir, 'former-submodule');
69
+ fs.mkdirSync(child);
70
+ fs.writeFileSync(path.join(child, '.git'), 'gitdir: ../.git/modules/missing\n');
71
+ fs.writeFileSync(path.join(child, 'readme.md'), 'still ordinary files');
72
+
73
+ const { observer, events } = makeObserver();
74
+ observer.enrollRoot(dir);
75
+
76
+ assert.deepEqual(eventsOf(events, 'repo.discovered'), []);
77
+ assert.deepEqual(eventsOf(events, 'file.created').map((event) => event.path), ['former-submodule/readme.md']);
78
+ assert.equal(observer.listRoots().some((root) => root.path === child && root.kind === 'repo'), false);
79
+ });
80
+
66
81
  test('a .git vanishing returns its territory to files: channels are reborn', () => {
67
82
  const dir = makeDir('territory-back');
68
83
  fs.mkdirSync(path.join(dir, 'vendor', '.git'), { recursive: true });
@@ -655,3 +655,88 @@ test('watcher death rebuilds the doorbell and reconciles the gap once', () => {
655
655
  fs.watch = realWatch;
656
656
  }
657
657
  });
658
+
659
+ // --- the scoped scan ------------------------------------------------------
660
+ // A NAMED doorbell earns a look at its own directory, never a walk of the
661
+ // world; anything structural escalates to the full walk unchanged. The
662
+ // injected enrollment policy is the witness: the full walk asks it about
663
+ // every directory's entries, so a scoped look must never ask about a
664
+ // sibling directory's ground.
665
+
666
+ const { fakeWatchKit } = require('./observer.rig');
667
+
668
+ function scopedRig() {
669
+ const dir = makeDir('scoped');
670
+ fs.mkdirSync(path.join(dir, 'a'));
671
+ fs.mkdirSync(path.join(dir, 'b'));
672
+ fs.writeFileSync(path.join(dir, 'a', 'one.md'), 'v1');
673
+ fs.writeFileSync(path.join(dir, 'b', 'two.md'), 'v1');
674
+ const asked = [];
675
+ const bells = fakeWatchKit();
676
+ const { observer, events } = makeObserver({
677
+ settleMs: 15,
678
+ watch: bells.watch,
679
+ shouldEnroll: (relPath) => {
680
+ asked.push(relPath);
681
+ return true;
682
+ },
683
+ });
684
+ observer.enrollRoot(dir);
685
+ observer.start();
686
+ events.length = 0;
687
+ asked.length = 0;
688
+ return { dir, asked, bells, observer, events };
689
+ }
690
+
691
+ async function settled(events, arrived, deadlineMs = 5_000) {
692
+ const deadline = Date.now() + deadlineMs;
693
+ while (!arrived() && Date.now() < deadline) {
694
+ await new Promise((resolve) => setTimeout(resolve, 10));
695
+ }
696
+ return arrived();
697
+ }
698
+
699
+ test('a named doorbell scans only its own directory', async () => {
700
+ const { dir, asked, bells, observer, events } = scopedRig();
701
+ fs.writeFileSync(path.join(dir, 'a', 'one.md'), 'v2');
702
+ bells.ring(dir, 'a/one.md');
703
+ assert.ok(await settled(events, () => eventsOf(events, 'file.changed').length > 0));
704
+ observer.stop();
705
+
706
+ const changed = eventsOf(events, 'file.changed');
707
+ assert.equal(changed.length, 1);
708
+ assert.equal(changed[0].path, 'a/one.md');
709
+ assert.ok(asked.includes('a/one.md'), 'the rung directory was read');
710
+ assert.ok(!asked.includes('b/two.md'), `the sibling directory was never walked: ${asked.join(', ')}`);
711
+ });
712
+
713
+ test('a create under a named doorbell escalates to the full walk', async () => {
714
+ const { dir, asked, bells, observer, events } = scopedRig();
715
+ fs.writeFileSync(path.join(dir, 'a', 'new.md'), 'fresh');
716
+ bells.ring(dir, 'a/new.md');
717
+ assert.ok(await settled(events, () => eventsOf(events, 'file.created').length > 0));
718
+ observer.stop();
719
+
720
+ const created = eventsOf(events, 'file.created');
721
+ assert.equal(created.length, 1);
722
+ assert.equal(created[0].path, 'a/new.md');
723
+ assert.ok(asked.includes('b/two.md'), 'structural news pays for the whole walk');
724
+ });
725
+
726
+ test('a cross-directory move naming only the new side still concludes a rename', async () => {
727
+ const { dir, bells, observer, events } = scopedRig();
728
+ fs.renameSync(path.join(dir, 'a', 'one.md'), path.join(dir, 'b', 'moved.md'));
729
+ // A rename may lawfully deliver only the NEW name: the scoped look sees
730
+ // an orphan entry, refuses to conclude, and the full walk's inode rescue
731
+ // names the move — never a create beside a ghost.
732
+ bells.ring(dir, 'b/moved.md');
733
+ assert.ok(await settled(events, () => eventsOf(events, 'file.renamed').length > 0));
734
+ observer.stop();
735
+
736
+ const renamed = eventsOf(events, 'file.renamed');
737
+ assert.equal(renamed.length, 1);
738
+ assert.equal(renamed[0].from, 'a/one.md');
739
+ assert.equal(renamed[0].to, 'b/moved.md');
740
+ assert.equal(eventsOf(events, 'file.created').length, 0);
741
+ assert.equal(eventsOf(events, 'file.deleted').length, 0);
742
+ });
@@ -435,6 +435,56 @@ test('a signed-in register call writes every entity delivery intent in identity.
435
435
  }
436
436
  });
437
437
 
438
+ test('service status exposes only a verified cloud path as a navigable workspace', async () => {
439
+ const service = createRegistrationService({
440
+ dir: path.join(tempRoot, 'state-cloud-status'),
441
+ homeDir: path.join(tempRoot, 'cloud-status-home'),
442
+ cloudUserId: 'user-cloud-status',
443
+ cloudDeviceId: 'computer-cloud-status',
444
+ });
445
+ const bootstrap = service.entityCloud.bootstrap(service.registration.registry.syncRecords());
446
+ service.entityCloud.install({
447
+ registry: service.registration.registry,
448
+ resourceId: bootstrap.resourceId,
449
+ authorityEpoch: 1,
450
+ headVersion: 0,
451
+ snapshotBase64: bootstrap.snapshotBase64,
452
+ snapshotChecksum: bootstrap.snapshotChecksum,
453
+ });
454
+ const ground = makeGround('cloud-status');
455
+ fs.mkdirSync(path.join(ground, 'src'));
456
+
457
+ try {
458
+ const registered = await service.register(ground);
459
+ const beforeAcknowledgement = service.status();
460
+ assert.equal(beforeAcknowledgement.cloud.catalog.current, false,
461
+ 'a pending local registration is visible to status but not a navigable verified cloud workspace');
462
+
463
+ let version = 0;
464
+ for (;;) {
465
+ const [claim] = service.entityCloud.claim();
466
+ if (!claim) break;
467
+ version += 1;
468
+ service.entityCloud.acknowledge({
469
+ resourceId: claim.resourceId,
470
+ mutationId: claim.mutationId,
471
+ version,
472
+ committedAt: '2026-08-05T00:00:00.000Z',
473
+ });
474
+ }
475
+
476
+ const status = service.status();
477
+ assert.equal(status.cloud.catalog.current, true);
478
+ const root = status.entities.find((entity) => entity.uuid === registered.record.uuid);
479
+ assert.equal(root.cloudPath, path.basename(ground));
480
+ const child = status.entities.find((entity) => entity.cloudPath === `${path.basename(ground)}/src`);
481
+ assert.ok(child, 'the cloud path is derived from canonical parentage, never a local absolute path');
482
+ assert.equal(child.localPath, path.join(ground, 'src'));
483
+ } finally {
484
+ service.close();
485
+ }
486
+ });
487
+
438
488
  test('a large signed-in register call publishes one complete cloud graph before Watch begins', async () => {
439
489
  const service = createRegistrationService({
440
490
  dir: path.join(tempRoot, 'state-cloud-register-large'),
@@ -175,6 +175,27 @@ test('an unborn repo with staged and untracked work round-trips', () => {
175
175
  assert.equal(state.card.bundle, null);
176
176
  });
177
177
 
178
+ test('a file that disappears after Git lists it is omitted instead of aborting capture', () => {
179
+ const source = makeRepo('vanishing-untracked');
180
+ const gone = path.join(source, 'gone-during-capture.md');
181
+ fs.writeFileSync(gone, 'transient');
182
+ const originalLstat = fs.lstatSync;
183
+ fs.lstatSync = (candidate, ...args) => {
184
+ if (candidate === gone) {
185
+ const error = new Error('gone');
186
+ error.code = 'ENOENT';
187
+ throw error;
188
+ }
189
+ return originalLstat(candidate, ...args);
190
+ };
191
+ try {
192
+ const state = capture(source, { withBundle: false });
193
+ assert.equal(state.checkpoint.untracked.some((file) => file.path === 'gone-during-capture.md'), false);
194
+ } finally {
195
+ fs.lstatSync = originalLstat;
196
+ }
197
+ });
198
+
178
199
  // --- identity ------------------------------------------------------------------
179
200
 
180
201
  test('capture is deterministic: same state, same ids, twice', () => {