@logicseek/hello-cc 1.1.0-rc.8 → 1.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 (74) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +2 -0
  3. package/README.zh-CN.md +2 -0
  4. package/docs/README.md +2 -0
  5. package/docs/README.zh-CN.md +2 -0
  6. package/docs/commands.md +16 -0
  7. package/docs/commands.zh-CN.md +14 -0
  8. package/docs/dsh.md +6 -0
  9. package/docs/dsh.zh-CN.md +5 -0
  10. package/docs/native.md +21 -1
  11. package/docs/native.zh-CN.md +19 -1
  12. package/docs/private-state.md +115 -0
  13. package/docs/private-state.zh-CN.md +92 -0
  14. package/lib/cli/app.mjs +46 -5
  15. package/lib/cli/commands/hook.mjs +31 -4
  16. package/lib/cli/commands/mcp.mjs +6 -0
  17. package/lib/cli/commands/native.mjs +17 -4
  18. package/lib/cli/commands/peer.mjs +3 -0
  19. package/lib/cli/commands/run.mjs +196 -62
  20. package/lib/cli/commands/tmux.mjs +28 -19
  21. package/lib/cli/commands/up.mjs +25 -17
  22. package/lib/cli-runtime.mjs +16 -1
  23. package/lib/core/coordination/event-retention.mjs +2 -0
  24. package/lib/core/sessions/codex-thread-root.mjs +144 -0
  25. package/lib/core/sessions/launch.mjs +4 -0
  26. package/lib/discover.mjs +95 -231
  27. package/lib/integrations/dsh-collaboration.mjs +9 -2
  28. package/lib/integrations/native/claude.mjs +6 -0
  29. package/lib/integrations/native/codex.mjs +4 -2
  30. package/lib/integrations/native/dsh-acp.mjs +1 -1
  31. package/lib/integrations/native/jsonrpc.mjs +2 -2
  32. package/lib/mcp/scope.mjs +50 -8
  33. package/lib/mcp/tools.mjs +15 -2
  34. package/lib/process/pinned-cwd.mjs +64 -0
  35. package/lib/process/pinned-guidance.mjs +1 -0
  36. package/lib/process/pinned-project-state.mjs +1 -0
  37. package/lib/process/tmux-cwd-handoff.mjs +277 -0
  38. package/lib/runtime/native/cli-submissions.mjs +220 -0
  39. package/lib/runtime/native/client.mjs +7 -1
  40. package/lib/runtime/native/launcher.mjs +20 -4
  41. package/lib/runtime/native/service.mjs +143 -50
  42. package/lib/runtime/native/store.mjs +303 -14
  43. package/lib/runtime/private-state.mjs +435 -24
  44. package/lib/runtime/project-path.mjs +53 -1
  45. package/lib/runtime/project-trust.mjs +6 -2
  46. package/lib/runtime/state-migration.mjs +66 -13
  47. package/lib/shared/redact.mjs +4 -1
  48. package/lib/terminal/tmux.mjs +13 -2
  49. package/lib/ui/help.mjs +1 -0
  50. package/lib/web/codex-app-server.mjs +3 -1
  51. package/lib/web/codex-sessions.mjs +68 -8
  52. package/lib/web/context-files.mjs +194 -41
  53. package/lib/web/http-routes.mjs +43 -12
  54. package/lib/web/native-sessions.mjs +3 -0
  55. package/lib/web/project-contexts.mjs +27 -5
  56. package/lib/web/project-files.mjs +108 -26
  57. package/lib/web/pty-sessions.mjs +15 -2
  58. package/lib/web/session-serialize.mjs +6 -1
  59. package/lib/web/startup.mjs +3 -1
  60. package/lib/web/tmux-sessions.mjs +153 -50
  61. package/lib/web/ui-interactions.mjs +7 -4
  62. package/native/bin/darwin-arm64/hcc-cwd-handoff +0 -0
  63. package/native/bin/darwin-x64/hcc-cwd-handoff +0 -0
  64. package/native/bin/linux-arm64/hcc-cwd-handoff +0 -0
  65. package/native/bin/linux-x64/hcc-cwd-handoff +0 -0
  66. package/native/cwd-handoff/README.md +45 -0
  67. package/native/cwd-handoff/hcc-cwd-handoff.c +693 -0
  68. package/package.json +8 -2
  69. package/scripts/build-cwd-handoff.mjs +47 -0
  70. package/scripts/pack-cwd-handoff.mjs +27 -0
  71. package/scripts/regression.mjs +113 -7
  72. package/scripts/verify-cwd-handoff-package.mjs +60 -0
  73. package/scripts/web-workbench-acceptance.mjs +79 -1
  74. package/scripts/web-workbench-installed-acceptance.mjs +11 -1
@@ -12,7 +12,11 @@ const FILE_READ_FLAGS = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW;
12
12
  const FILE_CREATE_FLAGS = fs.constants.O_WRONLY | fs.constants.O_CREAT |
13
13
  fs.constants.O_EXCL | fs.constants.O_NOFOLLOW;
14
14
  const MANIFEST_NAME = '.project-root.json';
15
- const MANIFEST_VERSION = 1;
15
+ const LEGACY_MANIFEST_VERSION = 1;
16
+ const MANIFEST_VERSION = 2;
17
+ const GENERATION_VERSION = 2;
18
+ const GENERATION_NAME = /^[a-f0-9]{32}$/;
19
+ const DECIMAL_IDENTITY = /^(0|[1-9][0-9]*)$/;
16
20
  const AUTHORITY_KINDS = new Set(['pending', 'fresh', 'migrated', 'purging', 'reset']);
17
21
  const AUTHORITY_TRANSITIONS = {
18
22
  missing: new Set(['pending', 'migrated', 'purging']),
@@ -213,14 +217,117 @@ function optionalDirectory(target, description, {
213
217
  }
214
218
 
215
219
  function expectedManifest(root) {
220
+ const birthtimeNs = projectBirthtime(root);
221
+ if (!birthtimeNs) throw forbidden(`Project directory has no stable birth time: ${root.canonical}`);
216
222
  return {
217
223
  version: MANIFEST_VERSION,
218
224
  canonicalRoot: root.canonical,
219
225
  dev: root.stat.dev.toString(),
220
- ino: root.stat.ino.toString()
226
+ ino: root.stat.ino.toString(),
227
+ birthtimeNs
221
228
  };
222
229
  }
223
230
 
231
+ function projectBirthtime(project) {
232
+ const birthtimeNs = project.stat?.birthtimeNs;
233
+ return typeof birthtimeNs === 'bigint' && birthtimeNs > 0n ? birthtimeNs.toString() : null;
234
+ }
235
+
236
+ function validBirthtime(value) {
237
+ return typeof value === 'string' && /^[1-9][0-9]*$/.test(value);
238
+ }
239
+
240
+ function generationParent(base) {
241
+ return `${base}.generations`;
242
+ }
243
+
244
+ function validGenerationMarker(value, canonical, id) {
245
+ return value?.version === GENERATION_VERSION && value.generation === id &&
246
+ value.canonicalRoot === canonical && DECIMAL_IDENTITY.test(value.dev) &&
247
+ DECIMAL_IDENTITY.test(value.ino) && validBirthtime(value.birthtimeNs) &&
248
+ AUTHORITY_KINDS.has(value.kind);
249
+ }
250
+
251
+ function generationBinding(project, home) {
252
+ if (!project.stat) return null;
253
+ const base = statePath(project.canonical, home);
254
+ const parent = generationParent(base);
255
+ let entries;
256
+ try {
257
+ fs.lstatSync(parent);
258
+ } catch (error) {
259
+ if (error?.code === 'ENOENT') return null;
260
+ throw inspectError(error, 'private project generations', parent);
261
+ }
262
+ inspectDirectory(parent, 'private project generations', { mode: 0o700 });
263
+ try { entries = fs.readdirSync(parent); }
264
+ catch (error) { throw inspectError(error, 'private project generations', parent); }
265
+ if (entries.length > 256) throw forbidden(`Too many private project generations: ${parent}`);
266
+ const names = new Set(entries);
267
+ const markers = new Set();
268
+ let matched = null;
269
+ for (const name of entries) {
270
+ if (!name.endsWith('.authority.json')) continue;
271
+ const id = name.slice(0, -'.authority.json'.length);
272
+ if (!GENERATION_NAME.test(id)) throw forbidden(`Invalid private project generation: ${parent}`);
273
+ markers.add(id);
274
+ const actual = readManifest(path.join(parent, name));
275
+ if (!validGenerationMarker(actual, project.canonical, id)) {
276
+ throw forbidden(`Invalid private project generation marker: ${path.join(parent, name)}`);
277
+ }
278
+ if (actual.dev === project.stat.dev.toString() && actual.ino === project.stat.ino.toString() &&
279
+ actual.birthtimeNs === projectBirthtime(project)) {
280
+ if (matched) throw forbidden(`Ambiguous private project generation: ${project.canonical}`);
281
+ matched = { directory: path.join(parent, id), marker: path.join(parent, name),
282
+ generation: id, authority: actual, parent };
283
+ }
284
+ }
285
+ for (const name of entries) {
286
+ if (GENERATION_NAME.test(name)) {
287
+ if (!markers.has(name)) throw forbidden(`Unbound private project generation: ${path.join(parent, name)}`);
288
+ const entry = fs.lstatSync(path.join(parent, name));
289
+ if (entry.isSymbolicLink() || !entry.isDirectory()) {
290
+ throw forbidden(`Invalid private project generation directory: ${path.join(parent, name)}`);
291
+ }
292
+ } else if (!name.endsWith('.authority.json')) {
293
+ throw forbidden(`Unexpected private project generation entry: ${path.join(parent, name)}`);
294
+ }
295
+ }
296
+ const fence = readManifest(`${base}.authority.json`);
297
+ if (fence?.version !== GENERATION_VERSION ||
298
+ !['legacy-v1', 'legacy-v2'].includes(fence.fence) ||
299
+ fence.canonicalRoot !== project.canonical || !DECIMAL_IDENTITY.test(fence.dev) ||
300
+ !DECIMAL_IDENTITY.test(fence.ino) || !AUTHORITY_KINDS.has(fence.kind) ||
301
+ (fence.fence === 'legacy-v1' ? fence.birthtimeNs !== null
302
+ : !validBirthtime(fence.birthtimeNs))) {
303
+ throw forbidden(`Private project generation fence is missing or invalid: ${base}`);
304
+ }
305
+ return matched;
306
+ }
307
+
308
+ function projectStateBinding(project, home) {
309
+ return generationBinding(project, home) || {
310
+ directory: statePath(project.canonical, home),
311
+ marker: `${statePath(project.canonical, home)}.authority.json`,
312
+ generation: null,
313
+ parent: path.join(home, '.hello-cc', 'projects')
314
+ };
315
+ }
316
+
317
+ function expectedGenerationManifest(project, generation) {
318
+ const birthtimeNs = projectBirthtime(project);
319
+ if (!birthtimeNs) throw forbidden(`Project directory has no stable birth time: ${project.canonical}`);
320
+ return { version: GENERATION_VERSION, canonicalRoot: project.canonical,
321
+ dev: project.stat.dev.toString(), ino: project.stat.ino.toString(),
322
+ birthtimeNs, generation };
323
+ }
324
+
325
+ function expectedBindingManifest(project, binding) {
326
+ return binding.generation
327
+ ? expectedGenerationManifest(project, binding.generation)
328
+ : expectedManifest(project);
329
+ }
330
+
224
331
  function readManifest(manifestPath) {
225
332
  let before;
226
333
  try { before = fs.lstatSync(manifestPath, { bigint: true }); }
@@ -253,7 +360,7 @@ function readManifest(manifestPath) {
253
360
  }
254
361
  }
255
362
 
256
- function ensureManifest(directory, root, create) {
363
+ function ensureManifest(directory, root, create, binding) {
257
364
  const manifestPath = path.join(directory, MANIFEST_NAME);
258
365
  let exists;
259
366
  try { fs.lstatSync(manifestPath); exists = true; }
@@ -265,12 +372,12 @@ function ensureManifest(directory, root, create) {
265
372
  if (!create || fs.readdirSync(directory).length !== 0) {
266
373
  throw forbidden(`Project state directory has no binding manifest: ${directory}`);
267
374
  }
268
- const temporary = path.join(path.dirname(directory),
375
+ const temporary = path.join(path.dirname(binding.parent),
269
376
  `.${path.basename(directory)}.manifest-tmp-${randomBytes(16).toString('hex')}`);
270
377
  let fd;
271
378
  try {
272
379
  fd = fs.openSync(temporary, FILE_CREATE_FLAGS, 0o600);
273
- fs.writeFileSync(fd, `${JSON.stringify(expectedManifest(root))}\n`);
380
+ fs.writeFileSync(fd, `${JSON.stringify(expectedBindingManifest(root, binding))}\n`);
274
381
  fs.fchmodSync(fd, 0o600);
275
382
  fs.fsyncSync(fd);
276
383
  fs.closeSync(fd);
@@ -285,9 +392,16 @@ function ensureManifest(directory, root, create) {
285
392
  }
286
393
  }
287
394
  const actual = readManifest(manifestPath);
288
- const expected = expectedManifest(root);
395
+ const expected = expectedBindingManifest(root, binding);
396
+ if (!binding.generation && actual?.version === LEGACY_MANIFEST_VERSION) {
397
+ throw new CliError('STATE_BINDING_UPGRADE_REQUIRED',
398
+ `Historical private project state requires explicit offline identity upgrade: ${directory}`);
399
+ }
289
400
  if (actual?.version !== expected.version || actual.canonicalRoot !== expected.canonicalRoot ||
290
- actual.dev !== expected.dev || actual.ino !== expected.ino) {
401
+ actual.dev !== expected.dev || actual.ino !== expected.ino ||
402
+ (!binding.generation && actual.birthtimeNs !== expected.birthtimeNs) ||
403
+ (binding.generation && (actual.birthtimeNs !== expected.birthtimeNs ||
404
+ actual.generation !== expected.generation))) {
291
405
  throw forbidden(`Project root identity differs from private state binding: ${root.canonical}. ` +
292
406
  'Back up the old private store and authority marker, stop all writers, and review both root identities ' +
293
407
  'before manual recovery; automatic rebinding is refused.');
@@ -298,7 +412,8 @@ function assertProjectUnchanged(root) {
298
412
  let current;
299
413
  try { current = canonicalProjectRoot(root.requested); }
300
414
  catch { throw forbidden(`Project root changed during private state validation: ${root.requested}`); }
301
- if (current.canonical !== root.canonical || !sameIdentity(current.stat, root.stat)) {
415
+ if (current.canonical !== root.canonical || !sameIdentity(current.stat, root.stat) ||
416
+ projectBirthtime(current) !== projectBirthtime(root)) {
302
417
  throw forbidden(`Project root changed during private state validation: ${root.requested}`);
303
418
  }
304
419
  }
@@ -307,24 +422,188 @@ function assertProjectUnchanged(root) {
307
422
  // path solely for hashing; state access always requires an existing directory.
308
423
  export function privateProjectStateDir(root) {
309
424
  const project = canonicalProjectRoot(root, { allowMissing: true });
310
- return statePath(project.canonical, canonicalHome({ allowMissing: true }));
425
+ return projectStateBinding(project, canonicalHome({ allowMissing: true })).directory;
311
426
  }
312
427
 
313
428
  export function privateProjectAuthorityPath(root) {
314
- return `${privateProjectStateDir(root)}.authority.json`;
429
+ const project = canonicalProjectRoot(root, { allowMissing: true });
430
+ return projectStateBinding(project, canonicalHome({ allowMissing: true })).marker;
431
+ }
432
+
433
+ export function privateProjectStateManifest(root) {
434
+ const project = canonicalProjectRoot(root);
435
+ return expectedBindingManifest(project, projectStateBinding(project, canonicalHome()));
436
+ }
437
+
438
+ export function privateProjectGenerationFromPath(value) {
439
+ if (typeof value !== 'string' || !value) return null;
440
+ return value.match(/(?:^|[/\\])[a-f0-9]{64}\.generations[/\\]([a-f0-9]{32})(?:[/\\]|$)/)?.[1] || null;
441
+ }
442
+
443
+ function upgradeReceipt(project, kind) {
444
+ return createHash('sha256').update(JSON.stringify({
445
+ operation: 'private-binding-v1-to-v2',
446
+ canonicalRoot: project.canonical,
447
+ dev: project.stat.dev.toString(),
448
+ ino: project.stat.ino.toString(),
449
+ birthtimeNs: projectBirthtime(project),
450
+ kind
451
+ })).digest('hex');
452
+ }
453
+
454
+ function inspectBindingUpgrade(project, home) {
455
+ inspectHomeAncestry(home);
456
+ const global = path.join(home, '.hello-cc');
457
+ const projects = path.join(global, 'projects');
458
+ if (!optionalDirectory(global, 'global state directory', {
459
+ create: false, mode: 0o700, allowReadOnlyPublic: true
460
+ }) || !optionalDirectory(projects, 'private projects directory', {
461
+ create: false, mode: 0o700
462
+ })) return { status: 'not-required', receipt: null };
463
+ const base = statePath(project.canonical, home);
464
+ const marker = `${base}.authority.json`;
465
+ let markerExists = false;
466
+ try { fs.lstatSync(marker); markerExists = true; }
467
+ catch (error) {
468
+ if (error?.code !== 'ENOENT') throw error;
469
+ }
470
+ if (!markerExists) {
471
+ try { fs.lstatSync(base); }
472
+ catch (storeError) {
473
+ if (storeError?.code === 'ENOENT') return { status: 'not-required', receipt: null };
474
+ throw inspectError(storeError, 'private project state', base);
475
+ }
476
+ throw forbidden(`Historical private project state has no authority marker: ${base}`);
477
+ }
478
+ const authority = readManifest(marker);
479
+ inspectDirectory(base, 'historical private project state', { mode: 0o700 });
480
+ if (authority.version === LEGACY_MANIFEST_VERSION && authority.kind === 'pending') {
481
+ let manifestPresent = false;
482
+ try { fs.lstatSync(path.join(base, MANIFEST_NAME)); manifestPresent = true; }
483
+ catch (error) {
484
+ if (error?.code !== 'ENOENT') throw inspectError(error, 'project state manifest', base);
485
+ }
486
+ if (!manifestPresent) {
487
+ throw new CliError('STATE_BINDING_MANUAL_RECOVERY_REQUIRED',
488
+ `Historical v1 initialization has no committed root manifest: ${base}. ` +
489
+ 'Cold-drain writers, back up the root, private directory and authority marker, ' +
490
+ 'and inspect any project-local legacy state before manual recovery. ' +
491
+ 'Do not purge or fabricate a v2 marker.');
492
+ }
493
+ }
494
+ const manifest = readManifest(path.join(base, MANIFEST_NAME));
495
+ const identity = expectedManifest(project);
496
+ const kind = authority.kind ?? 'migrated';
497
+ if (!['fresh', 'migrated'].includes(kind) ||
498
+ authority.canonicalRoot !== project.canonical ||
499
+ authority.dev !== identity.dev || authority.ino !== identity.ino ||
500
+ manifest.canonicalRoot !== project.canonical ||
501
+ manifest.dev !== identity.dev || manifest.ino !== identity.ino) {
502
+ throw forbidden(`Cannot assert that current root owns historical private state: ${base}`);
503
+ }
504
+ if (authority.version === MANIFEST_VERSION && authority.fence === undefined &&
505
+ manifest.version === MANIFEST_VERSION && authority.birthtimeNs === identity.birthtimeNs &&
506
+ manifest.birthtimeNs === identity.birthtimeNs) {
507
+ return { status: 'already-current', receipt: null, stateDir: base };
508
+ }
509
+ const receipt = upgradeReceipt(project, kind);
510
+ const historical = authority.version === LEGACY_MANIFEST_VERSION &&
511
+ manifest.version === LEGACY_MANIFEST_VERSION;
512
+ const pending = authority.version === MANIFEST_VERSION &&
513
+ authority.fence === 'upgrade-pending' && authority.upgradeReceipt === receipt &&
514
+ authority.birthtimeNs === identity.birthtimeNs &&
515
+ (manifest.version === LEGACY_MANIFEST_VERSION ||
516
+ (manifest.version === MANIFEST_VERSION && manifest.birthtimeNs === identity.birthtimeNs));
517
+ if (!historical && !pending) {
518
+ throw forbidden(`Private binding upgrade state needs manual recovery: ${base}`);
519
+ }
520
+ return {
521
+ status: pending ? 'pending' : 'required', receipt, stateDir: base,
522
+ canonicalRoot: project.canonical,
523
+ rootIdentity: { dev: identity.dev, ino: identity.ino,
524
+ birthtimeNs: identity.birthtimeNs },
525
+ warning: 'Receipt records current filesystem metadata, not proof that this root is historical A. '
526
+ + 'Proceed only after verifying A independently and cold-draining every old writer.'
527
+ };
528
+ }
529
+
530
+ export function inspectPrivateProjectBindingUpgrade(root) {
531
+ const project = canonicalProjectRoot(root);
532
+ return inspectBindingUpgrade(project, canonicalHome());
533
+ }
534
+
535
+ export function upgradePrivateProjectBinding(root, {
536
+ confirmedOffline = false, confirmedHistoricalRoot = false,
537
+ expectedReceipt = null, assertOffline = null
538
+ } = {}) {
539
+ if (!confirmedOffline || !confirmedHistoricalRoot ||
540
+ typeof expectedReceipt !== 'string' || !/^[a-f0-9]{64}$/.test(expectedReceipt) ||
541
+ typeof assertOffline !== 'function') {
542
+ throw new CliError('STATE_BINDING_UPGRADE_CONFIRMATION_REQUIRED',
543
+ 'Private binding upgrade requires --offline, --yes, --assert-historical-root and an exact inspection receipt');
544
+ }
545
+ const project = canonicalProjectRoot(root);
546
+ const original = expectedManifest(project);
547
+ return withPrivateProjectStateTransition(root, ({ projects, home }) => {
548
+ assertFullProjectIdentity(project);
549
+ const inspected = inspectBindingUpgrade(project, home);
550
+ if (inspected.status === 'already-current' || inspected.status === 'not-required') return inspected;
551
+ if (inspected.receipt !== expectedReceipt ||
552
+ original.birthtimeNs !== inspected.rootIdentity.birthtimeNs) {
553
+ throw new CliError('STATE_BINDING_UPGRADE_RECEIPT_MISMATCH',
554
+ 'Private binding inspection receipt or root identity changed; inspect again before upgrading');
555
+ }
556
+ if (assertOffline({ phase: 'before', stateDir: inspected.stateDir }) !== true) {
557
+ throw new CliError('STATE_BINDING_UPGRADE_OFFLINE_REQUIRED',
558
+ 'Cannot establish cold-drained private state');
559
+ }
560
+ const marker = `${inspected.stateDir}.authority.json`;
561
+ const manifestPath = path.join(inspected.stateDir, MANIFEST_NAME);
562
+ let authority = readManifest(marker);
563
+ if (inspected.status === 'required') {
564
+ publishPrivateManifest(marker, { ...original, kind: authority.kind ?? 'migrated',
565
+ fence: 'upgrade-pending', upgradeReceipt: expectedReceipt }, projects, authority);
566
+ authority = readManifest(marker);
567
+ }
568
+ // Once the fence is durable, v1 readers reject the store. A crash in this
569
+ // gap leaves a retryable pending state; v2 readers also reject it.
570
+ const manifest = readManifest(manifestPath);
571
+ if (manifest.version === LEGACY_MANIFEST_VERSION) {
572
+ publishPrivateManifest(manifestPath, original, inspected.stateDir, manifest);
573
+ }
574
+ assertFullProjectIdentity(project);
575
+ if (assertOffline({ phase: 'after', stateDir: inspected.stateDir }) !== true) {
576
+ throw new CliError('STATE_BINDING_UPGRADE_OFFLINE_REQUIRED',
577
+ 'Private binding remains fenced because offline evidence changed');
578
+ }
579
+ publishPrivateManifest(marker, { ...original, kind: authority.kind }, projects, authority);
580
+ assertFullProjectIdentity(project);
581
+ return { status: 'upgraded', stateDir: inspected.stateDir,
582
+ canonicalRoot: project.canonical, rootIdentity: inspected.rootIdentity };
583
+ });
315
584
  }
316
585
 
317
586
  export function inspectPrivateProjectAuthority(root) {
318
587
  const project = canonicalProjectRoot(root);
319
- const marker = privateProjectAuthorityPath(project.canonical);
588
+ const binding = projectStateBinding(project, canonicalHome());
589
+ const marker = binding.marker;
320
590
  try { fs.lstatSync(marker); }
321
591
  catch (error) {
322
592
  if (error?.code === 'ENOENT') return null;
323
593
  throw inspectError(error, 'project authority marker', marker);
324
594
  }
325
595
  const actual = readManifest(marker);
326
- const expected = expectedManifest(project);
327
- if (actual?.version !== expected.version || actual.canonicalRoot !== expected.canonicalRoot ||
596
+ const expected = expectedBindingManifest(project, binding);
597
+ if (!binding.generation && actual?.version === LEGACY_MANIFEST_VERSION) {
598
+ throw new CliError('STATE_BINDING_UPGRADE_REQUIRED',
599
+ `Historical private project state requires explicit offline identity upgrade: ${marker}`);
600
+ }
601
+ const valid = binding.generation
602
+ ? actual?.version === expected.version && actual?.generation === expected.generation &&
603
+ actual?.birthtimeNs === expected.birthtimeNs
604
+ : actual?.version === expected.version && actual?.birthtimeNs === expected.birthtimeNs &&
605
+ (actual.fence === undefined || actual.fence === 'legacy-v2');
606
+ if (!valid || actual.canonicalRoot !== expected.canonicalRoot ||
328
607
  actual.dev !== expected.dev || actual.ino !== expected.ino) {
329
608
  throw forbidden(`Project authority marker differs from project root: ${marker}. ` +
330
609
  'Back up the old private store and authority marker, stop all writers, and review both root identities ' +
@@ -344,16 +623,141 @@ function fsyncDirectory(directory) {
344
623
  finally { fs.closeSync(fd); }
345
624
  }
346
625
 
626
+ function publishPrivateManifest(marker, contents, parent, expected = null) {
627
+ const temporary = path.join(path.dirname(parent),
628
+ `.${path.basename(marker)}.tmp-${randomBytes(16).toString('hex')}`);
629
+ let fd;
630
+ try {
631
+ fd = fs.openSync(temporary, FILE_CREATE_FLAGS, 0o600);
632
+ fs.writeFileSync(fd, `${JSON.stringify(contents)}\n`);
633
+ fs.fchmodSync(fd, 0o600);
634
+ fs.fsyncSync(fd);
635
+ fs.closeSync(fd);
636
+ fd = undefined;
637
+ if (expected && JSON.stringify(readManifest(marker)) !== JSON.stringify(expected)) {
638
+ throw forbidden(`Private project marker changed during publication: ${marker}`);
639
+ }
640
+ fs.renameSync(temporary, marker);
641
+ fsyncDirectory(parent);
642
+ } finally {
643
+ if (fd !== undefined) fs.closeSync(fd);
644
+ try { fs.unlinkSync(temporary); } catch { /* An inert private temp is safe to leave. */ }
645
+ }
646
+ }
647
+
648
+ function assertFullProjectIdentity(project, selected = null) {
649
+ const current = canonicalProjectRoot(project.requested);
650
+ if (current.canonical !== project.canonical || !sameIdentity(current.stat, project.stat) ||
651
+ projectBirthtime(current) !== projectBirthtime(project) ||
652
+ (selected && (selected.canonical !== project.canonical ||
653
+ selected.identity?.dev !== project.stat.dev.toString() ||
654
+ selected.identity?.ino !== project.stat.ino.toString() ||
655
+ selected.identity?.birthtimeNs !== projectBirthtime(project)))) {
656
+ throw forbidden(`Project root changed during private generation selection: ${project.requested}`);
657
+ }
658
+ }
659
+
660
+ // A replacement root gets a separate store only after an explicit selection.
661
+ // The v2 fence at the v1 pathname makes older binaries reject *both* roots,
662
+ // while retaining A's original directory and manifest byte-for-byte.
663
+ export function provisionPrivateProjectGeneration(root, { expectedIdentity = null } = {}) {
664
+ const project = canonicalProjectRoot(root);
665
+ // Refuse unknown creation times before fencing an old binding or creating
666
+ // generation records. Zero is an unavailable value, not a stable identity.
667
+ expectedManifest(project);
668
+ assertFullProjectIdentity(project, expectedIdentity);
669
+ const home = canonicalHome();
670
+ const base = statePath(project.canonical, home);
671
+ return withPrivateProjectStateTransition(project.requested, ({ projects }) => {
672
+ assertFullProjectIdentity(project, expectedIdentity);
673
+ const existing = generationBinding(project, home);
674
+ if (existing) return existing.directory;
675
+ const marker = `${base}.authority.json`;
676
+ let markerExists = false;
677
+ try { fs.lstatSync(marker); markerExists = true; }
678
+ catch (error) { if (error?.code !== 'ENOENT') throw inspectError(error, 'project authority marker', marker); }
679
+ if (!markerExists) {
680
+ try { fs.lstatSync(base); }
681
+ catch (error) { if (error?.code === 'ENOENT') return null; throw inspectError(error, 'private project state', base); }
682
+ throw forbidden(`Historical private project state has no authority marker: ${base}`);
683
+ }
684
+ let old;
685
+ old = readManifest(marker);
686
+ if (![LEGACY_MANIFEST_VERSION, GENERATION_VERSION].includes(old.version) ||
687
+ (old.version === GENERATION_VERSION &&
688
+ ((old.fence && !['legacy-v1', 'legacy-v2'].includes(old.fence)) ||
689
+ (old.fence !== 'legacy-v1' && !validBirthtime(old.birthtimeNs)))) ||
690
+ old.canonicalRoot !== project.canonical ||
691
+ !DECIMAL_IDENTITY.test(old.dev) || !DECIMAL_IDENTITY.test(old.ino) ||
692
+ !['fresh', 'migrated'].includes(old.version === LEGACY_MANIFEST_VERSION
693
+ ? old.kind ?? 'migrated' : old.kind)) {
694
+ throw forbidden(`Existing private project authority requires recovery: ${marker}`);
695
+ }
696
+ if (old.version === LEGACY_MANIFEST_VERSION) {
697
+ throw new CliError('STATE_BINDING_UPGRADE_REQUIRED',
698
+ `Historical v1 private state must be upgraded before selecting a replacement root: ${marker}. ` +
699
+ 'Restore and independently confirm historical A at this canonical path, cold-drain every writer, ' +
700
+ 'run migrate-state --inspect-private-binding, then migrate-state --upgrade-private-binding ' +
701
+ '--offline --yes --assert-historical-root --expect-receipt=SHA256, and only then select B. ' +
702
+ 'Matching dev/ino alone does not prove the current root is A.');
703
+ }
704
+ if (old.fence === 'legacy-v1') {
705
+ throw new CliError('STATE_BINDING_MANUAL_RECOVERY_REQUIRED',
706
+ `A historical v1 store was already fenced without a saved root birth time: ${marker}. ` +
707
+ 'Preserve both stores, cold-drain writers, and review the original A root and generation records ' +
708
+ 'before manual recovery; further automatic provisioning is refused.');
709
+ }
710
+ if (old.dev === project.stat.dev.toString() && old.ino === project.stat.ino.toString()) {
711
+ if (old.birthtimeNs === projectBirthtime(project) && old.fence !== 'legacy-v1') {
712
+ return null; // The fully identified original v2 root is still selected.
713
+ }
714
+ if (old.birthtimeNs === null) {
715
+ throw forbidden(`Historical project identity is ambiguous after fencing: ${project.canonical}`);
716
+ }
717
+ }
718
+ inspectDirectory(base, 'historical private project state', { mode: 0o700 });
719
+ const oldManifest = readManifest(path.join(base, MANIFEST_NAME));
720
+ if (oldManifest.version !== MANIFEST_VERSION || oldManifest.canonicalRoot !== old.canonicalRoot ||
721
+ oldManifest.dev !== old.dev || oldManifest.ino !== old.ino ||
722
+ oldManifest.birthtimeNs !== old.birthtimeNs) {
723
+ throw forbidden(`Historical private project state does not match its authority: ${base}`);
724
+ }
725
+ if (!old.fence) {
726
+ publishPrivateManifest(marker, { ...old, fence: 'legacy-v2' }, projects, old);
727
+ }
728
+ const parent = generationParent(base);
729
+ optionalDirectory(parent, 'private project generations', {
730
+ create: true, mode: 0o700, tighten: true
731
+ });
732
+ fsyncDirectory(projects);
733
+ const id = randomBytes(16).toString('hex');
734
+ const generationMarker = path.join(parent, `${id}.authority.json`);
735
+ if (fs.existsSync(generationMarker) || fs.existsSync(path.join(parent, id))) {
736
+ throw forbidden(`Private project generation identifier already exists: ${id}`);
737
+ }
738
+ publishPrivateManifest(generationMarker,
739
+ { ...expectedGenerationManifest(project, id), kind: 'pending' }, parent);
740
+ assertFullProjectIdentity(project, expectedIdentity);
741
+ return path.join(parent, id);
742
+ });
743
+ }
744
+
347
745
  function writePrivateProjectAuthorityKind(project, home, projects, kind) {
348
746
  if (!AUTHORITY_KINDS.has(kind)) throw forbidden(`Invalid project authority kind: ${kind}`);
349
- const marker = `${statePath(project.canonical, home)}.authority.json`;
747
+ const binding = projectStateBinding(project, home);
748
+ const marker = binding.marker;
350
749
  const current = inspectPrivateProjectAuthority(project.canonical);
351
750
  if (current === kind) return marker;
352
751
  if (!AUTHORITY_TRANSITIONS[current ?? 'missing']?.has(kind)) {
353
752
  throw forbidden(`Invalid project authority transition ${current ?? 'missing'} -> ${kind}: ${marker}`);
354
753
  }
355
- const contents = `${JSON.stringify({ ...expectedManifest(project), kind })}\n`;
356
- const temporary = `${marker}.tmp-${randomBytes(16).toString('hex')}`;
754
+ const previous = current === null ? null : readManifest(marker);
755
+ const manifest = binding.generation
756
+ ? expectedGenerationManifest(project, binding.generation)
757
+ : { ...expectedManifest(project), ...(previous?.fence ? { fence: previous.fence } : {}) };
758
+ const contents = `${JSON.stringify({ ...manifest, kind })}\n`;
759
+ const temporary = path.join(path.dirname(binding.parent),
760
+ `.${path.basename(marker)}.tmp-${randomBytes(16).toString('hex')}`);
357
761
  let fd;
358
762
  try {
359
763
  fd = fs.openSync(temporary, FILE_CREATE_FLAGS, 0o600);
@@ -374,7 +778,7 @@ function writePrivateProjectAuthorityKind(project, home, projects, kind) {
374
778
  if (fd !== undefined) fs.closeSync(fd);
375
779
  try { fs.unlinkSync(temporary); } catch { /* A private temp is inert. */ }
376
780
  }
377
- fsyncDirectory(projects);
781
+ fsyncDirectory(binding.parent);
378
782
  assertProjectUnchanged(project);
379
783
  return marker;
380
784
  }
@@ -395,12 +799,13 @@ export function withPrivateProjectStateTransition(root, fn) {
395
799
  optionalDirectory(projects, 'private projects directory', {
396
800
  create: true, mode: 0o700, tighten: true
397
801
  });
398
- const directory = statePath(project.canonical, home);
399
- return withFileLock(`${directory}.transition`, () => {
802
+ const legacyDirectory = statePath(project.canonical, home);
803
+ return withFileLock(`${legacyDirectory}.transition`, () => {
400
804
  inspectHomeAncestry(home);
401
805
  inspectDirectory(global, 'global state directory', { mode: 0o700 });
402
806
  inspectDirectory(projects, 'private projects directory', { mode: 0o700 });
403
807
  assertProjectUnchanged(project);
808
+ const directory = projectStateBinding(project, home).directory;
404
809
  const result = fn({
405
810
  directory, projects, project, home,
406
811
  authorityKind: () => inspectPrivateProjectAuthority(project.canonical),
@@ -448,7 +853,8 @@ function inspectPrivateProjectStateDir(project, home, create, setAuthorityKind =
448
853
  inspectHomeAncestry(home);
449
854
  const global = path.join(home, '.hello-cc');
450
855
  const projects = path.join(global, 'projects');
451
- const directory = statePath(project.canonical, home);
856
+ const binding = projectStateBinding(project, home);
857
+ const directory = binding.directory;
452
858
 
453
859
  // A pre-existing 0755 global directory can be read without mutation. Only
454
860
  // create mode may tighten it, and writable-by-others directories are rejected.
@@ -483,6 +889,11 @@ function inspectPrivateProjectStateDir(project, home, create, setAuthorityKind =
483
889
  if (error?.code !== 'ENOENT') throw inspectError(error, 'project state manifest', directory);
484
890
  }
485
891
  }
892
+ if (manifestExists && authority === null && !binding.generation &&
893
+ readManifest(path.join(directory, MANIFEST_NAME)).version === LEGACY_MANIFEST_VERSION) {
894
+ throw new CliError('STATE_BINDING_UPGRADE_REQUIRED',
895
+ `Markerless historical private state requires reviewed offline recovery: ${directory}`);
896
+ }
486
897
  if (create && !manifestExists) {
487
898
  // Resolver callers also check the legacy source. Repeat at the actual
488
899
  // transition boundary: a concurrent purge can invalidate their earlier
@@ -506,9 +917,9 @@ function inspectPrivateProjectStateDir(project, home, create, setAuthorityKind =
506
917
  create: Boolean(create), mode: 0o700, tighten: true
507
918
  })) return null;
508
919
 
509
- ensureManifest(directory, project, Boolean(create));
920
+ ensureManifest(directory, project, Boolean(create), binding);
510
921
  if (create && (authority === null || authority === 'pending' || authority === 'reset')) {
511
- fsyncDirectory(projects);
922
+ fsyncDirectory(binding.parent);
512
923
  setAuthorityKind('fresh');
513
924
  }
514
925
  assertProjectUnchanged(project);
@@ -526,7 +937,7 @@ export function ensurePrivateProjectStateDir(root, { create = false } = {}) {
526
937
  // The purging marker is a durable intent record. If deletion is interrupted,
527
938
  // normal state access stays closed and an explicit purge retry completes it.
528
939
  export function purgePrivateProjectState(root) {
529
- return withPrivateProjectStateTransition(root, ({ directory, projects, authorityKind, setAuthorityKind }) => {
940
+ return withPrivateProjectStateTransition(root, ({ directory, authorityKind, setAuthorityKind }) => {
530
941
  // The directory may be partially deleted after an earlier crash, so its
531
942
  // manifest need not remain. Its private ownership and no-follow identity
532
943
  // are still required before recursive deletion.
@@ -539,7 +950,7 @@ export function purgePrivateProjectState(root) {
539
950
  authorityKind();
540
951
  setAuthorityKind('purging');
541
952
  fs.rmSync(directory, { recursive: true, force: true });
542
- fsyncDirectory(projects);
953
+ fsyncDirectory(path.dirname(directory));
543
954
  setAuthorityKind('reset');
544
955
  return directory;
545
956
  });
@@ -221,13 +221,15 @@ function validateDatabaseTarget(stateDir, db) {
221
221
  }
222
222
  }
223
223
 
224
- export function resolveProjectDatabase({ root, db, createStateDir = false, createDatabaseParents = false }) {
224
+ function databaseLocation({ root, db }) {
225
225
  const requestedRoot = path.resolve(String(root || ''));
226
226
  const canonicalRoot = canonicalProjectRoot(requestedRoot);
227
227
  const rawDb = path.resolve(String(db || path.join(requestedRoot, '.hello-cc', 'mesh.db')));
228
228
  const intendedStateDir = projectStateDir(canonicalRoot);
229
229
  const requestedLegacyState = legacyProjectStateDir(requestedRoot);
230
230
  const canonicalLegacyState = legacyProjectStateDir(canonicalRoot);
231
+ const legacySpelling = pathIsWithin(requestedLegacyState, rawDb) ||
232
+ pathIsWithin(canonicalLegacyState, rawDb);
231
233
  let requestedDb = pathIsWithin(requestedLegacyState, rawDb)
232
234
  ? path.join(intendedStateDir, path.relative(requestedLegacyState, rawDb))
233
235
  : pathIsWithin(canonicalLegacyState, rawDb)
@@ -246,6 +248,56 @@ export function resolveProjectDatabase({ root, db, createStateDir = false, creat
246
248
  if (!pathIsWithin(intendedStateDir, requestedDb)) {
247
249
  throw projectPathForbidden(`Database path must live under ${intendedStateDir}`);
248
250
  }
251
+ return { canonicalRoot, intendedStateDir, requestedDb, legacySpelling };
252
+ }
253
+
254
+ // Check an explicitly requested DB path without creating a state directory,
255
+ // claiming a generation, or tightening permissions. The mutating resolver
256
+ // repeats all checks after selection; this is an admission guard, not a grant.
257
+ export function prevalidateProjectDatabaseLocation({ root, db }) {
258
+ const location = databaseLocation({ root, db });
259
+ const { canonicalRoot, intendedStateDir, requestedDb, legacySpelling } = location;
260
+ // A direct private path from A must not be used to provision replacement B.
261
+ // A legacy spelling is remapped to B's new generation after selection.
262
+ if (!legacySpelling && intendedStateDir !== legacyProjectStateDir(canonicalRoot)) {
263
+ inspectPrivateProjectAuthority(canonicalRoot);
264
+ }
265
+ const canonicalCandidate = canonicalCandidateFromExistingParent(requestedDb);
266
+ if (!canonicalCandidate || !pathIsWithin(intendedStateDir, canonicalCandidate)) {
267
+ throw projectPathForbidden(`Database path escapes project state directory: ${requestedDb}`);
268
+ }
269
+ for (let current = intendedStateDir;;) {
270
+ let stat;
271
+ try { stat = fs.lstatSync(current); }
272
+ catch (error) {
273
+ if (error?.code !== 'ENOENT') {
274
+ throw projectPathForbidden(`Cannot inspect database parent: ${current}`);
275
+ }
276
+ }
277
+ if (stat && (stat.isSymbolicLink() || !stat.isDirectory())) {
278
+ throw projectPathForbidden(`Database parent must be a real directory: ${current}`);
279
+ }
280
+ if (current === path.dirname(requestedDb)) break;
281
+ const next = path.join(current, path.relative(current, path.dirname(requestedDb)).split(path.sep)[0]);
282
+ if (next === current) throw projectPathForbidden(`Invalid database parent: ${requestedDb}`);
283
+ current = next;
284
+ }
285
+ try {
286
+ const target = fs.lstatSync(requestedDb);
287
+ if (target.isSymbolicLink() || !target.isFile()) {
288
+ throw projectPathForbidden(`Project database must be a regular file: ${requestedDb}`);
289
+ }
290
+ } catch (error) {
291
+ if (error?.code !== 'ENOENT') {
292
+ if (error instanceof CliError) throw error;
293
+ throw projectPathForbidden(`Cannot inspect project database: ${requestedDb}`);
294
+ }
295
+ }
296
+ return location;
297
+ }
298
+
299
+ export function resolveProjectDatabase({ root, db, createStateDir = false, createDatabaseParents = false }) {
300
+ const { canonicalRoot, intendedStateDir, requestedDb } = databaseLocation({ root, db });
249
301
  const state = ensureStateDirectory(canonicalRoot, Boolean(createStateDir));
250
302
  if (!state.exists) {
251
303
  return { root: canonicalRoot, stateDir: state.stateDir, db: requestedDb };