@namzu/cli 16.0.0 → 16.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +20 -6
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +43 -8
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/state.d.ts +9 -0
  6. package/dist/commands/state.d.ts.map +1 -0
  7. package/dist/commands/state.js +110 -0
  8. package/dist/commands/state.js.map +1 -0
  9. package/dist/integrations/sessions/store.d.ts.map +1 -1
  10. package/dist/integrations/sessions/store.js +25 -2
  11. package/dist/integrations/sessions/store.js.map +1 -1
  12. package/dist/integrations/state/private-directory.d.ts +15 -0
  13. package/dist/integrations/state/private-directory.d.ts.map +1 -0
  14. package/dist/integrations/state/private-directory.js +41 -0
  15. package/dist/integrations/state/private-directory.js.map +1 -0
  16. package/dist/integrations/state/report.d.ts +125 -0
  17. package/dist/integrations/state/report.d.ts.map +1 -0
  18. package/dist/integrations/state/report.js +784 -0
  19. package/dist/integrations/state/report.js.map +1 -0
  20. package/dist/integrations/subagents/activity.d.ts +18 -0
  21. package/dist/integrations/subagents/activity.d.ts.map +1 -1
  22. package/dist/integrations/subagents/activity.js +64 -7
  23. package/dist/integrations/subagents/activity.js.map +1 -1
  24. package/dist/integrations/subagents/runtime.d.ts.map +1 -1
  25. package/dist/integrations/subagents/runtime.js +45 -7
  26. package/dist/integrations/subagents/runtime.js.map +1 -1
  27. package/dist/tui/AgentExplorer.d.ts +26 -5
  28. package/dist/tui/AgentExplorer.d.ts.map +1 -1
  29. package/dist/tui/AgentExplorer.js +125 -14
  30. package/dist/tui/AgentExplorer.js.map +1 -1
  31. package/dist/tui/App.d.ts.map +1 -1
  32. package/dist/tui/App.js +231 -65
  33. package/dist/tui/App.js.map +1 -1
  34. package/dist/tui/agent.d.ts.map +1 -1
  35. package/dist/tui/agent.js +18 -2
  36. package/dist/tui/agent.js.map +1 -1
  37. package/dist/tui/slashCommands.d.ts +1 -1
  38. package/dist/tui/slashCommands.d.ts.map +1 -1
  39. package/dist/tui/slashCommands.js +2 -2
  40. package/dist/tui/slashCommands.js.map +1 -1
  41. package/dist/user-commands/store.d.ts.map +1 -1
  42. package/dist/user-commands/store.js +26 -5
  43. package/dist/user-commands/store.js.map +1 -1
  44. package/package.json +2 -2
@@ -0,0 +1,784 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, open, opendir, realpath } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { basename, isAbsolute, join, resolve, sep } from 'node:path';
5
+ const MAX_METADATA_BYTES = 4 * 1024 * 1024;
6
+ const MAX_ORIGIN_BYTES = 64 * 1024;
7
+ const MAX_REPORTED_ISSUES = 100;
8
+ const MAX_ENTRIES = 100_000;
9
+ const AUTHORED_TOP_LEVEL = new Set(['commands', 'plugins']);
10
+ const CONFIG_TOP_LEVEL = new Set([
11
+ 'config.yaml',
12
+ 'credentials.json',
13
+ 'preferences.json',
14
+ 'trust.json',
15
+ ]);
16
+ const RUNTIME_TOP_LEVEL = new Set([
17
+ 'attachments',
18
+ 'cli.json',
19
+ 'desktop-sessions.json',
20
+ 'feedback',
21
+ 'goals',
22
+ 'memory',
23
+ 'projects',
24
+ 'tenants',
25
+ 'titles.json',
26
+ 'worktrees',
27
+ ]);
28
+ const CONTROL_TOP_LEVEL = new Set(['.migration']);
29
+ const PRIVATE_BOUNDARIES = ['attachments', 'goals', 'memory', 'projects', 'tenants'];
30
+ class EntryChangedError extends Error {
31
+ }
32
+ /**
33
+ * Inspect Namzu's filesystem estate without constructing a store or changing it.
34
+ *
35
+ * Store constructors are intentionally absent here. Several of them create or
36
+ * heal paths while opening, which would make a report about an untouched tree
37
+ * change the tree it claims to describe.
38
+ */
39
+ export async function inspectNamzuState(options = {}) {
40
+ const cwd = await canonicalBase(options.cwd ?? process.cwd());
41
+ const home = await canonicalBase(options.home ?? homedir());
42
+ const projectRoot = join(cwd, '.namzu');
43
+ const userRoot = join(home, '.namzu');
44
+ const overlap = projectRoot === userRoot;
45
+ const specs = overlap
46
+ ? [{ path: projectRoot, roles: ['project', 'user'] }]
47
+ : [
48
+ { path: projectRoot, roles: ['project'] },
49
+ { path: userRoot, roles: ['user'] },
50
+ ];
51
+ const roots = [];
52
+ const collections = new Map();
53
+ for (const spec of specs) {
54
+ const collection = await collectRoot(spec.path, Math.max(1, Math.floor(options.entryLimit ?? MAX_ENTRIES)));
55
+ collections.set(spec.path, collection);
56
+ roots.push(await projectRootReport(collection, spec.roles, {
57
+ platform: options.platform ?? process.platform,
58
+ uid: options.uid ?? process.getuid?.(),
59
+ }));
60
+ }
61
+ const physicalTotals = roots.reduce((acc, root) => ({
62
+ roots: acc.roots + (root.exists ? 1 : 0),
63
+ files: acc.files + root.files,
64
+ logicalBytes: acc.logicalBytes + root.logicalBytes,
65
+ }), { roots: 0, files: 0, logicalBytes: 0 });
66
+ const projectConfigPath = join(cwd, 'namzu.config.json');
67
+ const projectConfig = await inspectOneRegularFile(projectConfigPath);
68
+ const projectCollection = collections.get(projectRoot);
69
+ const projectBinding = await inspectProjectBinding(projectCollection, cwd);
70
+ return {
71
+ version: 1,
72
+ readOnly: true,
73
+ snapshot: {
74
+ consistency: 'best-effort-unlocked',
75
+ detail: 'No writer lease is acquired. Metadata files are re-statted when inspected, but concurrent writers can change other counts after they are observed.',
76
+ },
77
+ complete: roots.every((root) => root.complete) &&
78
+ projectBinding.status !== 'unknown' &&
79
+ (projectConfig.status === 'present' || projectConfig.status === 'absent'),
80
+ scopeRoots: { project: projectRoot, user: userRoot, overlap },
81
+ physicalTotals,
82
+ roots,
83
+ projectConfig: {
84
+ path: projectConfigPath,
85
+ status: projectConfig.status,
86
+ logicalBytes: projectConfig.logicalBytes,
87
+ },
88
+ projectBinding,
89
+ };
90
+ }
91
+ async function canonicalBase(path) {
92
+ const absolute = resolve(path);
93
+ try {
94
+ return await realpath(absolute);
95
+ }
96
+ catch (error) {
97
+ if (error.code === 'ENOENT')
98
+ return absolute;
99
+ return absolute;
100
+ }
101
+ }
102
+ async function inspectOneRegularFile(path) {
103
+ try {
104
+ const stat = await lstat(path);
105
+ return stat.isFile()
106
+ ? { status: 'present', logicalBytes: stat.size }
107
+ : { status: 'unsupported', logicalBytes: 0 };
108
+ }
109
+ catch (error) {
110
+ if (error.code === 'ENOENT') {
111
+ return { status: 'absent', logicalBytes: 0 };
112
+ }
113
+ return { status: 'unreadable', logicalBytes: 0 };
114
+ }
115
+ }
116
+ async function collectRoot(root, entryLimit) {
117
+ const sink = { issues: [], omitted: 0 };
118
+ let rootStat;
119
+ try {
120
+ rootStat = await lstat(root);
121
+ }
122
+ catch (error) {
123
+ if (error.code === 'ENOENT') {
124
+ return { root, exists: false, entries: [], issues: [], omittedIssues: 0 };
125
+ }
126
+ addIssue(sink, root, error);
127
+ return {
128
+ root,
129
+ exists: true,
130
+ entries: [],
131
+ issues: sink.issues,
132
+ omittedIssues: sink.omitted,
133
+ };
134
+ }
135
+ if (rootStat.isSymbolicLink()) {
136
+ pushIssue(sink, {
137
+ code: 'symlink_not_followed',
138
+ path: '.',
139
+ detail: 'The state root is a symbolic link and was not followed.',
140
+ });
141
+ return {
142
+ root,
143
+ exists: true,
144
+ entries: [],
145
+ rootEntry: rootEntry(root, rootStat),
146
+ issues: sink.issues,
147
+ omittedIssues: sink.omitted,
148
+ };
149
+ }
150
+ if (!rootStat.isDirectory()) {
151
+ pushIssue(sink, {
152
+ code: 'unsupported_entry',
153
+ path: '.',
154
+ detail: 'The state root exists but is not a directory.',
155
+ });
156
+ return {
157
+ root,
158
+ exists: true,
159
+ entries: [],
160
+ rootEntry: rootEntry(root, rootStat),
161
+ issues: sink.issues,
162
+ omittedIssues: sink.omitted,
163
+ };
164
+ }
165
+ const entries = [];
166
+ await walk(root, '', entries, sink, entryLimit);
167
+ return {
168
+ root,
169
+ exists: true,
170
+ entries,
171
+ rootEntry: rootEntry(root, rootStat),
172
+ issues: sink.issues,
173
+ omittedIssues: sink.omitted,
174
+ };
175
+ }
176
+ function rootEntry(root, stat) {
177
+ return {
178
+ absolute: root,
179
+ relative: '.',
180
+ kind: stat.isDirectory()
181
+ ? 'directory'
182
+ : stat.isFile()
183
+ ? 'file'
184
+ : stat.isSymbolicLink()
185
+ ? 'symlink'
186
+ : 'other',
187
+ size: stat.isFile() ? stat.size : 0,
188
+ device: stat.dev,
189
+ inode: stat.ino,
190
+ modifiedAt: stat.mtimeMs,
191
+ mode: stat.mode,
192
+ ...(typeof stat.uid === 'number' ? { uid: stat.uid } : {}),
193
+ };
194
+ }
195
+ async function walk(root, relativeDir, entries, sink, entryLimit) {
196
+ const absoluteDir = relativeDir === '' ? root : join(root, relativeDir);
197
+ const childDirectories = [];
198
+ try {
199
+ const directory = await opendir(absoluteDir);
200
+ for await (const child of directory) {
201
+ const relativePath = relativeDir === '' ? child.name : join(relativeDir, child.name);
202
+ if (entries.length >= entryLimit) {
203
+ pushIssue(sink, {
204
+ code: 'inspection_skipped',
205
+ path: normalizeRelative(relativePath),
206
+ detail: `Filesystem inventory reached its ${entryLimit}-entry memory bound; remaining entries were not counted.`,
207
+ });
208
+ return false;
209
+ }
210
+ const absolute = join(root, relativePath);
211
+ let stat;
212
+ try {
213
+ stat = await lstat(absolute);
214
+ }
215
+ catch (error) {
216
+ addIssue(sink, relativePath, error);
217
+ continue;
218
+ }
219
+ const entry = {
220
+ absolute,
221
+ relative: normalizeRelative(relativePath),
222
+ kind: stat.isDirectory()
223
+ ? 'directory'
224
+ : stat.isFile()
225
+ ? 'file'
226
+ : stat.isSymbolicLink()
227
+ ? 'symlink'
228
+ : 'other',
229
+ size: stat.isFile() ? stat.size : 0,
230
+ device: stat.dev,
231
+ inode: stat.ino,
232
+ modifiedAt: stat.mtimeMs,
233
+ mode: stat.mode,
234
+ ...(typeof stat.uid === 'number' ? { uid: stat.uid } : {}),
235
+ };
236
+ entries.push(entry);
237
+ if (entry.kind === 'directory') {
238
+ childDirectories.push(relativePath);
239
+ }
240
+ else if (entry.kind === 'symlink') {
241
+ pushIssue(sink, {
242
+ code: 'symlink_not_followed',
243
+ path: entry.relative,
244
+ detail: 'Symbolic link was counted as an entry but its target was not read.',
245
+ });
246
+ }
247
+ else if (entry.kind === 'other') {
248
+ pushIssue(sink, {
249
+ code: 'unsupported_entry',
250
+ path: entry.relative,
251
+ detail: 'Entry is not a regular file, directory, or symbolic link.',
252
+ });
253
+ }
254
+ }
255
+ }
256
+ catch (error) {
257
+ addIssue(sink, relativeDir || '.', error);
258
+ return true;
259
+ }
260
+ for (const childDirectory of childDirectories) {
261
+ if (!(await walk(root, childDirectory, entries, sink, entryLimit)))
262
+ return false;
263
+ }
264
+ return true;
265
+ }
266
+ function normalizeRelative(path) {
267
+ return sep === '/' ? path : path.split(sep).join('/');
268
+ }
269
+ function addIssue(sink, path, error) {
270
+ const code = error.code;
271
+ pushIssue(sink, {
272
+ code: code === 'EACCES' || code === 'EPERM' ? 'permission_denied' : 'unreadable',
273
+ path: normalizeRelative(path),
274
+ detail: error instanceof Error ? error.message : String(error),
275
+ });
276
+ }
277
+ function pushIssue(sink, issue) {
278
+ if (sink.issues.length < MAX_REPORTED_ISSUES)
279
+ sink.issues.push(issue);
280
+ else
281
+ sink.omitted += 1;
282
+ }
283
+ async function projectRootReport(collection, roles, privacyContext) {
284
+ const categories = {
285
+ authored: emptyMeasure(),
286
+ configuration: emptyMeasure(),
287
+ runtime: emptyMeasure(),
288
+ control: emptyMeasure(),
289
+ transient: emptyMeasure(),
290
+ unknown: emptyMeasure(),
291
+ };
292
+ let files = 0;
293
+ let logicalBytes = 0;
294
+ let directories = 0;
295
+ for (const entry of collection.entries) {
296
+ if (entry.kind === 'directory')
297
+ directories += 1;
298
+ if (entry.kind !== 'file')
299
+ continue;
300
+ files += 1;
301
+ logicalBytes += entry.size;
302
+ const category = categoryOf(entry.relative);
303
+ categories[category].files += 1;
304
+ categories[category].logicalBytes += entry.size;
305
+ }
306
+ const analysisSink = {
307
+ issues: [...collection.issues],
308
+ omitted: collection.omittedIssues,
309
+ };
310
+ const inventory = await inventoryOf(collection, analysisSink);
311
+ const privacy = privacyOf(collection, privacyContext, analysisSink);
312
+ return {
313
+ path: collection.root,
314
+ roles,
315
+ exists: collection.exists,
316
+ complete: analysisSink.issues.length === 0 && analysisSink.omitted === 0,
317
+ files,
318
+ directories,
319
+ logicalBytes,
320
+ categories,
321
+ inventory,
322
+ privacy,
323
+ issues: analysisSink.issues,
324
+ omittedIssues: analysisSink.omitted,
325
+ };
326
+ }
327
+ function emptyMeasure() {
328
+ return { files: 0, logicalBytes: 0 };
329
+ }
330
+ function categoryOf(path) {
331
+ const top = path.split('/')[0] ?? path;
332
+ const name = basename(path);
333
+ if (name.endsWith('.lock') || name.includes('.tmp.') || name.endsWith('.candidate')) {
334
+ return 'transient';
335
+ }
336
+ if (AUTHORED_TOP_LEVEL.has(top))
337
+ return 'authored';
338
+ if (CONFIG_TOP_LEVEL.has(top))
339
+ return 'configuration';
340
+ if (RUNTIME_TOP_LEVEL.has(top))
341
+ return 'runtime';
342
+ if (CONTROL_TOP_LEVEL.has(top))
343
+ return 'control';
344
+ return 'unknown';
345
+ }
346
+ async function inventoryOf(collection, sink) {
347
+ const files = new Map(collection.entries
348
+ .filter((entry) => entry.kind === 'file')
349
+ .map((entry) => [entry.relative, entry]));
350
+ const directories = collection.entries.filter((entry) => entry.kind === 'directory');
351
+ const sessionDirs = directories.filter((entry) => isCanonicalSessionDir(entry.relative));
352
+ const runDirs = directories.filter((entry) => isCanonicalRunDir(entry.relative));
353
+ const validSessions = new Map();
354
+ let candidateAnalysisComplete = true;
355
+ for (const directory of sessionDirs) {
356
+ const record = files.get(`${directory.relative}/session.json`);
357
+ if (!record)
358
+ continue;
359
+ const parsed = await readJsonRecord(record, sink);
360
+ const expected = basename(directory.relative);
361
+ if (recordId(parsed) === expected)
362
+ validSessions.set(expected, directory);
363
+ else
364
+ candidateAnalysisComplete = false;
365
+ }
366
+ const validRuns = [];
367
+ for (const directory of runDirs) {
368
+ const record = files.get(`${directory.relative}/run.json`);
369
+ if (!record)
370
+ continue;
371
+ const parsed = await readJsonRecord(record, sink);
372
+ if (recordId(parsed) === basename(directory.relative))
373
+ validRuns.push(directory);
374
+ }
375
+ const checkpointFiles = [...files.values()].filter((entry) => isCanonicalCheckpointFile(entry.relative));
376
+ const emergencyFiles = [...files.values()].filter((entry) => isCanonicalEmergencyFile(entry.relative));
377
+ const attachmentFiles = [...files.values()].filter((entry) => entry.relative.startsWith('attachments/'));
378
+ const attachmentKeys = new Map();
379
+ for (const entry of attachmentFiles) {
380
+ const suffix = entry.relative.endsWith('.bin')
381
+ ? '.bin'
382
+ : entry.relative.endsWith('.type')
383
+ ? '.type'
384
+ : undefined;
385
+ if (!suffix)
386
+ continue;
387
+ const key = entry.relative.slice('attachments/'.length, -suffix.length);
388
+ const pair = attachmentKeys.get(key) ?? {};
389
+ if (suffix === '.bin')
390
+ pair.data = entry;
391
+ else
392
+ pair.type = entry;
393
+ attachmentKeys.set(key, pair);
394
+ }
395
+ const candidates = await originOnlyCandidates(files, validSessions, runDirs, sink);
396
+ candidateAnalysisComplete &&= candidates.complete;
397
+ return {
398
+ sessions: {
399
+ ...measureDirectories(validSessions.values(), collection.entries),
400
+ directories: sessionDirs.length,
401
+ invalidOrMissingRecords: sessionDirs.length - validSessions.size,
402
+ },
403
+ originOnlySessionCandidates: {
404
+ ...measureDirectories(candidates.directories, collection.entries),
405
+ complete: candidateAnalysisComplete,
406
+ limitation: 'Candidates have only a new-conversation origin, no messages, runs, goal, title, desktop mapping, fork reference, or sub-session link. They are not declared safe to delete because no writer lease was acquired.',
407
+ },
408
+ runs: {
409
+ ...measureDirectories(validRuns, collection.entries),
410
+ directories: runDirs.length,
411
+ invalidOrMissingRecords: runDirs.length - validRuns.length,
412
+ },
413
+ checkpointFiles: measureFiles(checkpointFiles),
414
+ emergencyDumpFiles: measureFiles(emergencyFiles),
415
+ attachments: {
416
+ ...measureFiles(attachmentFiles),
417
+ pairs: [...attachmentKeys.values()].filter((pair) => pair.data && pair.type).length,
418
+ orphanedDataFiles: [...attachmentKeys.values()].filter((pair) => pair.data && !pair.type)
419
+ .length,
420
+ orphanedTypeFiles: [...attachmentKeys.values()].filter((pair) => pair.type && !pair.data)
421
+ .length,
422
+ },
423
+ };
424
+ }
425
+ function isCanonicalSessionDir(path) {
426
+ return /^projects\/prj_[^/]+\/sessions\/ses_[^/]+$/u.test(path);
427
+ }
428
+ function isCanonicalRunDir(path) {
429
+ return (/^projects\/prj_[^/]+\/sessions\/ses_[^/]+\/runs\/run_[^/]+$/u.test(path) ||
430
+ /^projects\/prj_[^/]+\/sessions\/ses_[^/]+\/runs\/run_[^/]+\/children\/run_[^/]+$/u.test(path));
431
+ }
432
+ function isCanonicalCheckpointFile(path) {
433
+ return (/^projects\/prj_[^/]+\/sessions\/ses_[^/]+\/runs\/run_[^/]+\/checkpoints\/cp_[^/]+\.json$/u.test(path) ||
434
+ /^projects\/prj_[^/]+\/sessions\/ses_[^/]+\/runs\/run_[^/]+\/children\/run_[^/]+\/checkpoints\/cp_[^/]+\.json$/u.test(path));
435
+ }
436
+ function isCanonicalEmergencyFile(path) {
437
+ return /^projects\/prj_[^/]+\/sessions\/ses_[^/]+\/runs\/emergency\/run_[^/]+\.json$/u.test(path);
438
+ }
439
+ async function originOnlyCandidates(files, sessions, runDirs, sink) {
440
+ let complete = true;
441
+ const referenced = new Set();
442
+ const originKinds = new Map();
443
+ for (const [sessionId, directory] of sessions) {
444
+ const evidence = files.get(`${directory.relative}/turns.jsonl`);
445
+ if (!evidence)
446
+ continue;
447
+ if (evidence.size > MAX_ORIGIN_BYTES) {
448
+ complete = false;
449
+ pushIssue(sink, {
450
+ code: 'inspection_skipped',
451
+ path: evidence.relative,
452
+ detail: `Turn evidence exceeds the ${MAX_ORIGIN_BYTES}-byte origin-inspection cap; its raw bytes remain counted.`,
453
+ });
454
+ continue;
455
+ }
456
+ try {
457
+ const raw = await readBoundedRegularFile(evidence, MAX_ORIGIN_BYTES);
458
+ const lines = raw.split('\n').filter((line) => line.length > 0);
459
+ if (lines.length !== 1)
460
+ continue;
461
+ const parsed = JSON.parse(lines[0] ?? '');
462
+ if (parsed.type !== 'conversation_started' || parsed.sessionId !== sessionId)
463
+ continue;
464
+ const origin = parsed.origin;
465
+ if (typeof origin !== 'object' || origin === null)
466
+ continue;
467
+ const originRecord = origin;
468
+ if (typeof originRecord.kind === 'string')
469
+ originKinds.set(sessionId, originRecord.kind);
470
+ if (typeof originRecord.sourceSessionId === 'string') {
471
+ referenced.add(originRecord.sourceSessionId);
472
+ }
473
+ }
474
+ catch (error) {
475
+ complete = false;
476
+ metadataIssue(sink, evidence.relative, error);
477
+ }
478
+ }
479
+ const titles = await readStringKeys(files.get('titles.json'), sink);
480
+ const desktopTargets = await readStringValues(files.get('desktop-sessions.json'), sink);
481
+ if (titles === null || desktopTargets === null)
482
+ complete = false;
483
+ for (const id of titles ?? [])
484
+ referenced.add(id);
485
+ for (const id of desktopTargets ?? [])
486
+ referenced.add(id);
487
+ for (const entry of files.values()) {
488
+ if (!entry.relative.endsWith('/subsession.json'))
489
+ continue;
490
+ const parsed = await readJsonRecord(entry, sink);
491
+ if (!parsed) {
492
+ complete = false;
493
+ continue;
494
+ }
495
+ for (const key of ['parentSessionId', 'childSessionId']) {
496
+ const id = parsed[key];
497
+ if (typeof id === 'string')
498
+ referenced.add(id);
499
+ }
500
+ }
501
+ if (!complete)
502
+ return { complete: false, directories: [] };
503
+ const candidates = [];
504
+ for (const [sessionId, directory] of sessions) {
505
+ if (originKinds.get(sessionId) !== 'new' || referenced.has(sessionId))
506
+ continue;
507
+ const messages = files.get(`${directory.relative}/messages.jsonl`);
508
+ if (messages && messages.size > 0)
509
+ continue;
510
+ if (runDirs.some((run) => run.relative.startsWith(`${directory.relative}/runs/`)))
511
+ continue;
512
+ if (files.has(`goals/${sessionId}.json`))
513
+ continue;
514
+ candidates.push(directory);
515
+ }
516
+ return { complete: true, directories: candidates };
517
+ }
518
+ async function readStringKeys(entry, sink) {
519
+ if (!entry)
520
+ return [];
521
+ const parsed = await readJsonRecord(entry, sink);
522
+ if (!parsed)
523
+ return null;
524
+ return Object.keys(parsed);
525
+ }
526
+ async function readStringValues(entry, sink) {
527
+ if (!entry)
528
+ return [];
529
+ const parsed = await readJsonRecord(entry, sink);
530
+ if (!parsed)
531
+ return null;
532
+ return Object.values(parsed).filter((value) => typeof value === 'string');
533
+ }
534
+ function measureFiles(entries) {
535
+ return {
536
+ files: entries.length,
537
+ logicalBytes: entries.reduce((sum, entry) => sum + entry.size, 0),
538
+ };
539
+ }
540
+ function measureDirectories(directories, entries) {
541
+ const selected = [...directories];
542
+ let logicalBytes = 0;
543
+ for (const directory of selected) {
544
+ const prefix = `${directory.relative}/`;
545
+ logicalBytes += entries
546
+ .filter((entry) => entry.kind === 'file' && entry.relative.startsWith(prefix))
547
+ .reduce((sum, entry) => sum + entry.size, 0);
548
+ }
549
+ return { files: selected.length, logicalBytes };
550
+ }
551
+ function privacyOf(collection, context, sink) {
552
+ const byRelative = new Map(collection.entries.map((entry) => [entry.relative, entry]));
553
+ const out = [];
554
+ for (const segment of PRIVATE_BOUNDARIES) {
555
+ const entry = byRelative.get(segment);
556
+ if (!entry)
557
+ continue;
558
+ if (entry.kind !== 'directory') {
559
+ out.push({
560
+ path: segment,
561
+ status: 'insecure',
562
+ detail: 'Boundary is not a directory.',
563
+ });
564
+ continue;
565
+ }
566
+ if (context.platform === 'win32' || context.uid === undefined) {
567
+ out.push({
568
+ path: segment,
569
+ status: 'unknown',
570
+ detail: 'POSIX mode bits cannot establish the effective owner-only ACL on this platform.',
571
+ });
572
+ continue;
573
+ }
574
+ const root = collection.rootEntry;
575
+ const rootOwnerMatches = root?.uid === undefined || context.uid === undefined || root.uid === context.uid;
576
+ const rootIsPrivate = root?.kind === 'directory' && rootOwnerMatches && (root.mode & 0o077) === 0;
577
+ if (entry.uid !== undefined && entry.uid !== context.uid) {
578
+ pushIssue(sink, {
579
+ code: 'owner_mismatch',
580
+ path: segment,
581
+ detail: `Boundary is owned by uid ${entry.uid}, current uid is ${context.uid}.`,
582
+ });
583
+ out.push({
584
+ path: segment,
585
+ status: 'insecure',
586
+ detail: 'Boundary owner does not match.',
587
+ });
588
+ continue;
589
+ }
590
+ const exposed = entry.mode & 0o077;
591
+ out.push(exposed === 0 || rootIsPrivate
592
+ ? {
593
+ path: segment,
594
+ status: 'secure',
595
+ detail: exposed === 0
596
+ ? 'Owner-only directory boundary.'
597
+ : 'Shared child mode is contained by the owner-only state root.',
598
+ }
599
+ : {
600
+ path: segment,
601
+ status: 'insecure',
602
+ detail: `Directory mode ${modeText(entry.mode)} grants group or other access.`,
603
+ });
604
+ }
605
+ return out;
606
+ }
607
+ function modeText(mode) {
608
+ return (mode & 0o777).toString(8).padStart(3, '0');
609
+ }
610
+ async function inspectProjectBinding(collection, canonicalCwd) {
611
+ if (!collection.exists) {
612
+ return {
613
+ status: 'uninitialized',
614
+ detail: 'No project-local runtime state exists.',
615
+ };
616
+ }
617
+ const files = new Map(collection.entries
618
+ .filter((entry) => entry.kind === 'file')
619
+ .map((entry) => [entry.relative, entry]));
620
+ const pointer = files.get('cli.json');
621
+ if (!pointer) {
622
+ const hasProjects = collection.entries.some((entry) => entry.relative.startsWith('projects/'));
623
+ return hasProjects
624
+ ? {
625
+ status: 'missing-pointer',
626
+ detail: 'Runtime projects exist but cli.json does not select one.',
627
+ }
628
+ : { status: 'uninitialized', detail: 'No CLI project pointer exists.' };
629
+ }
630
+ let parsed;
631
+ try {
632
+ const value = JSON.parse(await readBoundedRegularFile(pointer, MAX_METADATA_BYTES));
633
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
634
+ return {
635
+ status: 'invalid-pointer',
636
+ detail: 'cli.json is not an object.',
637
+ };
638
+ }
639
+ parsed = value;
640
+ }
641
+ catch (error) {
642
+ return {
643
+ status: 'invalid-pointer',
644
+ detail: `cli.json could not be read as bounded JSON: ${errorMessage(error)}`,
645
+ };
646
+ }
647
+ const projectId = parsed.projectId;
648
+ if (typeof projectId !== 'string' || !/^prj_[A-Za-z0-9_-]+$/u.test(projectId)) {
649
+ return {
650
+ status: 'invalid-pointer',
651
+ detail: 'cli.json has no valid projectId.',
652
+ };
653
+ }
654
+ const project = files.get(`projects/${projectId}/project.json`);
655
+ if (!project) {
656
+ return {
657
+ status: 'missing-project',
658
+ projectId,
659
+ detail: 'cli.json selects a project record that is not present.',
660
+ };
661
+ }
662
+ let record;
663
+ try {
664
+ const value = JSON.parse(await readBoundedRegularFile(project, MAX_METADATA_BYTES));
665
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
666
+ return {
667
+ status: 'corrupt-project',
668
+ projectId,
669
+ detail: 'project.json is not an object.',
670
+ };
671
+ }
672
+ record = value;
673
+ }
674
+ catch (error) {
675
+ return {
676
+ status: 'corrupt-project',
677
+ projectId,
678
+ detail: `project.json could not be read as bounded JSON: ${errorMessage(error)}`,
679
+ };
680
+ }
681
+ if (record.id !== projectId) {
682
+ return {
683
+ status: 'corrupt-project',
684
+ projectId,
685
+ detail: 'project.json id does not match the selected directory.',
686
+ };
687
+ }
688
+ if (typeof record.rootPath !== 'string' || record.rootPath.length === 0) {
689
+ return {
690
+ status: 'legacy-unbound',
691
+ projectId,
692
+ detail: 'Project record predates canonical root binding; cli.json is its only locator.',
693
+ };
694
+ }
695
+ if (!isAbsolute(record.rootPath)) {
696
+ return {
697
+ status: 'corrupt-project',
698
+ projectId,
699
+ detail: 'project.json rootPath is not absolute.',
700
+ };
701
+ }
702
+ const recordedRoot = await canonicalBase(record.rootPath);
703
+ if (recordedRoot !== canonicalCwd) {
704
+ return {
705
+ status: 'root-mismatch',
706
+ projectId,
707
+ recordedRoot,
708
+ detail: 'Project record is bound to a different canonical working directory.',
709
+ };
710
+ }
711
+ return {
712
+ status: 'bound',
713
+ projectId,
714
+ detail: 'Project id and canonical root agree.',
715
+ };
716
+ }
717
+ async function readJsonRecord(entry, sink) {
718
+ if (entry.size > MAX_METADATA_BYTES) {
719
+ pushIssue(sink, {
720
+ code: 'inspection_skipped',
721
+ path: entry.relative,
722
+ detail: `Metadata exceeds the ${MAX_METADATA_BYTES}-byte semantic-inspection cap; its raw bytes remain counted.`,
723
+ });
724
+ return null;
725
+ }
726
+ try {
727
+ const value = JSON.parse(await readBoundedRegularFile(entry, MAX_METADATA_BYTES));
728
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
729
+ throw new Error('expected a JSON object');
730
+ }
731
+ return value;
732
+ }
733
+ catch (error) {
734
+ metadataIssue(sink, entry.relative, error);
735
+ return null;
736
+ }
737
+ }
738
+ function recordId(value) {
739
+ return typeof value?.id === 'string' ? value.id : undefined;
740
+ }
741
+ function metadataIssue(sink, path, error) {
742
+ pushIssue(sink, {
743
+ code: error instanceof EntryChangedError ? 'entry_changed' : 'corrupt_metadata',
744
+ path,
745
+ detail: errorMessage(error),
746
+ });
747
+ }
748
+ async function readBoundedRegularFile(entry, maxBytes) {
749
+ if (entry.kind !== 'file')
750
+ throw new Error('entry is not a regular file');
751
+ if (entry.size > maxBytes)
752
+ throw new Error(`metadata exceeds ${maxBytes} bytes`);
753
+ const noFollow = process.platform === 'win32' ? 0 : (constants.O_NOFOLLOW ?? 0);
754
+ const handle = await open(entry.absolute, constants.O_RDONLY | noFollow);
755
+ try {
756
+ const before = await handle.stat();
757
+ assertSameEntry(entry, before);
758
+ if (before.size > maxBytes)
759
+ throw new Error(`metadata exceeds ${maxBytes} bytes`);
760
+ const contents = await handle.readFile({ encoding: 'utf8' });
761
+ const after = await handle.stat();
762
+ if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) {
763
+ throw new EntryChangedError('entry changed while its contents were being read');
764
+ }
765
+ return contents;
766
+ }
767
+ finally {
768
+ await handle.close();
769
+ }
770
+ }
771
+ function assertSameEntry(entry, stat) {
772
+ if (!stat.isFile())
773
+ throw new EntryChangedError('entry is no longer a regular file');
774
+ if (stat.dev !== entry.device ||
775
+ stat.ino !== entry.inode ||
776
+ stat.size !== entry.size ||
777
+ stat.mtimeMs !== entry.modifiedAt) {
778
+ throw new EntryChangedError('entry changed after filesystem enumeration');
779
+ }
780
+ }
781
+ function errorMessage(error) {
782
+ return error instanceof Error ? error.message : String(error);
783
+ }
784
+ //# sourceMappingURL=report.js.map