@logicseek/hello-cc 0.1.3 → 0.1.5

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.
@@ -7,6 +7,7 @@ import process from 'node:process';
7
7
  import { spawnSync } from 'node:child_process';
8
8
  import { setTimeout as sleep } from 'node:timers/promises';
9
9
  import { DatabaseSync } from 'node:sqlite';
10
+ import { Readable } from 'node:stream';
10
11
  import WebSocket from 'ws';
11
12
 
12
13
  const repoRoot = path.resolve(import.meta.dirname, '..');
@@ -458,17 +459,21 @@ function assertLegacySchemaMigration() {
458
459
  for (const column of ['parent_id', 'team_role']) {
459
460
  if (!taskColumns.has(column)) fail(`legacy migration did not add tasks.${column}`);
460
461
  }
462
+ const lockColumns = new Set(db.prepare('PRAGMA table_info(locks)').all().map((row) => row.name));
463
+ for (const column of ['base_resource', 'scope']) {
464
+ if (!lockColumns.has(column)) fail(`legacy migration did not add locks.${column}`);
465
+ }
461
466
  const message = db.prepare('SELECT id, thread_id FROM messages WHERE body = ?').get('legacy-message');
462
467
  if (!message || message.thread_id !== message.id) {
463
468
  fail(`legacy message thread_id not backfilled:\n${JSON.stringify(message, null, 2)}`);
464
469
  }
465
470
  const metaVersion = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get()?.value;
466
471
  const pragmaVersion = db.prepare('PRAGMA user_version').get().user_version;
467
- if (metaVersion !== '4' || pragmaVersion !== 4) {
472
+ if (metaVersion !== '5' || pragmaVersion !== 5) {
468
473
  fail(`schema version not synchronized: meta=${metaVersion} pragma=${pragmaVersion}`);
469
474
  }
470
475
  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') {
476
+ if (migrations.length !== 5 || migrations[4].version !== 5 || migrations[4].name !== 'scoped advisory locks') {
472
477
  fail(`schema migrations history wrong:\n${JSON.stringify(migrations, null, 2)}`);
473
478
  }
474
479
  } finally {
@@ -500,7 +505,7 @@ function assertRegisteredProjectDbMigration() {
500
505
  fail(`registered project DB was not migrated:\n${JSON.stringify([...taskColumns], null, 2)}`);
501
506
  }
502
507
  const version = db.prepare('PRAGMA user_version').get().user_version;
503
- if (version !== 4) fail(`registered project DB user_version wrong: ${version}`);
508
+ if (version !== 5) fail(`registered project DB user_version wrong: ${version}`);
504
509
  } finally {
505
510
  db.close();
506
511
  }
@@ -521,7 +526,7 @@ function assertFutureSchemaMigrationHistoryRejected() {
521
526
  key TEXT PRIMARY KEY,
522
527
  value TEXT NOT NULL
523
528
  );
524
- INSERT INTO meta(key, value) VALUES ('schema_version', '4');
529
+ INSERT INTO meta(key, value) VALUES ('schema_version', '5');
525
530
  CREATE TABLE schema_migrations (
526
531
  version INTEGER PRIMARY KEY,
527
532
  name TEXT NOT NULL,
@@ -534,7 +539,7 @@ function assertFutureSchemaMigrationHistoryRejected() {
534
539
  db.close();
535
540
  }
536
541
  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)')) {
542
+ if (result.status === 0 || !`${result.stdout}\n${result.stderr}`.includes('Database schema version 999 is newer than this hcc (5)')) {
538
543
  fail(`future schema migration history was not rejected:\nstdout=${result.stdout}\nstderr=${result.stderr}`);
539
544
  }
540
545
  } finally {
@@ -676,6 +681,11 @@ function assertGuidanceLockPolicy(file) {
676
681
  'Do not silently treat a snapshot review as final approval',
677
682
  'Before mutating work:',
678
683
  'Before editing or mutating shared resources:',
684
+ 'module scope',
685
+ 'Locks are coordination signals',
686
+ '--scope db-schema',
687
+ '--scope web-ui',
688
+ 'narrower scoped locks',
679
689
  'Commit-readiness checks are read-only until staging begins',
680
690
  'lock `.git/index` only while staging and'
681
691
  ]) {
@@ -809,6 +819,39 @@ async function expectResizeReplaceSnapshot(peer, marker) {
809
819
  });
810
820
  }
811
821
 
822
+ async function expectWebSocketInputVisible(peer, marker) {
823
+ await new Promise((resolve, reject) => {
824
+ let sawSnapshot = false;
825
+ let sent = false;
826
+ let sawMarkerAfterInput = false;
827
+ let sawFrameAfterInput = false;
828
+ const ws = new WebSocket(runtimeWsUrl(peer));
829
+ const timer = setTimeout(() => reject(new Error(`${peer} websocket input visibility timeout`)), 5000);
830
+ ws.on('message', (raw) => {
831
+ const msg = JSON.parse(String(raw));
832
+ const data = String(msg.data || '');
833
+ if (msg.type === 'snapshot') {
834
+ sawSnapshot = true;
835
+ if (!sent) {
836
+ sent = true;
837
+ ws.send(JSON.stringify({ type: 'input', data: `echo ${marker}\r` }));
838
+ }
839
+ return;
840
+ }
841
+ if (sent && ['data', 'replace'].includes(msg.type)) {
842
+ sawFrameAfterInput = true;
843
+ if (data.includes(marker)) sawMarkerAfterInput = true;
844
+ }
845
+ if (sawSnapshot && sawFrameAfterInput && sawMarkerAfterInput) {
846
+ clearTimeout(timer);
847
+ ws.close();
848
+ resolve();
849
+ }
850
+ });
851
+ ws.on('error', reject);
852
+ });
853
+ }
854
+
812
855
  function tmuxStreamNodes() {
813
856
  const dir = path.join(root, '.hello-cc', 'bufs');
814
857
  if (!fs.existsSync(dir)) return [];
@@ -817,6 +860,13 @@ function tmuxStreamNodes() {
817
860
  .map((name) => path.join(dir, name));
818
861
  }
819
862
 
863
+ function libModuleFiles() {
864
+ return fs.readdirSync(path.join(repoRoot, 'lib'))
865
+ .filter((name) => name.endsWith('.mjs'))
866
+ .sort()
867
+ .map((name) => path.join('lib', name));
868
+ }
869
+
820
870
  async function expectBoundedTmuxStream(label) {
821
871
  let nodes = [];
822
872
  await waitFor(() => {
@@ -1125,6 +1175,13 @@ async function dbWorkflow() {
1125
1175
  if (stateAfterAck.automation.next_action.kind !== 'lock.acquire') {
1126
1176
  fail(`state automation did not suggest lock acquire after ack:\n${JSON.stringify(stateAfterAck.automation, null, 2)}`);
1127
1177
  }
1178
+ const readOnlyState = hccJson(['state', '--peer', 'codex-a', '--resource', 'src/read-only', '--intent', 'review']);
1179
+ if (readOnlyState.automation.next_action.kind === 'lock.acquire' || readOnlyState.automation.phase === 'acquire_lock') {
1180
+ fail(`read-only state suggested acquiring a lock:\n${JSON.stringify(readOnlyState.automation, null, 2)}`);
1181
+ }
1182
+ if (!(readOnlyState.automation.warnings || []).some((warning) => warning.includes('read-only'))) {
1183
+ fail(`read-only state did not explain no-lock behavior:\n${JSON.stringify(readOnlyState.automation, null, 2)}`);
1184
+ }
1128
1185
  const queuedTask = hcc(['task', 'create', '--from', 'human', '--to', 'codex-a', '--title', 'queued while busy']);
1129
1186
  const queuedTaskMatch = queuedTask.match(/created task #(\d+):/);
1130
1187
  if (!queuedTaskMatch) fail(`cannot parse queued task id: ${queuedTask}`);
@@ -1148,6 +1205,93 @@ async function dbWorkflow() {
1148
1205
  if (!nextAgain.includes(`current task #${taskId}`)) {
1149
1206
  fail(`task next did not preserve current task:\n${nextAgain}`);
1150
1207
  }
1208
+ const nextAgainJson = hccJson(['task', 'next', '--peer', 'codex-a']);
1209
+ if (String(nextAgainJson.id) !== String(taskId) || nextAgainJson.current !== true || nextAgainJson.tasks) {
1210
+ fail(`task next --json current task shape changed:\n${JSON.stringify(nextAgainJson, null, 2)}`);
1211
+ }
1212
+ const batchClaimIds = [];
1213
+ for (const title of ['batch claim one', 'batch claim two']) {
1214
+ const out = hcc(['task', 'create', '--from', 'human', '--title', title]);
1215
+ const match = out.match(/created task #(\d+):/);
1216
+ if (!match) fail(`cannot parse batch task id: ${out}`);
1217
+ batchClaimIds.push(match[1]);
1218
+ }
1219
+ const batchClaim = hccJson(['task', 'claim', '--peer', 'batch-a', '--ids', batchClaimIds.join(',')]);
1220
+ if (!Array.isArray(batchClaim) || batchClaim.length !== 2 || !batchClaim.every((task) => task.owner === 'batch-a')) {
1221
+ fail(`batch claim did not claim both tasks:\n${JSON.stringify(batchClaim, null, 2)}`);
1222
+ }
1223
+ for (const task of batchClaim) {
1224
+ hcc(['task', 'done', '--peer', 'batch-a', '--id', String(task.id), '--summary', 'batch claim cleanup']);
1225
+ }
1226
+ const batchNextIds = [];
1227
+ for (const title of ['batch next one', 'batch next two', 'batch next three']) {
1228
+ const out = hcc(['task', 'create', '--from', 'human', '--title', title]);
1229
+ const match = out.match(/created task #(\d+):/);
1230
+ if (!match) fail(`cannot parse batch next task id: ${out}`);
1231
+ batchNextIds.push(match[1]);
1232
+ }
1233
+ const batchNext = hccJson(['task', 'next', '--peer', 'batch-b', '--force', '--count', '2']);
1234
+ if (!batchNext?.tasks || batchNext.tasks.length !== 2 || !batchNext.tasks.every((task) => task.owner === 'batch-b')) {
1235
+ fail(`task next --count did not claim two tasks:\n${JSON.stringify(batchNext, null, 2)}`);
1236
+ }
1237
+ for (const task of batchNext.tasks) {
1238
+ hcc(['task', 'done', '--peer', 'batch-b', '--id', String(task.id), '--summary', 'batch next cleanup']);
1239
+ }
1240
+ hcc(['task', 'update', '--peer', 'human', '--id', batchNextIds[2], '--status', 'abandoned', '--summary', 'batch next leftover cleanup']);
1241
+ const takeoverOpen = hcc(['task', 'create', '--from', 'human', '--to', 'takeover-owner', '--title', 'takeover open']);
1242
+ const takeoverOpenMatch = takeoverOpen.match(/created task #(\d+):/);
1243
+ if (!takeoverOpenMatch) fail(`cannot parse takeover open id: ${takeoverOpen}`);
1244
+ const takeoverOpenId = takeoverOpenMatch[1];
1245
+ hcc(['task', 'claim', '--peer', 'takeover-owner', '--id', takeoverOpenId]);
1246
+ const blockedPolicyReject = hccMaybe(['task', 'takeover', '--peer', 'takeover-a', '--id', takeoverOpenId, '--reason', 'blocked policy smoke', '--policy', 'blocked']);
1247
+ if (blockedPolicyReject.status === 0 || !String(blockedPolicyReject.stderr || blockedPolicyReject.stdout).includes('does not match takeover policy blocked')) {
1248
+ fail(`takeover --policy blocked accepted non-blocked task:\n${blockedPolicyReject.stdout}\n${blockedPolicyReject.stderr}`);
1249
+ }
1250
+ hcc(['task', 'update', '--peer', 'takeover-owner', '--id', takeoverOpenId, '--status', 'blocked', '--summary', 'blocked for takeover smoke']);
1251
+ const blockedTakeover = hccJson(['task', 'takeover', '--peer', 'takeover-a', '--id', takeoverOpenId, '--reason', 'blocked takeover smoke', '--policy', 'blocked']);
1252
+ if (String(blockedTakeover.owner) !== 'takeover-a') {
1253
+ fail(`blocked takeover did not transfer owner:\n${JSON.stringify(blockedTakeover, null, 2)}`);
1254
+ }
1255
+ hcc(['task', 'done', '--peer', 'takeover-a', '--id', takeoverOpenId, '--summary', 'blocked takeover cleanup']);
1256
+ const staleOut = hcc(['task', 'create', '--from', 'human', '--to', 'stale-owner', '--title', 'stale takeover']);
1257
+ const staleMatch = staleOut.match(/created task #(\d+):/);
1258
+ if (!staleMatch) fail(`cannot parse stale takeover id: ${staleOut}`);
1259
+ const staleTaskId = staleMatch[1];
1260
+ hcc(['task', 'claim', '--peer', 'stale-owner', '--id', staleTaskId]);
1261
+ withMeshDb((db) => {
1262
+ const staleAt = Math.floor(Date.now() / 1000) - 7200;
1263
+ db.prepare('UPDATE peers SET last_seen_at = ? WHERE id = ?').run(staleAt, 'stale-owner');
1264
+ });
1265
+ const staleTakeover = hccJson(['task', 'takeover', '--peer', 'takeover-b', '--id', staleTaskId, '--reason', 'stale takeover smoke', '--policy', 'stale', '--stale-after', '60']);
1266
+ if (String(staleTakeover.owner) !== 'takeover-b') {
1267
+ fail(`stale takeover did not transfer owner:\n${JSON.stringify(staleTakeover, null, 2)}`);
1268
+ }
1269
+ const takeoverInbox = hcc(['msg', 'inbox', '--peer', 'stale-owner', '--all']);
1270
+ if (!takeoverInbox.includes(`Task #${staleTaskId} taken over by takeover-b`)) {
1271
+ fail(`stale takeover did not notify previous owner:\n${takeoverInbox}`);
1272
+ }
1273
+ hcc(['task', 'done', '--peer', 'takeover-b', '--id', staleTaskId, '--summary', 'stale takeover cleanup']);
1274
+ const staleLivenessOut = hcc(['task', 'create', '--from', 'human', '--to', 'stale-liveness-owner', '--title', 'stale liveness task']);
1275
+ const staleLivenessMatch = staleLivenessOut.match(/created task #(\d+):/);
1276
+ if (!staleLivenessMatch) fail(`cannot parse stale liveness task id: ${staleLivenessOut}`);
1277
+ const staleLivenessTaskId = staleLivenessMatch[1];
1278
+ hcc(['task', 'claim', '--peer', 'stale-liveness-owner', '--id', staleLivenessTaskId]);
1279
+ withMeshDb((db) => {
1280
+ const staleAt = Math.floor(Date.now() / 1000) - 7200;
1281
+ db.prepare('UPDATE peers SET last_seen_at = ? WHERE id = ?').run(staleAt, 'stale-liveness-owner');
1282
+ });
1283
+ const staleLivenessList = hcc(['task', 'list', '--status', 'claimed']);
1284
+ if (!staleLivenessList.includes(`#${staleLivenessTaskId}`) || !staleLivenessList.includes('stale/no-lock')) {
1285
+ fail(`task list did not surface stale/no-lock owner state:\n${staleLivenessList}`);
1286
+ }
1287
+ const staleLivenessState = hccJson(['state', '--peer', 'takeover-ready-peer']);
1288
+ if (staleLivenessState.automation?.phase !== 'takeover_task' ||
1289
+ staleLivenessState.automation?.next_action?.kind !== 'task.takeover' ||
1290
+ !staleLivenessState.automation.next_action.argv.includes('--policy') ||
1291
+ !staleLivenessState.automation.next_action.argv.includes('stale')) {
1292
+ fail(`state automation did not suggest stale takeover:\n${JSON.stringify(staleLivenessState.automation, null, 2)}`);
1293
+ }
1294
+ hcc(['task', 'update', '--peer', 'human', '--force', '--id', staleLivenessTaskId, '--status', 'abandoned', '--summary', 'stale liveness cleanup']);
1151
1295
  hcc(['task', 'update', '--peer', 'human', '--id', queuedTaskId, '--status', 'abandoned', '--summary', 'queued task cleanup']);
1152
1296
  hcc(['handoff', 'create', '--from', 'codex-a', '--to', 'claude-a', '--task', taskId, '--summary', 'handoff summary', '--tests', 'full script', '--risks', 'none']);
1153
1297
  if (!hcc(['handoff', 'list', '--task', taskId]).includes('handoff summary')) fail('handoff missing');
@@ -1221,6 +1365,27 @@ async function dbWorkflow() {
1221
1365
  }
1222
1366
  hcc(['task', 'done', '--peer', 'codex-team-captain', '--id', teamParentId, '--summary', 'team regression done']);
1223
1367
 
1368
+ const scopedA = hcc(['lock', 'acquire', '--peer', 'codex-a', '--resource', 'bin/hcc.mjs', '--scope', 'db-schema', '--ttl', '60', '--reason', 'db split']);
1369
+ const scopedB = hcc(['lock', 'acquire', '--peer', 'codex-b', '--resource', 'bin/hcc.mjs', '--scope', 'web-ui', '--ttl', '60', '--reason', 'ui split']);
1370
+ if (!scopedA.includes('bin/hcc.mjs [db-schema]') || !scopedB.includes('bin/hcc.mjs [web-ui]')) {
1371
+ fail(`scoped lock output missing labels:\n${scopedA}\n${scopedB}`);
1372
+ }
1373
+ const scopedRows = hccJson(['lock', 'list']);
1374
+ if (!scopedRows.some((row) => row.base_resource === 'bin/hcc.mjs' && row.scope === 'db-schema') ||
1375
+ !scopedRows.some((row) => row.base_resource === 'bin/hcc.mjs' && row.scope === 'web-ui')) {
1376
+ fail(`scoped locks missing from list:\n${JSON.stringify(scopedRows, null, 2)}`);
1377
+ }
1378
+ const duplicateScope = hccMaybe(['lock', 'acquire', '--peer', 'claude-a', '--resource', 'bin/hcc.mjs', '--scope', 'db-schema', '--ttl', '60', '--reason', 'same scope']);
1379
+ if (duplicateScope.status === 0 || !`${duplicateScope.stdout}\n${duplicateScope.stderr}`.includes('conflicts with lock bin/hcc.mjs [db-schema]')) {
1380
+ fail(`same-scope lock was not rejected:\n${duplicateScope.stdout}\n${duplicateScope.stderr}`);
1381
+ }
1382
+ const wholeResource = hccMaybe(['lock', 'acquire', '--peer', 'claude-a', '--resource', 'bin/hcc.mjs', '--ttl', '60', '--reason', 'whole file']);
1383
+ if (wholeResource.status === 0 || !`${wholeResource.stdout}\n${wholeResource.stderr}`.includes('conflicts with lock bin/hcc.mjs [db-schema]')) {
1384
+ fail(`whole-resource lock did not conflict with scoped locks:\n${wholeResource.stdout}\n${wholeResource.stderr}`);
1385
+ }
1386
+ hcc(['lock', 'release', '--peer', 'codex-a', '--resource', 'bin/hcc.mjs', '--scope', 'db-schema']);
1387
+ hcc(['lock', 'release', '--peer', 'codex-b', '--resource', 'bin/hcc.mjs', '--scope', 'web-ui']);
1388
+
1224
1389
  const conflictTask = hcc(['task', 'create', '--from', 'human', '--to', 'codex-b', '--title', 'conflict automation task']);
1225
1390
  const conflictTaskMatch = conflictTask.match(/created task #(\d+):/);
1226
1391
  if (!conflictTaskMatch) fail(`cannot parse conflict task id: ${conflictTask}`);
@@ -1237,6 +1402,23 @@ async function dbWorkflow() {
1237
1402
  hcc(['lock', 'release', '--peer', 'codex-a', '--resource', 'src/conflict']);
1238
1403
  hcc(['task', 'done', '--peer', 'codex-b', '--id', conflictTaskId, '--summary', 'conflict done']);
1239
1404
 
1405
+ const takeoverTask = hcc(['task', 'create', '--from', 'human', '--to', 'codex-owner', '--title', 'takeover regression task']);
1406
+ const takeoverTaskMatch = takeoverTask.match(/created task #(\d+):/);
1407
+ if (!takeoverTaskMatch) fail(`cannot parse takeover task id: ${takeoverTask}`);
1408
+ const takeoverTaskId = takeoverTaskMatch[1];
1409
+ hcc(['task', 'claim', '--peer', 'codex-owner', '--id', takeoverTaskId]);
1410
+ const takeoverOutput = hcc(['task', 'takeover', '--peer', 'codex-taker', '--id', takeoverTaskId, '--reason', 'owner inactive']);
1411
+ if (!takeoverOutput.includes(`took over task #${takeoverTaskId}`)) fail(`takeover output wrong:\n${takeoverOutput}`);
1412
+ const takeoverRow = hccJson(['task', 'list', '--all']).find((row) => String(row.id) === String(takeoverTaskId));
1413
+ if (!takeoverRow || takeoverRow.owner !== 'codex-taker' || takeoverRow.status !== 'claimed') {
1414
+ fail(`takeover task row wrong:\n${JSON.stringify(takeoverRow, null, 2)}`);
1415
+ }
1416
+ const takeoverOwnerInbox = hcc(['msg', 'inbox', '--peer', 'codex-owner']);
1417
+ if (!takeoverOwnerInbox.includes(`Task #${takeoverTaskId} taken over by codex-taker`)) {
1418
+ fail(`takeover did not notify previous owner:\n${takeoverOwnerInbox}`);
1419
+ }
1420
+ hcc(['task', 'done', '--peer', 'codex-taker', '--id', takeoverTaskId, '--summary', 'takeover done']);
1421
+
1240
1422
  const abandoned = hcc(['task', 'create', '--from', 'human', '--title', 'abandoned regression task']);
1241
1423
  const abandonedMatch = abandoned.match(/created task #(\d+):/);
1242
1424
  if (!abandonedMatch) fail(`cannot parse abandoned task id: ${abandoned}`);
@@ -1436,13 +1618,56 @@ async function multiProjectWebWorkflow() {
1436
1618
  ]) {
1437
1619
  if (!html.includes(expected)) fail(`web layout missing resizable sidebar support: ${expected}`);
1438
1620
  }
1439
- if (!html.includes('state-card') || !html.includes('peerStateView') || !html.includes('savedCardScroll') || !html.includes('lastStateRoot') || !html.includes('data-section="peers"')) {
1621
+ if (!html.includes('state-card') || !html.includes('peerStateView') || !html.includes('savedCardScroll') || !html.includes('lastStateRoot') || !html.includes("stateCardHtml('peers'")) {
1440
1622
  fail('web state panel missing scrollable peer state UI');
1441
1623
  }
1442
- if (!html.includes('data-section="timeline"') || !html.includes('renderTimelineItem') || !html.includes('bodyPinned') || !html.includes('refreshCurrentState')) {
1624
+ for (const expected of [
1625
+ 'function stateCardCollapsed(section)',
1626
+ "localStorage.getItem('hcc.stateCard.' + section + '.collapsed')",
1627
+ 'function stateCardHtml(section, title, count, bodyHtml)',
1628
+ 'state-card-toggle',
1629
+ 'aria-expanded=',
1630
+ 'state-card-collapsed',
1631
+ 'function bindStateCardToggles()',
1632
+ "localStorage.setItem('hcc.stateCard.' + section + '.collapsed'",
1633
+ "stateCardHtml('automation'",
1634
+ "stateCardHtml('timeline'",
1635
+ "stateCardHtml('messages'",
1636
+ "stateCardHtml('peers'",
1637
+ "stateCardHtml('tasks'",
1638
+ "stateCardHtml('locks'",
1639
+ 'bindStateCardToggles();'
1640
+ ]) {
1641
+ if (!html.includes(expected)) fail(`web state panel missing collapsible card support: ${expected}`);
1642
+ }
1643
+ for (const expected of [
1644
+ 'id="actionResult"',
1645
+ 'data-action="state"',
1646
+ 'data-action="status"',
1647
+ 'data-action="inbox"',
1648
+ 'data-action="task-next"',
1649
+ 'data-action="heartbeat"',
1650
+ 'data-action="register"',
1651
+ 'data-terminal-action="status"',
1652
+ 'function runPeerAction(action)',
1653
+ "'/api/peers/' + encodeURIComponent(info.peerId) + '/actions/' + encodeURIComponent(action)",
1654
+ 'showActionResult(result)'
1655
+ ]) {
1656
+ if (!html.includes(expected)) fail(`web actions missing API result UI: ${expected}`);
1657
+ }
1658
+ if (html.includes('data-send=') || html.includes("document.querySelectorAll('[data-send]')")) {
1659
+ fail('web actions still use implicit terminal command injection');
1660
+ }
1661
+ for (const expected of [
1662
+ 'term.onData((data) => {',
1663
+ "ws.send(JSON.stringify({ type: 'input', data }))"
1664
+ ]) {
1665
+ if (!html.includes(expected)) fail(`web terminal input forwarding missing: ${expected}`);
1666
+ }
1667
+ if (!html.includes("stateCardHtml('timeline'") || !html.includes('renderTimelineItem') || !html.includes('bodyPinned') || !html.includes('refreshCurrentState')) {
1443
1668
  fail('web state panel missing collaboration timeline or refresh routing');
1444
1669
  }
1445
- if (!html.includes('data-section="messages"') || !html.includes('messagesData.length')) {
1670
+ if (!html.includes("stateCardHtml('messages'") || !html.includes('messagesData.length')) {
1446
1671
  fail('web state panel missing dedicated messages card');
1447
1672
  }
1448
1673
 
@@ -1463,6 +1688,40 @@ async function multiProjectWebWorkflow() {
1463
1688
  fail(`second project API did not see its session:\n${JSON.stringify(otherSessions)}`);
1464
1689
  }
1465
1690
 
1691
+ hcc(['register', '--peer', 'web-action-peer', '--kind', 'codex', '--role', 'peer']);
1692
+ hcc(['msg', 'send', '--from', 'human', '--to', 'web-action-peer', '--body', 'web action inbox ok']);
1693
+ const actionStatus = await (await runtimeFetch('/api/peers/web-action-peer/actions/status', {}, { root })).json();
1694
+ if (!actionStatus.ok || actionStatus.action !== 'status' || actionStatus.peer !== 'web-action-peer' || !actionStatus.data || typeof actionStatus.data.unread !== 'number') {
1695
+ fail(`web status action did not return structured status:\n${JSON.stringify(actionStatus, null, 2)}`);
1696
+ }
1697
+ const actionInbox = await (await runtimeFetch('/api/peers/web-action-peer/actions/inbox', {}, { root })).json();
1698
+ if (!actionInbox.ok || actionInbox.action !== 'inbox' || !(actionInbox.data?.messages || []).some((m) => m.body === 'web action inbox ok')) {
1699
+ fail(`web inbox action did not return unread messages:\n${JSON.stringify(actionInbox, null, 2)}`);
1700
+ }
1701
+ const actionState = await (await runtimeFetch('/api/peers/web-action-peer/actions/state', {}, { root })).json();
1702
+ if (!actionState.ok || actionState.action !== 'state' || actionState.data?.automation?.peer?.id !== 'web-action-peer') {
1703
+ fail(`web state action did not return peer automation:\n${JSON.stringify(actionState, null, 2)}`);
1704
+ }
1705
+ const taskOutput = hcc(['task', 'create', '--title', 'web action task', '--body', 'claim through web action']);
1706
+ const taskMatch = taskOutput.match(/created task #(\d+):/);
1707
+ if (!taskMatch) fail(`cannot parse web action task id:\n${taskOutput}`);
1708
+ const actionNext = await (await runtimeFetch('/api/peers/web-action-peer/actions/task-next', {
1709
+ method: 'POST',
1710
+ headers: { 'Content-Type': 'application/json' },
1711
+ body: '{}'
1712
+ }, { root })).json();
1713
+ if (!actionNext.ok || actionNext.action !== 'task-next' || String(actionNext.data?.task?.id) !== taskMatch[1]) {
1714
+ fail(`web task-next action did not claim pending task #${taskMatch[1]}:\n${JSON.stringify(actionNext, null, 2)}`);
1715
+ }
1716
+ const actionHeartbeat = await (await runtimeFetch('/api/peers/web-action-peer/actions/heartbeat', {
1717
+ method: 'POST',
1718
+ headers: { 'Content-Type': 'application/json' },
1719
+ body: JSON.stringify({ renew_locks: true })
1720
+ }, { root })).json();
1721
+ if (!actionHeartbeat.ok || actionHeartbeat.action !== 'heartbeat' || actionHeartbeat.data?.peer !== 'web-action-peer') {
1722
+ fail(`web heartbeat action did not return structured result:\n${JSON.stringify(actionHeartbeat, null, 2)}`);
1723
+ }
1724
+
1466
1725
  const startProvider = async (payload) => {
1467
1726
  const response = await runtimeFetch('/api/sessions', {
1468
1727
  method: 'POST',
@@ -1599,6 +1858,7 @@ async function tmuxBackedStartWorkflow() {
1599
1858
  await waitForFile(file, 'PTY_OK', 'pty injection');
1600
1859
  await expectWebSocketMarker('shell-a', 'WS_PTY_OK');
1601
1860
  await expectResizeReplaceSnapshot('shell-a', 'WS_RESIZE_OK');
1861
+ await expectWebSocketInputVisible('shell-a', 'WS_INPUT_VISIBLE_OK');
1602
1862
  await expectBoundedTmuxStream('tmux-backed FIFO stream');
1603
1863
 
1604
1864
  await stopRuntime();
@@ -1845,17 +2105,29 @@ async function downGcPackWorkflow() {
1845
2105
  hcc(['gc', '--older-than', '0', '--yes']);
1846
2106
  const pack = JSON.parse(run('npm', ['pack', '--dry-run', '--json']));
1847
2107
  const files = new Set(pack[0]?.files?.map((entry) => entry.path) || []);
1848
- for (const file of ['assets/logo.svg', 'CHANGELOG.md', 'docs/commands.md', 'docs/commands.zh-CN.md', 'scripts/github-release.mjs']) {
2108
+ for (const file of [
2109
+ 'assets/logo.svg',
2110
+ 'CHANGELOG.md',
2111
+ 'docs/commands.md',
2112
+ 'docs/commands.zh-CN.md',
2113
+ ...libModuleFiles(),
2114
+ 'scripts/github-release.mjs'
2115
+ ]) {
1849
2116
  if (!files.has(file)) fail(`npm package missing ${file}`);
1850
2117
  }
2118
+ const packageVersion = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')).version;
1851
2119
  const releaseCheck = run('npm', ['run', 'release:check']);
1852
- if (!releaseCheck.includes('release notes ok: 0.1.3')) fail(`release check output wrong:\n${releaseCheck}`);
2120
+ if (!releaseCheck.includes(`release notes ok: ${packageVersion}`)) fail(`release check output wrong:\n${releaseCheck}`);
1853
2121
  const releaseNotes = run('npm', ['run', 'release:notes']);
1854
- if (!releaseNotes.includes('## 0.1.3') || !releaseNotes.includes('### Summary')) {
2122
+ if (!releaseNotes.includes(`## ${packageVersion}`) || !releaseNotes.includes('### Summary')) {
1855
2123
  fail(`release notes output wrong:\n${releaseNotes}`);
1856
2124
  }
1857
- const githubRelease = JSON.parse(run(process.execPath, [path.join(repoRoot, 'scripts', 'github-release.mjs'), '--dry-run', '--version', '0.1.3']));
1858
- if (!githubRelease.ok || !githubRelease.dry_run || githubRelease.repo !== 'Dullne/hello-cc' || githubRelease.tag !== 'v0.1.3' || githubRelease.body_length < 100) {
2125
+ const releaseNotesWithV = run(process.execPath, [path.join(repoRoot, 'scripts', 'release-notes.mjs'), '--version', `v${packageVersion}`]);
2126
+ if (!releaseNotesWithV.includes(`## ${packageVersion}`) || releaseNotesWithV.includes(`## v${packageVersion}`)) {
2127
+ fail(`release notes v-prefixed version output wrong:\n${releaseNotesWithV}`);
2128
+ }
2129
+ const githubRelease = JSON.parse(run(process.execPath, [path.join(repoRoot, 'scripts', 'github-release.mjs'), '--dry-run', '--version', packageVersion]));
2130
+ if (!githubRelease.ok || !githubRelease.dry_run || githubRelease.repo !== 'Dullne/hello-cc' || githubRelease.tag !== `v${packageVersion}` || githubRelease.body_length < 100) {
1859
2131
  fail(`github release dry run output wrong:\n${JSON.stringify(githubRelease, null, 2)}`);
1860
2132
  }
1861
2133
  const docsIndex = fs.readFileSync(path.join(repoRoot, 'docs', 'README.md'), 'utf8');
@@ -1905,11 +2177,151 @@ function oldNameScan() {
1905
2177
  if (rg.status !== 1) fail(`rg failed:\n${rg.stderr || rg.stdout}`);
1906
2178
  }
1907
2179
 
1908
- function syntaxAndHelp() {
2180
+ async function syntaxAndHelp() {
1909
2181
  log('[11/12] syntax/help');
1910
2182
  run(process.execPath, ['--check', path.join(repoRoot, 'bin', 'hcc.mjs')]);
1911
- run(process.execPath, ['--check', path.join(repoRoot, 'lib', 'setup.mjs')]);
1912
- run(process.execPath, ['--check', path.join(repoRoot, 'lib', 'discover.mjs')]);
2183
+ for (const file of libModuleFiles()) {
2184
+ run(process.execPath, ['--check', path.join(repoRoot, file)]);
2185
+ }
2186
+ const hccSource = fs.readFileSync(hccBin, 'utf8');
2187
+ const webUiTemplateSource = fs.readFileSync(path.join(repoRoot, 'lib', 'web-ui-template.mjs'), 'utf8');
2188
+ for (const expected of [
2189
+ 'function scheduleTmuxInputRefresh(session)',
2190
+ "runTmux(['pipe-pane', '-t', session.pane]);",
2191
+ 'if (session.inputRefreshTimer) return;',
2192
+ 'session.inputRefreshTimer = setTimeout',
2193
+ 'scheduleTmuxInputRefresh(session)',
2194
+ "if (session.inputRefreshTimer) { clearTimeout(session.inputRefreshTimer); session.inputRefreshTimer = null; }",
2195
+ "broadcast(session, { type: 'replace', data: refreshTmuxSnapshot(session) });"
2196
+ ]) {
2197
+ if (!hccSource.includes(expected)) fail(`web terminal input refresh support missing: ${expected}`);
2198
+ }
2199
+ for (const expected of [
2200
+ 'function detectedPeerCanStop(peer)',
2201
+ "if (['exited', 'detached'].includes(status)) return false;",
2202
+ 'const canStop = detectedPeerCanStop(p);',
2203
+ '${canStop ?',
2204
+ "if (e.target.closest('[data-action]')) return;",
2205
+ 'id="stopKillLabel" data-i18n="dialog.killTmux"',
2206
+ 'id="stopCancelBtn" type="button" data-i18n="dialog.cancel"',
2207
+ 'id="stopConfirmBtn" type="button" data-i18n="stop"'
2208
+ ]) {
2209
+ if (!webUiTemplateSource.includes(expected)) fail(`web display regression guard missing: ${expected}`);
2210
+ }
2211
+ if (webUiTemplateSource.includes("p.status === 'running' ?")) {
2212
+ fail('detected peer action rendering still depends on status === running instead of liveness');
2213
+ }
2214
+ for (const expected of [
2215
+ 'function webPeerAction(projectCtx, peer, action, input = {})',
2216
+ 'function claimNextTasksForPeer(db, peer, { force = false, count = 1 } = {})',
2217
+ 'function takeOverTaskForPeer(db, peer, id, { reason, policy = ',
2218
+ 'function statusSummary(ctx, peer = null, identity = null)',
2219
+ 'const peerActionMatch = url.pathname.match(/^\\/api\\/peers\\/([^/]+)\\/actions\\/([^/]+)$/)',
2220
+ "const readOnly = ['status', 'state', 'inbox'].includes(action)",
2221
+ "sendJson(res, 200, webPeerAction(reqCtx, peer, action, input));"
2222
+ ]) {
2223
+ if (!hccSource.includes(expected)) fail(`web peer action API support missing: ${expected}`);
2224
+ }
2225
+ const packageVersion = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')).version;
2226
+ const cliVersion = run(process.execPath, [hccBin, '--version']).trim();
2227
+ if (cliVersion !== packageVersion) {
2228
+ fail(`CLI version ${cliVersion} does not match package.json ${packageVersion}`);
2229
+ }
2230
+ if (!hccSource.includes("import { readPackageMeta } from '../lib/package-meta.mjs'") ||
2231
+ !hccSource.includes("} from '../lib/cli-args.mjs'") ||
2232
+ !hccSource.includes("import { CliError } from '../lib/errors.mjs'") ||
2233
+ !hccSource.includes("} from '../lib/db-schema.mjs'") ||
2234
+ !hccSource.includes("} from '../lib/format.mjs'") ||
2235
+ !hccSource.includes("} from '../lib/runtime-paths.mjs'") ||
2236
+ !hccSource.includes("} from '../lib/web-runtime.mjs'") ||
2237
+ !hccSource.includes("} from '../lib/web-http.mjs'") ||
2238
+ !hccSource.includes("import { webIndexHtml } from '../lib/web-ui-template.mjs'") ||
2239
+ !hccSource.includes('const VERSION = PACKAGE_META.version') ||
2240
+ !hccSource.includes('writeGuidanceForRoot(ctx.root)')) {
2241
+ fail('CLI still has duplicated package metadata, cli args, DB schema helpers, format helpers, runtime paths, web runtime/HTTP/UI helpers, or guidance wiring');
2242
+ }
2243
+ if (hccSource.includes('function createBaseSchema') ||
2244
+ hccSource.includes('function runSchemaMigrations') ||
2245
+ hccSource.includes('function readSchemaVersion')) {
2246
+ fail('CLI still embeds DB schema or migration helpers');
2247
+ }
2248
+ for (const helper of [
2249
+ 'function runtimeBaseUrl',
2250
+ 'function runtimeApiUrl',
2251
+ 'function requestUrl',
2252
+ 'function runtimeUrlQuery',
2253
+ 'function makeWebToken',
2254
+ 'function validateWebTokenOpts',
2255
+ 'function expectedWebHost',
2256
+ 'function webRuntimeMatchesRequest',
2257
+ 'function publicRuntimeUrl',
2258
+ 'function localRuntimeUrl'
2259
+ ]) {
2260
+ if (hccSource.includes(helper)) fail(`CLI still embeds web runtime helper: ${helper}`);
2261
+ }
2262
+ for (const helper of [
2263
+ 'function readRequestBody',
2264
+ 'function readJsonRequest',
2265
+ 'function sendHttp',
2266
+ 'function sendJson',
2267
+ 'function sendFile',
2268
+ 'function authOk'
2269
+ ]) {
2270
+ if (hccSource.includes(helper)) fail(`CLI still embeds web HTTP helper: ${helper}`);
2271
+ }
2272
+ if (hccSource.includes('new URL(req.url')) fail('CLI still embeds raw server request URL parsing');
2273
+ if (hccSource.includes('function webIndexHtml()')) fail('CLI still embeds the web UI template');
2274
+ const webRuntime = await import(path.join(repoRoot, 'lib', 'web-runtime.mjs'));
2275
+ const webHttp = await import(path.join(repoRoot, 'lib', 'web-http.mjs'));
2276
+ const webUiTemplate = await import(path.join(repoRoot, 'lib', 'web-ui-template.mjs'));
2277
+ const expectEqual = (actual, expected, label) => {
2278
+ if (actual !== expected) fail(`${label}: expected ${expected}, got ${actual}`);
2279
+ };
2280
+ const html = webUiTemplate.webIndexHtml();
2281
+ if (!html.includes('<!doctype html>') ||
2282
+ !html.includes('<div class="app">') ||
2283
+ !html.includes('<script src="/assets/xterm.js"></script>')) {
2284
+ fail('web UI template module did not render the expected shell HTML');
2285
+ }
2286
+ const jsonReq = Readable.from(['{"ok":true}']);
2287
+ jsonReq.headers = {};
2288
+ const parsedJson = await webHttp.readJsonRequest(jsonReq);
2289
+ if (parsedJson.ok !== true) fail(`web HTTP helper failed to parse JSON request: ${JSON.stringify(parsedJson)}`);
2290
+ const emptyReq = Readable.from(['']);
2291
+ emptyReq.headers = {};
2292
+ const parsedEmpty = await webHttp.readJsonRequest(emptyReq);
2293
+ if (Object.keys(parsedEmpty).length !== 0) fail(`web HTTP helper failed empty request fallback: ${JSON.stringify(parsedEmpty)}`);
2294
+ const mockRes = {
2295
+ status: null,
2296
+ headers: null,
2297
+ body: '',
2298
+ writeHead(status, headers) { this.status = status; this.headers = headers; },
2299
+ end(body) { this.body = body; }
2300
+ };
2301
+ webHttp.sendJson(mockRes, 202, { ok: true });
2302
+ if (mockRes.status !== 202 ||
2303
+ mockRes.headers?.['Content-Type'] !== 'application/json; charset=utf-8' ||
2304
+ mockRes.headers?.['Cache-Control'] !== 'no-store' ||
2305
+ !mockRes.body.includes('"ok": true')) {
2306
+ fail(`web HTTP helper failed sendJson response: ${JSON.stringify(mockRes)}`);
2307
+ }
2308
+ if (!webHttp.authOk(new URL('http://example.test/?token=tok'), { headers: {} }, 'tok') ||
2309
+ !webHttp.authOk(new URL('http://example.test/'), { headers: { authorization: 'Bearer tok' } }, 'tok') ||
2310
+ webHttp.authOk(new URL('http://example.test/?token=bad'), { headers: {} }, 'tok')) {
2311
+ fail('web HTTP helper authOk token checks failed');
2312
+ }
2313
+ const wildcardRuntime = { host: '0.0.0.0', port: 8787, token: 'tok' };
2314
+ const ipv6WildcardRuntime = { host: '::', port: 8788, token: 'tok' };
2315
+ const localRuntime = { host: '127.0.0.1', port: 8789, token: 'tok' };
2316
+ expectEqual(webRuntime.runtimeBaseUrl('0.0.0.0', 8787), 'http://127.0.0.1:8787', 'runtimeBaseUrl 0.0.0.0');
2317
+ expectEqual(webRuntime.runtimeBaseUrl('::', 8788), 'http://127.0.0.1:8788', 'runtimeBaseUrl ::');
2318
+ 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');
2319
+ 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');
2320
+ expectEqual(String(webRuntime.requestUrl({ url: '', headers: {} })), 'http://localhost/', 'requestUrl fallback');
2321
+ expectEqual(webRuntime.publicRuntimeUrl(wildcardRuntime, '/tmp/hcc project'), 'http://<machine-ip>:8787/?token=tok&project=%2Ftmp%2Fhcc%20project', 'publicRuntimeUrl wildcard');
2322
+ expectEqual(webRuntime.localRuntimeUrl(wildcardRuntime, '/tmp/hcc project'), 'http://127.0.0.1:8787/?token=tok&project=%2Ftmp%2Fhcc%20project', 'localRuntimeUrl wildcard');
2323
+ expectEqual(webRuntime.publicRuntimeUrl(ipv6WildcardRuntime, '/tmp/hcc project'), 'http://<machine-ip>:8788/?token=tok&project=%2Ftmp%2Fhcc%20project', 'publicRuntimeUrl ipv6 wildcard');
2324
+ expectEqual(webRuntime.localRuntimeUrl(localRuntime, null), 'http://127.0.0.1:8789/?token=tok', 'localRuntimeUrl no project');
1913
2325
  const mainHelp = run(process.execPath, [hccBin, '--help']);
1914
2326
  if (mainHelp.includes('setup') || mainHelp.includes('--web-managed')) {
1915
2327
  fail(`public help exposes maintenance or removed commands:\n${mainHelp}`);
@@ -1932,7 +2344,11 @@ function syntaxAndHelp() {
1932
2344
  }
1933
2345
  const taskHelp = run(process.execPath, [hccBin, 'task', '--help']);
1934
2346
  if (!taskHelp.includes('task next [--peer ID] [--force]') ||
2347
+ !taskHelp.includes('[--count N]') ||
1935
2348
  !taskHelp.includes('existing claimed/running/review/blocked task') ||
2349
+ !taskHelp.includes('task takeover [--peer ID] --id N --reason TEXT') ||
2350
+ !taskHelp.includes('[--policy any|blocked|stale|blocked-or-stale]') ||
2351
+ !taskHelp.includes('task claim [--peer ID] --id N[,N]') ||
1936
2352
  !taskHelp.includes('task create --title TEXT --parent N')) {
1937
2353
  fail(`task help missing current-task task next semantics:\n${taskHelp}`);
1938
2354
  }
@@ -1941,9 +2357,18 @@ function syntaxAndHelp() {
1941
2357
  fail(`team help missing expected content:\n${teamHelp}`);
1942
2358
  }
1943
2359
  const stateHelp = run(process.execPath, [hccBin, 'state', '--help']);
1944
- if (!stateHelp.includes('hcc state') || !stateHelp.includes('automation.next_action.argv') || !stateHelp.includes('automation.current_task') || !stateHelp.includes('hcc team plan')) {
2360
+ if (!stateHelp.includes('hcc state') ||
2361
+ !stateHelp.includes('--scope SCOPE') ||
2362
+ !stateHelp.includes('--intent read|review|work|write|stop|finish') ||
2363
+ !stateHelp.includes('automation.next_action.argv') ||
2364
+ !stateHelp.includes('automation.current_task') ||
2365
+ !stateHelp.includes('hcc team plan')) {
1945
2366
  fail(`state help missing expected content:\n${stateHelp}`);
1946
2367
  }
2368
+ const lockHelp = run(process.execPath, [hccBin, 'lock', '--help']);
2369
+ if (!lockHelp.includes('--scope SCOPE') || !lockHelp.toLowerCase().includes('different scopes on the same resource')) {
2370
+ fail(`lock help missing scoped lock content:\n${lockHelp}`);
2371
+ }
1947
2372
  const updateHelp = run(process.execPath, [hccBin, 'update', '--help']);
1948
2373
  if (!updateHelp.includes('hcc update') || !updateHelp.includes('npm install -g @logicseek/hello-cc@TAG')) {
1949
2374
  fail(`update help missing expected content:\n${updateHelp}`);
@@ -2054,7 +2479,7 @@ async function main() {
2054
2479
  await askBroadcastWorkflow();
2055
2480
  await downGcPackWorkflow();
2056
2481
  oldNameScan();
2057
- syntaxAndHelp();
2482
+ await syntaxAndHelp();
2058
2483
  uninstallWorkflow();
2059
2484
  log('FULL_REGRESSION_OK');
2060
2485
  }