@logicseek/hello-cc 0.1.2 → 0.1.4

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.
@@ -107,7 +107,9 @@ function withMeshDb(fn) {
107
107
 
108
108
  function providerBindingRows(provider, sessionName) {
109
109
  return withMeshDb((db) => db.prepare(`
110
- SELECT peer, provider, provider_session_id, provider_session_name, transport, runtime_target
110
+ SELECT
111
+ peer, provider, provider_session_id, provider_session_name,
112
+ resume_mode, resume_arg, command, transport, runtime_target
111
113
  FROM peer_bindings
112
114
  WHERE provider = ? AND provider_session_name = ?
113
115
  ORDER BY peer
@@ -180,6 +182,42 @@ function insertRuntimeTargetBinding(peer, provider, sessionName, runtimeTarget)
180
182
  });
181
183
  }
182
184
 
185
+ function moveRuntimeBindingPeer(fromPeer, toPeer) {
186
+ withMeshDb((db) => {
187
+ const peer = db.prepare('SELECT * FROM peers WHERE id = ?').get(fromPeer);
188
+ if (!peer) fail(`cannot move missing peer ${fromPeer}`);
189
+ const binding = db.prepare('SELECT * FROM peer_bindings WHERE peer = ?').get(fromPeer);
190
+ if (!binding) fail(`cannot move missing peer binding ${fromPeer}`);
191
+ const t = Math.floor(Date.now() / 1000);
192
+ db.prepare(`
193
+ INSERT INTO peers(id, kind, role, worktree, branch, pid, status, capabilities, created_at, last_seen_at)
194
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
195
+ ON CONFLICT(id) DO UPDATE SET
196
+ kind = excluded.kind,
197
+ role = excluded.role,
198
+ worktree = excluded.worktree,
199
+ branch = excluded.branch,
200
+ pid = excluded.pid,
201
+ status = excluded.status,
202
+ capabilities = excluded.capabilities,
203
+ last_seen_at = excluded.last_seen_at
204
+ `).run(
205
+ toPeer,
206
+ peer.kind,
207
+ peer.role,
208
+ peer.worktree,
209
+ peer.branch,
210
+ peer.pid,
211
+ peer.status,
212
+ peer.capabilities,
213
+ t,
214
+ t
215
+ );
216
+ db.prepare('DELETE FROM peer_bindings WHERE peer = ?').run(toPeer);
217
+ db.prepare('UPDATE peer_bindings SET peer = ?, updated_at = ? WHERE peer = ?').run(toPeer, t, fromPeer);
218
+ });
219
+ }
220
+
183
221
  function assertSqliteUniqueFailure(label, fn) {
184
222
  try {
185
223
  fn();
@@ -283,6 +321,227 @@ function createLegacyBindingDb(dbPath) {
283
321
  }
284
322
  }
285
323
 
324
+ function createLegacySchemaDb(dbPath) {
325
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
326
+ const db = new DatabaseSync(dbPath);
327
+ try {
328
+ const t = Math.floor(Date.now() / 1000) - 3600;
329
+ db.exec(`
330
+ CREATE TABLE meta (
331
+ key TEXT PRIMARY KEY,
332
+ value TEXT NOT NULL
333
+ );
334
+ INSERT INTO meta(key, value) VALUES ('schema_version', '1');
335
+
336
+ CREATE TABLE peers (
337
+ id TEXT PRIMARY KEY,
338
+ kind TEXT NOT NULL,
339
+ role TEXT,
340
+ worktree TEXT,
341
+ branch TEXT,
342
+ pid INTEGER,
343
+ status TEXT NOT NULL DEFAULT 'idle',
344
+ capabilities TEXT,
345
+ created_at INTEGER NOT NULL,
346
+ last_seen_at INTEGER NOT NULL
347
+ );
348
+
349
+ CREATE TABLE peer_bindings (
350
+ peer TEXT PRIMARY KEY,
351
+ provider TEXT NOT NULL,
352
+ provider_session_id TEXT,
353
+ provider_session_name TEXT,
354
+ resume_mode TEXT NOT NULL DEFAULT 'new',
355
+ resume_arg TEXT,
356
+ command TEXT,
357
+ transport TEXT NOT NULL,
358
+ runtime_session_id TEXT,
359
+ created_at INTEGER NOT NULL,
360
+ updated_at INTEGER NOT NULL
361
+ );
362
+
363
+ CREATE TABLE tasks (
364
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
365
+ title TEXT NOT NULL,
366
+ body TEXT,
367
+ status TEXT NOT NULL DEFAULT 'pending',
368
+ assignee TEXT,
369
+ owner TEXT,
370
+ priority INTEGER NOT NULL DEFAULT 100,
371
+ created_by TEXT,
372
+ claimed_at INTEGER,
373
+ completed_at INTEGER,
374
+ created_at INTEGER NOT NULL,
375
+ updated_at INTEGER NOT NULL
376
+ );
377
+
378
+ CREATE TABLE messages (
379
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
380
+ sender TEXT NOT NULL,
381
+ recipient TEXT,
382
+ task_id INTEGER,
383
+ kind TEXT NOT NULL DEFAULT 'note',
384
+ body TEXT NOT NULL,
385
+ created_at INTEGER NOT NULL
386
+ );
387
+
388
+ CREATE TABLE message_reads (
389
+ message_id INTEGER NOT NULL,
390
+ peer TEXT NOT NULL,
391
+ read_at INTEGER NOT NULL,
392
+ PRIMARY KEY (message_id, peer)
393
+ );
394
+
395
+ CREATE TABLE locks (
396
+ resource TEXT PRIMARY KEY,
397
+ owner TEXT NOT NULL,
398
+ task_id INTEGER,
399
+ reason TEXT,
400
+ expires_at INTEGER NOT NULL,
401
+ created_at INTEGER NOT NULL
402
+ );
403
+
404
+ CREATE TABLE handoffs (
405
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
406
+ task_id INTEGER,
407
+ from_peer TEXT NOT NULL,
408
+ to_peer TEXT,
409
+ summary TEXT NOT NULL,
410
+ changed_files TEXT,
411
+ tests TEXT,
412
+ risks TEXT,
413
+ created_at INTEGER NOT NULL
414
+ );
415
+
416
+ CREATE TABLE events (
417
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
418
+ type TEXT NOT NULL,
419
+ actor TEXT,
420
+ task_id INTEGER,
421
+ payload TEXT,
422
+ created_at INTEGER NOT NULL
423
+ );
424
+
425
+ CREATE INDEX idx_tasks_status_priority ON tasks(status, priority, id);
426
+ CREATE INDEX idx_tasks_owner ON tasks(owner);
427
+ CREATE INDEX idx_messages_recipient_id ON messages(recipient, id);
428
+ CREATE INDEX idx_events_id ON events(id);
429
+ CREATE INDEX idx_locks_expires ON locks(expires_at);
430
+
431
+ INSERT INTO peers(id, kind, role, worktree, branch, pid, status, capabilities, created_at, last_seen_at)
432
+ VALUES ('legacy-peer', 'codex', 'peer', '${root.replace(/'/g, "''")}', '', NULL, 'idle', 'legacy', ${t}, ${t});
433
+ INSERT INTO peer_bindings(peer, provider, provider_session_id, provider_session_name, resume_mode, resume_arg, command, transport, runtime_session_id, created_at, updated_at)
434
+ VALUES ('legacy-peer', 'codex', NULL, 'legacy-session', 'detected', NULL, NULL, 'detected', 'legacy-peer', ${t}, ${t});
435
+ INSERT INTO messages(sender, recipient, task_id, kind, body, created_at)
436
+ VALUES ('legacy-peer', 'all', NULL, 'note', 'legacy-message', ${t});
437
+ `);
438
+ } finally {
439
+ db.close();
440
+ }
441
+ }
442
+
443
+ function assertLegacySchemaMigration() {
444
+ const legacyRoot = path.join(os.tmpdir(), `hcc-reg-legacy-schema-root-${testId}`);
445
+ const legacyDb = path.join(legacyRoot, '.hello-cc', 'mesh.db');
446
+ try {
447
+ createLegacySchemaDb(legacyDb);
448
+ run(process.execPath, [hccBin, '--root', legacyRoot, 'status', '--peer', 'legacy-check'], { env });
449
+ const db = new DatabaseSync(legacyDb);
450
+ try {
451
+ const peerBindingColumns = new Set(db.prepare('PRAGMA table_info(peer_bindings)').all().map((row) => row.name));
452
+ if (!peerBindingColumns.has('runtime_target')) fail('legacy migration did not add peer_bindings.runtime_target');
453
+ const messageColumns = new Set(db.prepare('PRAGMA table_info(messages)').all().map((row) => row.name));
454
+ for (const column of ['reply_to', 'thread_id']) {
455
+ if (!messageColumns.has(column)) fail(`legacy migration did not add messages.${column}`);
456
+ }
457
+ const taskColumns = new Set(db.prepare('PRAGMA table_info(tasks)').all().map((row) => row.name));
458
+ for (const column of ['parent_id', 'team_role']) {
459
+ if (!taskColumns.has(column)) fail(`legacy migration did not add tasks.${column}`);
460
+ }
461
+ const message = db.prepare('SELECT id, thread_id FROM messages WHERE body = ?').get('legacy-message');
462
+ if (!message || message.thread_id !== message.id) {
463
+ fail(`legacy message thread_id not backfilled:\n${JSON.stringify(message, null, 2)}`);
464
+ }
465
+ const metaVersion = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get()?.value;
466
+ const pragmaVersion = db.prepare('PRAGMA user_version').get().user_version;
467
+ if (metaVersion !== '4' || pragmaVersion !== 4) {
468
+ fail(`schema version not synchronized: meta=${metaVersion} pragma=${pragmaVersion}`);
469
+ }
470
+ 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') {
472
+ fail(`schema migrations history wrong:\n${JSON.stringify(migrations, null, 2)}`);
473
+ }
474
+ } finally {
475
+ db.close();
476
+ }
477
+ } finally {
478
+ try { fs.rmSync(legacyRoot, { recursive: true, force: true }); } catch {}
479
+ }
480
+ }
481
+
482
+ function assertRegisteredProjectDbMigration() {
483
+ const otherRoot = path.join(os.tmpdir(), `hcc-reg-registered-legacy-root-${testId}`);
484
+ const otherDb = path.join(otherRoot, '.hello-cc', 'mesh.db');
485
+ const registryFile = path.join(home, '.hello-cc', 'projects.json');
486
+ try {
487
+ createLegacySchemaDb(otherDb);
488
+ fs.mkdirSync(path.dirname(registryFile), { recursive: true });
489
+ fs.writeFileSync(registryFile, JSON.stringify({
490
+ projects: [
491
+ { root, db: path.join(root, '.hello-cc', 'mesh.db'), name: 'current', last_seen_at: 2 },
492
+ { root: otherRoot, db: otherDb, name: 'registered-legacy', last_seen_at: 1 }
493
+ ]
494
+ }, null, 2));
495
+ hcc(['status', '--peer', 'registered-migration-check']);
496
+ const db = new DatabaseSync(otherDb);
497
+ try {
498
+ const taskColumns = new Set(db.prepare('PRAGMA table_info(tasks)').all().map((row) => row.name));
499
+ if (!taskColumns.has('parent_id') || !taskColumns.has('team_role')) {
500
+ fail(`registered project DB was not migrated:\n${JSON.stringify([...taskColumns], null, 2)}`);
501
+ }
502
+ const version = db.prepare('PRAGMA user_version').get().user_version;
503
+ if (version !== 4) fail(`registered project DB user_version wrong: ${version}`);
504
+ } finally {
505
+ db.close();
506
+ }
507
+ } finally {
508
+ try { fs.rmSync(otherRoot, { recursive: true, force: true }); } catch {}
509
+ }
510
+ }
511
+
512
+ function assertFutureSchemaMigrationHistoryRejected() {
513
+ const futureRoot = path.join(os.tmpdir(), `hcc-reg-future-schema-root-${testId}`);
514
+ const futureDb = path.join(futureRoot, '.hello-cc', 'mesh.db');
515
+ try {
516
+ fs.mkdirSync(path.dirname(futureDb), { recursive: true });
517
+ const db = new DatabaseSync(futureDb);
518
+ try {
519
+ db.exec(`
520
+ CREATE TABLE meta (
521
+ key TEXT PRIMARY KEY,
522
+ value TEXT NOT NULL
523
+ );
524
+ INSERT INTO meta(key, value) VALUES ('schema_version', '4');
525
+ CREATE TABLE schema_migrations (
526
+ version INTEGER PRIMARY KEY,
527
+ name TEXT NOT NULL,
528
+ applied_at INTEGER NOT NULL
529
+ );
530
+ INSERT INTO schema_migrations(version, name, applied_at)
531
+ VALUES (999, 'future migration', 1);
532
+ `);
533
+ } finally {
534
+ db.close();
535
+ }
536
+ 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)')) {
538
+ fail(`future schema migration history was not rejected:\nstdout=${result.stdout}\nstderr=${result.stderr}`);
539
+ }
540
+ } finally {
541
+ try { fs.rmSync(futureRoot, { recursive: true, force: true }); } catch {}
542
+ }
543
+ }
544
+
286
545
  function assertLegacyBindingRepair() {
287
546
  const legacyRoot = path.join(os.tmpdir(), `hcc-reg-legacy-root-${testId}`);
288
547
  const legacyDb = path.join(legacyRoot, '.hello-cc', 'mesh.db');
@@ -403,6 +662,59 @@ function ensureFile(file, expected = null) {
403
662
  }
404
663
  }
405
664
 
665
+ function assertGuidanceLockPolicy(file) {
666
+ const text = fs.readFileSync(file, 'utf8');
667
+ for (const expected of [
668
+ 'Read-only work:',
669
+ 'do not require locks',
670
+ 'For read-only review, do not acquire file locks',
671
+ 'not a final commit-ready verdict',
672
+ 'Review and monitoring:',
673
+ "Reviewing another peer's work is a read-only activity",
674
+ 'proactively send that',
675
+ 'affected file or behavior',
676
+ 'Do not silently treat a snapshot review as final approval',
677
+ 'Before mutating work:',
678
+ 'Before editing or mutating shared resources:',
679
+ 'Commit-readiness checks are read-only until staging begins',
680
+ 'lock `.git/index` only while staging and'
681
+ ]) {
682
+ if (!text.includes(expected)) fail(`guidance lock policy missing ${expected} in ${file}`);
683
+ }
684
+ }
685
+
686
+ function runtimeUrl(runtime, route, params = {}) {
687
+ const url = new URL(route, runtime.base_url || `http://127.0.0.1:${port}`);
688
+ if (runtime.token) url.searchParams.set('token', runtime.token);
689
+ for (const [key, value] of Object.entries(params)) {
690
+ if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
691
+ }
692
+ return url.toString();
693
+ }
694
+
695
+ function currentRuntime() {
696
+ return JSON.parse(fs.readFileSync(path.join(root, '.hello-cc', 'runtime.json'), 'utf8'));
697
+ }
698
+
699
+ function currentRuntimeUrl(route, params = {}) {
700
+ return runtimeUrl(currentRuntime(), route, params);
701
+ }
702
+
703
+ function runtimeFetch(route, options = {}, params = {}) {
704
+ const runtime = currentRuntime();
705
+ const headers = { ...(options.headers || {}) };
706
+ if (runtime.token) headers.Authorization = `Bearer ${runtime.token}`;
707
+ return fetch(runtimeUrl(runtime, route, params), { ...options, headers });
708
+ }
709
+
710
+ function runtimeWsUrl(peer) {
711
+ const runtime = currentRuntime();
712
+ const url = new URL(`/ws/terminal/${encodeURIComponent(peer)}`, runtime.base_url || `http://127.0.0.1:${port}`);
713
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
714
+ if (runtime.token) url.searchParams.set('token', runtime.token);
715
+ return url.toString();
716
+ }
717
+
406
718
  async function waitFor(check, label, timeoutMs = 10000) {
407
719
  const deadline = Date.now() + timeoutMs;
408
720
  while (Date.now() < deadline) {
@@ -438,8 +750,7 @@ async function waitRuntime() {
438
750
  await waitFor(async () => {
439
751
  if (!fs.existsSync(runtimeFile)) return false;
440
752
  try {
441
- const runtime = JSON.parse(fs.readFileSync(runtimeFile, 'utf8'));
442
- const response = await fetch(`${runtime.base_url}/api/runtime`);
753
+ const response = await runtimeFetch('/api/runtime');
443
754
  return response.ok;
444
755
  } catch {
445
756
  return false;
@@ -451,7 +762,7 @@ async function expectWebSocketMarker(peer, marker) {
451
762
  await new Promise((resolve, reject) => {
452
763
  let sawSnapshot = false;
453
764
  let sawMarker = false;
454
- const ws = new WebSocket(`ws://127.0.0.1:${port}/ws/terminal/${encodeURIComponent(peer)}`);
765
+ const ws = new WebSocket(runtimeWsUrl(peer));
455
766
  const timer = setTimeout(() => reject(new Error(`${peer} websocket timeout`)), 5000);
456
767
  ws.on('open', () => {
457
768
  const result = hccMaybe(['inject', peer, `echo ${marker}`]);
@@ -476,7 +787,7 @@ async function expectWebSocketMarker(peer, marker) {
476
787
  async function expectResizeReplaceSnapshot(peer, marker) {
477
788
  await new Promise((resolve, reject) => {
478
789
  let sawSnapshot = false;
479
- const ws = new WebSocket(`ws://127.0.0.1:${port}/ws/terminal/${encodeURIComponent(peer)}`);
790
+ const ws = new WebSocket(runtimeWsUrl(peer));
480
791
  const timer = setTimeout(() => reject(new Error(`${peer} resize replace timeout`)), 5000);
481
792
  ws.on('open', () => {
482
793
  const result = hccMaybe(['inject', peer, `echo ${marker}`]);
@@ -498,6 +809,39 @@ async function expectResizeReplaceSnapshot(peer, marker) {
498
809
  });
499
810
  }
500
811
 
812
+ async function expectWebSocketInputVisible(peer, marker) {
813
+ await new Promise((resolve, reject) => {
814
+ let sawSnapshot = false;
815
+ let sent = false;
816
+ let sawMarkerAfterInput = false;
817
+ let sawFrameAfterInput = false;
818
+ const ws = new WebSocket(runtimeWsUrl(peer));
819
+ const timer = setTimeout(() => reject(new Error(`${peer} websocket input visibility timeout`)), 5000);
820
+ ws.on('message', (raw) => {
821
+ const msg = JSON.parse(String(raw));
822
+ const data = String(msg.data || '');
823
+ if (msg.type === 'snapshot') {
824
+ sawSnapshot = true;
825
+ if (!sent) {
826
+ sent = true;
827
+ ws.send(JSON.stringify({ type: 'input', data: `echo ${marker}\r` }));
828
+ }
829
+ return;
830
+ }
831
+ if (sent && ['data', 'replace'].includes(msg.type)) {
832
+ sawFrameAfterInput = true;
833
+ if (data.includes(marker)) sawMarkerAfterInput = true;
834
+ }
835
+ if (sawSnapshot && sawFrameAfterInput && sawMarkerAfterInput) {
836
+ clearTimeout(timer);
837
+ ws.close();
838
+ resolve();
839
+ }
840
+ });
841
+ ws.on('error', reject);
842
+ });
843
+ }
844
+
501
845
  function tmuxStreamNodes() {
502
846
  const dir = path.join(root, '.hello-cc', 'bufs');
503
847
  if (!fs.existsSync(dir)) return [];
@@ -640,24 +984,92 @@ async function setupRegression() {
640
984
  }
641
985
  await stopRuntime();
642
986
  assertPeerBindingUniqueConstraints();
987
+ assertLegacySchemaMigration();
988
+ assertRegisteredProjectDbMigration();
989
+ assertFutureSchemaMigrationHistoryRejected();
643
990
  assertLegacyBindingRepair();
644
991
 
645
- const noTokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
992
+ const tokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
993
+ const tokenMatch = tokenOutput.match(/^pid:\s*(\d+)/m);
994
+ if (!tokenMatch) fail(`token web did not print background pid:\n${tokenOutput}`);
995
+ runtimePid = Number.parseInt(tokenMatch[1], 10);
996
+ if (!tokenOutput.includes('token=') || !tokenOutput.includes('open: http://<machine-ip>:')) {
997
+ fail(`default web output did not include remote token URL:\n${tokenOutput}`);
998
+ }
999
+ await waitRuntime();
1000
+ const tokenRuntime = currentRuntime();
1001
+ if (tokenRuntime.host !== '0.0.0.0' || !tokenRuntime.token || tokenRuntime.token.length < 24) {
1002
+ fail(`default web runtime did not store remote token data:\n${JSON.stringify(tokenRuntime, null, 2)}`);
1003
+ }
1004
+ const unauthorizedResponse = await fetch(`${tokenRuntime.base_url}/api/runtime`);
1005
+ if (unauthorizedResponse.status !== 401) fail(`default web API allowed missing token: ${unauthorizedResponse.status}`);
1006
+ const tokenResponse = await runtimeFetch('/api/runtime');
1007
+ if (!tokenResponse.ok) fail(`default web API rejected runtime token: ${tokenResponse.status}`);
1008
+ const tokenFile = path.join(home, '.hello-cc', 'web-token');
1009
+ ensureFile(tokenFile, tokenRuntime.token);
1010
+ fs.rmSync(tokenFile, { force: true });
1011
+ const existingTokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
1012
+ if (!existingTokenOutput.includes('web already running in background')) {
1013
+ fail(`web did not reuse existing token runtime:\n${existingTokenOutput}`);
1014
+ }
1015
+ ensureFile(tokenFile, tokenRuntime.token);
1016
+ await stopRuntime();
1017
+
1018
+ const stableTokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
1019
+ const stableTokenMatch = stableTokenOutput.match(/^pid:\s*(\d+)/m);
1020
+ if (!stableTokenMatch) fail(`stable-token web did not print background pid:\n${stableTokenOutput}`);
1021
+ runtimePid = Number.parseInt(stableTokenMatch[1], 10);
1022
+ await waitRuntime();
1023
+ const stableTokenRuntime = currentRuntime();
1024
+ if (stableTokenRuntime.token !== tokenRuntime.token) {
1025
+ fail(`default web token changed across restart:\nfirst=${tokenRuntime.token}\nsecond=${stableTokenRuntime.token}`);
1026
+ }
1027
+ await stopRuntime();
1028
+
1029
+ const fixedToken = `fixed-token-${testId}`;
1030
+ const fixedTokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance'], {
1031
+ env: { ...env, HCC_WEB_TOKEN: fixedToken }
1032
+ });
1033
+ const fixedTokenMatch = fixedTokenOutput.match(/^pid:\s*(\d+)/m);
1034
+ if (!fixedTokenMatch) fail(`fixed-token web did not print background pid:\n${fixedTokenOutput}`);
1035
+ runtimePid = Number.parseInt(fixedTokenMatch[1], 10);
1036
+ await waitRuntime();
1037
+ const fixedTokenRuntime = currentRuntime();
1038
+ if (fixedTokenRuntime.token !== fixedToken || !fixedTokenOutput.includes(`token=${encodeURIComponent(fixedToken)}`)) {
1039
+ fail(`explicit stable token was not used:\n${fixedTokenOutput}\n${JSON.stringify(fixedTokenRuntime, null, 2)}`);
1040
+ }
1041
+ await stopRuntime();
1042
+
1043
+ const persistedFixedOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
1044
+ const persistedFixedMatch = persistedFixedOutput.match(/^pid:\s*(\d+)/m);
1045
+ if (!persistedFixedMatch) fail(`persisted fixed-token web did not print background pid:\n${persistedFixedOutput}`);
1046
+ runtimePid = Number.parseInt(persistedFixedMatch[1], 10);
1047
+ await waitRuntime();
1048
+ const persistedFixedRuntime = currentRuntime();
1049
+ if (persistedFixedRuntime.token !== fixedToken) {
1050
+ fail(`explicit stable token was not persisted:\n${JSON.stringify(persistedFixedRuntime, null, 2)}`);
1051
+ }
1052
+ await stopRuntime();
1053
+
1054
+ const noTokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-token', '--no-discover', '--no-guidance']);
646
1055
  const noTokenMatch = noTokenOutput.match(/^pid:\s*(\d+)/m);
647
- if (!noTokenMatch) fail(`no-token web did not print background pid:\n${noTokenOutput}`);
1056
+ if (!noTokenMatch) fail(`explicit no-token web did not print background pid:\n${noTokenOutput}`);
648
1057
  runtimePid = Number.parseInt(noTokenMatch[1], 10);
649
- if (noTokenOutput.includes('token=') || noTokenOutput.includes('token was generated')) {
650
- fail(`default web output unexpectedly required token:\n${noTokenOutput}`);
1058
+ if (noTokenOutput.includes('token=')) {
1059
+ fail(`explicit no-token web output included token:\n${noTokenOutput}`);
651
1060
  }
652
1061
  await waitRuntime();
653
- const noTokenRuntime = JSON.parse(fs.readFileSync(path.join(root, '.hello-cc', 'runtime.json'), 'utf8'));
654
- if (noTokenRuntime.token || Object.hasOwn(noTokenRuntime, 'token_generated')) {
655
- fail(`default web runtime unexpectedly stored token data:\n${JSON.stringify(noTokenRuntime, null, 2)}`);
656
- }
1062
+ const noTokenRuntime = currentRuntime();
1063
+ if (noTokenRuntime.token) fail(`explicit no-token runtime stored token:\n${JSON.stringify(noTokenRuntime, null, 2)}`);
657
1064
  const noTokenResponse = await fetch(`${noTokenRuntime.base_url}/api/runtime`);
658
- if (!noTokenResponse.ok) fail(`default web API required token: ${noTokenResponse.status}`);
1065
+ if (!noTokenResponse.ok) fail(`explicit no-token web API required token: ${noTokenResponse.status}`);
659
1066
  await stopRuntime();
660
1067
 
1068
+ const conflictingToken = hccMaybe(['web', '--local', '--port', String(port), '--token', 'abc', '--no-token', '--no-discover', '--no-guidance']);
1069
+ if (conflictingToken.status === 0 || !String(conflictingToken.stderr || conflictingToken.stdout).includes('--no-token cannot be combined')) {
1070
+ fail(`web accepted conflicting --token and --no-token:\n${conflictingToken.stdout}\n${conflictingToken.stderr}`);
1071
+ }
1072
+
661
1073
  const childDir = path.join(root, 'packages', 'child');
662
1074
  fs.mkdirSync(childDir, { recursive: true });
663
1075
  const childFindRoot = hccFrom(['find-root'], childDir).trim();
@@ -714,9 +1126,71 @@ async function dbWorkflow() {
714
1126
  if (!inbox.includes('please review')) fail('inbox did not include message');
715
1127
  const msgMatch = inbox.match(/^#(\d+)/m);
716
1128
  if (!msgMatch) fail(`cannot parse message id: ${inbox}`);
717
- hcc(['msg', 'ack', '--peer', 'claude-a', '--id', msgMatch[1]]);
1129
+ const msgId = msgMatch[1];
1130
+ const reply = hcc(['msg', 'reply', '--from', 'claude-a', '--id', msgId, '--body', 'review reply']);
1131
+ const replyMatch = reply.match(/sent reply #(\d+) to #(\d+)/);
1132
+ if (!replyMatch || replyMatch[2] !== msgId) fail(`cannot parse reply output: ${reply}`);
1133
+ const thread = hcc(['msg', 'thread', '--id', msgId]);
1134
+ if (!thread.includes(`thread #${msgId}`) || !thread.includes('please review') || !thread.includes('review reply')) {
1135
+ fail(`thread output missing original or reply:\n${thread}`);
1136
+ }
1137
+ const replyInbox = hcc(['msg', 'inbox', '--peer', 'codex-a']);
1138
+ if (!replyInbox.includes('review reply') || !replyInbox.includes(`#${msgId}`)) {
1139
+ fail(`reply did not reach original sender inbox with thread context:\n${replyInbox}`);
1140
+ }
1141
+ const stateBeforeAck = hccJson(['state', '--peer', 'codex-a', '--resource', 'src/router']);
1142
+ if (stateBeforeAck.automation?.schema_version !== 1 || stateBeforeAck.automation.phase !== 'reply_message') {
1143
+ fail(`state automation did not prioritize unread reply:\n${JSON.stringify(stateBeforeAck, null, 2)}`);
1144
+ }
1145
+ if (stateBeforeAck.automation.next_action.kind !== 'msg.reply' || !stateBeforeAck.automation.next_action.argv.includes(replyMatch[1])) {
1146
+ fail(`state next action did not target unread reply:\n${JSON.stringify(stateBeforeAck.automation, null, 2)}`);
1147
+ }
1148
+ const timelineIds = new Set((stateBeforeAck.timeline || []).map((item) => item.id));
1149
+ if (!timelineIds.has(`message:${msgId}`) || !timelineIds.has(`message:${replyMatch[1]}`)) {
1150
+ fail(`state timeline missing message thread entries:\n${JSON.stringify(stateBeforeAck.timeline, null, 2)}`);
1151
+ }
1152
+ if ([...(stateBeforeAck.timeline || [])].some((item) => item.kind === 'message.sent' || item.kind === 'message.ack')) {
1153
+ fail(`state timeline includes noisy message events:\n${JSON.stringify(stateBeforeAck.timeline, null, 2)}`);
1154
+ }
1155
+ hcc(['msg', 'ack', '--peer', 'claude-a', '--id', msgId]);
1156
+ hcc(['msg', 'ack', '--peer', 'codex-a', '--id', replyMatch[1]]);
1157
+ const stateAfterAck = hccJson(['state', '--peer', 'codex-a', '--resource', 'src/new-lock']);
1158
+ if (stateAfterAck.automation.next_action.kind !== 'lock.acquire') {
1159
+ fail(`state automation did not suggest lock acquire after ack:\n${JSON.stringify(stateAfterAck.automation, null, 2)}`);
1160
+ }
1161
+ const queuedTask = hcc(['task', 'create', '--from', 'human', '--to', 'codex-a', '--title', 'queued while busy']);
1162
+ const queuedTaskMatch = queuedTask.match(/created task #(\d+):/);
1163
+ if (!queuedTaskMatch) fail(`cannot parse queued task id: ${queuedTask}`);
1164
+ const queuedTaskId = queuedTaskMatch[1];
1165
+ const busyState = hccJson(['state', '--peer', 'codex-a']);
1166
+ if (String(busyState.automation.current_task?.id) !== String(taskId)) {
1167
+ fail(`state did not preserve current task while another task was assigned:\n${JSON.stringify(busyState.automation, null, 2)}`);
1168
+ }
1169
+ if (['task.claim', 'task.next', 'msg.inbox'].includes(busyState.automation.next_action.kind)) {
1170
+ fail(`state let a new assigned task interrupt current work:\n${JSON.stringify(busyState.automation, null, 2)}`);
1171
+ }
1172
+ const busyHookPayload = JSON.stringify({ session_id: 'codex-busy-session', cwd: root, hook_event_name: 'UserPromptSubmit', prompt: 'new user prompt while busy' });
1173
+ const busyHook = hookContext(hcc(['hook', 'userpromptsubmit'], { env: { ...env, HCC_PEER: 'codex-a' }, input: busyHookPayload }), 'UserPromptSubmit');
1174
+ if (!busyHook.includes('[hello-cc current task]') || !busyHook.includes(`#${taskId} running`)) {
1175
+ fail(`hook did not preserve current task while another task was assigned:\n${busyHook}`);
1176
+ }
1177
+ if (busyHook.includes(`hcc task claim --peer codex-a --id ${queuedTaskId}`)) {
1178
+ fail(`hook suggested claiming a new task while current task was active:\n${busyHook}`);
1179
+ }
1180
+ const nextAgain = hcc(['task', 'next', '--peer', 'codex-a']);
1181
+ if (!nextAgain.includes(`current task #${taskId}`)) {
1182
+ fail(`task next did not preserve current task:\n${nextAgain}`);
1183
+ }
1184
+ hcc(['task', 'update', '--peer', 'human', '--id', queuedTaskId, '--status', 'abandoned', '--summary', 'queued task cleanup']);
718
1185
  hcc(['handoff', 'create', '--from', 'codex-a', '--to', 'claude-a', '--task', taskId, '--summary', 'handoff summary', '--tests', 'full script', '--risks', 'none']);
719
1186
  if (!hcc(['handoff', 'list', '--task', taskId]).includes('handoff summary')) fail('handoff missing');
1187
+ const stateAfterHandoff = hccJson(['state', '--peer', 'claude-a']);
1188
+ if (!(stateAfterHandoff.timeline || []).some((item) => item.id.startsWith('handoff:') && item.text.includes('handoff summary'))) {
1189
+ fail(`state timeline missing handoff item:\n${JSON.stringify(stateAfterHandoff.timeline, null, 2)}`);
1190
+ }
1191
+ if ((stateAfterHandoff.timeline || []).some((item) => item.kind === 'handoff.created')) {
1192
+ fail(`state timeline includes noisy handoff event:\n${JSON.stringify(stateAfterHandoff.timeline, null, 2)}`);
1193
+ }
720
1194
  if (!hcc(['status', '--peer', 'codex-a']).includes('codex-a')) fail('status missing peer');
721
1195
  hcc(['lock', 'release', '--peer', 'codex-a', '--resource', 'src/router']);
722
1196
  hcc(['task', 'done', '--peer', 'codex-a', '--id', taskId, '--summary', 'done']);
@@ -725,6 +1199,77 @@ async function dbWorkflow() {
725
1199
  if (!hasTask(hccJson(['task', 'list', '--all']), taskId)) fail(`done task missing from --all list: #${taskId}`);
726
1200
  if (!hasTask(hccJson(['task', 'list', '--status', 'done']), taskId)) fail(`done task missing from --status done list: #${taskId}`);
727
1201
 
1202
+ const teamParent = hcc([
1203
+ 'task', 'create',
1204
+ '--from', 'human',
1205
+ '--to', 'codex-team-captain',
1206
+ '--title', 'parallel release cleanup',
1207
+ '--body', '- Update docs\n- Add regression\n- Verify migration'
1208
+ ]);
1209
+ const teamParentMatch = teamParent.match(/created task #(\d+):/);
1210
+ if (!teamParentMatch) fail(`cannot parse team parent id: ${teamParent}`);
1211
+ const teamParentId = teamParentMatch[1];
1212
+ hcc(['task', 'claim', '--peer', 'codex-team-captain', '--id', teamParentId]);
1213
+ hcc(['task', 'update', '--peer', 'codex-team-captain', '--id', teamParentId, '--status', 'running', '--summary', 'team captain']);
1214
+ const teamState = hccJson(['state', '--peer', 'codex-team-captain']);
1215
+ if (teamState.automation.phase !== 'team_plan' || teamState.automation.next_action.kind !== 'team.plan') {
1216
+ fail(`state did not suggest team plan for splittable task:\n${JSON.stringify(teamState.automation, null, 2)}`);
1217
+ }
1218
+ const teamPlan = hcc(['team', 'plan', '--from-task', teamParentId, '--item', 'docs:Update docs', '--item', 'tests:Add regression', '--workers', 'codex-worker-a,claude-worker-a']);
1219
+ if (!teamPlan.includes('team plan for task') || !teamPlan.includes('Update docs')) {
1220
+ fail(`team plan output wrong:\n${teamPlan}`);
1221
+ }
1222
+ const kindWorkerPlan = hcc(['team', 'plan', '--from-task', teamParentId, '--item', 'docs:Docs slot', '--item', 'tests:Tests slot', '--workers', 'codex:2']);
1223
+ if (!kindWorkerPlan.includes(`codex-team-${teamParentId}-1`) || !kindWorkerPlan.includes(`codex-team-${teamParentId}-2`)) {
1224
+ fail(`team workers kind-count syntax was not expanded:\n${kindWorkerPlan}`);
1225
+ }
1226
+ const childrenBeforeStart = withMeshDb((db) => db.prepare('SELECT COUNT(*) AS n FROM tasks WHERE parent_id = ?').get(teamParentId).n);
1227
+ if (childrenBeforeStart !== 0) fail(`team plan created children: ${childrenBeforeStart}`);
1228
+ const teamStart = hccJson(['team', 'start', '--from', 'codex-team-captain', '--from-task', teamParentId, '--item', 'docs:Update docs', '--item', 'tests:Add regression', '--workers', 'codex-worker-a,claude-worker-a']);
1229
+ if (teamStart.children.length !== 2) {
1230
+ fail(`team start created wrong child count:\n${JSON.stringify(teamStart, null, 2)}`);
1231
+ }
1232
+ const teamRows = withMeshDb((db) => db.prepare(`
1233
+ SELECT id, title, assignee, parent_id, team_role
1234
+ FROM tasks
1235
+ WHERE parent_id = ?
1236
+ ORDER BY id
1237
+ `).all(teamParentId));
1238
+ if (teamRows.length !== 2 ||
1239
+ teamRows[0].assignee !== 'codex-worker-a' ||
1240
+ teamRows[1].assignee !== 'claude-worker-a' ||
1241
+ teamRows.some((row) => String(row.parent_id) !== String(teamParentId) || !row.team_role)) {
1242
+ fail(`team child rows wrong:\n${JSON.stringify(teamRows, null, 2)}`);
1243
+ }
1244
+ const duplicateTeamStart = hccMaybe(['team', 'start', '--from-task', teamParentId]);
1245
+ if (duplicateTeamStart.status === 0 || !String(duplicateTeamStart.stderr || duplicateTeamStart.stdout).includes('already has')) {
1246
+ fail(`duplicate team start was not rejected:\n${duplicateTeamStart.stdout}\n${duplicateTeamStart.stderr}`);
1247
+ }
1248
+ const teamStatus = hcc(['team', 'status', '--task', teamParentId]);
1249
+ if (!teamStatus.includes('subtasks: 2') || !teamStatus.includes('codex-worker-a')) {
1250
+ fail(`team status output wrong:\n${teamStatus}`);
1251
+ }
1252
+ for (const row of teamRows) {
1253
+ hcc(['task', 'update', '--peer', 'human', '--force', '--id', String(row.id), '--status', 'abandoned', '--summary', 'team regression cleanup']);
1254
+ }
1255
+ hcc(['task', 'done', '--peer', 'codex-team-captain', '--id', teamParentId, '--summary', 'team regression done']);
1256
+
1257
+ const conflictTask = hcc(['task', 'create', '--from', 'human', '--to', 'codex-b', '--title', 'conflict automation task']);
1258
+ const conflictTaskMatch = conflictTask.match(/created task #(\d+):/);
1259
+ if (!conflictTaskMatch) fail(`cannot parse conflict task id: ${conflictTask}`);
1260
+ const conflictTaskId = conflictTaskMatch[1];
1261
+ hcc(['task', 'claim', '--peer', 'codex-b', '--id', conflictTaskId]);
1262
+ hcc(['lock', 'acquire', '--peer', 'codex-a', '--resource', 'src/conflict', '--ttl', '60', '--reason', 'held by other peer']);
1263
+ const conflictState = hccJson(['state', '--peer', 'codex-b', '--resource', 'src/conflict']);
1264
+ if (conflictState.automation.phase !== 'coordinate_lock' || conflictState.automation.next_action.kind !== 'msg.send') {
1265
+ fail(`state automation did not suggest lock coordination:\n${JSON.stringify(conflictState.automation, null, 2)}`);
1266
+ }
1267
+ if (conflictState.automation.next_action.argv.includes('--force')) {
1268
+ fail(`state automation suggested forcing a lock:\n${JSON.stringify(conflictState.automation, null, 2)}`);
1269
+ }
1270
+ hcc(['lock', 'release', '--peer', 'codex-a', '--resource', 'src/conflict']);
1271
+ hcc(['task', 'done', '--peer', 'codex-b', '--id', conflictTaskId, '--summary', 'conflict done']);
1272
+
728
1273
  const abandoned = hcc(['task', 'create', '--from', 'human', '--title', 'abandoned regression task']);
729
1274
  const abandonedMatch = abandoned.match(/created task #(\d+):/);
730
1275
  if (!abandonedMatch) fail(`cannot parse abandoned task id: ${abandoned}`);
@@ -753,9 +1298,12 @@ async function dbWorkflow() {
753
1298
  if (!firstHook.includes('[hello-cc known peers]') || !firstHook.includes('do not say sessions are isolated')) {
754
1299
  fail(`UserPromptSubmit hook missing strong cross-session instruction:\n${firstHook}`);
755
1300
  }
756
- if (!firstHook.includes('hcc task list') || !firstHook.includes('hook-only-message')) {
1301
+ if (!firstHook.includes('hcc task list') || !firstHook.includes('hcc msg reply --id <message-id>') || !firstHook.includes('hook-only-message')) {
757
1302
  fail(`UserPromptSubmit hook missing instructions or unread message:\n${firstHook}`);
758
1303
  }
1304
+ if (!firstHook.includes('[hello-cc next action]') || !firstHook.includes('phase: reply_message') || !firstHook.includes('hcc msg reply')) {
1305
+ fail(`UserPromptSubmit hook missing executable next action:\n${firstHook}`);
1306
+ }
759
1307
  const secondHook = hookContext(hcc(['hook', 'userpromptsubmit'], { env: hookEnv, input: hookPayload }), 'UserPromptSubmit');
760
1308
  if (!secondHook.includes(`#${hookTaskId} running`)) {
761
1309
  fail(`UserPromptSubmit hook stopped showing open task after first read:\n${secondHook}`);
@@ -763,6 +1311,9 @@ async function dbWorkflow() {
763
1311
  if (secondHook.includes('hook-only-message')) {
764
1312
  fail(`UserPromptSubmit hook repeated acked unread message:\n${secondHook}`);
765
1313
  }
1314
+ if (!secondHook.includes('[hello-cc next action]')) {
1315
+ fail(`UserPromptSubmit hook dropped next action after ack:\n${secondHook}`);
1316
+ }
766
1317
  const sessionHookPayload = JSON.stringify({ session_id: 'claude-hook-session', cwd: root, hook_event_name: 'SessionStart', source: 'resume' });
767
1318
  const sessionHook = hookContext(hcc(['hook', 'sessionstart'], { env: hookEnv, input: sessionHookPayload }), 'SessionStart');
768
1319
  if (!sessionHook.includes(`#${hookTaskId} running`) || sessionHook.includes('hook-only-message')) {
@@ -784,12 +1335,22 @@ async function dbWorkflow() {
784
1335
  const autoTaskId = autoTaskMatch[1];
785
1336
  hcc(['task', 'claim', '--id', autoTaskId], { env: autoEnv });
786
1337
  hcc(['task', 'update', '--id', autoTaskId, '--status', 'running', '--summary', 'auto running'], { env: autoEnv });
1338
+ const autoState = hccJson(['state'], { env: autoEnv });
1339
+ if (autoState.automation.peer?.id !== autoPeer) {
1340
+ fail(`auto state used wrong peer: ${autoPeer}\n${JSON.stringify(autoState.automation, null, 2)}`);
1341
+ }
1342
+ if (String(autoState.automation.current_task?.id) !== String(autoTaskId)) {
1343
+ fail(`auto state lost current task #${autoTaskId}:\n${JSON.stringify(autoState.automation, null, 2)}`);
1344
+ }
1345
+ if (autoState.automation.next_action.kind === 'task.next') {
1346
+ fail(`auto state suggested task.next while current task was active:\n${JSON.stringify(autoState.automation, null, 2)}`);
1347
+ }
787
1348
  hcc(['lock', 'acquire', '--resource', 'auto/resource', '--task', autoTaskId, '--ttl', '60'], { env: autoEnv });
788
1349
  hcc(['lock', 'renew', '--resource', 'auto/resource', '--ttl', '60'], { env: autoEnv });
789
1350
  hcc(['handoff', 'create', '--summary', 'auto handoff', '--tests', 'auto test', '--risks', 'none'], { env: autoEnv });
790
1351
  hcc(['lock', 'release', '--resource', 'auto/resource'], { env: autoEnv });
791
1352
  hcc(['task', 'done', '--id', autoTaskId, '--summary', 'auto done'], { env: autoEnv });
792
- const autoEvents = hcc(['event', 'tail', '--limit', '50']);
1353
+ const autoEvents = hcc(['event', 'tail', '--limit', '200']);
793
1354
  if (!autoEvents.includes('peer.auto_joined') || !autoEvents.includes(autoPeer)) {
794
1355
  fail(`auto join event missing for ${autoPeer}:\n${autoEvents}`);
795
1356
  }
@@ -818,7 +1379,7 @@ async function multiProjectWebWorkflow() {
818
1379
  fail(`second project runtime did not point at global runtime:\n${JSON.stringify(otherRuntime, null, 2)}`);
819
1380
  }
820
1381
 
821
- const projectsResponse = await fetch(`http://127.0.0.1:${port}/api/projects?root=${encodeURIComponent(otherRoot)}`);
1382
+ const projectsResponse = await runtimeFetch('/api/projects', {}, { root: otherRoot });
822
1383
  const projects = await projectsResponse.json();
823
1384
  if (!projectsResponse.ok) fail(`projects API failed: ${JSON.stringify(projects)}`);
824
1385
  const roots = new Set((projects.projects || []).map((p) => p.root));
@@ -826,23 +1387,140 @@ async function multiProjectWebWorkflow() {
826
1387
  fail(`projects API did not include both roots:\n${JSON.stringify(projects, null, 2)}`);
827
1388
  }
828
1389
 
829
- const detectedResponse = await fetch(`http://127.0.0.1:${port}/api/detected?root=${encodeURIComponent(root)}`);
1390
+ const detectedResponse = await runtimeFetch('/api/detected', {}, { root });
830
1391
  const detectedJson = await detectedResponse.json();
831
1392
  if (!detectedResponse.ok || typeof detectedJson.active_peer_ttl !== 'number' || typeof detectedJson.now !== 'number' || !Array.isArray(detectedJson.detected)) {
832
1393
  fail(`detected API did not return liveness metadata:\n${JSON.stringify(detectedJson, null, 2)}`);
833
1394
  }
834
1395
 
835
- const htmlResponse = await fetch(`http://127.0.0.1:${port}/`);
1396
+ const htmlResponse = await fetch(currentRuntimeUrl('/'));
836
1397
  const html = await htmlResponse.text();
837
1398
  for (const forbidden of ['Alias optional', 'Role tag', 'Command<input', 'Working directory', 'commandbar', 'lineInput', 'Send text to active terminal']) {
838
1399
  if (html.includes(forbidden)) fail(`web form still exposes ${forbidden}`);
839
1400
  }
840
- if (!html.includes('Register Project') || !html.includes('New session') || !html.includes('<strong>Sessions</strong>') || !html.includes('<label>View<select')) {
841
- fail('web form missing simplified project/session controls');
1401
+ for (const expected of [
1402
+ 'id="projectSelect"',
1403
+ 'id="projectPath"',
1404
+ 'id="addProjectBtn"',
1405
+ 'id="startForm"',
1406
+ 'id="kind"',
1407
+ 'id="sessionKindFilter"',
1408
+ 'id="sessions"'
1409
+ ]) {
1410
+ if (!html.includes(expected)) fail(`web form missing simplified project/session control: ${expected}`);
1411
+ }
1412
+ for (const expected of [
1413
+ 'id="langSelect"',
1414
+ "localStorage.getItem('hcc.lang')",
1415
+ "localStorage.setItem('hcc.lang', lang)",
1416
+ "document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en'",
1417
+ "zh: {",
1418
+ "language: '语言'",
1419
+ "projectState: '项目状态'",
1420
+ "noSessionSelected: '未选择会话'",
1421
+ "sendMessage: '发送消息'",
1422
+ "peers: '协作方'",
1423
+ "noPeers: '没有协作方。'",
1424
+ "'status.active': '活跃'",
1425
+ "data-i18n=\"language\"",
1426
+ "data-i18n-placeholder=\"projectPathPlaceholder\"",
1427
+ "data-i18n-title=\"collapseSidebar\"",
1428
+ "function applyLanguage()",
1429
+ "function tr(key",
1430
+ "function statusText(status)",
1431
+ "function sessionMetaText(session)",
1432
+ "connText('attached')",
1433
+ "connText('coordinationOnly')",
1434
+ "tr('noActiveSessions')",
1435
+ "tr('nextAction')",
1436
+ "tr('detectedSession')",
1437
+ "tr('messageBodyPlaceholder')",
1438
+ "activeType === 'detected' && activeDetected",
1439
+ "const draft = document.getElementById('detMsg')?.value || ''",
1440
+ "statusText(state.label)",
1441
+ "statusText(automation.phase || 'idle')",
1442
+ "tr('runtime')"
1443
+ ]) {
1444
+ if (!html.includes(expected)) fail(`web UI missing i18n support: ${expected}`);
1445
+ }
1446
+ if (!html.includes('id="startMode"') || !html.includes('id="resumeArg"') || !html.includes('syncStartModeOptions') || !html.includes("mode === 'resume'")) {
1447
+ fail('web form missing provider resume controls');
1448
+ }
1449
+ for (const expected of [
1450
+ '--left-width',
1451
+ '--right-width',
1452
+ 'hcc.sidebar.left.width',
1453
+ 'hcc.sidebar.right.width',
1454
+ 'class="edge-resizer edge-resizer-left"',
1455
+ 'class="edge-resizer edge-resizer-right"',
1456
+ 'id="resizeLeft"',
1457
+ 'id="resizeRight"',
1458
+ 'role="separator"',
1459
+ 'aria-orientation="vertical"',
1460
+ 'bindSideHandle(resizeLeftHandle',
1461
+ 'bindSideHandle(resizeRightHandle',
1462
+ 'bindSideHandle(toggleLeftBtn',
1463
+ 'bindSideHandle(toggleRightBtn',
1464
+ 'setPointerCapture',
1465
+ 'sideIsCollapsed(opposite) ? 0',
1466
+ 'Math.abs(delta) <= 3',
1467
+ "localStorage.setItem('hcc.collapse.' + side, on ? '1' : '0');\n applySideWidths();",
1468
+ 'cursor: col-resize'
1469
+ ]) {
1470
+ if (!html.includes(expected)) fail(`web layout missing resizable sidebar support: ${expected}`);
842
1471
  }
843
- if (!html.includes('state-card') || !html.includes('peerStateView') || !html.includes('savedCardScroll') || !html.includes('lastStateRoot') || !html.includes('data-section="peers"')) {
1472
+ if (!html.includes('state-card') || !html.includes('peerStateView') || !html.includes('savedCardScroll') || !html.includes('lastStateRoot') || !html.includes("stateCardHtml('peers'")) {
844
1473
  fail('web state panel missing scrollable peer state UI');
845
1474
  }
1475
+ for (const expected of [
1476
+ 'function stateCardCollapsed(section)',
1477
+ "localStorage.getItem('hcc.stateCard.' + section + '.collapsed')",
1478
+ 'function stateCardHtml(section, title, count, bodyHtml)',
1479
+ 'state-card-toggle',
1480
+ 'aria-expanded=',
1481
+ 'state-card-collapsed',
1482
+ 'function bindStateCardToggles()',
1483
+ "localStorage.setItem('hcc.stateCard.' + section + '.collapsed'",
1484
+ "stateCardHtml('automation'",
1485
+ "stateCardHtml('timeline'",
1486
+ "stateCardHtml('messages'",
1487
+ "stateCardHtml('peers'",
1488
+ "stateCardHtml('tasks'",
1489
+ "stateCardHtml('locks'",
1490
+ 'bindStateCardToggles();'
1491
+ ]) {
1492
+ if (!html.includes(expected)) fail(`web state panel missing collapsible card support: ${expected}`);
1493
+ }
1494
+ for (const expected of [
1495
+ 'id="actionResult"',
1496
+ 'data-action="state"',
1497
+ 'data-action="status"',
1498
+ 'data-action="inbox"',
1499
+ 'data-action="task-next"',
1500
+ 'data-action="heartbeat"',
1501
+ 'data-action="register"',
1502
+ 'data-terminal-action="status"',
1503
+ 'function runPeerAction(action)',
1504
+ "'/api/peers/' + encodeURIComponent(info.peerId) + '/actions/' + encodeURIComponent(action)",
1505
+ 'showActionResult(result)'
1506
+ ]) {
1507
+ if (!html.includes(expected)) fail(`web actions missing API result UI: ${expected}`);
1508
+ }
1509
+ if (html.includes('data-send=') || html.includes("document.querySelectorAll('[data-send]')")) {
1510
+ fail('web actions still use implicit terminal command injection');
1511
+ }
1512
+ for (const expected of [
1513
+ 'term.onData((data) => {',
1514
+ "ws.send(JSON.stringify({ type: 'input', data }))"
1515
+ ]) {
1516
+ if (!html.includes(expected)) fail(`web terminal input forwarding missing: ${expected}`);
1517
+ }
1518
+ if (!html.includes("stateCardHtml('timeline'") || !html.includes('renderTimelineItem') || !html.includes('bodyPinned') || !html.includes('refreshCurrentState')) {
1519
+ fail('web state panel missing collaboration timeline or refresh routing');
1520
+ }
1521
+ if (!html.includes("stateCardHtml('messages'") || !html.includes('messagesData.length')) {
1522
+ fail('web state panel missing dedicated messages card');
1523
+ }
846
1524
 
847
1525
  if (!tmuxAvailable()) return;
848
1526
  const started = hccFrom(['peer', 'start', 'other-shell', '--kind', 'shell', '--', 'bash', '--noprofile', '--norc'], otherRoot);
@@ -852,8 +1530,8 @@ async function multiProjectWebWorkflow() {
852
1530
  if (rootList.includes('other-shell')) fail(`root project saw second project session:\n${rootList}`);
853
1531
  if (!otherList.includes('other-shell')) fail(`second project did not see its session:\n${otherList}`);
854
1532
 
855
- const rootSessions = await (await fetch(`http://127.0.0.1:${port}/api/sessions?root=${encodeURIComponent(root)}`)).json();
856
- const otherSessions = await (await fetch(`http://127.0.0.1:${port}/api/sessions?root=${encodeURIComponent(otherRoot)}`)).json();
1533
+ const rootSessions = await (await runtimeFetch('/api/sessions', {}, { root })).json();
1534
+ const otherSessions = await (await runtimeFetch('/api/sessions', {}, { root: otherRoot })).json();
857
1535
  if ((rootSessions.sessions || []).some((s) => s.id === 'other-shell')) {
858
1536
  fail(`root API saw second project session:\n${JSON.stringify(rootSessions)}`);
859
1537
  }
@@ -861,12 +1539,146 @@ async function multiProjectWebWorkflow() {
861
1539
  fail(`second project API did not see its session:\n${JSON.stringify(otherSessions)}`);
862
1540
  }
863
1541
 
1542
+ hcc(['register', '--peer', 'web-action-peer', '--kind', 'codex', '--role', 'peer']);
1543
+ hcc(['msg', 'send', '--from', 'human', '--to', 'web-action-peer', '--body', 'web action inbox ok']);
1544
+ const actionStatus = await (await runtimeFetch('/api/peers/web-action-peer/actions/status', {}, { root })).json();
1545
+ if (!actionStatus.ok || actionStatus.action !== 'status' || actionStatus.peer !== 'web-action-peer' || !actionStatus.data || typeof actionStatus.data.unread !== 'number') {
1546
+ fail(`web status action did not return structured status:\n${JSON.stringify(actionStatus, null, 2)}`);
1547
+ }
1548
+ const actionInbox = await (await runtimeFetch('/api/peers/web-action-peer/actions/inbox', {}, { root })).json();
1549
+ if (!actionInbox.ok || actionInbox.action !== 'inbox' || !(actionInbox.data?.messages || []).some((m) => m.body === 'web action inbox ok')) {
1550
+ fail(`web inbox action did not return unread messages:\n${JSON.stringify(actionInbox, null, 2)}`);
1551
+ }
1552
+ const actionState = await (await runtimeFetch('/api/peers/web-action-peer/actions/state', {}, { root })).json();
1553
+ if (!actionState.ok || actionState.action !== 'state' || actionState.data?.automation?.peer?.id !== 'web-action-peer') {
1554
+ fail(`web state action did not return peer automation:\n${JSON.stringify(actionState, null, 2)}`);
1555
+ }
1556
+ const taskOutput = hcc(['task', 'create', '--title', 'web action task', '--body', 'claim through web action']);
1557
+ const taskMatch = taskOutput.match(/created task #(\d+):/);
1558
+ if (!taskMatch) fail(`cannot parse web action task id:\n${taskOutput}`);
1559
+ const actionNext = await (await runtimeFetch('/api/peers/web-action-peer/actions/task-next', {
1560
+ method: 'POST',
1561
+ headers: { 'Content-Type': 'application/json' },
1562
+ body: '{}'
1563
+ }, { root })).json();
1564
+ if (!actionNext.ok || actionNext.action !== 'task-next' || String(actionNext.data?.task?.id) !== taskMatch[1]) {
1565
+ fail(`web task-next action did not claim pending task #${taskMatch[1]}:\n${JSON.stringify(actionNext, null, 2)}`);
1566
+ }
1567
+ const actionHeartbeat = await (await runtimeFetch('/api/peers/web-action-peer/actions/heartbeat', {
1568
+ method: 'POST',
1569
+ headers: { 'Content-Type': 'application/json' },
1570
+ body: JSON.stringify({ renew_locks: true })
1571
+ }, { root })).json();
1572
+ if (!actionHeartbeat.ok || actionHeartbeat.action !== 'heartbeat' || actionHeartbeat.data?.peer !== 'web-action-peer') {
1573
+ fail(`web heartbeat action did not return structured result:\n${JSON.stringify(actionHeartbeat, null, 2)}`);
1574
+ }
1575
+
1576
+ const startProvider = async (payload) => {
1577
+ const response = await runtimeFetch('/api/sessions', {
1578
+ method: 'POST',
1579
+ headers: { 'Content-Type': 'application/json' },
1580
+ body: JSON.stringify({
1581
+ env: {
1582
+ HOME: home,
1583
+ PATH: env.PATH,
1584
+ SHELL: process.env.SHELL || 'bash',
1585
+ HCC_FAKE_STAY_ALIVE: '1'
1586
+ },
1587
+ ...payload
1588
+ })
1589
+ }, { root });
1590
+ const json = await response.json();
1591
+ if (!response.ok) fail(`web provider session start failed: ${JSON.stringify(json)}`);
1592
+ return json.session;
1593
+ };
1594
+ const stopSession = async (id) => {
1595
+ await runtimeFetch(`/api/sessions/${encodeURIComponent(id)}/stop`, { method: 'POST' }, { root });
1596
+ };
1597
+
1598
+ const claudeResumeName = `web-claude-resume-${testId}`;
1599
+ const claudeResume = await startProvider({ kind: 'claude', mode: 'resume', resume: claudeResumeName });
1600
+ const expectedClaudePeer = `claude-${claudeResumeName.slice(0, 8)}`;
1601
+ if (!claudeResume.command.includes(`claude --resume ${claudeResumeName}`)) {
1602
+ fail(`web claude resume command wrong:\n${JSON.stringify(claudeResume, null, 2)}`);
1603
+ }
1604
+ if (claudeResume.id !== expectedClaudePeer || claudeResume.peer_id !== expectedClaudePeer) {
1605
+ fail(`web claude resume did not use canonical provider peer id ${expectedClaudePeer}:\n${JSON.stringify(claudeResume, null, 2)}`);
1606
+ }
1607
+ const claudeRows = providerBindingRows('claude', claudeResumeName);
1608
+ if (claudeRows.length !== 1 ||
1609
+ claudeRows[0].peer !== claudeResume.peer_id ||
1610
+ claudeRows[0].resume_mode !== 'resume' ||
1611
+ claudeRows[0].resume_arg !== claudeResumeName ||
1612
+ claudeRows[0].transport !== 'tmux' ||
1613
+ !claudeRows[0].runtime_target) {
1614
+ fail(`web claude resume binding wrong:\n${JSON.stringify(claudeRows, null, 2)}`);
1615
+ }
1616
+ await stopSession(claudeResume.id);
1617
+
1618
+ const codexResumeName = `web-codex-resume-${testId}`;
1619
+ const codexResume = await startProvider({ kind: 'codex', mode: 'resume', resume: codexResumeName });
1620
+ const expectedCodexPeer = `codex-${codexResumeName.slice(0, 8)}`;
1621
+ if (!codexResume.command.includes(`codex resume ${codexResumeName}`)) {
1622
+ fail(`web codex resume command wrong:\n${JSON.stringify(codexResume, null, 2)}`);
1623
+ }
1624
+ if (codexResume.id !== expectedCodexPeer || codexResume.peer_id !== expectedCodexPeer) {
1625
+ fail(`web codex resume did not use canonical provider peer id ${expectedCodexPeer}:\n${JSON.stringify(codexResume, null, 2)}`);
1626
+ }
1627
+ const codexRows = providerBindingRows('codex', codexResumeName);
1628
+ if (codexRows.length !== 1 ||
1629
+ codexRows[0].peer !== codexResume.peer_id ||
1630
+ codexRows[0].resume_mode !== 'resume' ||
1631
+ codexRows[0].resume_arg !== codexResumeName ||
1632
+ codexRows[0].transport !== 'tmux' ||
1633
+ !codexRows[0].runtime_target) {
1634
+ fail(`web codex resume binding wrong:\n${JSON.stringify(codexRows, null, 2)}`);
1635
+ }
1636
+ await stopSession(codexResume.id);
1637
+ const codexRowsAfterStop = providerBindingRows('codex', codexResumeName);
1638
+ if (codexRowsAfterStop.length !== 1 || codexRowsAfterStop[0].peer !== expectedCodexPeer) {
1639
+ fail(`web codex resume binding was not stable after stop:\n${JSON.stringify(codexRowsAfterStop, null, 2)}`);
1640
+ }
1641
+
1642
+ const resumableResponse = await runtimeFetch('/api/resumable', {}, { root });
1643
+ const resumableJson = await resumableResponse.json();
1644
+ if (!resumableResponse.ok) fail(`resumable API failed: ${JSON.stringify(resumableJson)}`);
1645
+ const resumableRows = resumableJson.resumable || [];
1646
+ const claudeResumable = resumableRows.find((row) => row.provider === 'claude' && row.resume === claudeResumeName);
1647
+ const codexResumable = resumableRows.find((row) => row.provider === 'codex' && row.resume === codexResumeName);
1648
+ if (!claudeResumable || claudeResumable.session_name !== claudeResumeName || claudeResumable.session_id !== null) {
1649
+ fail(`resumable API omitted named claude resume session:\n${JSON.stringify(resumableRows, null, 2)}`);
1650
+ }
1651
+ if (!codexResumable || codexResumable.session_name !== codexResumeName || codexResumable.session_id !== null) {
1652
+ fail(`resumable API omitted named codex resume session:\n${JSON.stringify(resumableRows, null, 2)}`);
1653
+ }
1654
+
1655
+ const codexLast = await startProvider({ kind: 'codex', mode: 'last' });
1656
+ if (codexLast.command !== 'codex resume --last') {
1657
+ fail(`web codex last command wrong:\n${JSON.stringify(codexLast, null, 2)}`);
1658
+ }
1659
+ await stopSession(codexLast.id);
1660
+
1661
+ const claudeContinue = await startProvider({ kind: 'claude', mode: 'continue' });
1662
+ if (claudeContinue.command !== 'claude --continue') {
1663
+ fail(`web claude continue command wrong:\n${JSON.stringify(claudeContinue, null, 2)}`);
1664
+ }
1665
+ await stopSession(claudeContinue.id);
1666
+
1667
+ const badShellResume = await runtimeFetch('/api/sessions', {
1668
+ method: 'POST',
1669
+ headers: { 'Content-Type': 'application/json' },
1670
+ body: JSON.stringify({ kind: 'shell', mode: 'resume', resume: 'not-supported' })
1671
+ }, { root });
1672
+ if (badShellResume.ok) {
1673
+ fail('web shell resume was accepted');
1674
+ }
1675
+
864
1676
  const startAuto = async () => {
865
- const response = await fetch(`http://127.0.0.1:${port}/api/sessions?root=${encodeURIComponent(otherRoot)}`, {
1677
+ const response = await runtimeFetch('/api/sessions', {
866
1678
  method: 'POST',
867
1679
  headers: { 'Content-Type': 'application/json' },
868
1680
  body: JSON.stringify({ kind: 'shell', command: 'bash --noprofile --norc' })
869
- });
1681
+ }, { root: otherRoot });
870
1682
  const json = await response.json();
871
1683
  if (!response.ok) fail(`auto web session start failed: ${JSON.stringify(json)}`);
872
1684
  return json.session;
@@ -876,8 +1688,8 @@ async function multiProjectWebWorkflow() {
876
1688
  if (!autoOne.id.startsWith('shell-') || !autoTwo.id.startsWith('shell-') || autoOne.id === autoTwo.id) {
877
1689
  fail(`auto web session ids were not unique: ${autoOne.id}, ${autoTwo.id}`);
878
1690
  }
879
- await fetch(`http://127.0.0.1:${port}/api/sessions/${encodeURIComponent(autoOne.id)}/stop?root=${encodeURIComponent(otherRoot)}`, { method: 'POST' });
880
- await fetch(`http://127.0.0.1:${port}/api/sessions/${encodeURIComponent(autoTwo.id)}/stop?root=${encodeURIComponent(otherRoot)}`, { method: 'POST' });
1691
+ await runtimeFetch(`/api/sessions/${encodeURIComponent(autoOne.id)}/stop`, { method: 'POST' }, { root: otherRoot });
1692
+ await runtimeFetch(`/api/sessions/${encodeURIComponent(autoTwo.id)}/stop`, { method: 'POST' }, { root: otherRoot });
881
1693
  hccFromMaybe(['peer', 'stop', 'other-shell'], otherRoot);
882
1694
  }
883
1695
 
@@ -897,6 +1709,7 @@ async function tmuxBackedStartWorkflow() {
897
1709
  await waitForFile(file, 'PTY_OK', 'pty injection');
898
1710
  await expectWebSocketMarker('shell-a', 'WS_PTY_OK');
899
1711
  await expectResizeReplaceSnapshot('shell-a', 'WS_RESIZE_OK');
1712
+ await expectWebSocketInputVisible('shell-a', 'WS_INPUT_VISIBLE_OK');
900
1713
  await expectBoundedTmuxStream('tmux-backed FIFO stream');
901
1714
 
902
1715
  await stopRuntime();
@@ -908,6 +1721,23 @@ async function tmuxBackedStartWorkflow() {
908
1721
  hcc(['inject', 'shell-a', `echo PTY_RESTORED_OK > ${restoredFile}`]);
909
1722
  await waitForFile(restoredFile, 'PTY_RESTORED_OK', 'tmux restore injection');
910
1723
 
1724
+ const aliasPeer = 'shell-canonical-alias';
1725
+ parsePane(hcc(['peer', 'start', 'shell-runtime-alias', '--kind', 'shell', '--', 'bash', '--noprofile', '--norc']));
1726
+ moveRuntimeBindingPeer('shell-runtime-alias', aliasPeer);
1727
+ const aliasSessions = await (await runtimeFetch('/api/sessions', {}, { root })).json();
1728
+ const aliasSession = (aliasSessions.sessions || []).find((session) => session.id === 'shell-runtime-alias');
1729
+ if (!aliasSession || aliasSession.peer_id !== aliasPeer) {
1730
+ fail(`sessions API did not expose canonical peer id for runtime alias:\n${JSON.stringify(aliasSessions, null, 2)}`);
1731
+ }
1732
+ const aliasDetected = await (await runtimeFetch('/api/detected', {}, { root })).json();
1733
+ if ((aliasDetected.detected || []).some((peer) => peer.id === aliasPeer || peer.id === 'shell-runtime-alias')) {
1734
+ fail(`detected API showed managed runtime/canonical duplicate:\n${JSON.stringify(aliasDetected, null, 2)}`);
1735
+ }
1736
+ const aliasFile = path.join(outDir, 'runtime-alias-ok');
1737
+ hcc(['inject', aliasPeer, `echo RUNTIME_ALIAS_OK > ${aliasFile}`]);
1738
+ await waitForFile(aliasFile, 'RUNTIME_ALIAS_OK', 'canonical peer injection to runtime alias');
1739
+ hcc(['peer', 'stop', aliasPeer]);
1740
+
911
1741
  const canonicalSession = 'canonical-session';
912
1742
  insertStaleProviderBinding('claude-stale-canonical', 'claude', canonicalSession);
913
1743
  const canonicalStarted = hcc(['peer', 'start', 'claude-live-canonical', '--kind', 'claude', '--resume', canonicalSession], {
@@ -1126,15 +1956,54 @@ async function downGcPackWorkflow() {
1126
1956
  hcc(['gc', '--older-than', '0', '--yes']);
1127
1957
  const pack = JSON.parse(run('npm', ['pack', '--dry-run', '--json']));
1128
1958
  const files = new Set(pack[0]?.files?.map((entry) => entry.path) || []);
1129
- for (const file of ['assets/logo.svg', 'CHANGELOG.md', 'docs/commands.md', 'docs/commands.zh-CN.md']) {
1959
+ for (const file of [
1960
+ 'assets/logo.svg',
1961
+ 'CHANGELOG.md',
1962
+ 'docs/commands.md',
1963
+ 'docs/commands.zh-CN.md',
1964
+ 'lib/guidance.mjs',
1965
+ 'lib/package-meta.mjs',
1966
+ 'lib/release-notes.mjs',
1967
+ 'scripts/github-release.mjs'
1968
+ ]) {
1130
1969
  if (!files.has(file)) fail(`npm package missing ${file}`);
1131
1970
  }
1971
+ const packageVersion = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')).version;
1132
1972
  const releaseCheck = run('npm', ['run', 'release:check']);
1133
- if (!releaseCheck.includes('release notes ok: 0.1.2')) fail(`release check output wrong:\n${releaseCheck}`);
1973
+ if (!releaseCheck.includes(`release notes ok: ${packageVersion}`)) fail(`release check output wrong:\n${releaseCheck}`);
1134
1974
  const releaseNotes = run('npm', ['run', 'release:notes']);
1135
- if (!releaseNotes.includes('## 0.1.2') || !releaseNotes.includes('### Summary')) {
1975
+ if (!releaseNotes.includes(`## ${packageVersion}`) || !releaseNotes.includes('### Summary')) {
1136
1976
  fail(`release notes output wrong:\n${releaseNotes}`);
1137
1977
  }
1978
+ const releaseNotesWithV = run(process.execPath, [path.join(repoRoot, 'scripts', 'release-notes.mjs'), '--version', `v${packageVersion}`]);
1979
+ if (!releaseNotesWithV.includes(`## ${packageVersion}`) || releaseNotesWithV.includes(`## v${packageVersion}`)) {
1980
+ fail(`release notes v-prefixed version output wrong:\n${releaseNotesWithV}`);
1981
+ }
1982
+ const githubRelease = JSON.parse(run(process.execPath, [path.join(repoRoot, 'scripts', 'github-release.mjs'), '--dry-run', '--version', packageVersion]));
1983
+ if (!githubRelease.ok || !githubRelease.dry_run || githubRelease.repo !== 'Dullne/hello-cc' || githubRelease.tag !== `v${packageVersion}` || githubRelease.body_length < 100) {
1984
+ fail(`github release dry run output wrong:\n${JSON.stringify(githubRelease, null, 2)}`);
1985
+ }
1986
+ const docsIndex = fs.readFileSync(path.join(repoRoot, 'docs', 'README.md'), 'utf8');
1987
+ const docsIndexZh = fs.readFileSync(path.join(repoRoot, 'docs', 'README.zh-CN.md'), 'utf8');
1988
+ if (!docsIndex.includes('github-release.yml') || !docsIndex.includes('workflow_dispatch') || !docsIndexZh.includes('github-release.yml') || !docsIndexZh.includes('workflow_dispatch')) {
1989
+ fail('docs index missing GitHub Release publishing command');
1990
+ }
1991
+ const workflow = fs.readFileSync(path.join(repoRoot, '.github', 'workflows', 'github-release.yml'), 'utf8');
1992
+ for (const expected of [
1993
+ 'Publish GitHub Release',
1994
+ "tags:\n - 'v*'",
1995
+ 'workflow_dispatch:',
1996
+ 'contents: write',
1997
+ 'actions/checkout@v4',
1998
+ 'actions/setup-node@v4',
1999
+ 'node-version: 24',
2000
+ 'npm run release:check',
2001
+ 'npm run release:github:dry-run',
2002
+ 'npm run release:github',
2003
+ 'GITHUB_TOKEN: ${{ github.token }}'
2004
+ ]) {
2005
+ if (!workflow.includes(expected)) fail(`github release workflow missing ${expected}`);
2006
+ }
1138
2007
  }
1139
2008
 
1140
2009
  function oldNameScan() {
@@ -1166,6 +2035,41 @@ function syntaxAndHelp() {
1166
2035
  run(process.execPath, ['--check', path.join(repoRoot, 'bin', 'hcc.mjs')]);
1167
2036
  run(process.execPath, ['--check', path.join(repoRoot, 'lib', 'setup.mjs')]);
1168
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')]);
2041
+ const hccSource = fs.readFileSync(hccBin, 'utf8');
2042
+ for (const expected of [
2043
+ 'function scheduleTmuxInputRefresh(session)',
2044
+ "runTmux(['pipe-pane', '-t', session.pane]);",
2045
+ 'if (session.inputRefreshTimer) return;',
2046
+ 'session.inputRefreshTimer = setTimeout',
2047
+ 'scheduleTmuxInputRefresh(session)',
2048
+ "if (session.inputRefreshTimer) { clearTimeout(session.inputRefreshTimer); session.inputRefreshTimer = null; }",
2049
+ "broadcast(session, { type: 'replace', data: refreshTmuxSnapshot(session) });"
2050
+ ]) {
2051
+ if (!hccSource.includes(expected)) fail(`web terminal input refresh support missing: ${expected}`);
2052
+ }
2053
+ 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)',
2057
+ 'const peerActionMatch = url.pathname.match(/^\\/api\\/peers\\/([^/]+)\\/actions\\/([^/]+)$/)',
2058
+ "const readOnly = ['status', 'state', 'inbox'].includes(action)",
2059
+ "sendJson(res, 200, webPeerAction(reqCtx, peer, action, input));"
2060
+ ]) {
2061
+ if (!hccSource.includes(expected)) fail(`web peer action API support missing: ${expected}`);
2062
+ }
2063
+ const packageVersion = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')).version;
2064
+ const cliVersion = run(process.execPath, [hccBin, '--version']).trim();
2065
+ if (cliVersion !== packageVersion) {
2066
+ fail(`CLI version ${cliVersion} does not match package.json ${packageVersion}`);
2067
+ }
2068
+ if (!hccSource.includes("import { readPackageMeta } from '../lib/package-meta.mjs'") ||
2069
+ !hccSource.includes('const VERSION = PACKAGE_META.version') ||
2070
+ !hccSource.includes('writeGuidanceForRoot(ctx.root)')) {
2071
+ fail('CLI still has duplicated package metadata or guidance wiring');
2072
+ }
1169
2073
  const mainHelp = run(process.execPath, [hccBin, '--help']);
1170
2074
  if (mainHelp.includes('setup') || mainHelp.includes('--web-managed')) {
1171
2075
  fail(`public help exposes maintenance or removed commands:\n${mainHelp}`);
@@ -1173,9 +2077,33 @@ function syntaxAndHelp() {
1173
2077
  if (!mainHelp.includes(' update Update the global npm install of hello-cc')) {
1174
2078
  fail(`main help missing update command:\n${mainHelp}`);
1175
2079
  }
2080
+ if (!mainHelp.includes(' state [--peer ID] Show timeline and next coordination action')) {
2081
+ fail(`main help missing state command:\n${mainHelp}`);
2082
+ }
2083
+ if (!mainHelp.includes(' team <subcommand> Plan, start, and inspect explicit task teams')) {
2084
+ fail(`main help missing team command:\n${mainHelp}`);
2085
+ }
1176
2086
  if (!mainHelp.includes(' uninstall Remove hooks, shims, and optional project data')) {
1177
2087
  fail(`main help missing uninstall command:\n${mainHelp}`);
1178
2088
  }
2089
+ const msgHelp = run(process.execPath, [hccBin, 'msg', '--help']);
2090
+ if (!msgHelp.includes('msg reply') || !msgHelp.includes('msg thread')) {
2091
+ fail(`msg help missing reply/thread commands:\n${msgHelp}`);
2092
+ }
2093
+ const taskHelp = run(process.execPath, [hccBin, 'task', '--help']);
2094
+ if (!taskHelp.includes('task next [--peer ID] [--force]') ||
2095
+ !taskHelp.includes('existing claimed/running/review/blocked task') ||
2096
+ !taskHelp.includes('task create --title TEXT --parent N')) {
2097
+ fail(`task help missing current-task task next semantics:\n${taskHelp}`);
2098
+ }
2099
+ const teamHelp = run(process.execPath, [hccBin, 'team', '--help']);
2100
+ if (!teamHelp.includes('hcc team') || !teamHelp.includes('team plan') || !teamHelp.includes('team start') || !teamHelp.includes('team status')) {
2101
+ fail(`team help missing expected content:\n${teamHelp}`);
2102
+ }
2103
+ 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')) {
2105
+ fail(`state help missing expected content:\n${stateHelp}`);
2106
+ }
1179
2107
  const updateHelp = run(process.execPath, [hccBin, 'update', '--help']);
1180
2108
  if (!updateHelp.includes('hcc update') || !updateHelp.includes('npm install -g @logicseek/hello-cc@TAG')) {
1181
2109
  fail(`update help missing expected content:\n${updateHelp}`);
@@ -1200,7 +2128,7 @@ function syntaxAndHelp() {
1200
2128
  if (runHelp.includes('--web-managed')) fail(`run help exposes removed --web-managed:\n${runHelp}`);
1201
2129
  const subcommandHelpCases = [
1202
2130
  ['task', 'done', 'hcc task done'],
1203
- ['msg', 'ack', 'hcc msg ack'],
2131
+ ['msg', 'reply', 'hcc msg reply'],
1204
2132
  ['peer', 'attach', 'peer attach'],
1205
2133
  ['lock', 'release', 'hcc lock release'],
1206
2134
  ['handoff', 'create', 'hcc handoff create'],
@@ -1233,8 +2161,12 @@ function uninstallWorkflow() {
1233
2161
 
1234
2162
  run(process.execPath, [hccBin, '--root', uninstallRoot, 'setup', '--quiet'], { env: uninstallEnv });
1235
2163
  ensureFile(path.join(uninstallRoot, '.hello-cc', 'mesh.db'));
2164
+ ensureFile(path.join(uninstallRoot, '.hello-cc', 'HCC.md'));
1236
2165
  ensureFile(path.join(uninstallRoot, 'CLAUDE.md'));
1237
2166
  ensureFile(path.join(uninstallRoot, 'AGENTS.md'));
2167
+ assertGuidanceLockPolicy(path.join(uninstallRoot, '.hello-cc', 'HCC.md'));
2168
+ assertGuidanceLockPolicy(path.join(uninstallRoot, 'CLAUDE.md'));
2169
+ assertGuidanceLockPolicy(path.join(uninstallRoot, 'AGENTS.md'));
1238
2170
  ensureFile(path.join(uninstallHome, '.claude', 'settings.json'));
1239
2171
  ensureFile(path.join(uninstallHome, '.codex', 'hooks.json'));
1240
2172
  ensureFile(path.join(uninstallHome, '.hcc-shims', 'claude'));