@logicseek/hello-cc 0.1.4 → 0.1.6

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 (90) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/bin/hcc.mjs +2739 -6482
  3. package/docs/README.md +2 -0
  4. package/docs/README.zh-CN.md +3 -1
  5. package/docs/architecture-split-review.md +217 -0
  6. package/docs/architecture.md +385 -0
  7. package/docs/architecture.zh-CN.md +364 -0
  8. package/docs/commands.md +21 -9
  9. package/docs/commands.zh-CN.md +19 -9
  10. package/docs/layout-split-stack-audit.md +298 -0
  11. package/lib/automation.mjs +8 -0
  12. package/lib/cli-args.mjs +89 -0
  13. package/lib/cli-runtime.mjs +44 -0
  14. package/lib/coordination-state.mjs +218 -0
  15. package/lib/core/coordination/automation.mjs +222 -0
  16. package/lib/core/coordination/locks.mjs +46 -0
  17. package/lib/core/coordination/messages.mjs +116 -0
  18. package/lib/core/coordination/tasks.mjs +224 -0
  19. package/lib/core/coordination/teams.mjs +73 -0
  20. package/lib/core/coordination/timeline.mjs +187 -0
  21. package/lib/core/peers/bindings.mjs +83 -0
  22. package/lib/core/peers/format.mjs +13 -0
  23. package/lib/core/peers/identity.mjs +53 -0
  24. package/lib/core/peers/liveness.mjs +90 -0
  25. package/lib/core/peers/reconcile.mjs +238 -0
  26. package/lib/core/peers/session.mjs +14 -0
  27. package/lib/core/sessions/launch.mjs +89 -0
  28. package/lib/db/schema.mjs +324 -0
  29. package/lib/db/stores/peers.mjs +231 -0
  30. package/lib/db-schema.mjs +7 -0
  31. package/lib/errors.mjs +1 -0
  32. package/lib/format.mjs +39 -0
  33. package/lib/guidance.mjs +10 -2
  34. package/lib/handoff.mjs +26 -0
  35. package/lib/help.mjs +3 -0
  36. package/lib/integrations/hooks.mjs +171 -0
  37. package/lib/integrations/peers/identity.mjs +208 -0
  38. package/lib/integrations/peers/processes.mjs +151 -0
  39. package/lib/integrations/providers.mjs +282 -0
  40. package/lib/integrations/shims/script.mjs +384 -0
  41. package/lib/integrations/shims.mjs +106 -0
  42. package/lib/json-file.mjs +1 -0
  43. package/lib/locks.mjs +10 -0
  44. package/lib/messages.mjs +3 -0
  45. package/lib/package-meta.mjs +6 -24
  46. package/lib/peer-bindings.mjs +14 -0
  47. package/lib/peer-format.mjs +4 -0
  48. package/lib/peer-identity.mjs +15 -0
  49. package/lib/project-context.mjs +6 -0
  50. package/lib/project-registry.mjs +7 -0
  51. package/lib/provider-commands.mjs +13 -0
  52. package/lib/release/package-meta.mjs +31 -0
  53. package/lib/release/release-notes.mjs +40 -0
  54. package/lib/release-notes.mjs +6 -40
  55. package/lib/runtime/client.mjs +42 -0
  56. package/lib/runtime/paths.mjs +45 -0
  57. package/lib/runtime/project-context.mjs +32 -0
  58. package/lib/runtime/projects.mjs +76 -0
  59. package/lib/runtime/state.mjs +128 -0
  60. package/lib/runtime-client.mjs +3 -0
  61. package/lib/runtime-paths.mjs +11 -0
  62. package/lib/runtime-state.mjs +11 -0
  63. package/lib/session-launch.mjs +17 -0
  64. package/lib/setup.mjs +18 -765
  65. package/lib/shared/errors.mjs +7 -0
  66. package/lib/shared/json-file.mjs +14 -0
  67. package/lib/shell-path.mjs +69 -0
  68. package/lib/shim-script.mjs +1 -0
  69. package/lib/state-render.mjs +5 -0
  70. package/lib/task-cli.mjs +45 -0
  71. package/lib/task-liveness.mjs +8 -0
  72. package/lib/task-store.mjs +3 -0
  73. package/lib/team-planning.mjs +7 -0
  74. package/lib/terminal/tmux.mjs +372 -0
  75. package/lib/timeline.mjs +12 -0
  76. package/lib/tmux.mjs +18 -0
  77. package/lib/ui/help.mjs +419 -0
  78. package/lib/ui/state-render.mjs +61 -0
  79. package/lib/web/http.mjs +55 -0
  80. package/lib/web/peer-actions.mjs +270 -0
  81. package/lib/web/runtime.mjs +154 -0
  82. package/lib/web/ui-template.mjs +2055 -0
  83. package/lib/web-http.mjs +7 -0
  84. package/lib/web-peer-actions.mjs +3 -0
  85. package/lib/web-runtime.mjs +17 -0
  86. package/lib/web-ui-template.mjs +3 -0
  87. package/package.json +1 -1
  88. package/scripts/github-release.mjs +2 -2
  89. package/scripts/regression.mjs +2738 -22
  90. package/scripts/release-notes.mjs +2 -2
@@ -1,12 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import fs from 'node:fs';
4
+ import http from 'node:http';
4
5
  import os from 'node:os';
5
6
  import path from 'node:path';
6
7
  import process from 'node:process';
7
8
  import { spawnSync } from 'node:child_process';
9
+ import crypto from 'node:crypto';
8
10
  import { setTimeout as sleep } from 'node:timers/promises';
9
11
  import { DatabaseSync } from 'node:sqlite';
12
+ import { Readable } from 'node:stream';
13
+ import { pathToFileURL } from 'node:url';
10
14
  import WebSocket from 'ws';
11
15
 
12
16
  const repoRoot = path.resolve(import.meta.dirname, '..');
@@ -17,6 +21,11 @@ const home = path.join(os.tmpdir(), `hcc-reg-home-${testId}`);
17
21
  const fakeBin = path.join(os.tmpdir(), `hcc-reg-bin-${testId}`);
18
22
  const outDir = path.join(os.tmpdir(), `hcc-reg-out-${testId}`);
19
23
  const tmuxSession = `hcc-reg-${process.pid}`;
24
+ const tmuxSocketName = `hcc-reg-${testId}`.replace(/[^A-Za-z0-9_-]/g, '-');
25
+ const realTmuxBin = spawnSync('sh', ['-lc', 'command -v tmux || true'], {
26
+ encoding: 'utf8',
27
+ stdio: ['ignore', 'pipe', 'ignore']
28
+ }).stdout.trim();
20
29
  const port = 22000 + (process.pid % 10000);
21
30
 
22
31
  let tmuxStarted = false;
@@ -31,6 +40,8 @@ const env = {
31
40
  for (const key of Object.keys(env)) {
32
41
  if (key.startsWith('HCC_')) delete env[key];
33
42
  }
43
+ delete env.TMUX;
44
+ delete env.TMUX_PANE;
34
45
 
35
46
  function log(message) {
36
47
  process.stdout.write(`${message}\n`);
@@ -48,6 +59,19 @@ function sh(value) {
48
59
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
49
60
  }
50
61
 
62
+ function shortHash(value) {
63
+ return crypto.createHash('sha1').update(String(value)).digest('hex').slice(0, 8);
64
+ }
65
+
66
+ function sanitizePeerPart(value, fallback = 'peer') {
67
+ const cleaned = String(value || '').toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
68
+ return cleaned || fallback;
69
+ }
70
+
71
+ function tmuxManagedSession(projectRoot, peer) {
72
+ return `hcc-${shortHash(projectRoot)}-${sanitizePeerPart(peer, 'peer')}`.slice(0, 80);
73
+ }
74
+
51
75
  function run(command, args, options = {}) {
52
76
  const result = spawnSync(command, args, {
53
77
  cwd: options.cwd || repoRoot,
@@ -182,6 +206,14 @@ function insertRuntimeTargetBinding(peer, provider, sessionName, runtimeTarget)
182
206
  });
183
207
  }
184
208
 
209
+ function peerBindingRow(peer) {
210
+ return withMeshDb((db) => db.prepare(`
211
+ SELECT peer, transport, runtime_target, updated_at
212
+ FROM peer_bindings
213
+ WHERE peer = ?
214
+ `).get(peer));
215
+ }
216
+
185
217
  function moveRuntimeBindingPeer(fromPeer, toPeer) {
186
218
  withMeshDb((db) => {
187
219
  const peer = db.prepare('SELECT * FROM peers WHERE id = ?').get(fromPeer);
@@ -269,6 +301,162 @@ function assertPeerBindingUniqueConstraints() {
269
301
  cleanupBindingPeers(runtimePrefix);
270
302
  }
271
303
 
304
+ async function assertTmuxGcPolicy() {
305
+ const stalePeer = 'tmux-gc-stale';
306
+ const peerFilterPeer = 'tmux-gc-peer-filter';
307
+ const attachedPeer = 'tmux-gc-attached';
308
+ const eventPeer = 'tmux-gc-event';
309
+ const reusedEventPeer = 'tmux-gc-event-reused';
310
+ const manualSession = `hcc-${shortHash(root)}-manual-lookalike`;
311
+ const staleSession = tmuxManagedSession(root, stalePeer);
312
+ const peerFilterSession = tmuxManagedSession(root, peerFilterPeer);
313
+ const attachedSession = tmuxManagedSession(root, attachedPeer);
314
+ const eventSession = `${tmuxManagedSession(root, eventPeer)}-old-regression`;
315
+ const reusedEventSession = tmuxManagedSession(root, reusedEventPeer);
316
+ for (const session of [staleSession, peerFilterSession, attachedSession, eventSession, reusedEventSession, manualSession]) {
317
+ runMaybe('tmux', ['kill-session', '-t', session]);
318
+ }
319
+ cleanupBindingPeers('tmux-gc-');
320
+ managedTmuxSessions.add(peerFilterSession);
321
+ managedTmuxSessions.add(attachedSession);
322
+ managedTmuxSessions.add(eventSession);
323
+ managedTmuxSessions.add(reusedEventSession);
324
+ managedTmuxSessions.add(manualSession);
325
+
326
+ run('tmux', ['new-session', '-d', '-s', staleSession, '-e', `HCC_ROOT=${root}`, '-c', root, 'bash', '--noprofile', '--norc']);
327
+ run('tmux', ['new-session', '-d', '-s', peerFilterSession, '-e', `HCC_ROOT=${root}`, '-c', root, 'bash', '--noprofile', '--norc']);
328
+ run('tmux', ['new-session', '-d', '-s', attachedSession, '-e', `HCC_ROOT=${root}`, '-c', root, 'bash', '--noprofile', '--norc']);
329
+ run('tmux', ['new-session', '-d', '-s', eventSession, '-e', `HCC_ROOT=${root}`, '-c', root, 'bash', '--noprofile', '--norc']);
330
+ run('tmux', ['new-session', '-d', '-s', reusedEventSession, '-e', `HCC_ROOT=${root}`, '-c', root, 'bash', '--noprofile', '--norc']);
331
+ run('tmux', ['new-session', '-d', '-s', manualSession, '-e', `HCC_ROOT=${root}`, '-c', root, 'bash', '--noprofile', '--norc']);
332
+ const stalePane = run('tmux', ['display-message', '-p', '-t', `${staleSession}:0.0`, '#{pane_id}']).trim();
333
+ const peerFilterPane = run('tmux', ['display-message', '-p', '-t', `${peerFilterSession}:0.0`, '#{pane_id}']).trim();
334
+ const attachedPane = run('tmux', ['display-message', '-p', '-t', `${attachedSession}:0.0`, '#{pane_id}']).trim();
335
+ const eventPane = run('tmux', ['display-message', '-p', '-t', `${eventSession}:0.0`, '#{pane_id}']).trim();
336
+ const reusedEventPane = run('tmux', ['display-message', '-p', '-t', `${reusedEventSession}:0.0`, '#{pane_id}']).trim();
337
+ const manualPane = run('tmux', ['display-message', '-p', '-t', `${manualSession}:0.0`, '#{pane_id}']).trim();
338
+ insertRuntimeTargetBinding(stalePeer, 'shell', 'tmux-gc-stale-session', stalePane);
339
+ insertRuntimeTargetBinding(peerFilterPeer, 'shell', 'tmux-gc-peer-filter-session', peerFilterPane);
340
+ insertRuntimeTargetBinding(attachedPeer, 'shell', 'tmux-gc-attached-session', attachedPane);
341
+ const eventActivePane = parsePane(hcc(['peer', 'start', eventPeer, '--kind', 'shell', '--', 'bash', '--noprofile', '--norc']));
342
+ const eventActiveSession = run('tmux', ['display-message', '-p', '-t', eventActivePane, '#{session_name}']).trim();
343
+ withMeshDb((db) => {
344
+ const t = Math.floor(Date.now() / 1000) - 30 * 86400;
345
+ db.prepare('UPDATE peers SET last_seen_at = ? WHERE id IN (?, ?, ?)').run(t, stalePeer, peerFilterPeer, attachedPeer);
346
+ db.prepare('UPDATE peer_bindings SET updated_at = ? WHERE peer IN (?, ?, ?)').run(t, stalePeer, peerFilterPeer, attachedPeer);
347
+ db.prepare(`
348
+ INSERT INTO events(type, actor, task_id, payload, created_at)
349
+ VALUES ('tmux.session.rebind_cleanup_failed', ?, NULL, ?, ?)
350
+ `).run(eventPeer, JSON.stringify({
351
+ reason: 'has_clients',
352
+ old_peer: eventPeer,
353
+ old_runtime_target: eventPane,
354
+ new_runtime_target: eventActivePane,
355
+ old_tmux_session: eventSession
356
+ }), t);
357
+ db.prepare(`
358
+ INSERT INTO events(type, actor, task_id, payload, created_at)
359
+ VALUES ('tmux.session.rebind_cleanup_failed', ?, NULL, ?, ?)
360
+ `).run(reusedEventPeer, JSON.stringify({
361
+ reason: 'has_clients',
362
+ old_peer: reusedEventPeer,
363
+ old_runtime_target: `%dead-old-${process.pid}`,
364
+ new_runtime_target: `%new-${process.pid}`,
365
+ old_tmux_session: reusedEventSession
366
+ }), t);
367
+ });
368
+
369
+ const client = spawnSync('tmux', ['new-session', '-d', '-s', `${tmuxSession}-gc-client`, '-e', `HCC_ROOT=${root}`, 'sh', '-lc', `unset TMUX; exec tmux attach-session -t ${sh(attachedSession)}`], {
370
+ env,
371
+ encoding: 'utf8',
372
+ stdio: ['ignore', 'pipe', 'pipe']
373
+ });
374
+ if (client.status !== 0) fail(`could not attach client for tmux gc policy test:\n${client.stderr || client.stdout}`);
375
+ try {
376
+ await waitFor(() => Boolean(run('tmux', ['list-clients', '-t', attachedSession, '-F', '#{client_tty}']).trim()), 'tmux gc attached-client setup');
377
+ const dryRun = hcc(['tmux', 'gc', '--older-than', '14']);
378
+ if (!dryRun.includes(stalePeer) || !dryRun.includes('stale_hcc_managed_session') ||
379
+ !dryRun.includes(peerFilterPeer) ||
380
+ !dryRun.includes(eventPeer) || !dryRun.includes('stale_rebind_cleanup_failed_session')) {
381
+ fail(`tmux gc dry-run did not list stale DB-proven managed session:\n${dryRun}`);
382
+ }
383
+ if (dryRun.includes(reusedEventPeer)) {
384
+ fail(`tmux gc dry-run treated stale event session-name reuse as removable:\n${dryRun}`);
385
+ }
386
+ if (runMaybe('tmux', ['has-session', '-t', staleSession]).status !== 0) {
387
+ fail('tmux gc dry-run deleted stale managed session');
388
+ }
389
+ const filteredDryRun = hcc(['tmux', 'gc', '--older-than', '14', '--peer', peerFilterPeer]);
390
+ if (!filteredDryRun.includes(peerFilterPeer) || filteredDryRun.includes(stalePeer) || filteredDryRun.includes(eventPeer)) {
391
+ fail(`tmux gc --peer dry-run did not filter to requested peer:\n${filteredDryRun}`);
392
+ }
393
+ const filteredRemoved = hcc(['tmux', 'gc', '--older-than', '14', '--peer', peerFilterPeer, '--yes']);
394
+ if (!filteredRemoved.includes(peerFilterPeer) || filteredRemoved.includes(stalePeer) || filteredRemoved.includes(eventPeer)) {
395
+ fail(`tmux gc --peer --yes did not remove only requested peer:\n${filteredRemoved}`);
396
+ }
397
+ if (runMaybe('tmux', ['has-session', '-t', peerFilterSession]).status === 0) {
398
+ fail('tmux gc --peer --yes did not delete requested stale managed session');
399
+ }
400
+ if (runMaybe('tmux', ['has-session', '-t', staleSession]).status !== 0 ||
401
+ runMaybe('tmux', ['has-session', '-t', eventSession]).status !== 0) {
402
+ fail('tmux gc --peer --yes deleted an unrequested stale managed session');
403
+ }
404
+
405
+ const removed = hcc(['tmux', 'gc', '--older-than', '14', '--yes']);
406
+ if (!removed.includes(stalePeer) || !removed.includes(eventPeer) || removed.includes(peerFilterPeer) || removed.includes(attachedPeer) || removed.includes(manualPane)) {
407
+ fail(`tmux gc --yes output wrong:\n${removed}`);
408
+ }
409
+ if (runMaybe('tmux', ['has-session', '-t', staleSession]).status === 0) {
410
+ fail('tmux gc --yes did not delete stale DB-proven managed session');
411
+ }
412
+ if (runMaybe('tmux', ['has-session', '-t', eventSession]).status === 0) {
413
+ fail('tmux gc --yes did not delete stale rebind cleanup-failed session');
414
+ }
415
+ if (runMaybe('tmux', ['has-session', '-t', eventActiveSession]).status !== 0) {
416
+ fail('tmux gc deleted the active replacement session for the same peer');
417
+ }
418
+ if (runMaybe('tmux', ['has-session', '-t', reusedEventSession]).status !== 0) {
419
+ fail('tmux gc deleted a same-name session whose old runtime target no longer matched');
420
+ }
421
+ if (run('tmux', ['display-message', '-p', '-t', `${reusedEventSession}:0.0`, '#{pane_id}']).trim() !== reusedEventPane) {
422
+ fail('tmux gc touched the wrong same-name rebind cleanup session');
423
+ }
424
+ if (runMaybe('tmux', ['has-session', '-t', attachedSession]).status !== 0) {
425
+ fail('tmux gc deleted managed session with attached tmux client');
426
+ }
427
+ if (runMaybe('tmux', ['has-session', '-t', manualSession]).status !== 0) {
428
+ fail('tmux gc deleted manual lookalike tmux session without DB binding');
429
+ }
430
+ const staleBinding = peerBindingRow(stalePeer);
431
+ if (!staleBinding || staleBinding.transport !== 'detached' || staleBinding.runtime_target !== null) {
432
+ fail(`tmux gc did not detach deleted binding:\n${JSON.stringify(staleBinding, null, 2)}`);
433
+ }
434
+ const peerFilterBinding = peerBindingRow(peerFilterPeer);
435
+ if (!peerFilterBinding || peerFilterBinding.transport !== 'detached' || peerFilterBinding.runtime_target !== null) {
436
+ fail(`tmux gc --peer did not detach requested deleted binding:\n${JSON.stringify(peerFilterBinding, null, 2)}`);
437
+ }
438
+ const attachedBinding = peerBindingRow(attachedPeer);
439
+ if (!attachedBinding || attachedBinding.runtime_target !== attachedPane) {
440
+ fail(`tmux gc changed attached-client binding:\n${JSON.stringify(attachedBinding, null, 2)}`);
441
+ }
442
+ const eventBinding = peerBindingRow(eventPeer);
443
+ if (!eventBinding || eventBinding.runtime_target !== eventActivePane) {
444
+ fail(`tmux gc changed active replacement binding for event-backed stale session:\n${JSON.stringify(eventBinding, null, 2)}`);
445
+ }
446
+ } finally {
447
+ hccMaybe(['peer', 'stop', eventPeer, '--kill-tmux']);
448
+ runMaybe('tmux', ['kill-session', '-t', `${tmuxSession}-gc-client`]);
449
+ runMaybe('tmux', ['kill-session', '-t', peerFilterSession]);
450
+ runMaybe('tmux', ['kill-session', '-t', attachedSession]);
451
+ runMaybe('tmux', ['kill-session', '-t', eventSession]);
452
+ runMaybe('tmux', ['kill-session', '-t', reusedEventSession]);
453
+ runMaybe('tmux', ['kill-session', '-t', eventActiveSession]);
454
+ runMaybe('tmux', ['kill-session', '-t', manualSession]);
455
+ runMaybe('tmux', ['kill-session', '-t', staleSession]);
456
+ cleanupBindingPeers('tmux-gc-');
457
+ }
458
+ }
459
+
272
460
  function createLegacyBindingDb(dbPath) {
273
461
  fs.mkdirSync(path.dirname(dbPath), { recursive: true });
274
462
  const db = new DatabaseSync(dbPath);
@@ -458,17 +646,21 @@ function assertLegacySchemaMigration() {
458
646
  for (const column of ['parent_id', 'team_role']) {
459
647
  if (!taskColumns.has(column)) fail(`legacy migration did not add tasks.${column}`);
460
648
  }
649
+ const lockColumns = new Set(db.prepare('PRAGMA table_info(locks)').all().map((row) => row.name));
650
+ for (const column of ['base_resource', 'scope']) {
651
+ if (!lockColumns.has(column)) fail(`legacy migration did not add locks.${column}`);
652
+ }
461
653
  const message = db.prepare('SELECT id, thread_id FROM messages WHERE body = ?').get('legacy-message');
462
654
  if (!message || message.thread_id !== message.id) {
463
655
  fail(`legacy message thread_id not backfilled:\n${JSON.stringify(message, null, 2)}`);
464
656
  }
465
657
  const metaVersion = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get()?.value;
466
658
  const pragmaVersion = db.prepare('PRAGMA user_version').get().user_version;
467
- if (metaVersion !== '4' || pragmaVersion !== 4) {
659
+ if (metaVersion !== '5' || pragmaVersion !== 5) {
468
660
  fail(`schema version not synchronized: meta=${metaVersion} pragma=${pragmaVersion}`);
469
661
  }
470
662
  const migrations = db.prepare('SELECT version, name FROM schema_migrations ORDER BY version').all();
471
- if (migrations.length !== 4 || migrations[3].version !== 4 || migrations[3].name !== 'team task hierarchy') {
663
+ if (migrations.length !== 5 || migrations[4].version !== 5 || migrations[4].name !== 'scoped advisory locks') {
472
664
  fail(`schema migrations history wrong:\n${JSON.stringify(migrations, null, 2)}`);
473
665
  }
474
666
  } finally {
@@ -500,7 +692,7 @@ function assertRegisteredProjectDbMigration() {
500
692
  fail(`registered project DB was not migrated:\n${JSON.stringify([...taskColumns], null, 2)}`);
501
693
  }
502
694
  const version = db.prepare('PRAGMA user_version').get().user_version;
503
- if (version !== 4) fail(`registered project DB user_version wrong: ${version}`);
695
+ if (version !== 5) fail(`registered project DB user_version wrong: ${version}`);
504
696
  } finally {
505
697
  db.close();
506
698
  }
@@ -521,7 +713,7 @@ function assertFutureSchemaMigrationHistoryRejected() {
521
713
  key TEXT PRIMARY KEY,
522
714
  value TEXT NOT NULL
523
715
  );
524
- INSERT INTO meta(key, value) VALUES ('schema_version', '4');
716
+ INSERT INTO meta(key, value) VALUES ('schema_version', '5');
525
717
  CREATE TABLE schema_migrations (
526
718
  version INTEGER PRIMARY KEY,
527
719
  name TEXT NOT NULL,
@@ -534,7 +726,7 @@ function assertFutureSchemaMigrationHistoryRejected() {
534
726
  db.close();
535
727
  }
536
728
  const result = runMaybe(process.execPath, [hccBin, '--root', futureRoot, 'status', '--peer', 'future-schema-check'], { env });
537
- if (result.status === 0 || !`${result.stdout}\n${result.stderr}`.includes('Database schema version 999 is newer than this hcc (4)')) {
729
+ if (result.status === 0 || !`${result.stdout}\n${result.stderr}`.includes('Database schema version 999 is newer than this hcc (5)')) {
538
730
  fail(`future schema migration history was not rejected:\nstdout=${result.stdout}\nstderr=${result.stderr}`);
539
731
  }
540
732
  } finally {
@@ -587,6 +779,26 @@ function trackTmuxPane(pane) {
587
779
  if (result.status === 0) managedTmuxSessions.add(result.stdout.trim());
588
780
  }
589
781
 
782
+ function tmuxEnvironmentValue(session, key) {
783
+ const result = runMaybe('tmux', ['show-environment', '-t', session, key]);
784
+ if (result.status !== 0) return null;
785
+ const line = (result.stdout || '').trim();
786
+ const prefix = `${key}=`;
787
+ return line.startsWith(prefix) ? line.slice(prefix.length) : null;
788
+ }
789
+
790
+ function assertHccManagedTmuxEnv(pane, expectedRoot = root) {
791
+ const session = run('tmux', ['display-message', '-p', '-t', pane, '#{session_name}']).trim();
792
+ const hccRoot = tmuxEnvironmentValue(session, 'HCC_ROOT');
793
+ const hccDb = tmuxEnvironmentValue(session, 'HCC_DB');
794
+ const expectedDb = path.join(expectedRoot, '.hello-cc', 'mesh.db');
795
+ if (path.resolve(hccRoot || '') !== path.resolve(expectedRoot) ||
796
+ path.resolve(hccDb || '') !== path.resolve(expectedDb)) {
797
+ fail(`hcc-managed tmux session missing root/db markers: ${session} HCC_ROOT=${hccRoot} HCC_DB=${hccDb}`);
798
+ }
799
+ return session;
800
+ }
801
+
590
802
  function parsePane(output) {
591
803
  const match = String(output).match(/\bpane=(%\d+)/);
592
804
  if (!match) fail(`cannot parse tmux pane from:\n${output}`);
@@ -676,6 +888,11 @@ function assertGuidanceLockPolicy(file) {
676
888
  'Do not silently treat a snapshot review as final approval',
677
889
  'Before mutating work:',
678
890
  'Before editing or mutating shared resources:',
891
+ 'module scope',
892
+ 'Locks are coordination signals',
893
+ '--scope db-schema',
894
+ '--scope web-ui',
895
+ 'narrower scoped locks',
679
896
  'Commit-readiness checks are read-only until staging begins',
680
897
  'lock `.git/index` only while staging and'
681
898
  ]) {
@@ -850,6 +1067,13 @@ function tmuxStreamNodes() {
850
1067
  .map((name) => path.join(dir, name));
851
1068
  }
852
1069
 
1070
+ function libModuleFiles() {
1071
+ return fs.readdirSync(path.join(repoRoot, 'lib'))
1072
+ .filter((name) => name.endsWith('.mjs'))
1073
+ .sort()
1074
+ .map((name) => path.join('lib', name));
1075
+ }
1076
+
853
1077
  async function expectBoundedTmuxStream(label) {
854
1078
  let nodes = [];
855
1079
  await waitFor(() => {
@@ -871,6 +1095,10 @@ async function expectBoundedTmuxStream(label) {
871
1095
 
872
1096
  function writeFakeTools() {
873
1097
  fs.mkdirSync(fakeBin, { recursive: true });
1098
+ if (realTmuxBin) {
1099
+ const tmuxWrapper = path.join(fakeBin, 'tmux');
1100
+ fs.writeFileSync(tmuxWrapper, `#!/usr/bin/env bash\nexec ${sh(realTmuxBin)} -L ${sh(tmuxSocketName)} "$@"\n`, { mode: 0o755 });
1101
+ }
874
1102
  for (const name of ['claude', 'codex']) {
875
1103
  const file = path.join(fakeBin, name);
876
1104
  fs.writeFileSync(file, `#!/usr/bin/env bash\necho fake-${name} "$@"\nif [ -n "\${HCC_FAKE_LOG:-}" ]; then echo fake-${name} "$@" >> "$HCC_FAKE_LOG"; fi\nif [ "\${HCC_FAKE_STAY_ALIVE:-}" = "1" ]; then exec bash --noprofile --norc; fi\n`, { mode: 0o755 });
@@ -917,6 +1145,9 @@ function cleanup() {
917
1145
  for (const session of managedTmuxSessions) {
918
1146
  runMaybe('tmux', ['kill-session', '-t', session]);
919
1147
  }
1148
+ if (fs.existsSync(path.join(fakeBin, 'tmux'))) {
1149
+ runMaybe('tmux', ['kill-server']);
1150
+ }
920
1151
  for (const dir of [root, home, fakeBin, outDir]) {
921
1152
  try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
922
1153
  }
@@ -1158,6 +1389,13 @@ async function dbWorkflow() {
1158
1389
  if (stateAfterAck.automation.next_action.kind !== 'lock.acquire') {
1159
1390
  fail(`state automation did not suggest lock acquire after ack:\n${JSON.stringify(stateAfterAck.automation, null, 2)}`);
1160
1391
  }
1392
+ const readOnlyState = hccJson(['state', '--peer', 'codex-a', '--resource', 'src/read-only', '--intent', 'review']);
1393
+ if (readOnlyState.automation.next_action.kind === 'lock.acquire' || readOnlyState.automation.phase === 'acquire_lock') {
1394
+ fail(`read-only state suggested acquiring a lock:\n${JSON.stringify(readOnlyState.automation, null, 2)}`);
1395
+ }
1396
+ if (!(readOnlyState.automation.warnings || []).some((warning) => warning.includes('read-only'))) {
1397
+ fail(`read-only state did not explain no-lock behavior:\n${JSON.stringify(readOnlyState.automation, null, 2)}`);
1398
+ }
1161
1399
  const queuedTask = hcc(['task', 'create', '--from', 'human', '--to', 'codex-a', '--title', 'queued while busy']);
1162
1400
  const queuedTaskMatch = queuedTask.match(/created task #(\d+):/);
1163
1401
  if (!queuedTaskMatch) fail(`cannot parse queued task id: ${queuedTask}`);
@@ -1181,6 +1419,93 @@ async function dbWorkflow() {
1181
1419
  if (!nextAgain.includes(`current task #${taskId}`)) {
1182
1420
  fail(`task next did not preserve current task:\n${nextAgain}`);
1183
1421
  }
1422
+ const nextAgainJson = hccJson(['task', 'next', '--peer', 'codex-a']);
1423
+ if (String(nextAgainJson.id) !== String(taskId) || nextAgainJson.current !== true || nextAgainJson.tasks) {
1424
+ fail(`task next --json current task shape changed:\n${JSON.stringify(nextAgainJson, null, 2)}`);
1425
+ }
1426
+ const batchClaimIds = [];
1427
+ for (const title of ['batch claim one', 'batch claim two']) {
1428
+ const out = hcc(['task', 'create', '--from', 'human', '--title', title]);
1429
+ const match = out.match(/created task #(\d+):/);
1430
+ if (!match) fail(`cannot parse batch task id: ${out}`);
1431
+ batchClaimIds.push(match[1]);
1432
+ }
1433
+ const batchClaim = hccJson(['task', 'claim', '--peer', 'batch-a', '--ids', batchClaimIds.join(',')]);
1434
+ if (!Array.isArray(batchClaim) || batchClaim.length !== 2 || !batchClaim.every((task) => task.owner === 'batch-a')) {
1435
+ fail(`batch claim did not claim both tasks:\n${JSON.stringify(batchClaim, null, 2)}`);
1436
+ }
1437
+ for (const task of batchClaim) {
1438
+ hcc(['task', 'done', '--peer', 'batch-a', '--id', String(task.id), '--summary', 'batch claim cleanup']);
1439
+ }
1440
+ const batchNextIds = [];
1441
+ for (const title of ['batch next one', 'batch next two', 'batch next three']) {
1442
+ const out = hcc(['task', 'create', '--from', 'human', '--title', title]);
1443
+ const match = out.match(/created task #(\d+):/);
1444
+ if (!match) fail(`cannot parse batch next task id: ${out}`);
1445
+ batchNextIds.push(match[1]);
1446
+ }
1447
+ const batchNext = hccJson(['task', 'next', '--peer', 'batch-b', '--force', '--count', '2']);
1448
+ if (!batchNext?.tasks || batchNext.tasks.length !== 2 || !batchNext.tasks.every((task) => task.owner === 'batch-b')) {
1449
+ fail(`task next --count did not claim two tasks:\n${JSON.stringify(batchNext, null, 2)}`);
1450
+ }
1451
+ for (const task of batchNext.tasks) {
1452
+ hcc(['task', 'done', '--peer', 'batch-b', '--id', String(task.id), '--summary', 'batch next cleanup']);
1453
+ }
1454
+ hcc(['task', 'update', '--peer', 'human', '--id', batchNextIds[2], '--status', 'abandoned', '--summary', 'batch next leftover cleanup']);
1455
+ const takeoverOpen = hcc(['task', 'create', '--from', 'human', '--to', 'takeover-owner', '--title', 'takeover open']);
1456
+ const takeoverOpenMatch = takeoverOpen.match(/created task #(\d+):/);
1457
+ if (!takeoverOpenMatch) fail(`cannot parse takeover open id: ${takeoverOpen}`);
1458
+ const takeoverOpenId = takeoverOpenMatch[1];
1459
+ hcc(['task', 'claim', '--peer', 'takeover-owner', '--id', takeoverOpenId]);
1460
+ const blockedPolicyReject = hccMaybe(['task', 'takeover', '--peer', 'takeover-a', '--id', takeoverOpenId, '--reason', 'blocked policy smoke', '--policy', 'blocked']);
1461
+ if (blockedPolicyReject.status === 0 || !String(blockedPolicyReject.stderr || blockedPolicyReject.stdout).includes('does not match takeover policy blocked')) {
1462
+ fail(`takeover --policy blocked accepted non-blocked task:\n${blockedPolicyReject.stdout}\n${blockedPolicyReject.stderr}`);
1463
+ }
1464
+ hcc(['task', 'update', '--peer', 'takeover-owner', '--id', takeoverOpenId, '--status', 'blocked', '--summary', 'blocked for takeover smoke']);
1465
+ const blockedTakeover = hccJson(['task', 'takeover', '--peer', 'takeover-a', '--id', takeoverOpenId, '--reason', 'blocked takeover smoke', '--policy', 'blocked']);
1466
+ if (String(blockedTakeover.owner) !== 'takeover-a') {
1467
+ fail(`blocked takeover did not transfer owner:\n${JSON.stringify(blockedTakeover, null, 2)}`);
1468
+ }
1469
+ hcc(['task', 'done', '--peer', 'takeover-a', '--id', takeoverOpenId, '--summary', 'blocked takeover cleanup']);
1470
+ const staleOut = hcc(['task', 'create', '--from', 'human', '--to', 'stale-owner', '--title', 'stale takeover']);
1471
+ const staleMatch = staleOut.match(/created task #(\d+):/);
1472
+ if (!staleMatch) fail(`cannot parse stale takeover id: ${staleOut}`);
1473
+ const staleTaskId = staleMatch[1];
1474
+ hcc(['task', 'claim', '--peer', 'stale-owner', '--id', staleTaskId]);
1475
+ withMeshDb((db) => {
1476
+ const staleAt = Math.floor(Date.now() / 1000) - 7200;
1477
+ db.prepare('UPDATE peers SET last_seen_at = ? WHERE id = ?').run(staleAt, 'stale-owner');
1478
+ });
1479
+ const staleTakeover = hccJson(['task', 'takeover', '--peer', 'takeover-b', '--id', staleTaskId, '--reason', 'stale takeover smoke', '--policy', 'stale', '--stale-after', '60']);
1480
+ if (String(staleTakeover.owner) !== 'takeover-b') {
1481
+ fail(`stale takeover did not transfer owner:\n${JSON.stringify(staleTakeover, null, 2)}`);
1482
+ }
1483
+ const takeoverInbox = hcc(['msg', 'inbox', '--peer', 'stale-owner', '--all']);
1484
+ if (!takeoverInbox.includes(`Task #${staleTaskId} taken over by takeover-b`)) {
1485
+ fail(`stale takeover did not notify previous owner:\n${takeoverInbox}`);
1486
+ }
1487
+ hcc(['task', 'done', '--peer', 'takeover-b', '--id', staleTaskId, '--summary', 'stale takeover cleanup']);
1488
+ const staleLivenessOut = hcc(['task', 'create', '--from', 'human', '--to', 'stale-liveness-owner', '--title', 'stale liveness task']);
1489
+ const staleLivenessMatch = staleLivenessOut.match(/created task #(\d+):/);
1490
+ if (!staleLivenessMatch) fail(`cannot parse stale liveness task id: ${staleLivenessOut}`);
1491
+ const staleLivenessTaskId = staleLivenessMatch[1];
1492
+ hcc(['task', 'claim', '--peer', 'stale-liveness-owner', '--id', staleLivenessTaskId]);
1493
+ withMeshDb((db) => {
1494
+ const staleAt = Math.floor(Date.now() / 1000) - 7200;
1495
+ db.prepare('UPDATE peers SET last_seen_at = ? WHERE id = ?').run(staleAt, 'stale-liveness-owner');
1496
+ });
1497
+ const staleLivenessList = hcc(['task', 'list', '--status', 'claimed']);
1498
+ if (!staleLivenessList.includes(`#${staleLivenessTaskId}`) || !staleLivenessList.includes('stale/no-lock')) {
1499
+ fail(`task list did not surface stale/no-lock owner state:\n${staleLivenessList}`);
1500
+ }
1501
+ const staleLivenessState = hccJson(['state', '--peer', 'takeover-ready-peer']);
1502
+ if (staleLivenessState.automation?.phase !== 'takeover_task' ||
1503
+ staleLivenessState.automation?.next_action?.kind !== 'task.takeover' ||
1504
+ !staleLivenessState.automation.next_action.argv.includes('--policy') ||
1505
+ !staleLivenessState.automation.next_action.argv.includes('stale')) {
1506
+ fail(`state automation did not suggest stale takeover:\n${JSON.stringify(staleLivenessState.automation, null, 2)}`);
1507
+ }
1508
+ hcc(['task', 'update', '--peer', 'human', '--force', '--id', staleLivenessTaskId, '--status', 'abandoned', '--summary', 'stale liveness cleanup']);
1184
1509
  hcc(['task', 'update', '--peer', 'human', '--id', queuedTaskId, '--status', 'abandoned', '--summary', 'queued task cleanup']);
1185
1510
  hcc(['handoff', 'create', '--from', 'codex-a', '--to', 'claude-a', '--task', taskId, '--summary', 'handoff summary', '--tests', 'full script', '--risks', 'none']);
1186
1511
  if (!hcc(['handoff', 'list', '--task', taskId]).includes('handoff summary')) fail('handoff missing');
@@ -1254,6 +1579,27 @@ async function dbWorkflow() {
1254
1579
  }
1255
1580
  hcc(['task', 'done', '--peer', 'codex-team-captain', '--id', teamParentId, '--summary', 'team regression done']);
1256
1581
 
1582
+ const scopedA = hcc(['lock', 'acquire', '--peer', 'codex-a', '--resource', 'bin/hcc.mjs', '--scope', 'db-schema', '--ttl', '60', '--reason', 'db split']);
1583
+ const scopedB = hcc(['lock', 'acquire', '--peer', 'codex-b', '--resource', 'bin/hcc.mjs', '--scope', 'web-ui', '--ttl', '60', '--reason', 'ui split']);
1584
+ if (!scopedA.includes('bin/hcc.mjs [db-schema]') || !scopedB.includes('bin/hcc.mjs [web-ui]')) {
1585
+ fail(`scoped lock output missing labels:\n${scopedA}\n${scopedB}`);
1586
+ }
1587
+ const scopedRows = hccJson(['lock', 'list']);
1588
+ if (!scopedRows.some((row) => row.base_resource === 'bin/hcc.mjs' && row.scope === 'db-schema') ||
1589
+ !scopedRows.some((row) => row.base_resource === 'bin/hcc.mjs' && row.scope === 'web-ui')) {
1590
+ fail(`scoped locks missing from list:\n${JSON.stringify(scopedRows, null, 2)}`);
1591
+ }
1592
+ const duplicateScope = hccMaybe(['lock', 'acquire', '--peer', 'claude-a', '--resource', 'bin/hcc.mjs', '--scope', 'db-schema', '--ttl', '60', '--reason', 'same scope']);
1593
+ if (duplicateScope.status === 0 || !`${duplicateScope.stdout}\n${duplicateScope.stderr}`.includes('conflicts with lock bin/hcc.mjs [db-schema]')) {
1594
+ fail(`same-scope lock was not rejected:\n${duplicateScope.stdout}\n${duplicateScope.stderr}`);
1595
+ }
1596
+ const wholeResource = hccMaybe(['lock', 'acquire', '--peer', 'claude-a', '--resource', 'bin/hcc.mjs', '--ttl', '60', '--reason', 'whole file']);
1597
+ if (wholeResource.status === 0 || !`${wholeResource.stdout}\n${wholeResource.stderr}`.includes('conflicts with lock bin/hcc.mjs [db-schema]')) {
1598
+ fail(`whole-resource lock did not conflict with scoped locks:\n${wholeResource.stdout}\n${wholeResource.stderr}`);
1599
+ }
1600
+ hcc(['lock', 'release', '--peer', 'codex-a', '--resource', 'bin/hcc.mjs', '--scope', 'db-schema']);
1601
+ hcc(['lock', 'release', '--peer', 'codex-b', '--resource', 'bin/hcc.mjs', '--scope', 'web-ui']);
1602
+
1257
1603
  const conflictTask = hcc(['task', 'create', '--from', 'human', '--to', 'codex-b', '--title', 'conflict automation task']);
1258
1604
  const conflictTaskMatch = conflictTask.match(/created task #(\d+):/);
1259
1605
  if (!conflictTaskMatch) fail(`cannot parse conflict task id: ${conflictTask}`);
@@ -1270,6 +1616,23 @@ async function dbWorkflow() {
1270
1616
  hcc(['lock', 'release', '--peer', 'codex-a', '--resource', 'src/conflict']);
1271
1617
  hcc(['task', 'done', '--peer', 'codex-b', '--id', conflictTaskId, '--summary', 'conflict done']);
1272
1618
 
1619
+ const takeoverTask = hcc(['task', 'create', '--from', 'human', '--to', 'codex-owner', '--title', 'takeover regression task']);
1620
+ const takeoverTaskMatch = takeoverTask.match(/created task #(\d+):/);
1621
+ if (!takeoverTaskMatch) fail(`cannot parse takeover task id: ${takeoverTask}`);
1622
+ const takeoverTaskId = takeoverTaskMatch[1];
1623
+ hcc(['task', 'claim', '--peer', 'codex-owner', '--id', takeoverTaskId]);
1624
+ const takeoverOutput = hcc(['task', 'takeover', '--peer', 'codex-taker', '--id', takeoverTaskId, '--reason', 'owner inactive']);
1625
+ if (!takeoverOutput.includes(`took over task #${takeoverTaskId}`)) fail(`takeover output wrong:\n${takeoverOutput}`);
1626
+ const takeoverRow = hccJson(['task', 'list', '--all']).find((row) => String(row.id) === String(takeoverTaskId));
1627
+ if (!takeoverRow || takeoverRow.owner !== 'codex-taker' || takeoverRow.status !== 'claimed') {
1628
+ fail(`takeover task row wrong:\n${JSON.stringify(takeoverRow, null, 2)}`);
1629
+ }
1630
+ const takeoverOwnerInbox = hcc(['msg', 'inbox', '--peer', 'codex-owner']);
1631
+ if (!takeoverOwnerInbox.includes(`Task #${takeoverTaskId} taken over by codex-taker`)) {
1632
+ fail(`takeover did not notify previous owner:\n${takeoverOwnerInbox}`);
1633
+ }
1634
+ hcc(['task', 'done', '--peer', 'codex-taker', '--id', takeoverTaskId, '--summary', 'takeover done']);
1635
+
1273
1636
  const abandoned = hcc(['task', 'create', '--from', 'human', '--title', 'abandoned regression task']);
1274
1637
  const abandonedMatch = abandoned.match(/created task #(\d+):/);
1275
1638
  if (!abandonedMatch) fail(`cannot parse abandoned task id: ${abandoned}`);
@@ -1538,6 +1901,11 @@ async function multiProjectWebWorkflow() {
1538
1901
  if (!(otherSessions.sessions || []).some((s) => s.id === 'other-shell')) {
1539
1902
  fail(`second project API did not see its session:\n${JSON.stringify(otherSessions)}`);
1540
1903
  }
1904
+ const otherShellSession = (otherSessions.sessions || []).find((s) => s.id === 'other-shell');
1905
+ if (!otherShellSession?.binding || otherShellSession.binding.runtime_target !== otherShellSession.pane ||
1906
+ otherShellSession.provider_session_known !== false || otherShellSession.provider_session_label !== null) {
1907
+ fail(`sessions API did not expose unknown provider binding without marking it shared/known:\n${JSON.stringify(otherShellSession, null, 2)}`);
1908
+ }
1541
1909
 
1542
1910
  hcc(['register', '--peer', 'web-action-peer', '--kind', 'codex', '--role', 'peer']);
1543
1911
  hcc(['msg', 'send', '--from', 'human', '--to', 'web-action-peer', '--body', 'web action inbox ok']);
@@ -1604,6 +1972,9 @@ async function multiProjectWebWorkflow() {
1604
1972
  if (claudeResume.id !== expectedClaudePeer || claudeResume.peer_id !== expectedClaudePeer) {
1605
1973
  fail(`web claude resume did not use canonical provider peer id ${expectedClaudePeer}:\n${JSON.stringify(claudeResume, null, 2)}`);
1606
1974
  }
1975
+ if (!claudeResume.binding || !claudeResume.provider_session_known || claudeResume.provider_session_label !== claudeResumeName) {
1976
+ fail(`web claude resume session response did not include known provider binding:\n${JSON.stringify(claudeResume, null, 2)}`);
1977
+ }
1607
1978
  const claudeRows = providerBindingRows('claude', claudeResumeName);
1608
1979
  if (claudeRows.length !== 1 ||
1609
1980
  claudeRows[0].peer !== claudeResume.peer_id ||
@@ -1624,6 +1995,9 @@ async function multiProjectWebWorkflow() {
1624
1995
  if (codexResume.id !== expectedCodexPeer || codexResume.peer_id !== expectedCodexPeer) {
1625
1996
  fail(`web codex resume did not use canonical provider peer id ${expectedCodexPeer}:\n${JSON.stringify(codexResume, null, 2)}`);
1626
1997
  }
1998
+ if (!codexResume.binding || !codexResume.provider_session_known || codexResume.provider_session_label !== codexResumeName) {
1999
+ fail(`web codex resume session response did not include known provider binding:\n${JSON.stringify(codexResume, null, 2)}`);
2000
+ }
1627
2001
  const codexRows = providerBindingRows('codex', codexResumeName);
1628
2002
  if (codexRows.length !== 1 ||
1629
2003
  codexRows[0].peer !== codexResume.peer_id ||
@@ -1703,6 +2077,7 @@ async function tmuxBackedStartWorkflow() {
1703
2077
  const file = path.join(outDir, 'pty-ok');
1704
2078
  const started = hcc(['peer', 'start', 'shell-a', '--kind', 'shell', '--', 'bash']);
1705
2079
  const pane = parsePane(started);
2080
+ assertHccManagedTmuxEnv(pane);
1706
2081
  const list = hcc(['peer', 'list']);
1707
2082
  if (!list.includes('shell-a') || !list.includes('tmux')) fail(`tmux-backed peer missing from list:\n${list}`);
1708
2083
  hcc(['inject', 'shell-a', `echo PTY_OK > ${file}`]);
@@ -1744,6 +2119,7 @@ async function tmuxBackedStartWorkflow() {
1744
2119
  env: { ...env, HCC_FAKE_STAY_ALIVE: '1' }
1745
2120
  });
1746
2121
  const canonicalPane = parsePane(canonicalStarted);
2122
+ assertHccManagedTmuxEnv(canonicalPane);
1747
2123
  const canonicalRows = providerBindingRows('claude', canonicalSession);
1748
2124
  if (canonicalRows.length !== 1 ||
1749
2125
  canonicalRows[0].peer !== 'claude-live-canonical' ||
@@ -1757,17 +2133,22 @@ async function tmuxBackedStartWorkflow() {
1757
2133
  const forceFirst = hcc(['peer', 'start', 'claude-force-a', '--kind', 'claude', '--resume', forceSession], {
1758
2134
  env: { ...env, HCC_FAKE_STAY_ALIVE: '1' }
1759
2135
  });
1760
- parsePane(forceFirst);
2136
+ const forceFirstPane = parsePane(forceFirst);
2137
+ const forceFirstSession = run('tmux', ['display-message', '-p', '-t', forceFirstPane, '#{session_name}']).trim();
1761
2138
  const forceConflict = hccMaybe(['peer', 'start', 'claude-force-b', '--kind', 'claude', '--resume', forceSession], {
1762
2139
  env: { ...env, HCC_FAKE_STAY_ALIVE: '1' }
1763
2140
  });
1764
2141
  if (forceConflict.status === 0 || !String(forceConflict.stderr || forceConflict.stdout).includes('already bound to claude-force-a')) {
1765
2142
  fail(`provider session duplicate start was not rejected:\n${forceConflict.stdout}\n${forceConflict.stderr}`);
1766
2143
  }
2144
+ if (runMaybe('tmux', ['has-session', '-t', tmuxManagedSession(root, 'claude-force-b')]).status === 0) {
2145
+ fail('failed duplicate provider start left a new claude-force-b tmux session behind');
2146
+ }
1767
2147
  const forceSecond = hcc(['peer', 'start', 'claude-force-b', '--kind', 'claude', '--resume', forceSession, '--force'], {
1768
2148
  env: { ...env, HCC_FAKE_STAY_ALIVE: '1' }
1769
2149
  });
1770
2150
  const forcePane = parsePane(forceSecond);
2151
+ assertHccManagedTmuxEnv(forcePane);
1771
2152
  const forceRows = providerBindingRows('claude', forceSession);
1772
2153
  if (forceRows.length !== 1 ||
1773
2154
  forceRows[0].peer !== 'claude-force-b' ||
@@ -1775,6 +2156,9 @@ async function tmuxBackedStartWorkflow() {
1775
2156
  forceRows[0].runtime_target !== forcePane) {
1776
2157
  fail(`--force did not move provider binding to replacement tmux peer:\n${JSON.stringify(forceRows, null, 2)}`);
1777
2158
  }
2159
+ if (runMaybe('tmux', ['has-session', '-t', forceFirstSession]).status === 0) {
2160
+ fail('--force provider rebind did not remove old hcc-managed tmux session');
2161
+ }
1778
2162
  hccMaybe(['peer', 'stop', 'claude-force-a']);
1779
2163
  hcc(['peer', 'stop', 'claude-force-b']);
1780
2164
 
@@ -1782,12 +2166,14 @@ async function tmuxBackedStartWorkflow() {
1782
2166
  startRuntime({ env: envWithoutProvider({ HCC_REG_VALUE: 'runtime-old', ANTHROPIC_BASE_URL: 'runtime-old-url' }) });
1783
2167
  await waitRuntime();
1784
2168
  const envFile = path.join(outDir, 'caller-env-ok');
1785
- parsePane(hcc(['peer', 'start', 'env-a', '--kind', 'shell', '--', 'bash', '--noprofile', '--norc'], {
2169
+ const envPane = parsePane(hcc(['peer', 'start', 'env-a', '--kind', 'shell', '--', 'bash', '--noprofile', '--norc'], {
1786
2170
  env: envWithoutProvider({ HCC_REG_VALUE: 'caller-new' })
1787
2171
  }));
2172
+ assertHccManagedTmuxEnv(envPane);
1788
2173
  hcc(['inject', 'env-a', `printf '%s|%s\\n' "$HCC_REG_VALUE" "\${ANTHROPIC_BASE_URL:-}" > ${envFile}`]);
1789
2174
  await waitForFile(envFile, 'caller-new|', 'caller env propagation');
1790
2175
  hcc(['peer', 'stop', 'env-a']);
2176
+ await assertTmuxGcPolicy();
1791
2177
  }
1792
2178
 
1793
2179
  async function shimTmuxWorkflow() {
@@ -1961,9 +2347,7 @@ async function downGcPackWorkflow() {
1961
2347
  'CHANGELOG.md',
1962
2348
  'docs/commands.md',
1963
2349
  'docs/commands.zh-CN.md',
1964
- 'lib/guidance.mjs',
1965
- 'lib/package-meta.mjs',
1966
- 'lib/release-notes.mjs',
2350
+ ...libModuleFiles(),
1967
2351
  'scripts/github-release.mjs'
1968
2352
  ]) {
1969
2353
  if (!files.has(file)) fail(`npm package missing ${file}`);
@@ -2030,15 +2414,24 @@ function oldNameScan() {
2030
2414
  if (rg.status !== 1) fail(`rg failed:\n${rg.stderr || rg.stdout}`);
2031
2415
  }
2032
2416
 
2033
- function syntaxAndHelp() {
2417
+ async function syntaxAndHelp() {
2034
2418
  log('[11/12] syntax/help');
2035
2419
  run(process.execPath, ['--check', path.join(repoRoot, 'bin', 'hcc.mjs')]);
2036
- run(process.execPath, ['--check', path.join(repoRoot, 'lib', 'setup.mjs')]);
2037
- run(process.execPath, ['--check', path.join(repoRoot, 'lib', 'discover.mjs')]);
2038
- run(process.execPath, ['--check', path.join(repoRoot, 'lib', 'guidance.mjs')]);
2039
- run(process.execPath, ['--check', path.join(repoRoot, 'lib', 'package-meta.mjs')]);
2040
- run(process.execPath, ['--check', path.join(repoRoot, 'lib', 'release-notes.mjs')]);
2420
+ for (const file of libModuleFiles()) {
2421
+ run(process.execPath, ['--check', path.join(repoRoot, file)]);
2422
+ }
2041
2423
  const hccSource = fs.readFileSync(hccBin, 'utf8');
2424
+ const coordinationStateSource = fs.readFileSync(path.join(repoRoot, 'lib', 'coordination-state.mjs'), 'utf8');
2425
+ const errorsCompatSource = fs.readFileSync(path.join(repoRoot, 'lib', 'errors.mjs'), 'utf8');
2426
+ const sharedErrorsSource = fs.readFileSync(path.join(repoRoot, 'lib', 'shared', 'errors.mjs'), 'utf8');
2427
+ const setupSource = fs.readFileSync(path.join(repoRoot, 'lib', 'setup.mjs'), 'utf8');
2428
+ const shimScriptCompatSource = fs.readFileSync(path.join(repoRoot, 'lib', 'shim-script.mjs'), 'utf8');
2429
+ const integrationHooksSource = fs.readFileSync(path.join(repoRoot, 'lib', 'integrations', 'hooks.mjs'), 'utf8');
2430
+ const integrationShimsSource = fs.readFileSync(path.join(repoRoot, 'lib', 'integrations', 'shims.mjs'), 'utf8');
2431
+ const integrationShimScriptSource = fs.readFileSync(path.join(repoRoot, 'lib', 'integrations', 'shims', 'script.mjs'), 'utf8');
2432
+ const shellPathSource = fs.readFileSync(path.join(repoRoot, 'lib', 'shell-path.mjs'), 'utf8');
2433
+ const webPeerActionsSource = fs.readFileSync(path.join(repoRoot, 'lib', 'web', 'peer-actions.mjs'), 'utf8');
2434
+ const webUiTemplateSource = fs.readFileSync(path.join(repoRoot, 'lib', 'web', 'ui-template.mjs'), 'utf8');
2042
2435
  for (const expected of [
2043
2436
  'function scheduleTmuxInputRefresh(session)',
2044
2437
  "runTmux(['pipe-pane', '-t', session.pane]);",
@@ -2051,25 +2444,2318 @@ function syntaxAndHelp() {
2051
2444
  if (!hccSource.includes(expected)) fail(`web terminal input refresh support missing: ${expected}`);
2052
2445
  }
2053
2446
  for (const expected of [
2054
- 'function webPeerAction(projectCtx, peer, action, input = {})',
2055
- 'function claimNextTaskForPeer(db, peer, force = false)',
2056
- 'function statusSummary(ctx, peer = null, identity = null)',
2447
+ 'function listTmuxPanesOnce()',
2448
+ 'let autoAttachScanInFlight = false;',
2449
+ 'if (autoAttachScanInFlight) return;',
2450
+ 'const paneByPid = new Map();',
2451
+ 'const attached = attachedTmuxState(ctx, db);',
2452
+ 'function killOldTmuxForRebind(',
2453
+ 'function providerSessionBindingMatches(',
2454
+ 'function hccWebProcessMatches(',
2455
+ 'function splitProcessArgs(line)',
2456
+ 'const { global, rest } = splitGlobalArgs(hccArgs);',
2457
+ "if (rest[0] !== 'web') return false;",
2458
+ 'sameResolvedPath(global.root, ctx.root)',
2459
+ 'sameResolvedPath(global.db, ctx.dbPath)',
2460
+ 'sameResolvedPath(',
2461
+ 'async function stopOrphanWebRuntimes(',
2462
+ 'await stopOrphanWebRuntimes(ctx, existing.pid || null);',
2463
+ 'await stopOrphanWebRuntimes(ctx);',
2464
+ 'const restoredTmuxDbs = new Set();',
2465
+ 'if (restoredTmuxDbs.has(dbKey)) continue;',
2466
+ 'input.rebindOldTmux',
2467
+ 'rebindOldTmux: true',
2468
+ 'skipProviderRebindCleanup:',
2469
+ 'function safeOldTmuxRebindPlan(',
2470
+ 'function assertOldTmuxCanRebind(',
2471
+ 'TMUX_REBIND_NOT_HCC_MANAGED',
2472
+ 'TMUX_REBIND_ROOT_MISMATCH',
2473
+ "addEvent(db, 'tmux.session.rebind_cleanup_pending'",
2474
+ 'TMUX_REBIND_OLD_SESSION_IN_USE',
2475
+ "addEvent(db, 'tmux.session.rebind_cleanup_failed'",
2476
+ 'function restoreParkedOldTmuxSessions()',
2477
+ 'restoreParkedOldTmuxSessions();',
2478
+ 'parkedOldTmuxSessions.push',
2479
+ "addEvent(db, 'tmux.session.rebound'",
2480
+ "addEvent(eventDb, 'provider.session.rebound'"
2481
+ ]) {
2482
+ if (!hccSource.includes(expected)) fail(`tmux auto-attach/rebind guard missing: ${expected}`);
2483
+ }
2484
+ if (hccSource.includes("return line.includes(`--root ${root}`) || line.includes(`--db ${db}`);")) {
2485
+ fail('orphan web runtime cleanup still uses substring matching for --root/--db');
2486
+ }
2487
+ for (const expected of [
2488
+ 'function safeTmuxKillPlan(projectCtx, db, peerId, expectedTarget)',
2489
+ "binding.transport !== 'tmux'",
2490
+ 'const expectedSession = tmuxManagedSessionName(projectCtx, peerId);',
2491
+ "throw new CliError('TMUX_KILL_NOT_HCC_MANAGED'",
2492
+ "throw new CliError('TMUX_KILL_ROOT_MISMATCH'",
2493
+ "throw new CliError('TMUX_KILL_HAS_CLIENTS'",
2494
+ 'killDbProvenTmuxSession(reqCtx, db, peerId)',
2495
+ 'safeTmuxKillPlan(reqCtx, stopDb, peerId, session.pane || null)'
2496
+ ]) {
2497
+ if (!hccSource.includes(expected)) fail(`tmux kill safety guard missing: ${expected}`);
2498
+ }
2499
+ if (hccSource.includes("if (sessName) tmuxKillSession(sessName);")) {
2500
+ fail('web stop kill_tmux still kills tmux by session name without DB-proven guard');
2501
+ }
2502
+ for (const expected of [
2503
+ 'async function cmdTmux(',
2504
+ 'async function planTmuxGc(',
2505
+ "validateOpts('tmux gc'",
2506
+ 'const dryRun = !opts.yes;',
2507
+ 'const targetPeer = opts.peer || null;',
2508
+ 'if (targetPeer && row.peer !== targetPeer) continue;',
2509
+ 'if (targetPeer && rowPeer !== targetPeer) continue;',
2510
+ "b.transport = 'tmux'",
2511
+ 'b.runtime_target IS NOT NULL',
2512
+ 'actualSession !== expectedSession',
2513
+ "const hccRoot = tmuxSessionEnvironmentValue(actualSession, 'HCC_ROOT');",
2514
+ "skip('hcc_root_mismatch'",
2515
+ "skip('runtime_managed')",
2516
+ "skip('has_tmux_clients'",
2517
+ "reason: 'stale_hcc_managed_session'",
2518
+ "type IN ('tmux.session.rebind_cleanup_failed', 'tmux.session.rebind_cleanup_pending')",
2519
+ "'stale_rebind_cleanup_failed_session'",
2520
+ "'rebind_cleanup_failed'",
2521
+ "'rebind_cleanup_pending'",
2522
+ "'stale_rebind_cleanup_pending_session'",
2523
+ "SET transport = 'detached'",
2524
+ "addEvent(db, 'tmux.session.gc'"
2525
+ ]) {
2526
+ if (!hccSource.includes(expected)) fail(`tmux gc guard missing: ${expected}`);
2527
+ }
2528
+ const autoAttachSource = hccSource.slice(
2529
+ hccSource.indexOf('function scanAndAttachDetectedPeers()'),
2530
+ hccSource.indexOf('scanAndAttachDetectedPeers();')
2531
+ );
2532
+ if ((autoAttachSource.match(/runTmux\(\['list-panes'/g) || []).length > 0) {
2533
+ fail('auto-attach scan calls tmux list-panes inside scanAndAttachDetectedPeers instead of using listTmuxPanesOnce');
2534
+ }
2535
+ if (!autoAttachSource.includes('panes = listTmuxPanesOnce();')) {
2536
+ fail('auto-attach scan no longer uses one tmux pane listing per tick');
2537
+ }
2538
+ const managedStartSource = hccSource.slice(
2539
+ hccSource.indexOf('function startTmuxManagedSession(input)'),
2540
+ hccSource.indexOf('function restoreTmuxManagedSessions')
2541
+ );
2542
+ if (!managedStartSource.includes('rebindOldTmux: true')) {
2543
+ fail('managed tmux start no longer marks explicit rebind cleanup');
2544
+ }
2545
+ if (!managedStartSource.includes('skipProviderRebindCleanup: oldTmuxTargetsForRebind.length > 0')) {
2546
+ fail('managed tmux start no longer avoids duplicate provider rebind cleanup planning');
2547
+ }
2548
+ for (const expected of [
2549
+ 'HCC_ROOT: pctx.root',
2550
+ 'HCC_DB: pctx.dbPath',
2551
+ '[LAUNCH_FINGERPRINT_ENV]: env[LAUNCH_FINGERPRINT_ENV]'
2552
+ ]) {
2553
+ if (!managedStartSource.includes(expected)) fail(`managed tmux start no longer marks session env: ${expected}`);
2554
+ }
2555
+ const restoreSource = hccSource.slice(
2556
+ hccSource.indexOf('function restoreTmuxManagedSessions'),
2557
+ hccSource.indexOf('function startPtySession')
2558
+ );
2559
+ if (restoreSource.includes('rebindOldTmux')) {
2560
+ fail('restore path must not kill old tmux sessions as a rebind');
2561
+ }
2562
+ for (const expected of [
2563
+ 'function detectedPeerCanStop(peer)',
2564
+ "if (['exited', 'detached'].includes(status)) return false;",
2565
+ 'const canStop = detectedPeerCanStop(p);',
2566
+ '${canStop ?',
2567
+ 'function providerSessionKnown(session)',
2568
+ "tr('providerSession') + '=' + sessionProvider(session) + ':' + (value || tr('unknown'))",
2569
+ 'sessionCardDetailText(s)',
2570
+ 'const activeDetectedPeers = visibleDetected.filter((p) => peerIsActive(p));',
2571
+ 'const staleDetectedPeers = visibleDetected.filter((p) => !peerIsActive(p));',
2572
+ "localStorage.setItem('hcc.showStaleDetected'",
2573
+ 'id="toggleStaleDetected"',
2574
+ "if (e.target.closest('[data-action]')) return;",
2575
+ 'id="stopKillLabel" data-i18n="dialog.killTmux"',
2576
+ 'id="stopCancelBtn" type="button" data-i18n="dialog.cancel"',
2577
+ 'id="stopConfirmBtn" type="button" data-i18n="stop"'
2578
+ ]) {
2579
+ if (!webUiTemplateSource.includes(expected)) fail(`web display regression guard missing: ${expected}`);
2580
+ }
2581
+ if (webUiTemplateSource.includes("p.status === 'running' ?")) {
2582
+ fail('detected peer action rendering still depends on status === running instead of liveness');
2583
+ }
2584
+ if (webUiTemplateSource.indexOf("const stopDialog = document.getElementById('stopDialog');") >
2585
+ webUiTemplateSource.indexOf("document.getElementById('stopBtn').addEventListener('click'")) {
2586
+ fail('stop dialog DOM references are declared after the stop button handler uses them');
2587
+ }
2588
+ if (webUiTemplateSource.includes("sessionRuntimeNote(s) + (s.command || '')")) {
2589
+ fail('managed session display still uses command text as the primary runtime identity');
2590
+ }
2591
+ for (const expected of [
2592
+ 'function sessionBindingForSerialize(db, session, peerId)',
2593
+ 'function serializeBindingSummary(binding, session)',
2594
+ 'provider_session_known: Boolean(providerSessionLabel)',
2595
+ 'provider_session_label: providerSessionLabel'
2596
+ ]) {
2597
+ if (!hccSource.includes(expected)) fail(`sessions API binding summary missing: ${expected}`);
2598
+ }
2599
+ for (const expected of [
2600
+ 'import { createWebPeerActions } from \'../lib/web/peer-actions.mjs\'',
2601
+ '} = createWebPeerActions({',
2057
2602
  'const peerActionMatch = url.pathname.match(/^\\/api\\/peers\\/([^/]+)\\/actions\\/([^/]+)$/)',
2058
2603
  "const readOnly = ['status', 'state', 'inbox'].includes(action)",
2059
2604
  "sendJson(res, 200, webPeerAction(reqCtx, peer, action, input));"
2060
2605
  ]) {
2061
2606
  if (!hccSource.includes(expected)) fail(`web peer action API support missing: ${expected}`);
2062
2607
  }
2608
+ for (const helper of [
2609
+ 'function webPeerRegister(',
2610
+ 'function webPeerHeartbeat(',
2611
+ 'function webPeerTaskNext(',
2612
+ 'function webPeerTaskTakeover(',
2613
+ 'function webPeerLockAcquire(',
2614
+ 'function webPeerLockRelease(',
2615
+ 'function webPeerInbox(',
2616
+ 'function webPeerAction('
2617
+ ]) {
2618
+ if (hccSource.includes(helper)) fail(`CLI still embeds web peer action helper: ${helper}`);
2619
+ }
2620
+ for (const expected of [
2621
+ 'function webPeerAction(projectCtx, peer, action, input = {})',
2622
+ 'claimNextTasksForPeer(db, peer, { force: Boolean(input.force), count })',
2623
+ 'takeOverTaskForPeer(db, peer, id, { reason, policy, staleAfter, source: ',
2624
+ 'const status = statusSummary(projectCtx, peer)',
2625
+ "normalized === 'task-next'",
2626
+ "normalized === 'lock-acquire'",
2627
+ "normalized === 'lock-release'"
2628
+ ]) {
2629
+ if (!webPeerActionsSource.includes(expected)) fail(`web peer action helper missing: ${expected}`);
2630
+ }
2063
2631
  const packageVersion = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')).version;
2064
2632
  const cliVersion = run(process.execPath, [hccBin, '--version']).trim();
2065
2633
  if (cliVersion !== packageVersion) {
2066
2634
  fail(`CLI version ${cliVersion} does not match package.json ${packageVersion}`);
2067
2635
  }
2636
+ const releasePackageMeta = await import(path.join(repoRoot, 'lib', 'release', 'package-meta.mjs'));
2637
+ const releaseNotesModule = await import(path.join(repoRoot, 'lib', 'release', 'release-notes.mjs'));
2638
+ const compatPackageMeta = await import(path.join(repoRoot, 'lib', 'package-meta.mjs'));
2639
+ const compatReleaseNotes = await import(path.join(repoRoot, 'lib', 'release-notes.mjs'));
2640
+ for (const [moduleName, mod, names] of [
2641
+ ['release/package-meta', releasePackageMeta, ['packageRoot', 'readJson', 'readPackageJson', 'readPackageMeta']],
2642
+ ['release/release-notes', releaseNotesModule, ['normalizeVersion', 'releaseSection', 'validateReleaseSection', 'repoFromPackage']],
2643
+ ['package-meta compat', compatPackageMeta, ['packageRoot', 'readJson', 'readPackageJson', 'readPackageMeta']],
2644
+ ['release-notes compat', compatReleaseNotes, ['normalizeVersion', 'releaseSection', 'validateReleaseSection', 'repoFromPackage']]
2645
+ ]) {
2646
+ for (const name of names) {
2647
+ if (typeof mod[name] !== 'function') fail(`${moduleName} missing export: ${name}`);
2648
+ }
2649
+ }
2650
+ const releaseScriptRoot = releasePackageMeta.packageRoot(
2651
+ pathToFileURL(path.join(repoRoot, 'scripts', 'release-notes.mjs')).href
2652
+ );
2653
+ if (releaseScriptRoot !== repoRoot) {
2654
+ fail(`release packageRoot resolved ${releaseScriptRoot}, expected ${repoRoot}`);
2655
+ }
2656
+ const releaseNotesScriptSource = fs.readFileSync(path.join(repoRoot, 'scripts', 'release-notes.mjs'), 'utf8');
2657
+ const githubReleaseScriptSource = fs.readFileSync(path.join(repoRoot, 'scripts', 'github-release.mjs'), 'utf8');
2658
+ if (!releaseNotesScriptSource.includes("../lib/release/package-meta.mjs") ||
2659
+ !releaseNotesScriptSource.includes("../lib/release/release-notes.mjs") ||
2660
+ !githubReleaseScriptSource.includes("../lib/release/package-meta.mjs") ||
2661
+ !githubReleaseScriptSource.includes("../lib/release/release-notes.mjs")) {
2662
+ fail('release scripts do not import release helpers from lib/release');
2663
+ }
2664
+ const dbSchema = await import(path.join(repoRoot, 'lib', 'db', 'schema.mjs'));
2665
+ const compatDbSchema = await import(path.join(repoRoot, 'lib', 'db-schema.mjs'));
2666
+ for (const [moduleName, mod] of [
2667
+ ['db/schema', dbSchema],
2668
+ ['db-schema compat', compatDbSchema]
2669
+ ]) {
2670
+ if (typeof mod.DB_SCHEMA_VERSION !== 'number') fail(`${moduleName} missing DB_SCHEMA_VERSION export`);
2671
+ for (const name of ['execWithBusyRetry', 'initSchema', 'readSchemaVersion', 'tx']) {
2672
+ if (typeof mod[name] !== 'function') fail(`${moduleName} missing export: ${name}`);
2673
+ }
2674
+ }
2068
2675
  if (!hccSource.includes("import { readPackageMeta } from '../lib/package-meta.mjs'") ||
2676
+ !hccSource.includes("} from '../lib/cli-args.mjs'") ||
2677
+ !hccSource.includes("import { CliError } from '../lib/shared/errors.mjs'") ||
2678
+ !hccSource.includes("} from '../lib/db/schema.mjs'") ||
2679
+ !hccSource.includes("} from '../lib/cli-runtime.mjs'") ||
2680
+ !hccSource.includes("import { createCoordinationState } from '../lib/coordination-state.mjs'") ||
2681
+ !hccSource.includes("import { createWebPeerActions } from '../lib/web/peer-actions.mjs'") ||
2682
+ !hccSource.includes("} from '../lib/format.mjs'") ||
2683
+ !hccSource.includes("} from '../lib/runtime/paths.mjs'") ||
2684
+ !hccSource.includes("} from '../lib/runtime/state.mjs'") ||
2685
+ !hccSource.includes("} from '../lib/project-context.mjs'") ||
2686
+ !hccSource.includes("} from '../lib/handoff.mjs'") ||
2687
+ !hccSource.includes("} from '../lib/core/peers/liveness.mjs'") ||
2688
+ !hccSource.includes("} from '../lib/ui/state-render.mjs'") ||
2689
+ !hccSource.includes("import { createHelpFunctions } from '../lib/ui/help.mjs'") ||
2690
+ !hccSource.includes("import { runtimeRequest } from '../lib/runtime/client.mjs'") ||
2691
+ !hccSource.includes("import { createMessageStore } from '../lib/core/coordination/messages.mjs'") ||
2692
+ !hccSource.includes("import { createTaskStore } from '../lib/core/coordination/tasks.mjs'") ||
2693
+ !hccSource.includes("} from '../lib/task-cli.mjs'") ||
2694
+ !hccSource.includes("} from '../lib/core/sessions/launch.mjs'") ||
2695
+ !hccSource.includes("} from '../lib/integrations/providers.mjs'") ||
2696
+ !hccSource.includes("} from '../lib/core/peers/session.mjs'") ||
2697
+ !hccSource.includes("} from '../lib/core/peers/bindings.mjs'") ||
2698
+ !hccSource.includes("import { createPeerBindingStore } from '../lib/db/stores/peers.mjs'") ||
2699
+ !hccSource.includes("} from '../lib/tmux.mjs'") ||
2700
+ !hccSource.includes("} from '../lib/core/coordination/locks.mjs'") ||
2701
+ !hccSource.includes("} from '../lib/core/coordination/teams.mjs'") ||
2702
+ !hccSource.includes("} from '../lib/integrations/peers/identity.mjs'") ||
2703
+ !hccSource.includes("} from '../lib/runtime/projects.mjs'") ||
2704
+ !hccSource.includes("} from '../lib/web/runtime.mjs'") ||
2705
+ !hccSource.includes("} from '../lib/web/http.mjs'") ||
2706
+ !hccSource.includes("import { webIndexHtml } from '../lib/web/ui-template.mjs'") ||
2069
2707
  !hccSource.includes('const VERSION = PACKAGE_META.version') ||
2070
2708
  !hccSource.includes('writeGuidanceForRoot(ctx.root)')) {
2071
- fail('CLI still has duplicated package metadata or guidance wiring');
2709
+ fail('CLI still has duplicated package metadata, cli args, DB schema helpers, CLI runtime helpers, coordination state helpers, format helpers, runtime paths/state helpers, runtime client helpers, project context helpers, handoff helpers, timeline helpers, task liveness helpers, automation helpers, state render helpers, help text helpers, message store helpers, task store helpers, task CLI helpers, session launch helpers, provider command helpers, peer binding helpers, tmux helpers, lock helpers, team planning helpers, peer identity helpers, project registry helpers, web runtime/HTTP/UI helpers, or guidance wiring');
2710
+ }
2711
+ for (const expected of [
2712
+ "renderAutomationContext",
2713
+ "from './core/coordination/automation.mjs'",
2714
+ "formatOpenTaskLine",
2715
+ "from './core/peers/liveness.mjs'",
2716
+ "timelineFromRows",
2717
+ "from './core/coordination/timeline.mjs'",
2718
+ "from './core/coordination/locks.mjs'"
2719
+ ]) {
2720
+ if (!coordinationStateSource.includes(expected)) fail(`coordination state dependency missing: ${expected}`);
2721
+ }
2722
+ if (hccSource.includes('function createBaseSchema') ||
2723
+ hccSource.includes('function runSchemaMigrations') ||
2724
+ hccSource.includes('function readSchemaVersion')) {
2725
+ fail('CLI still embeds DB schema or migration helpers');
2726
+ }
2727
+ for (const helper of [
2728
+ 'function createContext(',
2729
+ 'function tailFile(',
2730
+ 'function commandPath(',
2731
+ 'function packageRoot('
2732
+ ]) {
2733
+ if (hccSource.includes(helper)) fail(`CLI still embeds CLI runtime helper: ${helper}`);
2734
+ }
2735
+ if (errorsCompatSource.includes('class CliError')) {
2736
+ fail('errors compatibility module still embeds CliError');
2737
+ }
2738
+ if (!errorsCompatSource.includes("from './shared/errors.mjs'") ||
2739
+ !sharedErrorsSource.includes('class CliError')) {
2740
+ fail('CliError is not wired through lib/shared/errors.mjs');
2741
+ }
2742
+ const oldErrorImportPattern = /from ['"](?:\.\.\/lib\/errors|\.\/errors|\.\.\/errors|\.\.\/\.\.\/errors)\.mjs['"]/;
2743
+ for (const file of ['bin/hcc.mjs', ...libModuleFiles()]) {
2744
+ const source = fs.readFileSync(path.join(repoRoot, file), 'utf8');
2745
+ if (file !== 'lib/errors.mjs' && oldErrorImportPattern.test(source)) {
2746
+ fail(`${file} still imports CliError through the old errors module`);
2747
+ }
2748
+ }
2749
+ const sharedErrors = await import(path.join(repoRoot, 'lib', 'shared', 'errors.mjs'));
2750
+ const compatErrors = await import(path.join(repoRoot, 'lib', 'errors.mjs'));
2751
+ if (typeof sharedErrors.CliError !== 'function') fail('shared errors module missing CliError');
2752
+ if (compatErrors.CliError !== sharedErrors.CliError) fail('errors compatibility export mismatch');
2753
+ if (!hccSource.includes('function shellCommand(args)') ||
2754
+ hccSource.includes('return args.map(shellQuoteArg).join')) {
2755
+ fail('CLI shellCommand wrapper no longer delegates to cli-runtime shellCommand helper');
2756
+ }
2757
+ for (const helper of [
2758
+ 'const CLAUDE_SETTINGS_PATH',
2759
+ 'const CODEX_HOOKS_PATH',
2760
+ 'function installClaudeHooks',
2761
+ 'function uninstallClaudeHooks',
2762
+ 'function verifyClaudeHooks',
2763
+ 'function installCodexHooks',
2764
+ 'function uninstallCodexHooks',
2765
+ 'function verifyCodexHooks',
2766
+ 'function mergeCodexHookEntry',
2767
+ 'function mergeHookEntry',
2768
+ 'function isHccHookCmd'
2769
+ ]) {
2770
+ if (setupSource.includes(helper)) fail(`setup module still embeds hook helper: ${helper}`);
2771
+ }
2772
+ if (!setupSource.includes("from './integrations/hooks.mjs'")) {
2773
+ fail('setup module does not re-export hook helpers from integrations/hooks.mjs');
2774
+ }
2775
+ for (const helper of [
2776
+ 'const SHIM_DIR',
2777
+ 'function installShims',
2778
+ 'function uninstallShims',
2779
+ 'function verifyShims',
2780
+ 'function findRealBinary',
2781
+ 'function fsExists'
2782
+ ]) {
2783
+ if (setupSource.includes(helper)) fail(`setup module still embeds shim helper: ${helper}`);
2784
+ }
2785
+ if (!setupSource.includes("from './integrations/shims.mjs'")) {
2786
+ fail('setup module does not re-export shim helpers from integrations/shims.mjs');
2787
+ }
2788
+ if (!setupSource.includes("from './shell-path.mjs'")) {
2789
+ fail('setup module does not re-export shell path helpers from shell-path.mjs');
2790
+ }
2791
+ if (!integrationHooksSource.includes("from '../shared/json-file.mjs'") ||
2792
+ !integrationHooksSource.includes("const CLAUDE_SETTINGS_PATH") ||
2793
+ !integrationHooksSource.includes("const CODEX_HOOKS_PATH")) {
2794
+ fail('integrations hook module is missing expected hook storage wiring');
2795
+ }
2796
+ if (!integrationShimsSource.includes("from './shims/script.mjs'") ||
2797
+ !integrationShimsSource.includes("const SHIM_DIR") ||
2798
+ !integrationShimsSource.includes('function findRealBinary') ||
2799
+ !integrationShimsSource.includes("spawnSync('which'")) {
2800
+ fail('integrations shim module is missing expected shim wiring');
2801
+ }
2802
+ if (!shimScriptCompatSource.includes("from './integrations/shims/script.mjs'")) {
2803
+ fail('shim script compatibility module does not re-export from integrations/shims/script.mjs');
2804
+ }
2805
+ if (!integrationShimScriptSource.includes('function generateShim') ||
2806
+ !integrationShimScriptSource.includes('hello-cc shim for ${tool.name}') ||
2807
+ !integrationShimScriptSource.includes('HCC_SHIM_ENSURED')) {
2808
+ fail('integrations shim script module is missing expected generateShim wiring');
2809
+ }
2810
+ if (!shellPathSource.includes('function installPathEntry') ||
2811
+ !shellPathSource.includes('function uninstallPathEntry') ||
2812
+ !shellPathSource.includes('.hcc-shims')) {
2813
+ fail('shell path module is missing expected setup path helpers');
2814
+ }
2815
+ const setupModule = await import(path.join(repoRoot, 'lib', 'setup.mjs'));
2816
+ const integrationHooks = await import(path.join(repoRoot, 'lib', 'integrations', 'hooks.mjs'));
2817
+ const integrationShims = await import(path.join(repoRoot, 'lib', 'integrations', 'shims.mjs'));
2818
+ const integrationShimScript = await import(path.join(repoRoot, 'lib', 'integrations', 'shims', 'script.mjs'));
2819
+ const shimScriptCompat = await import(path.join(repoRoot, 'lib', 'shim-script.mjs'));
2820
+ const shellPath = await import(path.join(repoRoot, 'lib', 'shell-path.mjs'));
2821
+ for (const name of [
2822
+ 'installClaudeHooks',
2823
+ 'uninstallClaudeHooks',
2824
+ 'verifyClaudeHooks',
2825
+ 'installCodexHooks',
2826
+ 'uninstallCodexHooks',
2827
+ 'verifyCodexHooks'
2828
+ ]) {
2829
+ if (typeof integrationHooks[name] !== 'function') fail(`integrations hook module missing export: ${name}`);
2830
+ if (setupModule[name] !== integrationHooks[name]) fail(`setup hook export mismatch: ${name}`);
2831
+ }
2832
+ for (const name of ['installShims', 'uninstallShims', 'verifyShims', 'findRealBinary']) {
2833
+ if (typeof integrationShims[name] !== 'function') fail(`integrations shim module missing export: ${name}`);
2834
+ if (setupModule[name] !== integrationShims[name]) fail(`setup shim export mismatch: ${name}`);
2835
+ }
2836
+ if (typeof integrationShims.SHIM_DIR !== 'string' || !integrationShims.SHIM_DIR.endsWith('.hcc-shims')) {
2837
+ fail(`integrations shim module has unexpected SHIM_DIR: ${integrationShims.SHIM_DIR}`);
2838
+ }
2839
+ if (setupModule.SHIM_DIR !== integrationShims.SHIM_DIR) fail('setup shim dir export mismatch');
2840
+ if (typeof integrationShimScript.generateShim !== 'function') fail('integrations shim script module missing generateShim export');
2841
+ if (shimScriptCompat.generateShim !== integrationShimScript.generateShim) fail('shim script compatibility export mismatch');
2842
+ for (const name of ['installPathEntry', 'uninstallPathEntry']) {
2843
+ if (typeof shellPath[name] !== 'function') fail(`shell path module missing export: ${name}`);
2844
+ if (setupModule[name] !== shellPath[name]) fail(`setup shell path export mismatch: ${name}`);
2845
+ }
2846
+ const cliRuntime = await import(path.join(repoRoot, 'lib', 'cli-runtime.mjs'));
2847
+ for (const name of ['commandPath', 'createContext', 'packageRoot', 'shellCommand', 'tailFile']) {
2848
+ if (typeof cliRuntime[name] !== 'function') fail(`CLI runtime module missing function: ${name}`);
2849
+ }
2850
+ const runtimeCtx = cliRuntime.createContext(
2851
+ { root: '/tmp/project', db: '.hello-cc/test.db', json: true },
2852
+ { cwd: '/tmp/project/subdir', detectRoot: (_cwd, rootHint) => rootHint || '/tmp/project' }
2853
+ );
2854
+ if (runtimeCtx.cwd !== '/tmp/project/subdir' ||
2855
+ runtimeCtx.root !== '/tmp/project' ||
2856
+ !runtimeCtx.dbPath.endsWith('/.hello-cc/test.db') ||
2857
+ runtimeCtx.json !== true ||
2858
+ runtimeCtx.explicitRoot !== true) {
2859
+ fail(`CLI runtime createContext changed: ${JSON.stringify(runtimeCtx)}`);
2860
+ }
2861
+ const quotedCommand = cliRuntime.shellCommand(['alpha', 'two words'], (value) => `[${value}]`);
2862
+ if (quotedCommand !== '[alpha] [two words]') fail(`CLI runtime shellCommand changed: ${quotedCommand}`);
2863
+ const tailPath = path.join(outDir, 'tail-smoke.txt');
2864
+ fs.writeFileSync(tailPath, '0123456789\n');
2865
+ if (cliRuntime.tailFile(tailPath, 4) !== '789' || cliRuntime.tailFile(path.join(outDir, 'missing-tail')) !== '') {
2866
+ fail('CLI runtime tailFile behavior changed');
2867
+ }
2868
+ for (const helper of [
2869
+ 'function collectStateSnapshot(',
2870
+ 'function buildHookCoordinationContext(',
2871
+ 'function ackMessages(',
2872
+ 'function statusSummary(',
2873
+ 'function statusSnapshot('
2874
+ ]) {
2875
+ if (hccSource.includes(helper)) fail(`CLI still embeds coordination state helper: ${helper}`);
2876
+ }
2877
+ const coordinationState = await import(path.join(repoRoot, 'lib', 'coordination-state.mjs'));
2878
+ if (typeof coordinationState.createCoordinationState !== 'function') {
2879
+ fail('coordination state module missing createCoordinationState');
2880
+ }
2881
+ const stateHelpers = coordinationState.createCoordinationState({
2882
+ connect: () => ({ close() {} }),
2883
+ queryInbox: () => [],
2884
+ queryOpenTasks: () => [],
2885
+ queryTimelineMessages: () => []
2886
+ });
2887
+ for (const name of ['ackMessages', 'buildHookCoordinationContext', 'collectStateSnapshot', 'statusSnapshot', 'statusSummary']) {
2888
+ if (typeof stateHelpers[name] !== 'function') fail(`coordination state factory missing function: ${name}`);
2889
+ }
2890
+ for (const helper of [
2891
+ 'function readGlobalRuntimeFile',
2892
+ 'function writeGlobalRuntime',
2893
+ 'function writeRuntime',
2894
+ 'function readRuntime',
2895
+ 'function readRuntimeFile',
2896
+ 'function probeRuntime',
2897
+ 'function readHealthyRuntime',
2898
+ 'function readHealthyGlobalRuntime',
2899
+ 'function clearRuntime'
2900
+ ]) {
2901
+ if (hccSource.includes(helper)) fail(`CLI still embeds runtime state helper: ${helper}`);
2902
+ }
2903
+ const runtimePaths = await import(path.join(repoRoot, 'lib', 'runtime', 'paths.mjs'));
2904
+ const compatRuntimePaths = await import(path.join(repoRoot, 'lib', 'runtime-paths.mjs'));
2905
+ for (const [moduleName, mod] of [
2906
+ ['runtime/paths', runtimePaths],
2907
+ ['runtime-paths compat', compatRuntimePaths]
2908
+ ]) {
2909
+ for (const name of [
2910
+ 'contextForProject',
2911
+ 'globalRuntimePath',
2912
+ 'globalStateDir',
2913
+ 'globalWebTokenPath',
2914
+ 'projectDbPath',
2915
+ 'projectRegistryPath',
2916
+ 'projectStateDir',
2917
+ 'runtimePath',
2918
+ 'webLogPath'
2919
+ ]) {
2920
+ if (typeof mod[name] !== 'function') fail(`${moduleName} missing export: ${name}`);
2921
+ }
2922
+ }
2923
+ const runtimeState = await import(path.join(repoRoot, 'lib', 'runtime', 'state.mjs'));
2924
+ const compatRuntimeState = await import(path.join(repoRoot, 'lib', 'runtime-state.mjs'));
2925
+ for (const name of [
2926
+ 'readGlobalRuntimeFile',
2927
+ 'writeGlobalRuntime',
2928
+ 'writeRuntime',
2929
+ 'readRuntime',
2930
+ 'readRuntimeFile',
2931
+ 'probeRuntime',
2932
+ 'readHealthyRuntime',
2933
+ 'readHealthyGlobalRuntime',
2934
+ 'clearRuntime'
2935
+ ]) {
2936
+ if (typeof runtimeState[name] !== 'function') fail(`runtime state module missing export: ${name}`);
2937
+ if (typeof compatRuntimeState[name] !== 'function') fail(`runtime state compat module missing export: ${name}`);
2938
+ }
2939
+ const savedRuntimeEnv = {
2940
+ HOME: process.env.HOME,
2941
+ HCC_RUNTIME_URL: process.env.HCC_RUNTIME_URL,
2942
+ HCC_RUNTIME_TOKEN: process.env.HCC_RUNTIME_TOKEN
2943
+ };
2944
+ try {
2945
+ process.env.HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-runtime-state-home-'));
2946
+ delete process.env.HCC_RUNTIME_URL;
2947
+ delete process.env.HCC_RUNTIME_TOKEN;
2948
+ const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-runtime-state-root-'));
2949
+ const runtimeCtx = { root: runtimeRoot };
2950
+
2951
+ const projectRuntimeFile = runtimeState.writeRuntime(runtimeCtx, {
2952
+ base_url: 'http://127.0.0.1:11',
2953
+ token: 'project-token',
2954
+ pid: 101
2955
+ });
2956
+ if (!fs.existsSync(projectRuntimeFile) || (fs.statSync(projectRuntimeFile).mode & 0o777) !== 0o600) {
2957
+ fail('runtime state writeRuntime did not create a 0600 runtime file');
2958
+ }
2959
+ const projectRuntime = runtimeState.readRuntime(runtimeCtx);
2960
+ if (projectRuntime.base_url !== 'http://127.0.0.1:11' ||
2961
+ projectRuntime.token !== 'project-token' ||
2962
+ projectRuntime.source !== projectRuntimeFile) {
2963
+ fail(`runtime state project read changed: ${JSON.stringify(projectRuntime)}`);
2964
+ }
2965
+ if (runtimeState.readRuntimeFile(runtimeCtx)?.token !== 'project-token') {
2966
+ fail('runtime state readRuntimeFile changed');
2967
+ }
2968
+ runtimeState.clearRuntime(runtimeCtx, 202);
2969
+ if (!fs.existsSync(projectRuntimeFile)) fail('runtime state clearRuntime removed a different pid');
2970
+ runtimeState.clearRuntime(runtimeCtx, 101);
2971
+ if (fs.existsSync(projectRuntimeFile)) fail('runtime state clearRuntime did not remove matching project pid');
2972
+
2973
+ const globalRuntimeFile = runtimeState.writeGlobalRuntime({
2974
+ base_url: 'http://127.0.0.1:12',
2975
+ token: 'global-token',
2976
+ pid: 303
2977
+ });
2978
+ const globalRuntime = runtimeState.readRuntime(runtimeCtx);
2979
+ if (globalRuntime.base_url !== 'http://127.0.0.1:12' ||
2980
+ globalRuntime.token !== 'global-token' ||
2981
+ globalRuntime.source !== globalRuntimeFile ||
2982
+ globalRuntime.global !== true) {
2983
+ fail(`runtime state global fallback changed: ${JSON.stringify(globalRuntime)}`);
2984
+ }
2985
+ runtimeState.clearRuntime(runtimeCtx, 404);
2986
+ if (!fs.existsSync(globalRuntimeFile)) fail('runtime state clearRuntime removed a different global pid');
2987
+ runtimeState.clearRuntime(runtimeCtx, 303);
2988
+ if (fs.existsSync(globalRuntimeFile)) fail('runtime state clearRuntime did not remove matching global pid');
2989
+
2990
+ fs.writeFileSync(globalRuntimeFile, '{bad');
2991
+ if (runtimeState.readGlobalRuntimeFile() !== null || fs.existsSync(globalRuntimeFile)) {
2992
+ fail('runtime state readGlobalRuntimeFile did not remove invalid JSON');
2993
+ }
2994
+
2995
+ process.env.HCC_RUNTIME_URL = 'http://env-runtime.test';
2996
+ process.env.HCC_RUNTIME_TOKEN = 'env-token';
2997
+ const envRuntime = runtimeState.readRuntime(runtimeCtx);
2998
+ if (envRuntime.base_url !== 'http://env-runtime.test' ||
2999
+ envRuntime.token !== 'env-token' ||
3000
+ envRuntime.source !== 'env') {
3001
+ fail(`runtime state env runtime precedence changed: ${JSON.stringify(envRuntime)}`);
3002
+ }
3003
+ delete process.env.HCC_RUNTIME_URL;
3004
+ delete process.env.HCC_RUNTIME_TOKEN;
3005
+
3006
+ try {
3007
+ runtimeState.readRuntime(runtimeCtx, { productName: 'product-x', cliName: 'cli-x' });
3008
+ fail('runtime state readRuntime succeeded without runtime');
3009
+ } catch (err) {
3010
+ if (err?.code !== 'RUNTIME_NOT_RUNNING' ||
3011
+ !String(err.message || '').includes('product-x') ||
3012
+ !String(err.message || '').includes('cli-x web')) {
3013
+ throw err;
3014
+ }
3015
+ }
3016
+ if (await runtimeState.probeRuntime(null) !== false ||
3017
+ await runtimeState.readHealthyRuntime(runtimeCtx) !== null ||
3018
+ await runtimeState.readHealthyGlobalRuntime() !== null) {
3019
+ fail('runtime state unhealthy runtime behavior changed');
3020
+ }
3021
+ } finally {
3022
+ for (const [key, value] of Object.entries(savedRuntimeEnv)) {
3023
+ if (value === undefined) delete process.env[key];
3024
+ else process.env[key] = value;
3025
+ }
3026
+ }
3027
+ if (hccSource.includes('async function runtimeRequest')) fail('CLI still embeds runtime request client helper');
3028
+ const runtimeClient = await import(path.join(repoRoot, 'lib', 'runtime', 'client.mjs'));
3029
+ const compatRuntimeClient = await import(path.join(repoRoot, 'lib', 'runtime-client.mjs'));
3030
+ if (typeof runtimeClient.runtimeRequest !== 'function') fail('runtime client module missing runtimeRequest export');
3031
+ if (typeof compatRuntimeClient.runtimeRequest !== 'function') fail('runtime client compat module missing runtimeRequest export');
3032
+ const savedFetch = globalThis.fetch;
3033
+ const runtimeFetchCalls = [];
3034
+ try {
3035
+ globalThis.fetch = async (url, opts = {}) => {
3036
+ runtimeFetchCalls.push({ url, opts });
3037
+ return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' } });
3038
+ };
3039
+ const runtimeClientData = await runtimeClient.runtimeRequest(
3040
+ { root: '/repo-root', dbPath: '/repo-root/.hello-cc/mesh.db' },
3041
+ 'POST',
3042
+ '/api/test',
3043
+ { value: 1 },
3044
+ { base_url: 'http://127.0.0.1:8787/', token: 'runtime-token', source: 'runtime-file' },
3045
+ { cliName: 'hccx' }
3046
+ );
3047
+ if (runtimeClientData.ok !== true || runtimeFetchCalls.length !== 1) {
3048
+ fail(`runtime client request data changed: ${JSON.stringify({ runtimeClientData, runtimeFetchCalls })}`);
3049
+ }
3050
+ const call = runtimeFetchCalls[0];
3051
+ if (String(call.url) !== 'http://127.0.0.1:8787/api/test' ||
3052
+ call.opts.method !== 'POST' ||
3053
+ call.opts.headers.Authorization !== 'Bearer runtime-token' ||
3054
+ call.opts.headers['X-HCC-Root'] !== '/repo-root' ||
3055
+ call.opts.headers['X-HCC-DB'] !== '/repo-root/.hello-cc/mesh.db' ||
3056
+ call.opts.body !== JSON.stringify({ value: 1 })) {
3057
+ fail(`runtime client request shape changed: ${JSON.stringify(runtimeFetchCalls, null, 2)}`);
3058
+ }
3059
+ globalThis.fetch = async () => new Response('not-json', { status: 200 });
3060
+ try {
3061
+ await runtimeClient.runtimeRequest(
3062
+ { root: '/repo-root', dbPath: '/repo-root/.hello-cc/mesh.db' },
3063
+ 'GET',
3064
+ '/api/bad',
3065
+ null,
3066
+ { base_url: 'http://127.0.0.1:8787/' }
3067
+ );
3068
+ fail('runtime client accepted non-JSON runtime response');
3069
+ } catch (err) {
3070
+ if (err?.code !== 'RUNTIME_BAD_RESPONSE') throw err;
3071
+ }
3072
+ globalThis.fetch = async () => {
3073
+ throw new Error('offline');
3074
+ };
3075
+ try {
3076
+ await runtimeClient.runtimeRequest(
3077
+ { root: '/repo-root', dbPath: '/repo-root/.hello-cc/mesh.db' },
3078
+ 'GET',
3079
+ '/api/offline',
3080
+ null,
3081
+ { base_url: 'http://127.0.0.1:8787/', source: 'runtime-file' },
3082
+ { cliName: 'hccx' }
3083
+ );
3084
+ fail('runtime client accepted unreachable runtime');
3085
+ } catch (err) {
3086
+ if (err?.code !== 'RUNTIME_UNREACHABLE' ||
3087
+ !String(err.message || '').includes('Start hccx web again') ||
3088
+ err.extra?.runtime !== 'runtime-file') {
3089
+ throw err;
3090
+ }
3091
+ }
3092
+ } finally {
3093
+ globalThis.fetch = savedFetch;
3094
+ }
3095
+ for (const helper of [
3096
+ 'function runGit',
3097
+ 'function hasHccRootSync',
3098
+ 'function detectRoot',
3099
+ 'function detectBranch'
3100
+ ]) {
3101
+ if (hccSource.includes(helper)) fail(`CLI still embeds project context helper: ${helper}`);
3102
+ }
3103
+ const projectContext = await import(path.join(repoRoot, 'lib', 'project-context.mjs'));
3104
+ for (const name of [
3105
+ 'runGit',
3106
+ 'hasHccRootSync',
3107
+ 'detectRoot',
3108
+ 'detectBranch'
3109
+ ]) {
3110
+ if (typeof projectContext[name] !== 'function') fail(`project context module missing export: ${name}`);
3111
+ }
3112
+ const gitTop = projectContext.runGit(['rev-parse', '--show-toplevel'], repoRoot);
3113
+ if (path.resolve(gitTop || '') !== repoRoot) fail(`project context runGit changed: ${gitTop}`);
3114
+ const gitBranch = projectContext.runGit(['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot) || '';
3115
+ const detectedBranch = projectContext.detectBranch(repoRoot);
3116
+ if (detectedBranch !== gitBranch) {
3117
+ fail(`project context detectBranch changed: ${detectedBranch} != ${gitBranch}`);
3118
+ }
3119
+ const savedHccRoot = process.env.HCC_ROOT;
3120
+ try {
3121
+ delete process.env.HCC_ROOT;
3122
+ if (projectContext.detectRoot('/tmp/hcc-a', '') !== path.resolve('/tmp/hcc-a')) {
3123
+ fail('project context detectRoot cwd fallback changed');
3124
+ }
3125
+ process.env.HCC_ROOT = '/tmp/hcc-env-root';
3126
+ if (projectContext.detectRoot('/tmp/hcc-a', '') !== path.resolve('/tmp/hcc-env-root') ||
3127
+ projectContext.detectRoot('/tmp/hcc-a', '/tmp/hcc-explicit-root') !== path.resolve('/tmp/hcc-explicit-root')) {
3128
+ fail('project context detectRoot precedence changed');
3129
+ }
3130
+ } finally {
3131
+ if (savedHccRoot === undefined) delete process.env.HCC_ROOT;
3132
+ else process.env.HCC_ROOT = savedHccRoot;
3133
+ }
3134
+ const contextRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-project-context-'));
3135
+ fs.mkdirSync(path.join(contextRoot, '.hello-cc'), { recursive: true });
3136
+ if (projectContext.hasHccRootSync(contextRoot)) fail('project context detected root before marker file');
3137
+ fs.writeFileSync(path.join(contextRoot, '.hello-cc', 'config.json'), '{}');
3138
+ if (!projectContext.hasHccRootSync(contextRoot)) fail('project context did not detect config marker');
3139
+ for (const helper of [
3140
+ 'function normalizeListText',
3141
+ 'function changedFiles'
3142
+ ]) {
3143
+ if (hccSource.includes(helper)) fail(`CLI still embeds handoff helper: ${helper}`);
3144
+ }
3145
+ const handoffModule = await import(path.join(repoRoot, 'lib', 'handoff.mjs'));
3146
+ for (const name of [
3147
+ 'normalizeListText',
3148
+ 'changedFiles'
3149
+ ]) {
3150
+ if (typeof handoffModule[name] !== 'function') fail(`handoff module missing export: ${name}`);
3151
+ }
3152
+ if (handoffModule.normalizeListText(undefined, ['fallback']) !== JSON.stringify(['fallback'])) {
3153
+ fail('handoff normalizeListText fallback changed');
3154
+ }
3155
+ if (handoffModule.normalizeListText('["kept"]') !== '["kept"]') {
3156
+ fail('handoff normalizeListText JSON passthrough changed');
3157
+ }
3158
+ if (handoffModule.normalizeListText('one, two,, three') !== JSON.stringify(['one', 'two', 'three'])) {
3159
+ fail('handoff normalizeListText CSV parsing changed');
3160
+ }
3161
+ const handoffGitRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-handoff-git-'));
3162
+ run('git', ['init'], { cwd: handoffGitRoot });
3163
+ run('git', ['config', 'user.email', 'hcc-regression@example.invalid'], { cwd: handoffGitRoot });
3164
+ run('git', ['config', 'user.name', 'hcc regression'], { cwd: handoffGitRoot });
3165
+ fs.writeFileSync(path.join(handoffGitRoot, 'tracked.txt'), 'base\n');
3166
+ run('git', ['add', 'tracked.txt'], { cwd: handoffGitRoot });
3167
+ run('git', ['commit', '-m', 'init'], { cwd: handoffGitRoot });
3168
+ fs.writeFileSync(path.join(handoffGitRoot, 'tracked.txt'), 'changed\n');
3169
+ fs.writeFileSync(path.join(handoffGitRoot, 'staged.txt'), 'new\n');
3170
+ run('git', ['add', 'staged.txt'], { cwd: handoffGitRoot });
3171
+ const handoffChanged = handoffModule.changedFiles(handoffGitRoot);
3172
+ if (JSON.stringify(handoffChanged) !== JSON.stringify(['staged.txt', 'tracked.txt'])) {
3173
+ fail(`handoff changedFiles changed: ${JSON.stringify(handoffChanged)}`);
3174
+ }
3175
+ for (const helper of [
3176
+ 'function parseEventPayload',
3177
+ 'function uniqueList',
3178
+ 'function messageParticipants',
3179
+ 'function taskParticipants',
3180
+ 'function payloadParticipants',
3181
+ 'function peerMatchesTimelineItem',
3182
+ 'function shouldHideTimelineMessage',
3183
+ 'function shouldHideTimelineEvent',
3184
+ 'function timelineDirection',
3185
+ 'function timelineFromRows'
3186
+ ]) {
3187
+ if (hccSource.includes(helper)) fail(`CLI still embeds timeline helper: ${helper}`);
3188
+ }
3189
+ if (hccSource.includes('const TIMELINE_EVENT_ALLOW')) fail('CLI still embeds timeline event allow list');
3190
+ const timelineModule = await import(path.join(repoRoot, 'lib', 'core', 'coordination', 'timeline.mjs'));
3191
+ const compatTimelineModule = await import(path.join(repoRoot, 'lib', 'timeline.mjs'));
3192
+ for (const name of [
3193
+ 'parseEventPayload',
3194
+ 'uniqueList',
3195
+ 'messageParticipants',
3196
+ 'taskParticipants',
3197
+ 'payloadParticipants',
3198
+ 'peerMatchesTimelineItem',
3199
+ 'shouldHideTimelineMessage',
3200
+ 'shouldHideTimelineEvent',
3201
+ 'timelineDirection',
3202
+ 'timelineFromRows'
3203
+ ]) {
3204
+ if (typeof timelineModule[name] !== 'function') fail(`timeline module missing export: ${name}`);
3205
+ if (typeof compatTimelineModule[name] !== 'function') fail(`timeline compat module missing export: ${name}`);
3206
+ }
3207
+ if (JSON.stringify(timelineModule.uniqueList(['a', '', null, 'a', 7])) !== JSON.stringify(['a', '7'])) {
3208
+ fail('timeline uniqueList behavior changed');
3209
+ }
3210
+ if (JSON.stringify(timelineModule.parseEventPayload({ payload: '{bad' })) !== JSON.stringify({})) {
3211
+ fail('timeline parseEventPayload invalid JSON behavior changed');
3212
+ }
3213
+ if (!timelineModule.shouldHideTimelineMessage({ kind: 'task', body: 'Task #12 assigned: do work' }) ||
3214
+ !timelineModule.shouldHideTimelineEvent({ type: 'message.sent' }) ||
3215
+ timelineModule.shouldHideTimelineEvent({ type: 'task.done' })) {
3216
+ fail('timeline hide filters changed');
3217
+ }
3218
+ const timelineItems = timelineModule.timelineFromRows({
3219
+ messages: [
3220
+ { id: 1, sender: 'system', recipient: 'peer-b', task_id: 1, kind: 'task', body: 'Task #1 assigned: hidden', created_at: 1, read_at: null },
3221
+ { id: 2, sender: 'peer-a', recipient: 'peer-b', task_id: 2, kind: 'note', body: 'hello timeline', created_at: 2, read_at: null },
3222
+ { id: 3, sender: 'peer-b', recipient: 'all', task_id: null, kind: 'note', body: 'broadcast timeline', created_at: 3, read_at: 3 }
3223
+ ],
3224
+ handoffs: [
3225
+ { id: 4, from_peer: 'peer-a', to_peer: 'peer-b', task_id: 2, summary: 'handoff summary', created_at: 4, tests: 'tests', risks: 'risks' }
3226
+ ],
3227
+ tasks: [
3228
+ { id: 5, status: 'running', created_by: 'human', owner: 'peer-b', assignee: '', title: 'task title', created_at: 5, updated_at: 5, parent_id: null }
3229
+ ],
3230
+ locks: [
3231
+ { resource: 'bin/hcc.mjs', base_resource: 'bin/hcc.mjs', scope: 'timeline', owner: 'peer-a', task_id: 2, reason: 'hidden from peer-b', created_at: 6 }
3232
+ ],
3233
+ events: [
3234
+ { id: 7, type: 'message.sent', actor: 'peer-a', task_id: 2, payload: '{}', created_at: 7 },
3235
+ { id: 8, type: 'task.done', actor: 'peer-a', task_id: 2, payload: JSON.stringify({ owner: 'peer-b', summary: 'done summary' }), created_at: 8 }
3236
+ ]
3237
+ }, 'peer-b');
3238
+ const timelineIds = timelineItems.map((item) => item.id);
3239
+ if (JSON.stringify(timelineIds) !== JSON.stringify(['message:2', 'message:3', 'handoff:4', 'task:5', 'event:8'])) {
3240
+ fail(`timelineFromRows filtering/order changed: ${JSON.stringify(timelineItems)}`);
3241
+ }
3242
+ const inbound = timelineItems.find((item) => item.id === 'message:2');
3243
+ const broadcast = timelineItems.find((item) => item.id === 'message:3');
3244
+ if (inbound?.direction !== 'in' || inbound?.unread !== true ||
3245
+ broadcast?.direction !== 'out' || broadcast?.broadcast !== true) {
3246
+ fail(`timelineFromRows message metadata changed: ${JSON.stringify(timelineItems)}`);
3247
+ }
3248
+ for (const helper of [
3249
+ 'function taskRelatedLocks',
3250
+ 'function taskOwnerLiveness',
3251
+ 'function annotateTasksWithLiveness',
3252
+ 'function taskOwnerStateText',
3253
+ 'function summarizeTask',
3254
+ 'function formatOpenTaskLine'
3255
+ ]) {
3256
+ if (hccSource.includes(helper)) fail(`CLI still embeds task liveness helper: ${helper}`);
3257
+ }
3258
+ const taskLivenessModule = await import(path.join(repoRoot, 'lib', 'core', 'peers', 'liveness.mjs'));
3259
+ const compatTaskLivenessModule = await import(path.join(repoRoot, 'lib', 'task-liveness.mjs'));
3260
+ for (const name of [
3261
+ 'taskRelatedLocks',
3262
+ 'taskOwnerLiveness',
3263
+ 'annotateTasksWithLiveness',
3264
+ 'taskOwnerStateText',
3265
+ 'summarizeTask',
3266
+ 'formatOpenTaskLine'
3267
+ ]) {
3268
+ if (typeof taskLivenessModule[name] !== 'function') fail(`task liveness module missing export: ${name}`);
3269
+ if (typeof compatTaskLivenessModule[name] !== 'function') fail(`task liveness compat module missing export: ${name}`);
3270
+ }
3271
+ const livenessTasks = taskLivenessModule.annotateTasksWithLiveness([
3272
+ { id: 1, status: 'running', owner: 'active-owner', assignee: '', title: 'Active task', priority: 1 },
3273
+ { id: 2, status: 'running', owner: 'stale-owner', assignee: '', title: 'Stale task', priority: 2 },
3274
+ { id: 3, status: 'running', owner: 'locked-owner', assignee: '', title: 'Locked task', priority: 3 },
3275
+ { id: 4, status: 'pending', owner: 'stale-pending-owner', assignee: '', title: 'Pending stale task', priority: 4 },
3276
+ { id: 5, status: 'pending', owner: '', assignee: 'worker', title: 'Unowned task', priority: 5 }
3277
+ ], [
3278
+ { id: 'active-owner', age_sec: 10 },
3279
+ { id: 'stale-owner', last_seen_at: 100 },
3280
+ { id: 'locked-owner', age_sec: 900 },
3281
+ { id: 'stale-pending-owner', age_sec: 900 }
3282
+ ], [
3283
+ { owner: 'locked-owner', task_id: 3 }
3284
+ ], 1000, 600);
3285
+ const activeTask = livenessTasks.find((task) => task.id === 1);
3286
+ const staleTask = livenessTasks.find((task) => task.id === 2);
3287
+ const lockedTask = livenessTasks.find((task) => task.id === 3);
3288
+ const pendingTask = livenessTasks.find((task) => task.id === 4);
3289
+ const unownedTask = livenessTasks.find((task) => task.id === 5);
3290
+ if (!activeTask.owner_active || activeTask.owner_stale || taskLivenessModule.taskOwnerStateText(activeTask) !== 'active') {
3291
+ fail(`task liveness active owner changed: ${JSON.stringify(activeTask)}`);
3292
+ }
3293
+ if (!staleTask.owner_stale || !staleTask.takeover_ready || staleTask.owner_age_sec !== 900 ||
3294
+ taskLivenessModule.taskOwnerStateText(staleTask) !== 'stale/no-lock') {
3295
+ fail(`task liveness stale takeover changed: ${JSON.stringify(staleTask)}`);
3296
+ }
3297
+ if (!lockedTask.owner_stale || lockedTask.takeover_ready || lockedTask.related_lock_count !== 1 ||
3298
+ taskLivenessModule.taskOwnerStateText(lockedTask) !== 'stale/locks=1') {
3299
+ fail(`task liveness stale locked owner changed: ${JSON.stringify(lockedTask)}`);
3300
+ }
3301
+ if (!pendingTask.owner_stale || pendingTask.takeover_ready) {
3302
+ fail(`task liveness pending takeover changed: ${JSON.stringify(pendingTask)}`);
3303
+ }
3304
+ if (unownedTask.owner_known || unownedTask.owner_active !== null || unownedTask.owner_stale || unownedTask.takeover_ready) {
3305
+ fail(`task liveness unowned task changed: ${JSON.stringify(unownedTask)}`);
3306
+ }
3307
+ const summary = taskLivenessModule.summarizeTask(staleTask);
3308
+ if (!summary.takeover_ready || summary.owner_age_sec !== 900 || summary.related_lock_count !== 0) {
3309
+ fail(`task liveness summarizeTask changed: ${JSON.stringify(summary)}`);
3310
+ }
3311
+ if (taskLivenessModule.formatOpenTaskLine(staleTask) !== '#2 running owner=stale-owner owner_state=stale/no-lock: Stale task') {
3312
+ fail(`task liveness formatOpenTaskLine changed: ${taskLivenessModule.formatOpenTaskLine(staleTask)}`);
3313
+ }
3314
+ for (const helper of [
3315
+ 'function actionCommand',
3316
+ 'function makeAction',
3317
+ 'function looksLikeMultiTask',
3318
+ 'function selectCurrentTask',
3319
+ 'function deriveAutomation',
3320
+ 'function renderAutomationContext'
3321
+ ]) {
3322
+ if (hccSource.includes(helper)) fail(`CLI still embeds automation helper: ${helper}`);
3323
+ }
3324
+ const automationModule = await import(path.join(repoRoot, 'lib', 'core', 'coordination', 'automation.mjs'));
3325
+ const compatAutomationModule = await import(path.join(repoRoot, 'lib', 'automation.mjs'));
3326
+ for (const name of [
3327
+ 'actionCommand',
3328
+ 'makeAction',
3329
+ 'looksLikeMultiTask',
3330
+ 'selectCurrentTask',
3331
+ 'deriveAutomation',
3332
+ 'renderAutomationContext'
3333
+ ]) {
3334
+ if (typeof automationModule[name] !== 'function') fail(`automation module missing export: ${name}`);
3335
+ if (typeof compatAutomationModule[name] !== 'function') fail(`automation compat module missing export: ${name}`);
3336
+ }
3337
+ if (!automationModule.looksLikeMultiTask({ title: 'split', body: '- one\n- two' })) {
3338
+ fail('automation looksLikeMultiTask bullet detection changed');
3339
+ }
3340
+ const selectedTask = automationModule.selectCurrentTask([
3341
+ { id: 2, status: 'claimed', owner: 'peer-a', priority: 1 },
3342
+ { id: 1, status: 'running', owner: 'peer-a', priority: 9 }
3343
+ ], 'peer-a');
3344
+ if (selectedTask?.id !== 1) fail(`automation selectCurrentTask ranking changed: ${JSON.stringify(selectedTask)}`);
3345
+ const automationConfig = { cliName: 'hccx', activePeerTtl: 600, defaultLockTtl: 321 };
3346
+ const automationSnapshot = {
3347
+ now: 1000,
3348
+ active_peer_ttl: 600,
3349
+ peers: [
3350
+ { id: 'peer-a', age_sec: 10 },
3351
+ { id: 'other-peer', age_sec: 20 }
3352
+ ],
3353
+ tasks: [
3354
+ { id: 10, status: 'running', owner: 'peer-a', assignee: '', title: 'Main task', priority: 1, parent_id: null }
3355
+ ],
3356
+ locks: [],
3357
+ messages: []
3358
+ };
3359
+ const acquireAutomation = automationModule.deriveAutomation(
3360
+ automationSnapshot,
3361
+ 'peer-a',
3362
+ { resource: 'bin/hcc.mjs', scope: 'automation' },
3363
+ automationConfig
3364
+ );
3365
+ if (acquireAutomation.phase !== 'acquire_lock' ||
3366
+ acquireAutomation.next_action.kind !== 'lock.acquire' ||
3367
+ !acquireAutomation.next_action.argv.includes('321') ||
3368
+ !String(acquireAutomation.next_action.command || '').startsWith('hccx lock acquire')) {
3369
+ fail(`automation lock acquire action changed: ${JSON.stringify(acquireAutomation, null, 2)}`);
3370
+ }
3371
+ const finishAutomation = automationModule.deriveAutomation(
3372
+ automationSnapshot,
3373
+ 'peer-a',
3374
+ { intent: 'finish' },
3375
+ automationConfig
3376
+ );
3377
+ if (finishAutomation.phase !== 'handoff' || finishAutomation.next_action.kind !== 'handoff.create') {
3378
+ fail(`automation finish action changed: ${JSON.stringify(finishAutomation, null, 2)}`);
3379
+ }
3380
+ const claimAutomation = automationModule.deriveAutomation({
3381
+ ...automationSnapshot,
3382
+ tasks: [
3383
+ { id: 11, status: 'pending', owner: '', assignee: 'peer-a', title: 'Assigned task', priority: 1 }
3384
+ ]
3385
+ }, 'peer-a', {}, automationConfig);
3386
+ if (claimAutomation.phase !== 'claim_task' ||
3387
+ claimAutomation.next_action.kind !== 'task.claim' ||
3388
+ claimAutomation.next_action.task_id !== 11) {
3389
+ fail(`automation assigned claim action changed: ${JSON.stringify(claimAutomation, null, 2)}`);
3390
+ }
3391
+ const conflictAutomation = automationModule.deriveAutomation({
3392
+ ...automationSnapshot,
3393
+ locks: [
3394
+ { resource: 'scoped:runtime', base_resource: 'bin/hcc.mjs', scope: 'automation', owner: 'other-peer', task_id: 99, expires_at: 1200 }
3395
+ ]
3396
+ }, 'peer-a', { resource: 'bin/hcc.mjs', scope: 'automation' }, automationConfig);
3397
+ if (conflictAutomation.phase !== 'coordinate_lock' ||
3398
+ conflictAutomation.next_action.kind !== 'msg.send' ||
3399
+ conflictAutomation.next_action.lock_owner !== 'other-peer') {
3400
+ fail(`automation lock conflict action changed: ${JSON.stringify(conflictAutomation, null, 2)}`);
3401
+ }
3402
+ const automationContext = automationModule.renderAutomationContext(acquireAutomation);
3403
+ if (!automationContext.includes('phase: acquire_lock') ||
3404
+ !automationContext.includes('why: task #10 needs bin/hcc.mjs [automation] lock')) {
3405
+ fail(`automation render context changed: ${automationContext}`);
3406
+ }
3407
+ for (const helper of [
3408
+ 'function renderStatusSummary',
3409
+ 'function normalizeStateResources',
3410
+ 'function renderStateSummary'
3411
+ ]) {
3412
+ if (hccSource.includes(helper)) fail(`CLI still embeds state render helper: ${helper}`);
3413
+ }
3414
+ const stateRender = await import(path.join(repoRoot, 'lib', 'ui', 'state-render.mjs'));
3415
+ const compatStateRender = await import(path.join(repoRoot, 'lib', 'state-render.mjs'));
3416
+ for (const name of [
3417
+ 'renderStatusSummary',
3418
+ 'normalizeStateResources',
3419
+ 'renderStateSummary'
3420
+ ]) {
3421
+ if (typeof stateRender[name] !== 'function') fail(`state render module missing export: ${name}`);
3422
+ if (typeof compatStateRender[name] !== 'function') fail(`state render compat module missing export: ${name}`);
3423
+ }
3424
+ const normalizedResources = stateRender.normalizeStateResources(['bin/hcc.mjs,scripts/regression.mjs', 'bin/hcc.mjs', '', null]);
3425
+ if (JSON.stringify(normalizedResources) !== JSON.stringify(['bin/hcc.mjs', 'scripts/regression.mjs'])) {
3426
+ fail(`state resource normalization changed: ${JSON.stringify(normalizedResources)}`);
3427
+ }
3428
+ const renderedStatus = stateRender.renderStatusSummary({
3429
+ root: '/repo',
3430
+ db: '/repo/.hello-cc/mesh.db',
3431
+ active_peers: 2,
3432
+ stale_peers: 3,
3433
+ tasks: [
3434
+ { status: 'done', n: 4 },
3435
+ { status: 'running', n: 1 }
3436
+ ],
3437
+ active_locks: 5,
3438
+ unread: 6,
3439
+ recent_events: [
3440
+ { id: 7, type: 'task.done', actor: 'peer-a', task_id: 8, created_at: 9 }
3441
+ ]
3442
+ }, 'peer-a');
3443
+ if (!renderedStatus.includes('peers: active=2, stale=3') ||
3444
+ !renderedStatus.includes('tasks: done:4, running:1') ||
3445
+ !renderedStatus.includes('inbox(peer-a): unread=6') ||
3446
+ !renderedStatus.includes('1970-01-01T00:00:09.000Z')) {
3447
+ fail(`state render status output changed:\n${renderedStatus}`);
3448
+ }
3449
+ const renderedState = stateRender.renderStateSummary({
3450
+ root: '/repo',
3451
+ automation: {
3452
+ current_task: { id: 10, status: 'running', title: 'State task' },
3453
+ phase: 'work',
3454
+ next_action: { kind: 'none', command: '', reason: 'continue task #10' },
3455
+ finish_actions: [{ command: 'hcc handoff create' }],
3456
+ warnings: ['review locks before commit']
3457
+ },
3458
+ timeline: [
3459
+ { ts: 11, source: 'message', source_id: 12, title: 'note', text: 'body' }
3460
+ ]
3461
+ }, 'peer-a');
3462
+ if (!renderedState.includes('current task: #10 running State task') ||
3463
+ !renderedState.includes('next: none') ||
3464
+ !renderedState.includes('- hcc handoff create') ||
3465
+ !renderedState.includes('review locks before commit') ||
3466
+ !renderedState.includes('message:12 note')) {
3467
+ fail(`state render state output changed:\n${renderedState}`);
3468
+ }
3469
+ for (const helper of [
3470
+ 'function helpMain',
3471
+ 'function helpTask',
3472
+ 'function helpTeam',
3473
+ 'function helpState',
3474
+ 'function helpJoin',
3475
+ 'function helpEnv',
3476
+ 'function helpMsg',
3477
+ 'function helpAsk',
3478
+ 'function helpBroadcast',
3479
+ 'function helpInject',
3480
+ 'function helpPeer',
3481
+ 'function helpLock',
3482
+ 'function helpHandoff',
3483
+ 'function helpEvent',
3484
+ 'function helpRun',
3485
+ 'function helpUp',
3486
+ 'function helpDown',
3487
+ 'function helpUpdate',
3488
+ 'function helpUninstall',
3489
+ 'function helpWeb'
3490
+ ]) {
3491
+ if (hccSource.includes(helper)) fail(`CLI still embeds help text helper: ${helper}`);
3492
+ }
3493
+ const helpModule = await import(path.join(repoRoot, 'lib', 'ui', 'help.mjs'));
3494
+ const compatHelpModule = await import(path.join(repoRoot, 'lib', 'help.mjs'));
3495
+ if (typeof helpModule.createHelpFunctions !== 'function') fail('help module missing createHelpFunctions export');
3496
+ if (typeof compatHelpModule.createHelpFunctions !== 'function') fail('help compat module missing createHelpFunctions export');
3497
+ const capturedHelp = [];
3498
+ const savedConsoleLog = console.log;
3499
+ try {
3500
+ console.log = (value = '') => capturedHelp.push(String(value));
3501
+ const helpFns = helpModule.createHelpFunctions({
3502
+ productName: 'product-x',
3503
+ version: '1.2.3',
3504
+ cliName: 'hccx',
3505
+ npmPackageName: '@scope/pkg-x'
3506
+ });
3507
+ for (const name of [
3508
+ 'helpMain',
3509
+ 'helpTask',
3510
+ 'helpTeam',
3511
+ 'helpState',
3512
+ 'helpJoin',
3513
+ 'helpEnv',
3514
+ 'helpMsg',
3515
+ 'helpAsk',
3516
+ 'helpBroadcast',
3517
+ 'helpInject',
3518
+ 'helpPeer',
3519
+ 'helpLock',
3520
+ 'helpHandoff',
3521
+ 'helpEvent',
3522
+ 'helpRun',
3523
+ 'helpUp',
3524
+ 'helpDown',
3525
+ 'helpUpdate',
3526
+ 'helpUninstall',
3527
+ 'helpWeb'
3528
+ ]) {
3529
+ if (typeof helpFns[name] !== 'function') fail(`help factory missing function: ${name}`);
3530
+ }
3531
+ helpFns.helpMain();
3532
+ helpFns.helpUpdate();
3533
+ helpFns.helpUninstall();
3534
+ helpFns.helpWeb();
3535
+ } finally {
3536
+ console.log = savedConsoleLog;
3537
+ }
3538
+ const [factoryMainHelp, factoryUpdateHelp, factoryUninstallHelp, factoryWebHelp] = capturedHelp;
3539
+ if (!factoryMainHelp?.startsWith('product-x 1.2.3') ||
3540
+ !factoryMainHelp.includes('hccx [--root DIR]') ||
3541
+ !factoryUpdateHelp?.includes('npm install -g @scope/pkg-x@TAG') ||
3542
+ !factoryUninstallHelp?.includes('hccx uninstall [--purge --yes]') ||
3543
+ !factoryWebHelp?.includes("HCC_WEB_TOKEN='long-token' hccx web --port 8787")) {
3544
+ fail(`help factory output changed:\n${capturedHelp.join('\n---\n')}`);
3545
+ }
3546
+ for (const helper of [
3547
+ 'function sendMessage(',
3548
+ 'function queryInbox(',
3549
+ 'function queryTimelineMessages(',
3550
+ 'function getMessage(',
3551
+ 'function queryMessageThread(',
3552
+ 'function ackMessage('
3553
+ ]) {
3554
+ if (hccSource.includes(helper)) fail(`CLI still embeds message store helper: ${helper}`);
3555
+ }
3556
+ const messagesModule = await import(path.join(repoRoot, 'lib', 'core', 'coordination', 'messages.mjs'));
3557
+ const compatMessagesModule = await import(path.join(repoRoot, 'lib', 'messages.mjs'));
3558
+ if (typeof messagesModule.createMessageStore !== 'function') fail('messages module missing createMessageStore export');
3559
+ if (typeof compatMessagesModule.createMessageStore !== 'function') fail('messages compat module missing createMessageStore export');
3560
+ const messageEvents = [];
3561
+ const messageStore = messagesModule.createMessageStore({
3562
+ now: () => 1234,
3563
+ addEvent: (_db, type, actor, taskId, payload) => messageEvents.push({ type, actor, taskId, payload })
3564
+ });
3565
+ for (const name of [
3566
+ 'ackMessage',
3567
+ 'getMessage',
3568
+ 'queryInbox',
3569
+ 'queryMessageThread',
3570
+ 'queryTimelineMessages',
3571
+ 'sendMessage'
3572
+ ]) {
3573
+ if (typeof messageStore[name] !== 'function') fail(`message store missing function: ${name}`);
2072
3574
  }
3575
+ const messageDb = new DatabaseSync(':memory:');
3576
+ try {
3577
+ messageDb.exec(`
3578
+ CREATE TABLE messages (
3579
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3580
+ sender TEXT NOT NULL,
3581
+ recipient TEXT,
3582
+ task_id INTEGER,
3583
+ kind TEXT NOT NULL DEFAULT 'note',
3584
+ body TEXT NOT NULL,
3585
+ reply_to INTEGER,
3586
+ thread_id INTEGER,
3587
+ created_at INTEGER NOT NULL
3588
+ );
3589
+ CREATE TABLE message_reads (
3590
+ message_id INTEGER NOT NULL,
3591
+ peer TEXT NOT NULL,
3592
+ read_at INTEGER NOT NULL,
3593
+ PRIMARY KEY (message_id, peer)
3594
+ );
3595
+ `);
3596
+ const firstId = messageStore.sendMessage(messageDb, 'alice', 'bob', 42, 'note', 'hello');
3597
+ const replyId = messageStore.sendMessage(messageDb, 'bob', 'alice', 42, 'reply', 'ack', {
3598
+ reply_to: firstId,
3599
+ thread_id: firstId
3600
+ });
3601
+ const inbox = messageStore.queryInbox(messageDb, 'bob', false, 10);
3602
+ const thread = messageStore.queryMessageThread(messageDb, replyId, 10);
3603
+ messageStore.ackMessage(messageDb, 'bob', inbox[0]);
3604
+ const unread = messageStore.queryInbox(messageDb, 'bob', false, 10);
3605
+ const timeline = messageStore.queryTimelineMessages(messageDb, 'bob', 10);
3606
+ if (firstId !== 1 ||
3607
+ replyId !== 2 ||
3608
+ inbox.length !== 1 ||
3609
+ inbox[0].thread_id !== firstId ||
3610
+ thread.thread_id !== firstId ||
3611
+ thread.messages.length !== 2 ||
3612
+ unread.length !== 0 ||
3613
+ timeline.length !== 2 ||
3614
+ messageEvents.map((event) => event.type).join(',') !== 'message.sent,message.sent,message.ack') {
3615
+ fail('message store smoke test changed expected send/inbox/thread/ack behavior');
3616
+ }
3617
+ } finally {
3618
+ messageDb.close();
3619
+ }
3620
+ for (const helper of [
3621
+ 'function claimTaskRowsForPeer(',
3622
+ 'function takeoverPolicyDetails(',
3623
+ 'function takeOverTaskForPeer(',
3624
+ 'function queryOpenTasks(',
3625
+ 'function taskById(',
3626
+ 'function teamChildren(',
3627
+ 'function teamSummary(',
3628
+ 'function claimNextTasksForPeer('
3629
+ ]) {
3630
+ if (hccSource.includes(helper)) fail(`CLI still embeds task store helper: ${helper}`);
3631
+ }
3632
+ const taskStoreModule = await import(path.join(repoRoot, 'lib', 'core', 'coordination', 'tasks.mjs'));
3633
+ const compatTaskStoreModule = await import(path.join(repoRoot, 'lib', 'task-store.mjs'));
3634
+ if (typeof taskStoreModule.createTaskStore !== 'function') fail('task store module missing createTaskStore export');
3635
+ if (typeof compatTaskStoreModule.createTaskStore !== 'function') fail('task store compat module missing createTaskStore export');
3636
+ const taskEvents = [];
3637
+ const taskMessages = [];
3638
+ const taskStore = taskStoreModule.createTaskStore({
3639
+ now: () => 2000,
3640
+ activePeerTtl: 60,
3641
+ addEvent: (_db, type, actor, taskId, payload) => taskEvents.push({ type, actor, taskId, payload }),
3642
+ sendMessage: (_db, sender, recipient, taskId, kind, body) => {
3643
+ taskMessages.push({ sender, recipient, taskId, kind, body });
3644
+ return taskMessages.length;
3645
+ }
3646
+ });
3647
+ for (const name of [
3648
+ 'claimNextTasksForPeer',
3649
+ 'claimTaskRowsForPeer',
3650
+ 'queryOpenTasks',
3651
+ 'takeOverTaskForPeer',
3652
+ 'taskById',
3653
+ 'teamChildren',
3654
+ 'teamSummary',
3655
+ 'takeoverPolicyDetails'
3656
+ ]) {
3657
+ if (typeof taskStore[name] !== 'function') fail(`task store missing function: ${name}`);
3658
+ }
3659
+ const taskDb = new DatabaseSync(':memory:');
3660
+ try {
3661
+ taskDb.exec(`
3662
+ CREATE TABLE tasks (
3663
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3664
+ title TEXT NOT NULL,
3665
+ body TEXT,
3666
+ status TEXT NOT NULL DEFAULT 'pending',
3667
+ assignee TEXT,
3668
+ owner TEXT,
3669
+ parent_id INTEGER,
3670
+ team_role TEXT,
3671
+ priority INTEGER NOT NULL DEFAULT 100,
3672
+ created_by TEXT,
3673
+ claimed_at INTEGER,
3674
+ completed_at INTEGER,
3675
+ created_at INTEGER NOT NULL,
3676
+ updated_at INTEGER NOT NULL
3677
+ );
3678
+ CREATE TABLE peers (
3679
+ id TEXT PRIMARY KEY,
3680
+ last_seen_at INTEGER NOT NULL
3681
+ );
3682
+ CREATE TABLE handoffs (
3683
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3684
+ task_id INTEGER,
3685
+ summary TEXT
3686
+ );
3687
+ `);
3688
+ taskDb.prepare(`
3689
+ INSERT INTO tasks(title, status, assignee, owner, parent_id, team_role, priority, created_by, created_at, updated_at)
3690
+ VALUES
3691
+ ('assigned pending', 'pending', 'bob', NULL, NULL, NULL, 10, 'alice', 1000, 1000),
3692
+ ('stale owned', 'running', NULL, 'old-owner', NULL, NULL, 20, 'alice', 1000, 1000),
3693
+ ('parent', 'pending', NULL, NULL, NULL, NULL, 30, 'alice', 1000, 1000),
3694
+ ('child done', 'done', NULL, NULL, 3, 'worker', 31, 'alice', 1000, 1000),
3695
+ ('next pending', 'pending', NULL, NULL, NULL, NULL, 40, 'alice', 1000, 1000)
3696
+ `).run();
3697
+ taskDb.prepare('INSERT INTO peers(id, last_seen_at) VALUES (?, ?)').run('old-owner', 1000);
3698
+ taskDb.prepare('INSERT INTO handoffs(task_id, summary) VALUES (?, ?)').run(4, 'child handoff');
3699
+ const claimed = taskStore.claimTaskRowsForPeer(taskDb, 'bob', [1]);
3700
+ const blockedReject = (() => {
3701
+ try {
3702
+ taskStore.takeOverTaskForPeer(taskDb, 'taker', 2, { reason: 'blocked policy', policy: 'blocked' });
3703
+ return false;
3704
+ } catch (err) {
3705
+ return err?.code === 'TAKEOVER_POLICY';
3706
+ }
3707
+ })();
3708
+ const taken = taskStore.takeOverTaskForPeer(taskDb, 'taker', 2, { reason: 'stale policy', policy: 'stale', staleAfter: 60 });
3709
+ const next = taskStore.claimNextTasksForPeer(taskDb, 'next-peer', { count: 1 });
3710
+ const openForBob = taskStore.queryOpenTasks(taskDb, 10, 'bob');
3711
+ const summary = taskStore.teamSummary(taskDb, 3);
3712
+ if (claimed.length !== 1 ||
3713
+ claimed[0].owner !== 'bob' ||
3714
+ !blockedReject ||
3715
+ taken.owner !== 'taker' ||
3716
+ taskMessages.length !== 1 ||
3717
+ !taskMessages[0].body.includes('Task #2 taken over by taker') ||
3718
+ next.tasks.length !== 1 ||
3719
+ next.tasks[0].owner !== 'next-peer' ||
3720
+ openForBob.length !== 1 ||
3721
+ summary.children.length !== 1 ||
3722
+ summary.counts.done !== 1 ||
3723
+ taskEvents.map((event) => event.type).join(',') !== 'task.claimed,task.takeover,task.claimed') {
3724
+ fail('task store smoke test changed expected claim/takeover/next/team behavior');
3725
+ }
3726
+ } finally {
3727
+ taskDb.close();
3728
+ }
3729
+ for (const helper of [
3730
+ 'function parseTaskIds(',
3731
+ 'function positiveIntOpt(',
3732
+ 'function taskRowsText('
3733
+ ]) {
3734
+ if (hccSource.includes(helper)) fail(`CLI still embeds task CLI helper: ${helper}`);
3735
+ }
3736
+ const taskCliModule = await import(path.join(repoRoot, 'lib', 'task-cli.mjs'));
3737
+ for (const name of ['parseTaskIds', 'positiveIntOpt', 'taskRowsText']) {
3738
+ if (typeof taskCliModule[name] !== 'function') fail(`task CLI module missing function: ${name}`);
3739
+ }
3740
+ const parsedTaskIds = taskCliModule.parseTaskIds({
3741
+ id: ['1,2', '2'],
3742
+ ids: '3',
3743
+ _: ['4']
3744
+ });
3745
+ if (parsedTaskIds.join(',') !== '1,2,3,4') {
3746
+ fail(`task CLI parseTaskIds changed expected id normalization: ${parsedTaskIds.join(',')}`);
3747
+ }
3748
+ const parseRejects = (() => {
3749
+ try {
3750
+ taskCliModule.parseTaskIds({ id: 'bad' });
3751
+ return false;
3752
+ } catch (err) {
3753
+ return err?.code === 'BAD_ARGS';
3754
+ }
3755
+ })();
3756
+ const emptyRejects = (() => {
3757
+ try {
3758
+ taskCliModule.parseTaskIds({});
3759
+ return false;
3760
+ } catch (err) {
3761
+ return err?.code === 'BAD_ARGS';
3762
+ }
3763
+ })();
3764
+ const lowRejects = (() => {
3765
+ try {
3766
+ taskCliModule.positiveIntOpt({ count: '0' }, 'count', 1, { max: 5 });
3767
+ return false;
3768
+ } catch (err) {
3769
+ return err?.code === 'BAD_ARGS';
3770
+ }
3771
+ })();
3772
+ const highRejects = (() => {
3773
+ try {
3774
+ taskCliModule.positiveIntOpt({ count: '6' }, 'count', 1, { max: 5 });
3775
+ return false;
3776
+ } catch (err) {
3777
+ return err?.code === 'BAD_ARGS';
3778
+ }
3779
+ })();
3780
+ if (!parseRejects ||
3781
+ !emptyRejects ||
3782
+ !lowRejects ||
3783
+ !highRejects ||
3784
+ taskCliModule.positiveIntOpt({ count: '2' }, 'count', 1, { max: 5 }) !== 2 ||
3785
+ taskCliModule.taskRowsText([], 'claimed') !== 'no pending task' ||
3786
+ taskCliModule.taskRowsText([{ id: 7, title: 'demo' }], 'claimed') !== 'claimed task #7: demo') {
3787
+ fail('task CLI smoke test changed expected parse/count/render behavior');
3788
+ }
3789
+ for (const helper of [
3790
+ 'const WEB_CHILD_ENV',
3791
+ 'const LAUNCH_FINGERPRINT_ENV',
3792
+ 'const PROVIDER_STATE_ENV',
3793
+ 'const LAUNCH_ENV_IGNORED_KEYS',
3794
+ 'function childSessionEnv',
3795
+ 'function launchEnvironmentFingerprint',
3796
+ 'function launchFingerprint',
3797
+ 'function isLikelyShellCommand',
3798
+ 'function isProviderFallbackWrapper',
3799
+ 'function isRelaunchableProviderSession',
3800
+ 'function tmuxProviderState',
3801
+ 'function tmuxManagedSessionName',
3802
+ 'function tmuxEnvironmentArgs',
3803
+ 'function isolatedEnvCommandArgs'
3804
+ ]) {
3805
+ if (hccSource.includes(helper)) fail(`CLI still embeds session launch helper: ${helper}`);
3806
+ }
3807
+ const sessionLaunchSource = fs.readFileSync(path.join(repoRoot, 'lib', 'core', 'sessions', 'launch.mjs'), 'utf8');
3808
+ const compatSessionLaunchSource = fs.readFileSync(path.join(repoRoot, 'lib', 'session-launch.mjs'), 'utf8');
3809
+ if (sessionLaunchSource.includes("from '../../terminal/") ||
3810
+ sessionLaunchSource.includes("from '../terminal/") ||
3811
+ sessionLaunchSource.includes('tmuxSessionEnvironmentValue')) {
3812
+ fail('core session launch module depends on terminal/tmux helpers');
3813
+ }
3814
+ if (!compatSessionLaunchSource.includes("from './core/sessions/launch.mjs'") ||
3815
+ !compatSessionLaunchSource.includes("from './terminal/tmux.mjs'")) {
3816
+ fail('session launch compatibility module does not re-export from the new boundaries');
3817
+ }
3818
+ const sessionLaunch = await import(path.join(repoRoot, 'lib', 'core', 'sessions', 'launch.mjs'));
3819
+ const compatSessionLaunch = await import(path.join(repoRoot, 'lib', 'session-launch.mjs'));
3820
+ const tmuxModuleForSessionLaunch = await import(path.join(repoRoot, 'lib', 'tmux.mjs'));
3821
+ for (const name of [
3822
+ 'childSessionEnv',
3823
+ 'launchEnvironmentFingerprint',
3824
+ 'launchFingerprint',
3825
+ 'isLikelyShellCommand',
3826
+ 'isProviderFallbackWrapper',
3827
+ 'isRelaunchableProviderSession',
3828
+ 'isolatedEnvCommandArgs'
3829
+ ]) {
3830
+ if (typeof sessionLaunch[name] !== 'function') fail(`session launch module missing export: ${name}`);
3831
+ if (compatSessionLaunch[name] !== sessionLaunch[name]) fail(`session launch compat export mismatch: ${name}`);
3832
+ }
3833
+ for (const name of [
3834
+ 'tmuxProviderState',
3835
+ 'tmuxManagedSessionName',
3836
+ 'tmuxEnvironmentArgs'
3837
+ ]) {
3838
+ if (typeof tmuxModuleForSessionLaunch[name] !== 'function') fail(`tmux module missing session launch adapter: ${name}`);
3839
+ if (compatSessionLaunch[name] !== tmuxModuleForSessionLaunch[name]) {
3840
+ fail(`session launch compat terminal export mismatch: ${name}`);
3841
+ }
3842
+ }
3843
+ if (sessionLaunch.WEB_CHILD_ENV !== 'HCC_WEB_CHILD' ||
3844
+ sessionLaunch.LAUNCH_FINGERPRINT_ENV !== 'HCC_LAUNCH_FINGERPRINT' ||
3845
+ sessionLaunch.PROVIDER_STATE_ENV !== 'HCC_PROVIDER_STATE') {
3846
+ fail('session launch env constant changed');
3847
+ }
3848
+ const childEnv = sessionLaunch.childSessionEnv({ EXTRA: '1' }, { HCC_WEB_CHILD: '1', BASE: '2' });
3849
+ if (childEnv.HCC_WEB_CHILD !== undefined || childEnv.BASE !== '2' || childEnv.EXTRA !== '1') {
3850
+ fail(`session launch childSessionEnv changed: ${JSON.stringify(childEnv)}`);
3851
+ }
3852
+ const launchEnvA = sessionLaunch.launchEnvironmentFingerprint({
3853
+ B: '2',
3854
+ A: '1',
3855
+ PWD: '/ignored',
3856
+ TMUX: 'ignored',
3857
+ HCC_PEER: 'ignored'
3858
+ });
3859
+ const launchEnvB = sessionLaunch.launchEnvironmentFingerprint({ A: '1', B: '2' });
3860
+ const launchEnvC = sessionLaunch.launchEnvironmentFingerprint({ A: '1', B: '3' });
3861
+ if (launchEnvA !== launchEnvB || launchEnvA === launchEnvC) {
3862
+ fail('session launch environment fingerprint filtering/sorting changed');
3863
+ }
3864
+ const launchFingerprintA = sessionLaunch.launchFingerprint({ command: 'cmd', cwd: '/tmp/a', env: { A: '1' } });
3865
+ const launchFingerprintB = sessionLaunch.launchFingerprint({ command: 'cmd', cwd: '/tmp/b', env: { A: '1' } });
3866
+ if (launchFingerprintA === launchFingerprintB) {
3867
+ fail('session launch fingerprint no longer includes cwd');
3868
+ }
3869
+ if (!sessionLaunch.isLikelyShellCommand('/bin/bash') ||
3870
+ !sessionLaunch.isLikelyShellCommand('-zsh') ||
3871
+ sessionLaunch.isLikelyShellCommand('node')) {
3872
+ fail('session launch shell command detection changed');
3873
+ }
3874
+ if (!sessionLaunch.isProviderFallbackWrapper(`${sessionLaunch.PROVIDER_STATE_ENV}=starting exec bash`) ||
3875
+ !sessionLaunch.isProviderFallbackWrapper('exec zsh') ||
3876
+ sessionLaunch.isProviderFallbackWrapper('node script.js')) {
3877
+ fail('session launch provider fallback detection changed');
3878
+ }
3879
+ if (!sessionLaunch.isRelaunchableProviderSession('shell', 'exec bash', { provider: 'codex' }) ||
3880
+ sessionLaunch.isRelaunchableProviderSession('shell', 'exec bash', {}) ||
3881
+ sessionLaunch.isRelaunchableProviderSession('codex', 'node script.js', {})) {
3882
+ fail('session launch relaunchable provider detection changed');
3883
+ }
3884
+ const managedSessionName = tmuxModuleForSessionLaunch.tmuxManagedSessionName({ root: '/tmp/hcc root' }, 'Bad Peer!');
3885
+ if (!managedSessionName.startsWith('hcc-') ||
3886
+ !managedSessionName.endsWith('-bad-peer') ||
3887
+ managedSessionName.length > 80) {
3888
+ fail(`session launch tmux session name changed: ${managedSessionName}`);
3889
+ }
3890
+ const tmuxEnvArgs = tmuxModuleForSessionLaunch.tmuxEnvironmentArgs({
3891
+ A: '1',
3892
+ TMUX: 'ignored',
3893
+ 'BAD-NAME': 'ignored',
3894
+ B: 2,
3895
+ C: null
3896
+ });
3897
+ if (JSON.stringify(tmuxEnvArgs) !== JSON.stringify(['-e', 'A=1', '-e', 'B=2'])) {
3898
+ fail(`session launch tmux env args changed: ${JSON.stringify(tmuxEnvArgs)}`);
3899
+ }
3900
+ const isolatedEnvArgs = sessionLaunch.isolatedEnvCommandArgs({
3901
+ A: '1',
3902
+ TMUX_PANE: 'ignored',
3903
+ 'BAD-NAME': 'ignored',
3904
+ B: 2,
3905
+ C: null
3906
+ });
3907
+ if (!['/usr/bin/env', 'env'].includes(isolatedEnvArgs[0]) ||
3908
+ isolatedEnvArgs[1] !== '-i' ||
3909
+ !isolatedEnvArgs.includes('A=1') ||
3910
+ !isolatedEnvArgs.includes('B=2') ||
3911
+ isolatedEnvArgs.some((arg) => arg.includes('TMUX_PANE') || arg.includes('BAD-NAME'))) {
3912
+ fail(`session launch isolated env args changed: ${JSON.stringify(isolatedEnvArgs)}`);
3913
+ }
3914
+ for (const helper of [
3915
+ 'function runTmux',
3916
+ 'function tmuxInstallHint',
3917
+ 'function commandExists',
3918
+ 'function runInstallCommand',
3919
+ 'function tryInstallTmux',
3920
+ 'function ensureTmuxAvailable',
3921
+ 'function tmuxHasSession',
3922
+ 'function tmuxSessionHasClients',
3923
+ 'function tmuxKillSession',
3924
+ 'function tmuxSessionEnvironmentValue',
3925
+ 'function tmuxPaneInfo',
3926
+ 'function tmuxCapturePane',
3927
+ 'function tmuxCursorInfo',
3928
+ 'function tmuxCursorPayload',
3929
+ 'function tmuxSendKeys',
3930
+ 'function tmuxSendRawLiteral',
3931
+ 'function tmuxInCopyMode',
3932
+ 'function tmuxExitCopyMode',
3933
+ 'function tmuxPasteBuffer',
3934
+ 'function readTmuxEscapeSequence',
3935
+ 'function isTmuxRawControlChar',
3936
+ 'function tmuxSendLiteral'
3937
+ ]) {
3938
+ if (hccSource.includes(helper)) fail(`CLI still embeds tmux helper: ${helper}`);
3939
+ }
3940
+ const tmuxModule = await import(path.join(repoRoot, 'lib', 'tmux.mjs'));
3941
+ for (const name of [
3942
+ 'runTmux',
3943
+ 'tryInstallTmux',
3944
+ 'ensureTmuxAvailable',
3945
+ 'tmuxHasSession',
3946
+ 'tmuxSessionHasClients',
3947
+ 'tmuxKillSession',
3948
+ 'tmuxSessionEnvironmentValue',
3949
+ 'tmuxLaunchFingerprint',
3950
+ 'tmuxPaneInfo',
3951
+ 'tmuxCapturePane',
3952
+ 'tmuxCursorInfo',
3953
+ 'tmuxCursorPayload',
3954
+ 'tmuxSendLiteral'
3955
+ ]) {
3956
+ if (typeof tmuxModule[name] !== 'function') fail(`tmux module missing export: ${name}`);
3957
+ }
3958
+ const cursorPayload = tmuxModule.tmuxCursorPayload('a\nb\nc\nd', {
3959
+ x: 3,
3960
+ y: 1,
3961
+ visible: true,
3962
+ history: 2,
3963
+ height: 4
3964
+ });
3965
+ if (JSON.stringify(cursorPayload) !== JSON.stringify({ row: 3, col: 3, visible: true })) {
3966
+ fail(`tmux cursor payload mapping changed: ${JSON.stringify(cursorPayload)}`);
3967
+ }
3968
+ const clampedCursorPayload = tmuxModule.tmuxCursorPayload('a\nb\nc\nd\ne', {
3969
+ x: 4,
3970
+ y: 1,
3971
+ visible: false,
3972
+ history: 5,
3973
+ height: 3
3974
+ });
3975
+ if (JSON.stringify(clampedCursorPayload) !== JSON.stringify({ row: 2, col: 4, visible: false }) ||
3976
+ tmuxModule.tmuxCursorPayload('', null) !== null) {
3977
+ fail(`tmux cursor payload clamp/null behavior changed: ${JSON.stringify(clampedCursorPayload)}`);
3978
+ }
3979
+ for (const helper of [
3980
+ 'function providerSessionPeerId',
3981
+ 'function providerSessionParts',
3982
+ 'function inferPeerKind',
3983
+ 'function hasResumeOpts',
3984
+ 'function defaultSessionCommand',
3985
+ 'function buildPeerCommand',
3986
+ 'function buildCodexCommand',
3987
+ 'function buildClaudeCommand',
3988
+ 'function bindingFromRun',
3989
+ 'function parseClaudeCommandArgs',
3990
+ 'function parseCodexCommandArgs'
3991
+ ]) {
3992
+ if (hccSource.includes(helper)) fail(`CLI still embeds provider command helper: ${helper}`);
3993
+ }
3994
+ const providerCommandsSource = fs.readFileSync(path.join(repoRoot, 'lib', 'integrations', 'providers.mjs'), 'utf8');
3995
+ const compatProviderCommandsSource = fs.readFileSync(path.join(repoRoot, 'lib', 'provider-commands.mjs'), 'utf8');
3996
+ if (!providerCommandsSource.includes("from '../core/peers/session.mjs'") ||
3997
+ providerCommandsSource.includes('function providerSessionPeerId') ||
3998
+ providerCommandsSource.includes('function providerSessionParts')) {
3999
+ fail('integrations provider module no longer delegates peer session helpers to core/peers/session.mjs');
4000
+ }
4001
+ if (!compatProviderCommandsSource.includes("from './integrations/providers.mjs'")) {
4002
+ fail('provider command compat module no longer re-exports integrations/providers.mjs');
4003
+ }
4004
+ const providerCommands = await import(path.join(repoRoot, 'lib', 'integrations', 'providers.mjs'));
4005
+ const compatProviderCommands = await import(path.join(repoRoot, 'lib', 'provider-commands.mjs'));
4006
+ const peerSession = await import(path.join(repoRoot, 'lib', 'core', 'peers', 'session.mjs'));
4007
+ for (const name of [
4008
+ 'providerSessionPeerId',
4009
+ 'providerSessionParts',
4010
+ 'inferPeerKind',
4011
+ 'hasResumeOpts',
4012
+ 'defaultSessionCommand',
4013
+ 'buildPeerCommand',
4014
+ 'buildCodexCommand',
4015
+ 'buildClaudeCommand',
4016
+ 'bindingFromRun',
4017
+ 'parseClaudeCommandArgs',
4018
+ 'parseCodexCommandArgs'
4019
+ ]) {
4020
+ if (typeof providerCommands[name] !== 'function') fail(`provider command module missing export: ${name}`);
4021
+ if (typeof compatProviderCommands[name] !== 'function') fail(`provider command compat module missing export: ${name}`);
4022
+ }
4023
+ for (const name of ['providerSessionPeerId', 'providerSessionParts']) {
4024
+ if (typeof peerSession[name] !== 'function') fail(`peer session module missing export: ${name}`);
4025
+ if (providerCommands[name] !== peerSession[name]) fail(`provider command module no longer re-exports peer session helper: ${name}`);
4026
+ if (compatProviderCommands[name] !== providerCommands[name]) fail(`provider command compat module no longer re-exports helper: ${name}`);
4027
+ }
4028
+ const providerNameSession = peerSession.providerSessionParts('named-session');
4029
+ if (providerNameSession.provider_session_name !== 'named-session' || providerNameSession.provider_session_id !== null) {
4030
+ fail(`provider command module misclassified named session: ${JSON.stringify(providerNameSession)}`);
4031
+ }
4032
+ const providerUuidSession = peerSession.providerSessionParts('00000000-0000-0000-0000-000000000000');
4033
+ if (providerUuidSession.provider_session_id !== '00000000-0000-0000-0000-000000000000' || providerUuidSession.provider_session_name !== null) {
4034
+ fail(`provider command module misclassified UUID session: ${JSON.stringify(providerUuidSession)}`);
4035
+ }
4036
+ const builtClaude = providerCommands.buildPeerCommand('claude-peer', 'claude', { resume: 'named-session' }, []);
4037
+ if (builtClaude.command !== 'claude --resume named-session' ||
4038
+ builtClaude.binding.resume_mode !== 'resume' ||
4039
+ builtClaude.binding.provider_session_name !== 'named-session') {
4040
+ fail(`provider command module built wrong Claude resume command: ${JSON.stringify(builtClaude)}`);
4041
+ }
4042
+ const builtCodex = providerCommands.buildPeerCommand('codex-peer', 'codex', { resume: 'codex-session' }, []);
4043
+ if (builtCodex.command !== 'codex resume codex-session' ||
4044
+ builtCodex.binding.resume_mode !== 'resume' ||
4045
+ builtCodex.binding.provider_session_name !== 'codex-session') {
4046
+ fail(`provider command module built wrong Codex resume command: ${JSON.stringify(builtCodex)}`);
4047
+ }
4048
+ const parsedCodex = providerCommands.parseCodexCommandArgs(['codex', 'resume', '--model', 'gpt-test', 'codex-session']);
4049
+ if (parsedCodex.resume_mode !== 'resume' ||
4050
+ parsedCodex.resume_arg !== 'codex-session' ||
4051
+ parsedCodex.session.provider_session_name !== 'codex-session') {
4052
+ fail(`provider command module parsed Codex resume args wrong: ${JSON.stringify(parsedCodex)}`);
4053
+ }
4054
+ const parsedClaude = providerCommands.parseClaudeCommandArgs(['claude', '--resume', 'claude-session', '--fork-session']);
4055
+ if (parsedClaude.resume_mode !== 'fork-resume' ||
4056
+ parsedClaude.resume_arg !== 'claude-session' ||
4057
+ parsedClaude.session.provider_session_name !== null) {
4058
+ fail(`provider command module parsed Claude fork resume args wrong: ${JSON.stringify(parsedClaude)}`);
4059
+ }
4060
+ for (const helper of [
4061
+ 'function bindingFromDetected(',
4062
+ 'function peerBindingRuntimeRank(',
4063
+ 'function comparePeerBindings(',
4064
+ 'function dedupePeerBindingRows(',
4065
+ 'function dedupeProviderSessionColumn(',
4066
+ 'function dedupeRuntimeTargets(',
4067
+ 'function dedupePeerBindings(',
4068
+ 'function bindingHasProviderSession(',
4069
+ 'function bindingProviderSessionValue(',
4070
+ 'function bindingHasRuntime(',
4071
+ 'function mergePeerBinding(',
4072
+ 'function mergeRuntimeBinding(',
4073
+ 'function findProviderSessionBinding(',
4074
+ 'function canonicalizePeerBinding(',
4075
+ 'function upsertPeerBinding(',
4076
+ 'function upsertCanonicalPeerBinding('
4077
+ ]) {
4078
+ if (hccSource.includes(helper)) fail(`CLI still embeds peer binding helper: ${helper}`);
4079
+ }
4080
+ const peerBindings = await import(path.join(repoRoot, 'lib', 'core', 'peers', 'bindings.mjs'));
4081
+ const peerReconcile = await import(path.join(repoRoot, 'lib', 'core', 'peers', 'reconcile.mjs'));
4082
+ const peerBindingStoreModule = await import(path.join(repoRoot, 'lib', 'db', 'stores', 'peers.mjs'));
4083
+ const compatPeerBindings = await import(path.join(repoRoot, 'lib', 'peer-bindings.mjs'));
4084
+ for (const name of [
4085
+ 'bindingFromDetected',
4086
+ 'peerBindingRuntimeRank',
4087
+ 'comparePeerBindings',
4088
+ 'bindingHasProviderSession',
4089
+ 'bindingProviderSessionValue',
4090
+ 'bindingHasRuntime',
4091
+ 'mergePeerBinding',
4092
+ 'mergeRuntimeBinding'
4093
+ ]) {
4094
+ if (typeof peerBindings[name] !== 'function') fail(`peer binding module missing export: ${name}`);
4095
+ if (typeof compatPeerBindings[name] !== 'function') fail(`peer binding compat module missing export: ${name}`);
4096
+ }
4097
+ if (typeof peerBindingStoreModule.createPeerBindingStore !== 'function') {
4098
+ fail('peer binding store module missing createPeerBindingStore');
4099
+ }
4100
+ if (typeof peerReconcile.reconcileRunningPeerBindings !== 'function') {
4101
+ fail('peer reconcile module missing reconcileRunningPeerBindings');
4102
+ }
4103
+ if (typeof compatPeerBindings.createPeerBindingStore !== 'function') {
4104
+ fail('peer binding compat module missing createPeerBindingStore');
4105
+ }
4106
+ const detectedBinding = peerBindings.bindingFromDetected({
4107
+ id: 'detected-peer',
4108
+ kind: 'claude',
4109
+ sessionId: 'detected-session',
4110
+ command: 'claude'
4111
+ });
4112
+ if (detectedBinding.peer !== 'detected-peer' ||
4113
+ detectedBinding.provider !== 'claude' ||
4114
+ detectedBinding.provider_session_name !== 'detected-session' ||
4115
+ detectedBinding.resume_mode !== 'detected' ||
4116
+ detectedBinding.runtime_session_id !== 'detected-peer') {
4117
+ fail(`peer binding module built wrong detected binding: ${JSON.stringify(detectedBinding)}`);
4118
+ }
4119
+ if (peerBindings.peerBindingRuntimeRank({ transport: 'tmux', runtime_target: '%1' }) <= peerBindings.peerBindingRuntimeRank({ transport: 'detected' })) {
4120
+ fail('peer binding runtime rank no longer prefers tmux runtime bindings over detected bindings');
4121
+ }
4122
+ const mergedRuntime = peerBindings.mergeRuntimeBinding(
4123
+ { peer: 'runtime-peer', command: 'codex resume old', transport: 'tmux', runtime_session_id: 'runtime-peer', runtime_target: '%9' },
4124
+ { peer: 'runtime-peer', provider: 'codex', provider_session_name: 'session-a', transport: 'hook', runtime_session_id: 'runtime-peer' }
4125
+ );
4126
+ if (mergedRuntime.transport !== 'tmux' || mergedRuntime.runtime_target !== '%9' || mergedRuntime.command !== 'codex resume old') {
4127
+ fail(`peer binding module did not preserve existing runtime binding: ${JSON.stringify(mergedRuntime)}`);
4128
+ }
4129
+ const mergedProviderSession = peerBindings.mergePeerBinding(
4130
+ { peer: 'session-peer', provider: 'claude', provider_session_id: '00000000-0000-0000-0000-000000000000', provider_session_name: null, resume_mode: 'detected', resume_arg: null, transport: 'hook', runtime_session_id: 'session-peer' },
4131
+ { peer: 'session-peer', provider: 'claude', provider_session_id: null, provider_session_name: null, resume_mode: 'attached', resume_arg: '%pane', command: 'tmux %pane (claude)', transport: 'tmux', runtime_session_id: 'session-peer', runtime_target: '%pane' }
4132
+ );
4133
+ if (mergedProviderSession.provider_session_id !== '00000000-0000-0000-0000-000000000000' ||
4134
+ mergedProviderSession.transport !== 'tmux' ||
4135
+ mergedProviderSession.runtime_target !== '%pane' ||
4136
+ mergedProviderSession.resume_mode !== 'detected') {
4137
+ fail(`peer binding module did not preserve known provider session during runtime attach: ${JSON.stringify(mergedProviderSession)}`);
4138
+ }
4139
+ const peerBindingEvents = [];
4140
+ const peerBindingStore = peerBindingStoreModule.createPeerBindingStore({
4141
+ now: () => 2000,
4142
+ addEvent: (_db, type, actor, taskId, payload) => peerBindingEvents.push({ type, actor, taskId, payload })
4143
+ });
4144
+ for (const name of [
4145
+ 'canonicalizePeerBinding',
4146
+ 'dedupePeerBindings',
4147
+ 'dedupePeerBindingRows',
4148
+ 'dedupeProviderSessionColumn',
4149
+ 'dedupeRuntimeTargets',
4150
+ 'findProviderSessionBinding',
4151
+ 'upsertCanonicalPeerBinding',
4152
+ 'upsertPeerBinding'
4153
+ ]) {
4154
+ if (typeof peerBindingStore[name] !== 'function') fail(`peer binding store missing function: ${name}`);
4155
+ }
4156
+ const peerBindingDb = new DatabaseSync(':memory:');
4157
+ try {
4158
+ peerBindingDb.exec(`
4159
+ CREATE TABLE peer_bindings (
4160
+ peer TEXT PRIMARY KEY,
4161
+ provider TEXT NOT NULL,
4162
+ provider_session_id TEXT,
4163
+ provider_session_name TEXT,
4164
+ resume_mode TEXT NOT NULL DEFAULT 'new',
4165
+ resume_arg TEXT,
4166
+ command TEXT,
4167
+ transport TEXT NOT NULL,
4168
+ runtime_session_id TEXT,
4169
+ runtime_target TEXT,
4170
+ created_at INTEGER NOT NULL,
4171
+ updated_at INTEGER NOT NULL
4172
+ );
4173
+ `);
4174
+ const insertBinding = peerBindingDb.prepare(`
4175
+ INSERT INTO peer_bindings(
4176
+ peer, provider, provider_session_id, provider_session_name, resume_mode,
4177
+ resume_arg, command, transport, runtime_session_id, runtime_target, created_at, updated_at
4178
+ ) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)
4179
+ `);
4180
+ insertBinding.run('detected-old', 'codex', 'same-session', 'detected', null, null, 'detected', 'detected-old', null, 1000, 1000);
4181
+ insertBinding.run('runtime-new', 'codex', 'same-session', 'resume', 'same-session', 'codex resume same-session', 'tmux', 'runtime-new', '%pane', 1000, 1100);
4182
+ const deleted = peerBindingStore.dedupePeerBindings(peerBindingDb);
4183
+ const dedupedRows = peerBindingDb.prepare('SELECT peer FROM peer_bindings ORDER BY peer').all();
4184
+ if (deleted !== undefined || dedupedRows.length !== 1 || dedupedRows[0].peer !== 'runtime-new') {
4185
+ fail(`peer binding dedupe did not keep runtime binding: ${JSON.stringify(dedupedRows)}`);
4186
+ }
4187
+ const canonical = peerBindingStore.upsertCanonicalPeerBinding(peerBindingDb, {
4188
+ peer: 'hook-peer',
4189
+ provider: 'codex',
4190
+ provider_session_id: null,
4191
+ provider_session_name: 'same-session',
4192
+ resume_mode: 'detected',
4193
+ resume_arg: null,
4194
+ command: null,
4195
+ transport: 'hook',
4196
+ runtime_session_id: 'hook-peer'
4197
+ }, true);
4198
+ if (canonical.peer !== 'runtime-new' || canonical.merged_from !== 'hook-peer') {
4199
+ fail(`peer binding canonicalization did not merge hook peer into runtime peer: ${JSON.stringify(canonical)}`);
4200
+ }
4201
+ const canonicalRow = peerBindingDb.prepare('SELECT peer, transport, runtime_target, provider_session_name FROM peer_bindings WHERE peer = ?').get('runtime-new');
4202
+ if (canonicalRow.transport !== 'tmux' || canonicalRow.runtime_target !== '%pane' || canonicalRow.provider_session_name !== 'same-session') {
4203
+ fail(`peer binding canonical upsert lost runtime fields: ${JSON.stringify(canonicalRow)}`);
4204
+ }
4205
+ if (!peerBindingEvents.some((event) => event.type === 'provider.session.deduped')) {
4206
+ fail(`peer binding dedupe did not emit provider.session.deduped: ${JSON.stringify(peerBindingEvents)}`);
4207
+ }
4208
+ } finally {
4209
+ peerBindingDb.close();
4210
+ }
4211
+ const reconcileDb = new DatabaseSync(':memory:');
4212
+ try {
4213
+ reconcileDb.exec(`
4214
+ CREATE TABLE peers (
4215
+ id TEXT PRIMARY KEY,
4216
+ kind TEXT NOT NULL,
4217
+ role TEXT,
4218
+ worktree TEXT,
4219
+ branch TEXT,
4220
+ pid INTEGER,
4221
+ status TEXT NOT NULL DEFAULT 'idle',
4222
+ capabilities TEXT,
4223
+ created_at INTEGER NOT NULL,
4224
+ last_seen_at INTEGER NOT NULL
4225
+ );
4226
+ CREATE TABLE peer_bindings (
4227
+ peer TEXT PRIMARY KEY,
4228
+ provider TEXT NOT NULL,
4229
+ provider_session_id TEXT,
4230
+ provider_session_name TEXT,
4231
+ resume_mode TEXT NOT NULL DEFAULT 'new',
4232
+ resume_arg TEXT,
4233
+ command TEXT,
4234
+ transport TEXT NOT NULL,
4235
+ runtime_session_id TEXT,
4236
+ runtime_target TEXT,
4237
+ created_at INTEGER NOT NULL,
4238
+ updated_at INTEGER NOT NULL
4239
+ );
4240
+ CREATE TABLE events (
4241
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
4242
+ type TEXT NOT NULL,
4243
+ actor TEXT,
4244
+ task_id INTEGER,
4245
+ payload TEXT,
4246
+ created_at INTEGER NOT NULL
4247
+ );
4248
+ CREATE UNIQUE INDEX idx_reconcile_provider_session_id
4249
+ ON peer_bindings(provider, provider_session_id)
4250
+ WHERE provider_session_id IS NOT NULL;
4251
+ CREATE UNIQUE INDEX idx_reconcile_provider_session_name
4252
+ ON peer_bindings(provider, provider_session_name)
4253
+ WHERE provider_session_name IS NOT NULL;
4254
+ `);
4255
+ const t = 3000;
4256
+ const insertPeer = reconcileDb.prepare(`
4257
+ INSERT INTO peers(id, kind, role, worktree, branch, pid, status, capabilities, created_at, last_seen_at)
4258
+ VALUES (?, ?, 'peer', ?, 'master', ?, ?, '', ?, ?)
4259
+ `);
4260
+ const insertBinding = reconcileDb.prepare(`
4261
+ INSERT INTO peer_bindings(
4262
+ peer, provider, provider_session_id, provider_session_name, resume_mode,
4263
+ resume_arg, command, transport, runtime_session_id, runtime_target, created_at, updated_at
4264
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'tmux', ?, ?, ?, ?)
4265
+ `);
4266
+ insertPeer.run('hook-known-peer', 'claude', root, 111, 'running', t, t);
4267
+ insertBinding.run('hook-known-peer', 'claude', null, null, 'attached', '%1', 'tmux %1 (claude)', 'hook-known-peer', '%1', t, t);
4268
+ insertPeer.run('argv-peer', 'codex', root, 222, 'running', t, t);
4269
+ insertBinding.run('argv-peer', 'codex', null, null, 'unknown', '%2', 'codex resume', 'argv-peer', '%2', t, t);
4270
+ insertPeer.run('weak-peer', 'codex', root, 333, 'running', t, t);
4271
+ insertBinding.run('weak-peer', 'codex', null, null, 'unknown', '%3', 'codex resume', 'weak-peer', '%3', t, t);
4272
+ insertPeer.run('known-peer', 'claude', root, 444, 'running', t, t);
4273
+ insertBinding.run('known-peer', 'claude', '11111111-1111-1111-1111-111111111111', null, 'detected', null, null, 'known-peer', '%4', t, t);
4274
+ insertPeer.run('conflict-peer', 'claude', root, 555, 'running', t, t);
4275
+ insertBinding.run('conflict-peer', 'claude', '33333333-3333-3333-3333-333333333333', null, 'detected', null, null, 'conflict-peer', '%5', t, t);
4276
+ insertPeer.run('conflict-new-peer', 'claude', root, 556, 'running', t, t);
4277
+ insertBinding.run('conflict-new-peer', 'claude', null, null, 'attached', '%6', 'tmux %6 (claude)', 'conflict-new-peer', '%6', t, t);
4278
+ insertPeer.run('provider-mismatch-conflict', 'codex', root, 666, 'running', t, t);
4279
+ insertBinding.run('provider-mismatch-conflict', 'codex', null, 'mismatch-session', 'detected', null, null, 'provider-mismatch-conflict', '%7', t, t);
4280
+ insertPeer.run('provider-mismatch-new', 'shell', root, 667, 'running', t, t);
4281
+ insertBinding.run('provider-mismatch-new', 'shell', null, null, 'attached', '%8', 'tmux %8 (bash)', 'provider-mismatch-new', '%8', t, t);
4282
+
4283
+ const reconcileEvents = [];
4284
+ const reconciled = peerReconcile.reconcileRunningPeerBindings(reconcileDb, { root }, {
4285
+ now: () => 4000,
4286
+ panes: [
4287
+ { pane: '%1', pid: 111, cwd: root },
4288
+ { pane: '%2', pid: 222, cwd: root },
4289
+ { pane: '%3', pid: 333, cwd: root },
4290
+ { pane: '%4', pid: 444, cwd: root },
4291
+ { pane: '%5', pid: 555, cwd: root },
4292
+ { pane: '%6', pid: 556, cwd: root },
4293
+ { pane: '%7', pid: 666, cwd: root },
4294
+ { pane: '%8', pid: 667, cwd: root }
4295
+ ],
4296
+ latestProviderSessionForPeer: (peer) => {
4297
+ if (peer === 'hook-known-peer') return '22222222-2222-2222-2222-222222222222';
4298
+ if (peer === 'conflict-new-peer') return '33333333-3333-3333-3333-333333333333';
4299
+ return null;
4300
+ },
4301
+ inspectProcess: (pid) => {
4302
+ if (pid === 222) return { kind: 'codex', provider_session: 'argv-session', source: 'process.argv.codex' };
4303
+ if (pid === 667) return { kind: 'codex', provider_session: 'mismatch-session', source: 'process.argv.codex' };
4304
+ return null;
4305
+ },
4306
+ addEvent: (_db, type, actor, taskId, payload) => reconcileEvents.push({ type, actor, taskId, payload })
4307
+ });
4308
+ if (reconciled.backfilled !== 2) {
4309
+ fail(`peer binding reconcile did not backfill exactly two strong-evidence rows: ${JSON.stringify(reconciled, null, 2)}`);
4310
+ }
4311
+ const hookKnown = reconcileDb.prepare('SELECT provider_session_id, provider_session_name, resume_mode, resume_arg FROM peer_bindings WHERE peer = ?').get('hook-known-peer');
4312
+ if (hookKnown.provider_session_id !== '22222222-2222-2222-2222-222222222222' || hookKnown.provider_session_name !== null) {
4313
+ fail(`peer binding reconcile did not backfill hook event session id: ${JSON.stringify(hookKnown)}`);
4314
+ }
4315
+ const argvKnown = reconcileDb.prepare('SELECT provider_session_id, provider_session_name, resume_mode, resume_arg FROM peer_bindings WHERE peer = ?').get('argv-peer');
4316
+ if (argvKnown.provider_session_id !== null || argvKnown.provider_session_name !== 'argv-session') {
4317
+ fail(`peer binding reconcile did not backfill process argv session name: ${JSON.stringify(argvKnown)}`);
4318
+ }
4319
+ const weakKnown = reconcileDb.prepare('SELECT provider_session_id, provider_session_name FROM peer_bindings WHERE peer = ?').get('weak-peer');
4320
+ if (weakKnown.provider_session_id !== null || weakKnown.provider_session_name !== null) {
4321
+ fail(`peer binding reconcile wrote weak-evidence provider session: ${JSON.stringify(weakKnown)}`);
4322
+ }
4323
+ const conflictNew = reconcileDb.prepare('SELECT provider_session_id, provider_session_name FROM peer_bindings WHERE peer = ?').get('conflict-new-peer');
4324
+ if (conflictNew.provider_session_id !== null || conflictNew.provider_session_name !== null ||
4325
+ !reconciled.results.some((result) => result.peer === 'conflict-new-peer' && result.reason === 'provider_session_conflict')) {
4326
+ fail(`peer binding reconcile did not skip conflicting provider session safely: ${JSON.stringify({ conflictNew, reconciled }, null, 2)}`);
4327
+ }
4328
+ const mismatchNew = reconcileDb.prepare('SELECT provider, provider_session_id, provider_session_name FROM peer_bindings WHERE peer = ?').get('provider-mismatch-new');
4329
+ if (mismatchNew.provider !== 'shell' || mismatchNew.provider_session_id !== null || mismatchNew.provider_session_name !== null ||
4330
+ !reconciled.results.some((result) => result.peer === 'provider-mismatch-new' && result.provider === 'codex' && result.reason === 'provider_session_conflict')) {
4331
+ fail(`peer binding reconcile did not use candidate provider for conflict detection: ${JSON.stringify({ mismatchNew, reconciled }, null, 2)}`);
4332
+ }
4333
+ if (!reconcileEvents.some((event) => event.type === 'provider.session.backfilled' && event.actor === 'hook-known-peer') ||
4334
+ !reconcileEvents.some((event) => event.type === 'provider.session.backfilled' && event.actor === 'argv-peer')) {
4335
+ fail(`peer binding reconcile did not emit backfill events: ${JSON.stringify(reconcileEvents)}`);
4336
+ }
4337
+ } finally {
4338
+ reconcileDb.close();
4339
+ }
4340
+ for (const helper of [
4341
+ 'const WHOLE_LOCK_SCOPE',
4342
+ 'function normalizeLockScope',
4343
+ 'function scopedLockResource',
4344
+ 'function lockBaseResource',
4345
+ 'function lockScope',
4346
+ 'function lockLabel',
4347
+ 'function lockArgv',
4348
+ 'function locksConflict'
4349
+ ]) {
4350
+ if (hccSource.includes(helper)) fail(`CLI still embeds lock helper: ${helper}`);
4351
+ }
4352
+ const locksModule = await import(path.join(repoRoot, 'lib', 'core', 'coordination', 'locks.mjs'));
4353
+ const compatLocksModule = await import(path.join(repoRoot, 'lib', 'locks.mjs'));
4354
+ for (const name of [
4355
+ 'normalizeLockScope',
4356
+ 'scopedLockResource',
4357
+ 'lockBaseResource',
4358
+ 'lockScope',
4359
+ 'lockLabel',
4360
+ 'lockArgv',
4361
+ 'locksConflict'
4362
+ ]) {
4363
+ if (typeof locksModule[name] !== 'function') fail(`locks module missing export: ${name}`);
4364
+ if (typeof compatLocksModule[name] !== 'function') fail(`locks compat module missing export: ${name}`);
4365
+ }
4366
+ const wholeLock = locksModule.scopedLockResource('bin/hcc.mjs', '');
4367
+ const scopedLock = locksModule.scopedLockResource('bin/hcc.mjs', 'provider-commands');
4368
+ if (wholeLock.resource !== 'bin/hcc.mjs' ||
4369
+ wholeLock.base_resource !== 'bin/hcc.mjs' ||
4370
+ wholeLock.scope !== '*') {
4371
+ fail(`locks module built wrong whole-resource lock: ${JSON.stringify(wholeLock)}`);
4372
+ }
4373
+ if (!scopedLock.resource.startsWith('scoped:') ||
4374
+ scopedLock.base_resource !== 'bin/hcc.mjs' ||
4375
+ scopedLock.scope !== 'provider-commands' ||
4376
+ locksModule.lockLabel(scopedLock) !== 'bin/hcc.mjs [provider-commands]') {
4377
+ fail(`locks module built wrong scoped lock: ${JSON.stringify(scopedLock)}`);
4378
+ }
4379
+ if (!locksModule.locksConflict(wholeLock, scopedLock) ||
4380
+ locksModule.locksConflict(scopedLock, locksModule.scopedLockResource('bin/hcc.mjs', 'tmux-helpers')) ||
4381
+ locksModule.locksConflict(scopedLock, locksModule.scopedLockResource('scripts/regression.mjs', 'provider-commands'))) {
4382
+ fail('locks module conflict behavior changed');
4383
+ }
4384
+ for (const helper of [
4385
+ 'function splitCsvList',
4386
+ 'function parseTeamItems',
4387
+ 'function inferTeamItems',
4388
+ 'function expandTeamWorkers',
4389
+ 'function assignTeamWorkers'
4390
+ ]) {
4391
+ if (hccSource.includes(helper)) fail(`CLI still embeds team planning helper: ${helper}`);
4392
+ }
4393
+ const teamPlanning = await import(path.join(repoRoot, 'lib', 'core', 'coordination', 'teams.mjs'));
4394
+ const compatTeamPlanning = await import(path.join(repoRoot, 'lib', 'team-planning.mjs'));
4395
+ for (const name of [
4396
+ 'splitCsvList',
4397
+ 'parseTeamItems',
4398
+ 'inferTeamItems',
4399
+ 'expandTeamWorkers',
4400
+ 'assignTeamWorkers'
4401
+ ]) {
4402
+ if (typeof teamPlanning[name] !== 'function') fail(`team planning module missing export: ${name}`);
4403
+ if (typeof compatTeamPlanning[name] !== 'function') fail(`team planning compat module missing export: ${name}`);
4404
+ }
4405
+ const splitTeam = teamPlanning.splitCsvList(['codex:2, claude-a', ' docs-a ']);
4406
+ if (JSON.stringify(splitTeam) !== JSON.stringify(['codex:2', 'claude-a', 'docs-a'])) {
4407
+ fail(`team planning splitCsvList changed: ${JSON.stringify(splitTeam)}`);
4408
+ }
4409
+ const parsedTeam = teamPlanning.parseTeamItems({
4410
+ item: ['docs:Update docs', 'codex-a:tests:Add: regression']
4411
+ });
4412
+ if (parsedTeam.length !== 2 ||
4413
+ parsedTeam[0].role !== 'docs' ||
4414
+ parsedTeam[0].title !== 'Update docs' ||
4415
+ parsedTeam[1].assignee !== 'codex-a' ||
4416
+ parsedTeam[1].role !== 'tests' ||
4417
+ parsedTeam[1].title !== 'Add:regression') {
4418
+ fail(`team planning parseTeamItems changed: ${JSON.stringify(parsedTeam)}`);
4419
+ }
4420
+ const inferredTeam = teamPlanning.inferTeamItems({ title: 'Parent task' }, { count: '0' });
4421
+ if (inferredTeam.length !== 1 || inferredTeam[0].title !== 'Parent task / subtask 1') {
4422
+ fail(`team planning count normalization changed: ${JSON.stringify(inferredTeam)}`);
4423
+ }
4424
+ try {
4425
+ teamPlanning.inferTeamItems({ title: 'Parent task' }, { count: 'not-a-number' });
4426
+ fail('team planning accepted non-integer count');
4427
+ } catch (err) {
4428
+ if (err?.code !== 'BAD_ARGS') throw err;
4429
+ }
4430
+ const expandedWorkers = teamPlanning.expandTeamWorkers(['Codex:2', 'claude-a'], 42);
4431
+ if (JSON.stringify(expandedWorkers) !== JSON.stringify(['codex-team-42-1', 'codex-team-42-2', 'claude-a'])) {
4432
+ fail(`team planning worker expansion changed: ${JSON.stringify(expandedWorkers)}`);
4433
+ }
4434
+ const assignedTeam = teamPlanning.assignTeamWorkers(parsedTeam, ['codex:2'], 42);
4435
+ if (assignedTeam[0].assignee !== 'codex-team-42-1' || assignedTeam[1].assignee !== 'codex-a') {
4436
+ fail(`team planning worker assignment changed: ${JSON.stringify(assignedTeam)}`);
4437
+ }
4438
+ const peerFormat = await import(path.join(repoRoot, 'lib', 'core', 'peers', 'format.mjs'));
4439
+ const compatPeerFormat = await import(path.join(repoRoot, 'lib', 'peer-format.mjs'));
4440
+ if (typeof peerFormat.sanitizePeerPart !== 'function' ||
4441
+ typeof peerFormat.shortHash !== 'function' ||
4442
+ typeof compatPeerFormat.sanitizePeerPart !== 'function' ||
4443
+ typeof compatPeerFormat.shortHash !== 'function' ||
4444
+ peerFormat.sanitizePeerPart('Bad Peer!', 'fallback') !== 'bad-peer' ||
4445
+ peerFormat.sanitizePeerPart('!!!', 'fallback') !== 'fallback' ||
4446
+ peerFormat.shortHash('hello') !== 'aaf4c61d') {
4447
+ fail('peer format module behavior changed');
4448
+ }
4449
+ for (const [label, source, expectedImport] of [
4450
+ ['peer session', fs.readFileSync(path.join(repoRoot, 'lib', 'core', 'peers', 'session.mjs'), 'utf8'), "from './format.mjs'"],
4451
+ ['team planning', fs.readFileSync(path.join(repoRoot, 'lib', 'core', 'coordination', 'teams.mjs'), 'utf8'), "from '../peers/format.mjs'"],
4452
+ ['peer identity', fs.readFileSync(path.join(repoRoot, 'lib', 'core', 'peers', 'identity.mjs'), 'utf8'), "from './format.mjs'"]
4453
+ ]) {
4454
+ if (!source.includes(expectedImport)) fail(`${label} module does not import peer format helpers`);
4455
+ if (source.includes('function sanitizePeerPart') || source.includes('function shortHash')) {
4456
+ fail(`${label} module still embeds peer format helpers`);
4457
+ }
4458
+ }
4459
+ const corePeerIdentitySource = fs.readFileSync(path.join(repoRoot, 'lib', 'core', 'peers', 'identity.mjs'), 'utf8');
4460
+ if (corePeerIdentitySource.includes('process.env') ||
4461
+ corePeerIdentitySource.includes('spawnSync(') ||
4462
+ corePeerIdentitySource.includes('/proc/')) {
4463
+ fail('core peer identity still contains process or procfs observation logic');
4464
+ }
4465
+ for (const helper of [
4466
+ 'function sanitizePeerPart',
4467
+ 'function shortHash',
4468
+ 'function currentTtyName',
4469
+ 'function readProcCmdline',
4470
+ 'function readProcEnv',
4471
+ 'function readProcParentPid',
4472
+ 'function argsLookLikeCli',
4473
+ 'function detectCliKindFromProcess',
4474
+ 'function readAncestorCliInfo',
4475
+ 'function resumeIdFromArgs',
4476
+ 'function autoPeerProviderSession',
4477
+ 'function autoPeerSessionId',
4478
+ 'function autoPeerResumeId',
4479
+ 'function autoPeerKind',
4480
+ 'function autoPeerBasis',
4481
+ 'function autoPeerId',
4482
+ 'function resolveCurrentPeer',
4483
+ 'function currentPeer'
4484
+ ]) {
4485
+ if (hccSource.includes(helper)) fail(`CLI still embeds peer identity helper: ${helper}`);
4486
+ }
4487
+ const peerIdentity = await import(path.join(repoRoot, 'lib', 'core', 'peers', 'identity.mjs'));
4488
+ const integrationPeerIdentity = await import(path.join(repoRoot, 'lib', 'integrations', 'peers', 'identity.mjs'));
4489
+ const compatPeerIdentity = await import(path.join(repoRoot, 'lib', 'peer-identity.mjs'));
4490
+ for (const name of [
4491
+ 'sanitizePeerPart',
4492
+ 'shortHash',
4493
+ 'autoPeerProviderSession',
4494
+ 'autoPeerSessionId',
4495
+ 'autoPeerResumeId',
4496
+ 'autoPeerKind',
4497
+ 'autoPeerBasis',
4498
+ 'autoPeerId',
4499
+ 'resolveCurrentPeer',
4500
+ 'currentPeer'
4501
+ ]) {
4502
+ if (typeof peerIdentity[name] !== 'function') fail(`peer identity core module missing export: ${name}`);
4503
+ }
4504
+ for (const name of [
4505
+ 'sanitizePeerPart',
4506
+ 'shortHash',
4507
+ 'currentTtyName',
4508
+ 'readAncestorCliInfo',
4509
+ 'resumeIdFromArgs',
4510
+ 'autoPeerProviderSession',
4511
+ 'autoPeerSessionId',
4512
+ 'autoPeerResumeId',
4513
+ 'autoPeerKind',
4514
+ 'autoPeerBasis',
4515
+ 'autoPeerId',
4516
+ 'resolveCurrentPeer',
4517
+ 'currentPeer'
4518
+ ]) {
4519
+ if (typeof integrationPeerIdentity[name] !== 'function') fail(`peer identity integration module missing export: ${name}`);
4520
+ if (typeof compatPeerIdentity[name] !== 'function') fail(`peer identity compat module missing export: ${name}`);
4521
+ }
4522
+ if (peerIdentity.sanitizePeerPart('Bad Peer!', 'fallback') !== 'bad-peer' ||
4523
+ peerIdentity.sanitizePeerPart('!!!', 'fallback') !== 'fallback' ||
4524
+ peerIdentity.shortHash('hello') !== 'aaf4c61d') {
4525
+ fail('peer identity sanitize/hash behavior changed');
4526
+ }
4527
+ if (integrationPeerIdentity.resumeIdFromArgs('claude', ['claude', '--resume', 'named-session']) !== 'named-session' ||
4528
+ integrationPeerIdentity.resumeIdFromArgs('claude', ['claude', '--resume=inline-session']) !== 'inline-session' ||
4529
+ integrationPeerIdentity.resumeIdFromArgs('claude', ['claude', '--resume', 'named-session', '--fork-session']) !== null ||
4530
+ integrationPeerIdentity.resumeIdFromArgs('codex', ['codex', 'resume', 'codex-session']) !== 'codex-session' ||
4531
+ integrationPeerIdentity.resumeIdFromArgs('codex', ['codex', 'resume', '--last']) !== null) {
4532
+ fail('peer identity resume id parsing changed');
4533
+ }
4534
+ const savedPeerEnv = {
4535
+ HCC_PEER: process.env.HCC_PEER,
4536
+ CLAUDE_CODE_SESSION_ID: process.env.CLAUDE_CODE_SESSION_ID,
4537
+ CLAUDECODE: process.env.CLAUDECODE,
4538
+ CODEX_SESSION_ID: process.env.CODEX_SESSION_ID,
4539
+ CODEX_THREAD_ID: process.env.CODEX_THREAD_ID,
4540
+ CODEX_MANAGED_BY_NPM: process.env.CODEX_MANAGED_BY_NPM,
4541
+ CODEX_MANAGED_BY_BUN: process.env.CODEX_MANAGED_BY_BUN
4542
+ };
4543
+ try {
4544
+ process.env.HCC_PEER = 'env-peer';
4545
+ if (integrationPeerIdentity.resolveCurrentPeer({ root: repoRoot }, {}, 'peer', 'shell').id !== 'env-peer') {
4546
+ fail('peer identity resolveCurrentPeer ignored HCC_PEER');
4547
+ }
4548
+ delete process.env.HCC_PEER;
4549
+ delete process.env.CLAUDE_CODE_SESSION_ID;
4550
+ delete process.env.CLAUDECODE;
4551
+ process.env.CODEX_SESSION_ID = '0123456789abcdef';
4552
+ delete process.env.CODEX_THREAD_ID;
4553
+ delete process.env.CODEX_MANAGED_BY_NPM;
4554
+ delete process.env.CODEX_MANAGED_BY_BUN;
4555
+ const autoPeer = integrationPeerIdentity.resolveCurrentPeer({ root: repoRoot }, {}, 'peer', 'shell');
4556
+ if (!autoPeer.auto || autoPeer.id !== 'codex-01234567') {
4557
+ fail(`peer identity auto peer id changed: ${JSON.stringify(autoPeer)}`);
4558
+ }
4559
+ const explicitPeer = integrationPeerIdentity.resolveCurrentPeer({ root: repoRoot }, { peer: 'manual-peer' }, 'peer', 'shell');
4560
+ if (explicitPeer.auto || explicitPeer.id !== 'manual-peer') {
4561
+ fail(`peer identity explicit peer resolution changed: ${JSON.stringify(explicitPeer)}`);
4562
+ }
4563
+ } finally {
4564
+ for (const [key, value] of Object.entries(savedPeerEnv)) {
4565
+ if (value === undefined) delete process.env[key];
4566
+ else process.env[key] = value;
4567
+ }
4568
+ }
4569
+ for (const helper of [
4570
+ 'function projectRecord',
4571
+ 'function readProjectRegistry',
4572
+ 'function writeProjectRegistry',
4573
+ 'function registerProject',
4574
+ 'function registerProjectActivity'
4575
+ ]) {
4576
+ if (hccSource.includes(helper)) fail(`CLI still embeds project registry helper: ${helper}`);
4577
+ }
4578
+ const projectRegistry = await import(path.join(repoRoot, 'lib', 'runtime', 'projects.mjs'));
4579
+ const compatProjectRegistry = await import(path.join(repoRoot, 'lib', 'project-registry.mjs'));
4580
+ for (const name of [
4581
+ 'projectRecord',
4582
+ 'readProjectRegistry',
4583
+ 'writeProjectRegistry',
4584
+ 'registerProject',
4585
+ 'registerProjectActivity'
4586
+ ]) {
4587
+ if (typeof projectRegistry[name] !== 'function') fail(`project registry module missing export: ${name}`);
4588
+ if (typeof compatProjectRegistry[name] !== 'function') fail(`project registry compat module missing export: ${name}`);
4589
+ }
4590
+ const savedHome = process.env.HOME;
4591
+ process.env.HOME = home;
4592
+ try {
4593
+ const registryRootA = path.join(root, 'registry-a');
4594
+ const registryRootB = path.join(root, 'registry-b');
4595
+ const missingRegistryRoot = path.join(root, 'registry-missing');
4596
+ fs.mkdirSync(registryRootA, { recursive: true });
4597
+ fs.mkdirSync(registryRootB, { recursive: true });
4598
+ const written = projectRegistry.writeProjectRegistry([
4599
+ { root: registryRootA, db: '', name: '', last_seen_at: '5' },
4600
+ { root: missingRegistryRoot, db: path.join(missingRegistryRoot, 'stale.db'), name: 'Gone', last_seen_at: '20' },
4601
+ { root: registryRootB, db: path.join(registryRootB, 'custom.db'), name: 'Bee', last_seen_at: '10' },
4602
+ { root: registryRootA, db: path.join(registryRootA, 'new.db'), name: 'Aye', last_seen_at: '15' }
4603
+ ]);
4604
+ if (written.length !== 3 ||
4605
+ written[0].root !== path.resolve(missingRegistryRoot) ||
4606
+ written[1].root !== path.resolve(registryRootA) ||
4607
+ written[1].db !== path.resolve(path.join(registryRootA, 'new.db')) ||
4608
+ written[1].name !== 'Aye' ||
4609
+ written[1].last_seen_at !== 15 ||
4610
+ written[2].root !== path.resolve(registryRootB)) {
4611
+ fail(`project registry write/dedupe/sort changed: ${JSON.stringify(written)}`);
4612
+ }
4613
+ const readBack = projectRegistry.readProjectRegistry();
4614
+ if (readBack.length !== 2 ||
4615
+ readBack[0].root !== path.resolve(registryRootA) ||
4616
+ readBack[1].root !== path.resolve(registryRootB) ||
4617
+ readBack.some((p) => p.root === path.resolve(missingRegistryRoot))) {
4618
+ fail(`project registry kept missing root: ${JSON.stringify(readBack)}`);
4619
+ }
4620
+ const recorded = projectRegistry.projectRecord({
4621
+ root: path.resolve(registryRootB),
4622
+ dbPath: path.join(registryRootB, 'mesh.db')
4623
+ }, () => 123);
4624
+ if (recorded.root !== path.resolve(registryRootB) ||
4625
+ recorded.db !== path.join(registryRootB, 'mesh.db') ||
4626
+ recorded.name !== 'registry-b' ||
4627
+ recorded.last_seen_at !== 123) {
4628
+ fail(`project registry record changed: ${JSON.stringify(recorded)}`);
4629
+ }
4630
+ const registered = projectRegistry.registerProject({
4631
+ root: path.resolve(registryRootB),
4632
+ dbPath: path.join(registryRootB, 'mesh.db')
4633
+ });
4634
+ if (registered[0].root !== path.resolve(registryRootB) ||
4635
+ registered.filter((p) => p.root === path.resolve(registryRootB)).length !== 1) {
4636
+ fail(`project registry register changed: ${JSON.stringify(registered)}`);
4637
+ }
4638
+ } finally {
4639
+ if (savedHome === undefined) delete process.env.HOME;
4640
+ else process.env.HOME = savedHome;
4641
+ }
4642
+ for (const helper of [
4643
+ 'function runtimeBaseUrl',
4644
+ 'function runtimeApiUrl',
4645
+ 'function requestUrl',
4646
+ 'function isLoopbackHost',
4647
+ 'function nextSessionId',
4648
+ 'function listenServer',
4649
+ 'function runtimeUrlQuery',
4650
+ 'function makeWebToken',
4651
+ 'function validateWebTokenOpts',
4652
+ 'function expectedWebHost',
4653
+ 'function webRuntimeMatchesRequest',
4654
+ 'function publicRuntimeUrl',
4655
+ 'function localRuntimeUrl'
4656
+ ]) {
4657
+ if (hccSource.includes(helper)) fail(`CLI still embeds web runtime helper: ${helper}`);
4658
+ }
4659
+ for (const helper of [
4660
+ 'function readRequestBody',
4661
+ 'function readJsonRequest',
4662
+ 'function sendHttp',
4663
+ 'function sendJson',
4664
+ 'function sendFile',
4665
+ 'function authOk'
4666
+ ]) {
4667
+ if (hccSource.includes(helper)) fail(`CLI still embeds web HTTP helper: ${helper}`);
4668
+ }
4669
+ if (hccSource.includes('new URL(req.url')) fail('CLI still embeds raw server request URL parsing');
4670
+ if (hccSource.includes('function webIndexHtml()')) fail('CLI still embeds the web UI template');
4671
+ const webRuntime = await import(path.join(repoRoot, 'lib', 'web', 'runtime.mjs'));
4672
+ const webHttp = await import(path.join(repoRoot, 'lib', 'web', 'http.mjs'));
4673
+ const webUiTemplate = await import(path.join(repoRoot, 'lib', 'web', 'ui-template.mjs'));
4674
+ const compatWebRuntime = await import(path.join(repoRoot, 'lib', 'web-runtime.mjs'));
4675
+ const compatWebHttp = await import(path.join(repoRoot, 'lib', 'web-http.mjs'));
4676
+ const compatWebPeerActions = await import(path.join(repoRoot, 'lib', 'web-peer-actions.mjs'));
4677
+ const compatWebUiTemplate = await import(path.join(repoRoot, 'lib', 'web-ui-template.mjs'));
4678
+ for (const [moduleName, mod, names] of [
4679
+ ['web/runtime', webRuntime, ['runtimeConnectHost', 'runtimeBaseUrl', 'runtimeApiUrl', 'requestUrl', 'isLoopbackHost', 'nextSessionId', 'listenServer', 'publicRuntimeUrl', 'localRuntimeUrl', 'makeWebToken', 'expectedWebHost', 'webRuntimeMatchesRequest', 'rememberRuntimeToken']],
4680
+ ['web-runtime compat', compatWebRuntime, ['runtimeConnectHost', 'runtimeBaseUrl', 'runtimeApiUrl', 'requestUrl', 'isLoopbackHost', 'nextSessionId', 'listenServer', 'publicRuntimeUrl', 'localRuntimeUrl', 'makeWebToken', 'expectedWebHost', 'webRuntimeMatchesRequest', 'rememberRuntimeToken']],
4681
+ ['web/http', webHttp, ['readJsonRequest', 'sendHttp', 'sendJson', 'sendFile', 'authOk']],
4682
+ ['web-http compat', compatWebHttp, ['readJsonRequest', 'sendHttp', 'sendJson', 'sendFile', 'authOk']],
4683
+ ['web-peer-actions compat', compatWebPeerActions, ['createWebPeerActions']],
4684
+ ['web/ui-template', webUiTemplate, ['webIndexHtml']],
4685
+ ['web-ui-template compat', compatWebUiTemplate, ['webIndexHtml']]
4686
+ ]) {
4687
+ for (const name of names) {
4688
+ if (typeof mod[name] !== 'function') fail(`${moduleName} missing export: ${name}`);
4689
+ }
4690
+ }
4691
+ const expectEqual = (actual, expected, label) => {
4692
+ if (actual !== expected) fail(`${label}: expected ${expected}, got ${actual}`);
4693
+ };
4694
+ const html = webUiTemplate.webIndexHtml();
4695
+ if (!html.includes('<!doctype html>') ||
4696
+ !html.includes('<div class="app">') ||
4697
+ !html.includes('<script src="/assets/xterm.js"></script>')) {
4698
+ fail('web UI template module did not render the expected shell HTML');
4699
+ }
4700
+ const jsonReq = Readable.from(['{"ok":true}']);
4701
+ jsonReq.headers = {};
4702
+ const parsedJson = await webHttp.readJsonRequest(jsonReq);
4703
+ if (parsedJson.ok !== true) fail(`web HTTP helper failed to parse JSON request: ${JSON.stringify(parsedJson)}`);
4704
+ const emptyReq = Readable.from(['']);
4705
+ emptyReq.headers = {};
4706
+ const parsedEmpty = await webHttp.readJsonRequest(emptyReq);
4707
+ if (Object.keys(parsedEmpty).length !== 0) fail(`web HTTP helper failed empty request fallback: ${JSON.stringify(parsedEmpty)}`);
4708
+ const mockRes = {
4709
+ status: null,
4710
+ headers: null,
4711
+ body: '',
4712
+ writeHead(status, headers) { this.status = status; this.headers = headers; },
4713
+ end(body) { this.body = body; }
4714
+ };
4715
+ webHttp.sendJson(mockRes, 202, { ok: true });
4716
+ if (mockRes.status !== 202 ||
4717
+ mockRes.headers?.['Content-Type'] !== 'application/json; charset=utf-8' ||
4718
+ mockRes.headers?.['Cache-Control'] !== 'no-store' ||
4719
+ !mockRes.body.includes('"ok": true')) {
4720
+ fail(`web HTTP helper failed sendJson response: ${JSON.stringify(mockRes)}`);
4721
+ }
4722
+ if (!webHttp.authOk(new URL('http://example.test/?token=tok'), { headers: {} }, 'tok') ||
4723
+ !webHttp.authOk(new URL('http://example.test/'), { headers: { authorization: 'Bearer tok' } }, 'tok') ||
4724
+ webHttp.authOk(new URL('http://example.test/?token=bad'), { headers: {} }, 'tok')) {
4725
+ fail('web HTTP helper authOk token checks failed');
4726
+ }
4727
+ const wildcardRuntime = { host: '0.0.0.0', port: 8787, token: 'tok' };
4728
+ const ipv6WildcardRuntime = { host: '::', port: 8788, token: 'tok' };
4729
+ const localRuntime = { host: '127.0.0.1', port: 8789, token: 'tok' };
4730
+ expectEqual(webRuntime.runtimeBaseUrl('0.0.0.0', 8787), 'http://127.0.0.1:8787', 'runtimeBaseUrl 0.0.0.0');
4731
+ expectEqual(webRuntime.runtimeBaseUrl('::', 8788), 'http://127.0.0.1:8788', 'runtimeBaseUrl ::');
4732
+ expectEqual(String(webRuntime.runtimeApiUrl({ base_url: 'http://127.0.0.1:8787/base' }, '/api/state?peer=a b')), 'http://127.0.0.1:8787/api/state?peer=a%20b', 'runtimeApiUrl route');
4733
+ expectEqual(String(webRuntime.requestUrl({ url: '/api/state?peer=a b', headers: { host: 'example.test:8787' } })), 'http://example.test:8787/api/state?peer=a%20b', 'requestUrl host and query');
4734
+ expectEqual(String(webRuntime.requestUrl({ url: '', headers: {} })), 'http://localhost/', 'requestUrl fallback');
4735
+ if (!webRuntime.isLoopbackHost('127.0.0.1') ||
4736
+ !webRuntime.isLoopbackHost('localhost') ||
4737
+ !webRuntime.isLoopbackHost('::1') ||
4738
+ webRuntime.isLoopbackHost('0.0.0.0')) {
4739
+ fail('web runtime isLoopbackHost checks failed');
4740
+ }
4741
+ expectEqual(webRuntime.nextSessionId(['shell-1', 'shell-2'], 'shell'), 'shell-3', 'nextSessionId array');
4742
+ expectEqual(webRuntime.nextSessionId(new Map([
4743
+ ['a', { id: 'codex-1' }],
4744
+ ['b', 'codex-2']
4745
+ ]), 'codex'), 'codex-3', 'nextSessionId map');
4746
+ const listenProbe = http.createServer((req, res) => res.end('ok'));
4747
+ try {
4748
+ const listenPort = await webRuntime.listenServer(listenProbe, '127.0.0.1', 0, false);
4749
+ if (!Number.isInteger(listenPort) || listenPort <= 0) {
4750
+ fail(`listenServer did not return a usable port: ${listenPort}`);
4751
+ }
4752
+ } finally {
4753
+ await new Promise((resolve) => listenProbe.close(resolve));
4754
+ }
4755
+ expectEqual(webRuntime.publicRuntimeUrl(wildcardRuntime, '/tmp/hcc project'), 'http://<machine-ip>:8787/?token=tok&project=%2Ftmp%2Fhcc%20project', 'publicRuntimeUrl wildcard');
4756
+ expectEqual(webRuntime.localRuntimeUrl(wildcardRuntime, '/tmp/hcc project'), 'http://127.0.0.1:8787/?token=tok&project=%2Ftmp%2Fhcc%20project', 'localRuntimeUrl wildcard');
4757
+ expectEqual(webRuntime.publicRuntimeUrl(ipv6WildcardRuntime, '/tmp/hcc project'), 'http://<machine-ip>:8788/?token=tok&project=%2Ftmp%2Fhcc%20project', 'publicRuntimeUrl ipv6 wildcard');
4758
+ expectEqual(webRuntime.localRuntimeUrl(localRuntime, null), 'http://127.0.0.1:8789/?token=tok', 'localRuntimeUrl no project');
2073
4759
  const mainHelp = run(process.execPath, [hccBin, '--help']);
2074
4760
  if (mainHelp.includes('setup') || mainHelp.includes('--web-managed')) {
2075
4761
  fail(`public help exposes maintenance or removed commands:\n${mainHelp}`);
@@ -2086,13 +4772,20 @@ function syntaxAndHelp() {
2086
4772
  if (!mainHelp.includes(' uninstall Remove hooks, shims, and optional project data')) {
2087
4773
  fail(`main help missing uninstall command:\n${mainHelp}`);
2088
4774
  }
4775
+ if (!mainHelp.includes(' tmux gc [--yes] Clean stale DB-proven hcc-managed tmux sessions')) {
4776
+ fail(`main help missing tmux gc command:\n${mainHelp}`);
4777
+ }
2089
4778
  const msgHelp = run(process.execPath, [hccBin, 'msg', '--help']);
2090
4779
  if (!msgHelp.includes('msg reply') || !msgHelp.includes('msg thread')) {
2091
4780
  fail(`msg help missing reply/thread commands:\n${msgHelp}`);
2092
4781
  }
2093
4782
  const taskHelp = run(process.execPath, [hccBin, 'task', '--help']);
2094
4783
  if (!taskHelp.includes('task next [--peer ID] [--force]') ||
4784
+ !taskHelp.includes('[--count N]') ||
2095
4785
  !taskHelp.includes('existing claimed/running/review/blocked task') ||
4786
+ !taskHelp.includes('task takeover [--peer ID] --id N --reason TEXT') ||
4787
+ !taskHelp.includes('[--policy any|blocked|stale|blocked-or-stale]') ||
4788
+ !taskHelp.includes('task claim [--peer ID] --id N[,N]') ||
2096
4789
  !taskHelp.includes('task create --title TEXT --parent N')) {
2097
4790
  fail(`task help missing current-task task next semantics:\n${taskHelp}`);
2098
4791
  }
@@ -2101,13 +4794,35 @@ function syntaxAndHelp() {
2101
4794
  fail(`team help missing expected content:\n${teamHelp}`);
2102
4795
  }
2103
4796
  const stateHelp = run(process.execPath, [hccBin, 'state', '--help']);
2104
- if (!stateHelp.includes('hcc state') || !stateHelp.includes('automation.next_action.argv') || !stateHelp.includes('automation.current_task') || !stateHelp.includes('hcc team plan')) {
4797
+ if (!stateHelp.includes('hcc state') ||
4798
+ !stateHelp.includes('--scope SCOPE') ||
4799
+ !stateHelp.includes('--intent read|review|work|write|stop|finish') ||
4800
+ !stateHelp.includes('automation.next_action.argv') ||
4801
+ !stateHelp.includes('automation.current_task') ||
4802
+ !stateHelp.includes('hcc team plan')) {
2105
4803
  fail(`state help missing expected content:\n${stateHelp}`);
2106
4804
  }
4805
+ const lockHelp = run(process.execPath, [hccBin, 'lock', '--help']);
4806
+ if (!lockHelp.includes('--scope SCOPE') || !lockHelp.toLowerCase().includes('different scopes on the same resource')) {
4807
+ fail(`lock help missing scoped lock content:\n${lockHelp}`);
4808
+ }
2107
4809
  const updateHelp = run(process.execPath, [hccBin, 'update', '--help']);
2108
4810
  if (!updateHelp.includes('hcc update') || !updateHelp.includes('npm install -g @logicseek/hello-cc@TAG')) {
2109
4811
  fail(`update help missing expected content:\n${updateHelp}`);
2110
4812
  }
4813
+ const tmuxHelp = run(process.execPath, [hccBin, 'tmux', '--help']);
4814
+ if (!tmuxHelp.includes('hcc tmux') ||
4815
+ !tmuxHelp.includes('tmux gc [--peer ID] [--older-than DAYS]') ||
4816
+ !tmuxHelp.includes('peer_bindings.transport must be tmux') ||
4817
+ !tmuxHelp.includes('deletion requires --yes')) {
4818
+ fail(`tmux help missing DB-backed gc semantics:\n${tmuxHelp}`);
4819
+ }
4820
+ const gcHelp = run(process.execPath, [hccBin, 'gc', '--help']);
4821
+ if (!gcHelp.includes('hcc gc') ||
4822
+ !gcHelp.includes('gc [--older-than DAYS] [--yes]') ||
4823
+ !gcHelp.includes('deletion requires --yes')) {
4824
+ fail(`gc help missing cleanup semantics:\n${gcHelp}`);
4825
+ }
2111
4826
  const uninstallHelp = run(process.execPath, [hccBin, 'uninstall', '--help']);
2112
4827
  if (!uninstallHelp.includes('hcc uninstall') || !uninstallHelp.includes('hcc uninstall [--purge --yes]')) {
2113
4828
  fail(`uninstall help missing expected content:\n${uninstallHelp}`);
@@ -2130,6 +4845,7 @@ function syntaxAndHelp() {
2130
4845
  ['task', 'done', 'hcc task done'],
2131
4846
  ['msg', 'reply', 'hcc msg reply'],
2132
4847
  ['peer', 'attach', 'peer attach'],
4848
+ ['tmux', 'gc', 'hcc tmux'],
2133
4849
  ['lock', 'release', 'hcc lock release'],
2134
4850
  ['handoff', 'create', 'hcc handoff create'],
2135
4851
  ['event', 'tail', 'hcc event tail']
@@ -2214,7 +4930,7 @@ async function main() {
2214
4930
  await askBroadcastWorkflow();
2215
4931
  await downGcPackWorkflow();
2216
4932
  oldNameScan();
2217
- syntaxAndHelp();
4933
+ await syntaxAndHelp();
2218
4934
  uninstallWorkflow();
2219
4935
  log('FULL_REGRESSION_OK');
2220
4936
  }