@logicseek/hello-cc 0.1.2 → 0.1.3
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.
- package/CHANGELOG.md +86 -3
- package/README.md +4 -1
- package/README.zh-CN.md +5 -1
- package/bin/hcc.mjs +2307 -279
- package/docs/README.md +5 -3
- package/docs/README.zh-CN.md +5 -3
- package/docs/commands.md +38 -7
- package/docs/commands.zh-CN.md +31 -6
- package/docs/design.md +12 -4
- package/docs/guide.md +55 -10
- package/docs/guide.zh-CN.md +47 -9
- package/docs/implementation.md +21 -1
- package/package.json +3 -1
- package/scripts/github-release.mjs +190 -0
- package/scripts/regression.mjs +804 -32
package/scripts/regression.mjs
CHANGED
|
@@ -107,7 +107,9 @@ function withMeshDb(fn) {
|
|
|
107
107
|
|
|
108
108
|
function providerBindingRows(provider, sessionName) {
|
|
109
109
|
return withMeshDb((db) => db.prepare(`
|
|
110
|
-
SELECT
|
|
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
|
|
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(
|
|
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(
|
|
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}`]);
|
|
@@ -640,24 +951,92 @@ async function setupRegression() {
|
|
|
640
951
|
}
|
|
641
952
|
await stopRuntime();
|
|
642
953
|
assertPeerBindingUniqueConstraints();
|
|
954
|
+
assertLegacySchemaMigration();
|
|
955
|
+
assertRegisteredProjectDbMigration();
|
|
956
|
+
assertFutureSchemaMigrationHistoryRejected();
|
|
643
957
|
assertLegacyBindingRepair();
|
|
644
958
|
|
|
645
|
-
const
|
|
959
|
+
const tokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
|
|
960
|
+
const tokenMatch = tokenOutput.match(/^pid:\s*(\d+)/m);
|
|
961
|
+
if (!tokenMatch) fail(`token web did not print background pid:\n${tokenOutput}`);
|
|
962
|
+
runtimePid = Number.parseInt(tokenMatch[1], 10);
|
|
963
|
+
if (!tokenOutput.includes('token=') || !tokenOutput.includes('open: http://<machine-ip>:')) {
|
|
964
|
+
fail(`default web output did not include remote token URL:\n${tokenOutput}`);
|
|
965
|
+
}
|
|
966
|
+
await waitRuntime();
|
|
967
|
+
const tokenRuntime = currentRuntime();
|
|
968
|
+
if (tokenRuntime.host !== '0.0.0.0' || !tokenRuntime.token || tokenRuntime.token.length < 24) {
|
|
969
|
+
fail(`default web runtime did not store remote token data:\n${JSON.stringify(tokenRuntime, null, 2)}`);
|
|
970
|
+
}
|
|
971
|
+
const unauthorizedResponse = await fetch(`${tokenRuntime.base_url}/api/runtime`);
|
|
972
|
+
if (unauthorizedResponse.status !== 401) fail(`default web API allowed missing token: ${unauthorizedResponse.status}`);
|
|
973
|
+
const tokenResponse = await runtimeFetch('/api/runtime');
|
|
974
|
+
if (!tokenResponse.ok) fail(`default web API rejected runtime token: ${tokenResponse.status}`);
|
|
975
|
+
const tokenFile = path.join(home, '.hello-cc', 'web-token');
|
|
976
|
+
ensureFile(tokenFile, tokenRuntime.token);
|
|
977
|
+
fs.rmSync(tokenFile, { force: true });
|
|
978
|
+
const existingTokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
|
|
979
|
+
if (!existingTokenOutput.includes('web already running in background')) {
|
|
980
|
+
fail(`web did not reuse existing token runtime:\n${existingTokenOutput}`);
|
|
981
|
+
}
|
|
982
|
+
ensureFile(tokenFile, tokenRuntime.token);
|
|
983
|
+
await stopRuntime();
|
|
984
|
+
|
|
985
|
+
const stableTokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
|
|
986
|
+
const stableTokenMatch = stableTokenOutput.match(/^pid:\s*(\d+)/m);
|
|
987
|
+
if (!stableTokenMatch) fail(`stable-token web did not print background pid:\n${stableTokenOutput}`);
|
|
988
|
+
runtimePid = Number.parseInt(stableTokenMatch[1], 10);
|
|
989
|
+
await waitRuntime();
|
|
990
|
+
const stableTokenRuntime = currentRuntime();
|
|
991
|
+
if (stableTokenRuntime.token !== tokenRuntime.token) {
|
|
992
|
+
fail(`default web token changed across restart:\nfirst=${tokenRuntime.token}\nsecond=${stableTokenRuntime.token}`);
|
|
993
|
+
}
|
|
994
|
+
await stopRuntime();
|
|
995
|
+
|
|
996
|
+
const fixedToken = `fixed-token-${testId}`;
|
|
997
|
+
const fixedTokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance'], {
|
|
998
|
+
env: { ...env, HCC_WEB_TOKEN: fixedToken }
|
|
999
|
+
});
|
|
1000
|
+
const fixedTokenMatch = fixedTokenOutput.match(/^pid:\s*(\d+)/m);
|
|
1001
|
+
if (!fixedTokenMatch) fail(`fixed-token web did not print background pid:\n${fixedTokenOutput}`);
|
|
1002
|
+
runtimePid = Number.parseInt(fixedTokenMatch[1], 10);
|
|
1003
|
+
await waitRuntime();
|
|
1004
|
+
const fixedTokenRuntime = currentRuntime();
|
|
1005
|
+
if (fixedTokenRuntime.token !== fixedToken || !fixedTokenOutput.includes(`token=${encodeURIComponent(fixedToken)}`)) {
|
|
1006
|
+
fail(`explicit stable token was not used:\n${fixedTokenOutput}\n${JSON.stringify(fixedTokenRuntime, null, 2)}`);
|
|
1007
|
+
}
|
|
1008
|
+
await stopRuntime();
|
|
1009
|
+
|
|
1010
|
+
const persistedFixedOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
|
|
1011
|
+
const persistedFixedMatch = persistedFixedOutput.match(/^pid:\s*(\d+)/m);
|
|
1012
|
+
if (!persistedFixedMatch) fail(`persisted fixed-token web did not print background pid:\n${persistedFixedOutput}`);
|
|
1013
|
+
runtimePid = Number.parseInt(persistedFixedMatch[1], 10);
|
|
1014
|
+
await waitRuntime();
|
|
1015
|
+
const persistedFixedRuntime = currentRuntime();
|
|
1016
|
+
if (persistedFixedRuntime.token !== fixedToken) {
|
|
1017
|
+
fail(`explicit stable token was not persisted:\n${JSON.stringify(persistedFixedRuntime, null, 2)}`);
|
|
1018
|
+
}
|
|
1019
|
+
await stopRuntime();
|
|
1020
|
+
|
|
1021
|
+
const noTokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-token', '--no-discover', '--no-guidance']);
|
|
646
1022
|
const noTokenMatch = noTokenOutput.match(/^pid:\s*(\d+)/m);
|
|
647
|
-
if (!noTokenMatch) fail(`no-token web did not print background pid:\n${noTokenOutput}`);
|
|
1023
|
+
if (!noTokenMatch) fail(`explicit no-token web did not print background pid:\n${noTokenOutput}`);
|
|
648
1024
|
runtimePid = Number.parseInt(noTokenMatch[1], 10);
|
|
649
|
-
if (noTokenOutput.includes('token=')
|
|
650
|
-
fail(`
|
|
1025
|
+
if (noTokenOutput.includes('token=')) {
|
|
1026
|
+
fail(`explicit no-token web output included token:\n${noTokenOutput}`);
|
|
651
1027
|
}
|
|
652
1028
|
await waitRuntime();
|
|
653
|
-
const noTokenRuntime =
|
|
654
|
-
if (noTokenRuntime.token
|
|
655
|
-
fail(`default web runtime unexpectedly stored token data:\n${JSON.stringify(noTokenRuntime, null, 2)}`);
|
|
656
|
-
}
|
|
1029
|
+
const noTokenRuntime = currentRuntime();
|
|
1030
|
+
if (noTokenRuntime.token) fail(`explicit no-token runtime stored token:\n${JSON.stringify(noTokenRuntime, null, 2)}`);
|
|
657
1031
|
const noTokenResponse = await fetch(`${noTokenRuntime.base_url}/api/runtime`);
|
|
658
|
-
if (!noTokenResponse.ok) fail(`
|
|
1032
|
+
if (!noTokenResponse.ok) fail(`explicit no-token web API required token: ${noTokenResponse.status}`);
|
|
659
1033
|
await stopRuntime();
|
|
660
1034
|
|
|
1035
|
+
const conflictingToken = hccMaybe(['web', '--local', '--port', String(port), '--token', 'abc', '--no-token', '--no-discover', '--no-guidance']);
|
|
1036
|
+
if (conflictingToken.status === 0 || !String(conflictingToken.stderr || conflictingToken.stdout).includes('--no-token cannot be combined')) {
|
|
1037
|
+
fail(`web accepted conflicting --token and --no-token:\n${conflictingToken.stdout}\n${conflictingToken.stderr}`);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
661
1040
|
const childDir = path.join(root, 'packages', 'child');
|
|
662
1041
|
fs.mkdirSync(childDir, { recursive: true });
|
|
663
1042
|
const childFindRoot = hccFrom(['find-root'], childDir).trim();
|
|
@@ -714,9 +1093,71 @@ async function dbWorkflow() {
|
|
|
714
1093
|
if (!inbox.includes('please review')) fail('inbox did not include message');
|
|
715
1094
|
const msgMatch = inbox.match(/^#(\d+)/m);
|
|
716
1095
|
if (!msgMatch) fail(`cannot parse message id: ${inbox}`);
|
|
717
|
-
|
|
1096
|
+
const msgId = msgMatch[1];
|
|
1097
|
+
const reply = hcc(['msg', 'reply', '--from', 'claude-a', '--id', msgId, '--body', 'review reply']);
|
|
1098
|
+
const replyMatch = reply.match(/sent reply #(\d+) to #(\d+)/);
|
|
1099
|
+
if (!replyMatch || replyMatch[2] !== msgId) fail(`cannot parse reply output: ${reply}`);
|
|
1100
|
+
const thread = hcc(['msg', 'thread', '--id', msgId]);
|
|
1101
|
+
if (!thread.includes(`thread #${msgId}`) || !thread.includes('please review') || !thread.includes('review reply')) {
|
|
1102
|
+
fail(`thread output missing original or reply:\n${thread}`);
|
|
1103
|
+
}
|
|
1104
|
+
const replyInbox = hcc(['msg', 'inbox', '--peer', 'codex-a']);
|
|
1105
|
+
if (!replyInbox.includes('review reply') || !replyInbox.includes(`#${msgId}`)) {
|
|
1106
|
+
fail(`reply did not reach original sender inbox with thread context:\n${replyInbox}`);
|
|
1107
|
+
}
|
|
1108
|
+
const stateBeforeAck = hccJson(['state', '--peer', 'codex-a', '--resource', 'src/router']);
|
|
1109
|
+
if (stateBeforeAck.automation?.schema_version !== 1 || stateBeforeAck.automation.phase !== 'reply_message') {
|
|
1110
|
+
fail(`state automation did not prioritize unread reply:\n${JSON.stringify(stateBeforeAck, null, 2)}`);
|
|
1111
|
+
}
|
|
1112
|
+
if (stateBeforeAck.automation.next_action.kind !== 'msg.reply' || !stateBeforeAck.automation.next_action.argv.includes(replyMatch[1])) {
|
|
1113
|
+
fail(`state next action did not target unread reply:\n${JSON.stringify(stateBeforeAck.automation, null, 2)}`);
|
|
1114
|
+
}
|
|
1115
|
+
const timelineIds = new Set((stateBeforeAck.timeline || []).map((item) => item.id));
|
|
1116
|
+
if (!timelineIds.has(`message:${msgId}`) || !timelineIds.has(`message:${replyMatch[1]}`)) {
|
|
1117
|
+
fail(`state timeline missing message thread entries:\n${JSON.stringify(stateBeforeAck.timeline, null, 2)}`);
|
|
1118
|
+
}
|
|
1119
|
+
if ([...(stateBeforeAck.timeline || [])].some((item) => item.kind === 'message.sent' || item.kind === 'message.ack')) {
|
|
1120
|
+
fail(`state timeline includes noisy message events:\n${JSON.stringify(stateBeforeAck.timeline, null, 2)}`);
|
|
1121
|
+
}
|
|
1122
|
+
hcc(['msg', 'ack', '--peer', 'claude-a', '--id', msgId]);
|
|
1123
|
+
hcc(['msg', 'ack', '--peer', 'codex-a', '--id', replyMatch[1]]);
|
|
1124
|
+
const stateAfterAck = hccJson(['state', '--peer', 'codex-a', '--resource', 'src/new-lock']);
|
|
1125
|
+
if (stateAfterAck.automation.next_action.kind !== 'lock.acquire') {
|
|
1126
|
+
fail(`state automation did not suggest lock acquire after ack:\n${JSON.stringify(stateAfterAck.automation, null, 2)}`);
|
|
1127
|
+
}
|
|
1128
|
+
const queuedTask = hcc(['task', 'create', '--from', 'human', '--to', 'codex-a', '--title', 'queued while busy']);
|
|
1129
|
+
const queuedTaskMatch = queuedTask.match(/created task #(\d+):/);
|
|
1130
|
+
if (!queuedTaskMatch) fail(`cannot parse queued task id: ${queuedTask}`);
|
|
1131
|
+
const queuedTaskId = queuedTaskMatch[1];
|
|
1132
|
+
const busyState = hccJson(['state', '--peer', 'codex-a']);
|
|
1133
|
+
if (String(busyState.automation.current_task?.id) !== String(taskId)) {
|
|
1134
|
+
fail(`state did not preserve current task while another task was assigned:\n${JSON.stringify(busyState.automation, null, 2)}`);
|
|
1135
|
+
}
|
|
1136
|
+
if (['task.claim', 'task.next', 'msg.inbox'].includes(busyState.automation.next_action.kind)) {
|
|
1137
|
+
fail(`state let a new assigned task interrupt current work:\n${JSON.stringify(busyState.automation, null, 2)}`);
|
|
1138
|
+
}
|
|
1139
|
+
const busyHookPayload = JSON.stringify({ session_id: 'codex-busy-session', cwd: root, hook_event_name: 'UserPromptSubmit', prompt: 'new user prompt while busy' });
|
|
1140
|
+
const busyHook = hookContext(hcc(['hook', 'userpromptsubmit'], { env: { ...env, HCC_PEER: 'codex-a' }, input: busyHookPayload }), 'UserPromptSubmit');
|
|
1141
|
+
if (!busyHook.includes('[hello-cc current task]') || !busyHook.includes(`#${taskId} running`)) {
|
|
1142
|
+
fail(`hook did not preserve current task while another task was assigned:\n${busyHook}`);
|
|
1143
|
+
}
|
|
1144
|
+
if (busyHook.includes(`hcc task claim --peer codex-a --id ${queuedTaskId}`)) {
|
|
1145
|
+
fail(`hook suggested claiming a new task while current task was active:\n${busyHook}`);
|
|
1146
|
+
}
|
|
1147
|
+
const nextAgain = hcc(['task', 'next', '--peer', 'codex-a']);
|
|
1148
|
+
if (!nextAgain.includes(`current task #${taskId}`)) {
|
|
1149
|
+
fail(`task next did not preserve current task:\n${nextAgain}`);
|
|
1150
|
+
}
|
|
1151
|
+
hcc(['task', 'update', '--peer', 'human', '--id', queuedTaskId, '--status', 'abandoned', '--summary', 'queued task cleanup']);
|
|
718
1152
|
hcc(['handoff', 'create', '--from', 'codex-a', '--to', 'claude-a', '--task', taskId, '--summary', 'handoff summary', '--tests', 'full script', '--risks', 'none']);
|
|
719
1153
|
if (!hcc(['handoff', 'list', '--task', taskId]).includes('handoff summary')) fail('handoff missing');
|
|
1154
|
+
const stateAfterHandoff = hccJson(['state', '--peer', 'claude-a']);
|
|
1155
|
+
if (!(stateAfterHandoff.timeline || []).some((item) => item.id.startsWith('handoff:') && item.text.includes('handoff summary'))) {
|
|
1156
|
+
fail(`state timeline missing handoff item:\n${JSON.stringify(stateAfterHandoff.timeline, null, 2)}`);
|
|
1157
|
+
}
|
|
1158
|
+
if ((stateAfterHandoff.timeline || []).some((item) => item.kind === 'handoff.created')) {
|
|
1159
|
+
fail(`state timeline includes noisy handoff event:\n${JSON.stringify(stateAfterHandoff.timeline, null, 2)}`);
|
|
1160
|
+
}
|
|
720
1161
|
if (!hcc(['status', '--peer', 'codex-a']).includes('codex-a')) fail('status missing peer');
|
|
721
1162
|
hcc(['lock', 'release', '--peer', 'codex-a', '--resource', 'src/router']);
|
|
722
1163
|
hcc(['task', 'done', '--peer', 'codex-a', '--id', taskId, '--summary', 'done']);
|
|
@@ -725,6 +1166,77 @@ async function dbWorkflow() {
|
|
|
725
1166
|
if (!hasTask(hccJson(['task', 'list', '--all']), taskId)) fail(`done task missing from --all list: #${taskId}`);
|
|
726
1167
|
if (!hasTask(hccJson(['task', 'list', '--status', 'done']), taskId)) fail(`done task missing from --status done list: #${taskId}`);
|
|
727
1168
|
|
|
1169
|
+
const teamParent = hcc([
|
|
1170
|
+
'task', 'create',
|
|
1171
|
+
'--from', 'human',
|
|
1172
|
+
'--to', 'codex-team-captain',
|
|
1173
|
+
'--title', 'parallel release cleanup',
|
|
1174
|
+
'--body', '- Update docs\n- Add regression\n- Verify migration'
|
|
1175
|
+
]);
|
|
1176
|
+
const teamParentMatch = teamParent.match(/created task #(\d+):/);
|
|
1177
|
+
if (!teamParentMatch) fail(`cannot parse team parent id: ${teamParent}`);
|
|
1178
|
+
const teamParentId = teamParentMatch[1];
|
|
1179
|
+
hcc(['task', 'claim', '--peer', 'codex-team-captain', '--id', teamParentId]);
|
|
1180
|
+
hcc(['task', 'update', '--peer', 'codex-team-captain', '--id', teamParentId, '--status', 'running', '--summary', 'team captain']);
|
|
1181
|
+
const teamState = hccJson(['state', '--peer', 'codex-team-captain']);
|
|
1182
|
+
if (teamState.automation.phase !== 'team_plan' || teamState.automation.next_action.kind !== 'team.plan') {
|
|
1183
|
+
fail(`state did not suggest team plan for splittable task:\n${JSON.stringify(teamState.automation, null, 2)}`);
|
|
1184
|
+
}
|
|
1185
|
+
const teamPlan = hcc(['team', 'plan', '--from-task', teamParentId, '--item', 'docs:Update docs', '--item', 'tests:Add regression', '--workers', 'codex-worker-a,claude-worker-a']);
|
|
1186
|
+
if (!teamPlan.includes('team plan for task') || !teamPlan.includes('Update docs')) {
|
|
1187
|
+
fail(`team plan output wrong:\n${teamPlan}`);
|
|
1188
|
+
}
|
|
1189
|
+
const kindWorkerPlan = hcc(['team', 'plan', '--from-task', teamParentId, '--item', 'docs:Docs slot', '--item', 'tests:Tests slot', '--workers', 'codex:2']);
|
|
1190
|
+
if (!kindWorkerPlan.includes(`codex-team-${teamParentId}-1`) || !kindWorkerPlan.includes(`codex-team-${teamParentId}-2`)) {
|
|
1191
|
+
fail(`team workers kind-count syntax was not expanded:\n${kindWorkerPlan}`);
|
|
1192
|
+
}
|
|
1193
|
+
const childrenBeforeStart = withMeshDb((db) => db.prepare('SELECT COUNT(*) AS n FROM tasks WHERE parent_id = ?').get(teamParentId).n);
|
|
1194
|
+
if (childrenBeforeStart !== 0) fail(`team plan created children: ${childrenBeforeStart}`);
|
|
1195
|
+
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']);
|
|
1196
|
+
if (teamStart.children.length !== 2) {
|
|
1197
|
+
fail(`team start created wrong child count:\n${JSON.stringify(teamStart, null, 2)}`);
|
|
1198
|
+
}
|
|
1199
|
+
const teamRows = withMeshDb((db) => db.prepare(`
|
|
1200
|
+
SELECT id, title, assignee, parent_id, team_role
|
|
1201
|
+
FROM tasks
|
|
1202
|
+
WHERE parent_id = ?
|
|
1203
|
+
ORDER BY id
|
|
1204
|
+
`).all(teamParentId));
|
|
1205
|
+
if (teamRows.length !== 2 ||
|
|
1206
|
+
teamRows[0].assignee !== 'codex-worker-a' ||
|
|
1207
|
+
teamRows[1].assignee !== 'claude-worker-a' ||
|
|
1208
|
+
teamRows.some((row) => String(row.parent_id) !== String(teamParentId) || !row.team_role)) {
|
|
1209
|
+
fail(`team child rows wrong:\n${JSON.stringify(teamRows, null, 2)}`);
|
|
1210
|
+
}
|
|
1211
|
+
const duplicateTeamStart = hccMaybe(['team', 'start', '--from-task', teamParentId]);
|
|
1212
|
+
if (duplicateTeamStart.status === 0 || !String(duplicateTeamStart.stderr || duplicateTeamStart.stdout).includes('already has')) {
|
|
1213
|
+
fail(`duplicate team start was not rejected:\n${duplicateTeamStart.stdout}\n${duplicateTeamStart.stderr}`);
|
|
1214
|
+
}
|
|
1215
|
+
const teamStatus = hcc(['team', 'status', '--task', teamParentId]);
|
|
1216
|
+
if (!teamStatus.includes('subtasks: 2') || !teamStatus.includes('codex-worker-a')) {
|
|
1217
|
+
fail(`team status output wrong:\n${teamStatus}`);
|
|
1218
|
+
}
|
|
1219
|
+
for (const row of teamRows) {
|
|
1220
|
+
hcc(['task', 'update', '--peer', 'human', '--force', '--id', String(row.id), '--status', 'abandoned', '--summary', 'team regression cleanup']);
|
|
1221
|
+
}
|
|
1222
|
+
hcc(['task', 'done', '--peer', 'codex-team-captain', '--id', teamParentId, '--summary', 'team regression done']);
|
|
1223
|
+
|
|
1224
|
+
const conflictTask = hcc(['task', 'create', '--from', 'human', '--to', 'codex-b', '--title', 'conflict automation task']);
|
|
1225
|
+
const conflictTaskMatch = conflictTask.match(/created task #(\d+):/);
|
|
1226
|
+
if (!conflictTaskMatch) fail(`cannot parse conflict task id: ${conflictTask}`);
|
|
1227
|
+
const conflictTaskId = conflictTaskMatch[1];
|
|
1228
|
+
hcc(['task', 'claim', '--peer', 'codex-b', '--id', conflictTaskId]);
|
|
1229
|
+
hcc(['lock', 'acquire', '--peer', 'codex-a', '--resource', 'src/conflict', '--ttl', '60', '--reason', 'held by other peer']);
|
|
1230
|
+
const conflictState = hccJson(['state', '--peer', 'codex-b', '--resource', 'src/conflict']);
|
|
1231
|
+
if (conflictState.automation.phase !== 'coordinate_lock' || conflictState.automation.next_action.kind !== 'msg.send') {
|
|
1232
|
+
fail(`state automation did not suggest lock coordination:\n${JSON.stringify(conflictState.automation, null, 2)}`);
|
|
1233
|
+
}
|
|
1234
|
+
if (conflictState.automation.next_action.argv.includes('--force')) {
|
|
1235
|
+
fail(`state automation suggested forcing a lock:\n${JSON.stringify(conflictState.automation, null, 2)}`);
|
|
1236
|
+
}
|
|
1237
|
+
hcc(['lock', 'release', '--peer', 'codex-a', '--resource', 'src/conflict']);
|
|
1238
|
+
hcc(['task', 'done', '--peer', 'codex-b', '--id', conflictTaskId, '--summary', 'conflict done']);
|
|
1239
|
+
|
|
728
1240
|
const abandoned = hcc(['task', 'create', '--from', 'human', '--title', 'abandoned regression task']);
|
|
729
1241
|
const abandonedMatch = abandoned.match(/created task #(\d+):/);
|
|
730
1242
|
if (!abandonedMatch) fail(`cannot parse abandoned task id: ${abandoned}`);
|
|
@@ -753,9 +1265,12 @@ async function dbWorkflow() {
|
|
|
753
1265
|
if (!firstHook.includes('[hello-cc known peers]') || !firstHook.includes('do not say sessions are isolated')) {
|
|
754
1266
|
fail(`UserPromptSubmit hook missing strong cross-session instruction:\n${firstHook}`);
|
|
755
1267
|
}
|
|
756
|
-
if (!firstHook.includes('hcc task list') || !firstHook.includes('hook-only-message')) {
|
|
1268
|
+
if (!firstHook.includes('hcc task list') || !firstHook.includes('hcc msg reply --id <message-id>') || !firstHook.includes('hook-only-message')) {
|
|
757
1269
|
fail(`UserPromptSubmit hook missing instructions or unread message:\n${firstHook}`);
|
|
758
1270
|
}
|
|
1271
|
+
if (!firstHook.includes('[hello-cc next action]') || !firstHook.includes('phase: reply_message') || !firstHook.includes('hcc msg reply')) {
|
|
1272
|
+
fail(`UserPromptSubmit hook missing executable next action:\n${firstHook}`);
|
|
1273
|
+
}
|
|
759
1274
|
const secondHook = hookContext(hcc(['hook', 'userpromptsubmit'], { env: hookEnv, input: hookPayload }), 'UserPromptSubmit');
|
|
760
1275
|
if (!secondHook.includes(`#${hookTaskId} running`)) {
|
|
761
1276
|
fail(`UserPromptSubmit hook stopped showing open task after first read:\n${secondHook}`);
|
|
@@ -763,6 +1278,9 @@ async function dbWorkflow() {
|
|
|
763
1278
|
if (secondHook.includes('hook-only-message')) {
|
|
764
1279
|
fail(`UserPromptSubmit hook repeated acked unread message:\n${secondHook}`);
|
|
765
1280
|
}
|
|
1281
|
+
if (!secondHook.includes('[hello-cc next action]')) {
|
|
1282
|
+
fail(`UserPromptSubmit hook dropped next action after ack:\n${secondHook}`);
|
|
1283
|
+
}
|
|
766
1284
|
const sessionHookPayload = JSON.stringify({ session_id: 'claude-hook-session', cwd: root, hook_event_name: 'SessionStart', source: 'resume' });
|
|
767
1285
|
const sessionHook = hookContext(hcc(['hook', 'sessionstart'], { env: hookEnv, input: sessionHookPayload }), 'SessionStart');
|
|
768
1286
|
if (!sessionHook.includes(`#${hookTaskId} running`) || sessionHook.includes('hook-only-message')) {
|
|
@@ -784,12 +1302,22 @@ async function dbWorkflow() {
|
|
|
784
1302
|
const autoTaskId = autoTaskMatch[1];
|
|
785
1303
|
hcc(['task', 'claim', '--id', autoTaskId], { env: autoEnv });
|
|
786
1304
|
hcc(['task', 'update', '--id', autoTaskId, '--status', 'running', '--summary', 'auto running'], { env: autoEnv });
|
|
1305
|
+
const autoState = hccJson(['state'], { env: autoEnv });
|
|
1306
|
+
if (autoState.automation.peer?.id !== autoPeer) {
|
|
1307
|
+
fail(`auto state used wrong peer: ${autoPeer}\n${JSON.stringify(autoState.automation, null, 2)}`);
|
|
1308
|
+
}
|
|
1309
|
+
if (String(autoState.automation.current_task?.id) !== String(autoTaskId)) {
|
|
1310
|
+
fail(`auto state lost current task #${autoTaskId}:\n${JSON.stringify(autoState.automation, null, 2)}`);
|
|
1311
|
+
}
|
|
1312
|
+
if (autoState.automation.next_action.kind === 'task.next') {
|
|
1313
|
+
fail(`auto state suggested task.next while current task was active:\n${JSON.stringify(autoState.automation, null, 2)}`);
|
|
1314
|
+
}
|
|
787
1315
|
hcc(['lock', 'acquire', '--resource', 'auto/resource', '--task', autoTaskId, '--ttl', '60'], { env: autoEnv });
|
|
788
1316
|
hcc(['lock', 'renew', '--resource', 'auto/resource', '--ttl', '60'], { env: autoEnv });
|
|
789
1317
|
hcc(['handoff', 'create', '--summary', 'auto handoff', '--tests', 'auto test', '--risks', 'none'], { env: autoEnv });
|
|
790
1318
|
hcc(['lock', 'release', '--resource', 'auto/resource'], { env: autoEnv });
|
|
791
1319
|
hcc(['task', 'done', '--id', autoTaskId, '--summary', 'auto done'], { env: autoEnv });
|
|
792
|
-
const autoEvents = hcc(['event', 'tail', '--limit', '
|
|
1320
|
+
const autoEvents = hcc(['event', 'tail', '--limit', '200']);
|
|
793
1321
|
if (!autoEvents.includes('peer.auto_joined') || !autoEvents.includes(autoPeer)) {
|
|
794
1322
|
fail(`auto join event missing for ${autoPeer}:\n${autoEvents}`);
|
|
795
1323
|
}
|
|
@@ -818,7 +1346,7 @@ async function multiProjectWebWorkflow() {
|
|
|
818
1346
|
fail(`second project runtime did not point at global runtime:\n${JSON.stringify(otherRuntime, null, 2)}`);
|
|
819
1347
|
}
|
|
820
1348
|
|
|
821
|
-
const projectsResponse = await
|
|
1349
|
+
const projectsResponse = await runtimeFetch('/api/projects', {}, { root: otherRoot });
|
|
822
1350
|
const projects = await projectsResponse.json();
|
|
823
1351
|
if (!projectsResponse.ok) fail(`projects API failed: ${JSON.stringify(projects)}`);
|
|
824
1352
|
const roots = new Set((projects.projects || []).map((p) => p.root));
|
|
@@ -826,23 +1354,97 @@ async function multiProjectWebWorkflow() {
|
|
|
826
1354
|
fail(`projects API did not include both roots:\n${JSON.stringify(projects, null, 2)}`);
|
|
827
1355
|
}
|
|
828
1356
|
|
|
829
|
-
const detectedResponse = await
|
|
1357
|
+
const detectedResponse = await runtimeFetch('/api/detected', {}, { root });
|
|
830
1358
|
const detectedJson = await detectedResponse.json();
|
|
831
1359
|
if (!detectedResponse.ok || typeof detectedJson.active_peer_ttl !== 'number' || typeof detectedJson.now !== 'number' || !Array.isArray(detectedJson.detected)) {
|
|
832
1360
|
fail(`detected API did not return liveness metadata:\n${JSON.stringify(detectedJson, null, 2)}`);
|
|
833
1361
|
}
|
|
834
1362
|
|
|
835
|
-
const htmlResponse = await fetch(
|
|
1363
|
+
const htmlResponse = await fetch(currentRuntimeUrl('/'));
|
|
836
1364
|
const html = await htmlResponse.text();
|
|
837
1365
|
for (const forbidden of ['Alias optional', 'Role tag', 'Command<input', 'Working directory', 'commandbar', 'lineInput', 'Send text to active terminal']) {
|
|
838
1366
|
if (html.includes(forbidden)) fail(`web form still exposes ${forbidden}`);
|
|
839
1367
|
}
|
|
840
|
-
|
|
841
|
-
|
|
1368
|
+
for (const expected of [
|
|
1369
|
+
'id="projectSelect"',
|
|
1370
|
+
'id="projectPath"',
|
|
1371
|
+
'id="addProjectBtn"',
|
|
1372
|
+
'id="startForm"',
|
|
1373
|
+
'id="kind"',
|
|
1374
|
+
'id="sessionKindFilter"',
|
|
1375
|
+
'id="sessions"'
|
|
1376
|
+
]) {
|
|
1377
|
+
if (!html.includes(expected)) fail(`web form missing simplified project/session control: ${expected}`);
|
|
1378
|
+
}
|
|
1379
|
+
for (const expected of [
|
|
1380
|
+
'id="langSelect"',
|
|
1381
|
+
"localStorage.getItem('hcc.lang')",
|
|
1382
|
+
"localStorage.setItem('hcc.lang', lang)",
|
|
1383
|
+
"document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en'",
|
|
1384
|
+
"zh: {",
|
|
1385
|
+
"language: '语言'",
|
|
1386
|
+
"projectState: '项目状态'",
|
|
1387
|
+
"noSessionSelected: '未选择会话'",
|
|
1388
|
+
"sendMessage: '发送消息'",
|
|
1389
|
+
"peers: '协作方'",
|
|
1390
|
+
"noPeers: '没有协作方。'",
|
|
1391
|
+
"'status.active': '活跃'",
|
|
1392
|
+
"data-i18n=\"language\"",
|
|
1393
|
+
"data-i18n-placeholder=\"projectPathPlaceholder\"",
|
|
1394
|
+
"data-i18n-title=\"collapseSidebar\"",
|
|
1395
|
+
"function applyLanguage()",
|
|
1396
|
+
"function tr(key",
|
|
1397
|
+
"function statusText(status)",
|
|
1398
|
+
"function sessionMetaText(session)",
|
|
1399
|
+
"connText('attached')",
|
|
1400
|
+
"connText('coordinationOnly')",
|
|
1401
|
+
"tr('noActiveSessions')",
|
|
1402
|
+
"tr('nextAction')",
|
|
1403
|
+
"tr('detectedSession')",
|
|
1404
|
+
"tr('messageBodyPlaceholder')",
|
|
1405
|
+
"activeType === 'detected' && activeDetected",
|
|
1406
|
+
"const draft = document.getElementById('detMsg')?.value || ''",
|
|
1407
|
+
"statusText(state.label)",
|
|
1408
|
+
"statusText(automation.phase || 'idle')",
|
|
1409
|
+
"tr('runtime')"
|
|
1410
|
+
]) {
|
|
1411
|
+
if (!html.includes(expected)) fail(`web UI missing i18n support: ${expected}`);
|
|
1412
|
+
}
|
|
1413
|
+
if (!html.includes('id="startMode"') || !html.includes('id="resumeArg"') || !html.includes('syncStartModeOptions') || !html.includes("mode === 'resume'")) {
|
|
1414
|
+
fail('web form missing provider resume controls');
|
|
1415
|
+
}
|
|
1416
|
+
for (const expected of [
|
|
1417
|
+
'--left-width',
|
|
1418
|
+
'--right-width',
|
|
1419
|
+
'hcc.sidebar.left.width',
|
|
1420
|
+
'hcc.sidebar.right.width',
|
|
1421
|
+
'class="edge-resizer edge-resizer-left"',
|
|
1422
|
+
'class="edge-resizer edge-resizer-right"',
|
|
1423
|
+
'id="resizeLeft"',
|
|
1424
|
+
'id="resizeRight"',
|
|
1425
|
+
'role="separator"',
|
|
1426
|
+
'aria-orientation="vertical"',
|
|
1427
|
+
'bindSideHandle(resizeLeftHandle',
|
|
1428
|
+
'bindSideHandle(resizeRightHandle',
|
|
1429
|
+
'bindSideHandle(toggleLeftBtn',
|
|
1430
|
+
'bindSideHandle(toggleRightBtn',
|
|
1431
|
+
'setPointerCapture',
|
|
1432
|
+
'sideIsCollapsed(opposite) ? 0',
|
|
1433
|
+
'Math.abs(delta) <= 3',
|
|
1434
|
+
"localStorage.setItem('hcc.collapse.' + side, on ? '1' : '0');\n applySideWidths();",
|
|
1435
|
+
'cursor: col-resize'
|
|
1436
|
+
]) {
|
|
1437
|
+
if (!html.includes(expected)) fail(`web layout missing resizable sidebar support: ${expected}`);
|
|
842
1438
|
}
|
|
843
1439
|
if (!html.includes('state-card') || !html.includes('peerStateView') || !html.includes('savedCardScroll') || !html.includes('lastStateRoot') || !html.includes('data-section="peers"')) {
|
|
844
1440
|
fail('web state panel missing scrollable peer state UI');
|
|
845
1441
|
}
|
|
1442
|
+
if (!html.includes('data-section="timeline"') || !html.includes('renderTimelineItem') || !html.includes('bodyPinned') || !html.includes('refreshCurrentState')) {
|
|
1443
|
+
fail('web state panel missing collaboration timeline or refresh routing');
|
|
1444
|
+
}
|
|
1445
|
+
if (!html.includes('data-section="messages"') || !html.includes('messagesData.length')) {
|
|
1446
|
+
fail('web state panel missing dedicated messages card');
|
|
1447
|
+
}
|
|
846
1448
|
|
|
847
1449
|
if (!tmuxAvailable()) return;
|
|
848
1450
|
const started = hccFrom(['peer', 'start', 'other-shell', '--kind', 'shell', '--', 'bash', '--noprofile', '--norc'], otherRoot);
|
|
@@ -852,8 +1454,8 @@ async function multiProjectWebWorkflow() {
|
|
|
852
1454
|
if (rootList.includes('other-shell')) fail(`root project saw second project session:\n${rootList}`);
|
|
853
1455
|
if (!otherList.includes('other-shell')) fail(`second project did not see its session:\n${otherList}`);
|
|
854
1456
|
|
|
855
|
-
const rootSessions = await (await
|
|
856
|
-
const otherSessions = await (await
|
|
1457
|
+
const rootSessions = await (await runtimeFetch('/api/sessions', {}, { root })).json();
|
|
1458
|
+
const otherSessions = await (await runtimeFetch('/api/sessions', {}, { root: otherRoot })).json();
|
|
857
1459
|
if ((rootSessions.sessions || []).some((s) => s.id === 'other-shell')) {
|
|
858
1460
|
fail(`root API saw second project session:\n${JSON.stringify(rootSessions)}`);
|
|
859
1461
|
}
|
|
@@ -861,12 +1463,112 @@ async function multiProjectWebWorkflow() {
|
|
|
861
1463
|
fail(`second project API did not see its session:\n${JSON.stringify(otherSessions)}`);
|
|
862
1464
|
}
|
|
863
1465
|
|
|
1466
|
+
const startProvider = async (payload) => {
|
|
1467
|
+
const response = await runtimeFetch('/api/sessions', {
|
|
1468
|
+
method: 'POST',
|
|
1469
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1470
|
+
body: JSON.stringify({
|
|
1471
|
+
env: {
|
|
1472
|
+
HOME: home,
|
|
1473
|
+
PATH: env.PATH,
|
|
1474
|
+
SHELL: process.env.SHELL || 'bash',
|
|
1475
|
+
HCC_FAKE_STAY_ALIVE: '1'
|
|
1476
|
+
},
|
|
1477
|
+
...payload
|
|
1478
|
+
})
|
|
1479
|
+
}, { root });
|
|
1480
|
+
const json = await response.json();
|
|
1481
|
+
if (!response.ok) fail(`web provider session start failed: ${JSON.stringify(json)}`);
|
|
1482
|
+
return json.session;
|
|
1483
|
+
};
|
|
1484
|
+
const stopSession = async (id) => {
|
|
1485
|
+
await runtimeFetch(`/api/sessions/${encodeURIComponent(id)}/stop`, { method: 'POST' }, { root });
|
|
1486
|
+
};
|
|
1487
|
+
|
|
1488
|
+
const claudeResumeName = `web-claude-resume-${testId}`;
|
|
1489
|
+
const claudeResume = await startProvider({ kind: 'claude', mode: 'resume', resume: claudeResumeName });
|
|
1490
|
+
const expectedClaudePeer = `claude-${claudeResumeName.slice(0, 8)}`;
|
|
1491
|
+
if (!claudeResume.command.includes(`claude --resume ${claudeResumeName}`)) {
|
|
1492
|
+
fail(`web claude resume command wrong:\n${JSON.stringify(claudeResume, null, 2)}`);
|
|
1493
|
+
}
|
|
1494
|
+
if (claudeResume.id !== expectedClaudePeer || claudeResume.peer_id !== expectedClaudePeer) {
|
|
1495
|
+
fail(`web claude resume did not use canonical provider peer id ${expectedClaudePeer}:\n${JSON.stringify(claudeResume, null, 2)}`);
|
|
1496
|
+
}
|
|
1497
|
+
const claudeRows = providerBindingRows('claude', claudeResumeName);
|
|
1498
|
+
if (claudeRows.length !== 1 ||
|
|
1499
|
+
claudeRows[0].peer !== claudeResume.peer_id ||
|
|
1500
|
+
claudeRows[0].resume_mode !== 'resume' ||
|
|
1501
|
+
claudeRows[0].resume_arg !== claudeResumeName ||
|
|
1502
|
+
claudeRows[0].transport !== 'tmux' ||
|
|
1503
|
+
!claudeRows[0].runtime_target) {
|
|
1504
|
+
fail(`web claude resume binding wrong:\n${JSON.stringify(claudeRows, null, 2)}`);
|
|
1505
|
+
}
|
|
1506
|
+
await stopSession(claudeResume.id);
|
|
1507
|
+
|
|
1508
|
+
const codexResumeName = `web-codex-resume-${testId}`;
|
|
1509
|
+
const codexResume = await startProvider({ kind: 'codex', mode: 'resume', resume: codexResumeName });
|
|
1510
|
+
const expectedCodexPeer = `codex-${codexResumeName.slice(0, 8)}`;
|
|
1511
|
+
if (!codexResume.command.includes(`codex resume ${codexResumeName}`)) {
|
|
1512
|
+
fail(`web codex resume command wrong:\n${JSON.stringify(codexResume, null, 2)}`);
|
|
1513
|
+
}
|
|
1514
|
+
if (codexResume.id !== expectedCodexPeer || codexResume.peer_id !== expectedCodexPeer) {
|
|
1515
|
+
fail(`web codex resume did not use canonical provider peer id ${expectedCodexPeer}:\n${JSON.stringify(codexResume, null, 2)}`);
|
|
1516
|
+
}
|
|
1517
|
+
const codexRows = providerBindingRows('codex', codexResumeName);
|
|
1518
|
+
if (codexRows.length !== 1 ||
|
|
1519
|
+
codexRows[0].peer !== codexResume.peer_id ||
|
|
1520
|
+
codexRows[0].resume_mode !== 'resume' ||
|
|
1521
|
+
codexRows[0].resume_arg !== codexResumeName ||
|
|
1522
|
+
codexRows[0].transport !== 'tmux' ||
|
|
1523
|
+
!codexRows[0].runtime_target) {
|
|
1524
|
+
fail(`web codex resume binding wrong:\n${JSON.stringify(codexRows, null, 2)}`);
|
|
1525
|
+
}
|
|
1526
|
+
await stopSession(codexResume.id);
|
|
1527
|
+
const codexRowsAfterStop = providerBindingRows('codex', codexResumeName);
|
|
1528
|
+
if (codexRowsAfterStop.length !== 1 || codexRowsAfterStop[0].peer !== expectedCodexPeer) {
|
|
1529
|
+
fail(`web codex resume binding was not stable after stop:\n${JSON.stringify(codexRowsAfterStop, null, 2)}`);
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
const resumableResponse = await runtimeFetch('/api/resumable', {}, { root });
|
|
1533
|
+
const resumableJson = await resumableResponse.json();
|
|
1534
|
+
if (!resumableResponse.ok) fail(`resumable API failed: ${JSON.stringify(resumableJson)}`);
|
|
1535
|
+
const resumableRows = resumableJson.resumable || [];
|
|
1536
|
+
const claudeResumable = resumableRows.find((row) => row.provider === 'claude' && row.resume === claudeResumeName);
|
|
1537
|
+
const codexResumable = resumableRows.find((row) => row.provider === 'codex' && row.resume === codexResumeName);
|
|
1538
|
+
if (!claudeResumable || claudeResumable.session_name !== claudeResumeName || claudeResumable.session_id !== null) {
|
|
1539
|
+
fail(`resumable API omitted named claude resume session:\n${JSON.stringify(resumableRows, null, 2)}`);
|
|
1540
|
+
}
|
|
1541
|
+
if (!codexResumable || codexResumable.session_name !== codexResumeName || codexResumable.session_id !== null) {
|
|
1542
|
+
fail(`resumable API omitted named codex resume session:\n${JSON.stringify(resumableRows, null, 2)}`);
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
const codexLast = await startProvider({ kind: 'codex', mode: 'last' });
|
|
1546
|
+
if (codexLast.command !== 'codex resume --last') {
|
|
1547
|
+
fail(`web codex last command wrong:\n${JSON.stringify(codexLast, null, 2)}`);
|
|
1548
|
+
}
|
|
1549
|
+
await stopSession(codexLast.id);
|
|
1550
|
+
|
|
1551
|
+
const claudeContinue = await startProvider({ kind: 'claude', mode: 'continue' });
|
|
1552
|
+
if (claudeContinue.command !== 'claude --continue') {
|
|
1553
|
+
fail(`web claude continue command wrong:\n${JSON.stringify(claudeContinue, null, 2)}`);
|
|
1554
|
+
}
|
|
1555
|
+
await stopSession(claudeContinue.id);
|
|
1556
|
+
|
|
1557
|
+
const badShellResume = await runtimeFetch('/api/sessions', {
|
|
1558
|
+
method: 'POST',
|
|
1559
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1560
|
+
body: JSON.stringify({ kind: 'shell', mode: 'resume', resume: 'not-supported' })
|
|
1561
|
+
}, { root });
|
|
1562
|
+
if (badShellResume.ok) {
|
|
1563
|
+
fail('web shell resume was accepted');
|
|
1564
|
+
}
|
|
1565
|
+
|
|
864
1566
|
const startAuto = async () => {
|
|
865
|
-
const response = await
|
|
1567
|
+
const response = await runtimeFetch('/api/sessions', {
|
|
866
1568
|
method: 'POST',
|
|
867
1569
|
headers: { 'Content-Type': 'application/json' },
|
|
868
1570
|
body: JSON.stringify({ kind: 'shell', command: 'bash --noprofile --norc' })
|
|
869
|
-
});
|
|
1571
|
+
}, { root: otherRoot });
|
|
870
1572
|
const json = await response.json();
|
|
871
1573
|
if (!response.ok) fail(`auto web session start failed: ${JSON.stringify(json)}`);
|
|
872
1574
|
return json.session;
|
|
@@ -876,8 +1578,8 @@ async function multiProjectWebWorkflow() {
|
|
|
876
1578
|
if (!autoOne.id.startsWith('shell-') || !autoTwo.id.startsWith('shell-') || autoOne.id === autoTwo.id) {
|
|
877
1579
|
fail(`auto web session ids were not unique: ${autoOne.id}, ${autoTwo.id}`);
|
|
878
1580
|
}
|
|
879
|
-
await
|
|
880
|
-
await
|
|
1581
|
+
await runtimeFetch(`/api/sessions/${encodeURIComponent(autoOne.id)}/stop`, { method: 'POST' }, { root: otherRoot });
|
|
1582
|
+
await runtimeFetch(`/api/sessions/${encodeURIComponent(autoTwo.id)}/stop`, { method: 'POST' }, { root: otherRoot });
|
|
881
1583
|
hccFromMaybe(['peer', 'stop', 'other-shell'], otherRoot);
|
|
882
1584
|
}
|
|
883
1585
|
|
|
@@ -908,6 +1610,23 @@ async function tmuxBackedStartWorkflow() {
|
|
|
908
1610
|
hcc(['inject', 'shell-a', `echo PTY_RESTORED_OK > ${restoredFile}`]);
|
|
909
1611
|
await waitForFile(restoredFile, 'PTY_RESTORED_OK', 'tmux restore injection');
|
|
910
1612
|
|
|
1613
|
+
const aliasPeer = 'shell-canonical-alias';
|
|
1614
|
+
parsePane(hcc(['peer', 'start', 'shell-runtime-alias', '--kind', 'shell', '--', 'bash', '--noprofile', '--norc']));
|
|
1615
|
+
moveRuntimeBindingPeer('shell-runtime-alias', aliasPeer);
|
|
1616
|
+
const aliasSessions = await (await runtimeFetch('/api/sessions', {}, { root })).json();
|
|
1617
|
+
const aliasSession = (aliasSessions.sessions || []).find((session) => session.id === 'shell-runtime-alias');
|
|
1618
|
+
if (!aliasSession || aliasSession.peer_id !== aliasPeer) {
|
|
1619
|
+
fail(`sessions API did not expose canonical peer id for runtime alias:\n${JSON.stringify(aliasSessions, null, 2)}`);
|
|
1620
|
+
}
|
|
1621
|
+
const aliasDetected = await (await runtimeFetch('/api/detected', {}, { root })).json();
|
|
1622
|
+
if ((aliasDetected.detected || []).some((peer) => peer.id === aliasPeer || peer.id === 'shell-runtime-alias')) {
|
|
1623
|
+
fail(`detected API showed managed runtime/canonical duplicate:\n${JSON.stringify(aliasDetected, null, 2)}`);
|
|
1624
|
+
}
|
|
1625
|
+
const aliasFile = path.join(outDir, 'runtime-alias-ok');
|
|
1626
|
+
hcc(['inject', aliasPeer, `echo RUNTIME_ALIAS_OK > ${aliasFile}`]);
|
|
1627
|
+
await waitForFile(aliasFile, 'RUNTIME_ALIAS_OK', 'canonical peer injection to runtime alias');
|
|
1628
|
+
hcc(['peer', 'stop', aliasPeer]);
|
|
1629
|
+
|
|
911
1630
|
const canonicalSession = 'canonical-session';
|
|
912
1631
|
insertStaleProviderBinding('claude-stale-canonical', 'claude', canonicalSession);
|
|
913
1632
|
const canonicalStarted = hcc(['peer', 'start', 'claude-live-canonical', '--kind', 'claude', '--resume', canonicalSession], {
|
|
@@ -1126,15 +1845,40 @@ async function downGcPackWorkflow() {
|
|
|
1126
1845
|
hcc(['gc', '--older-than', '0', '--yes']);
|
|
1127
1846
|
const pack = JSON.parse(run('npm', ['pack', '--dry-run', '--json']));
|
|
1128
1847
|
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']) {
|
|
1848
|
+
for (const file of ['assets/logo.svg', 'CHANGELOG.md', 'docs/commands.md', 'docs/commands.zh-CN.md', 'scripts/github-release.mjs']) {
|
|
1130
1849
|
if (!files.has(file)) fail(`npm package missing ${file}`);
|
|
1131
1850
|
}
|
|
1132
1851
|
const releaseCheck = run('npm', ['run', 'release:check']);
|
|
1133
|
-
if (!releaseCheck.includes('release notes ok: 0.1.
|
|
1852
|
+
if (!releaseCheck.includes('release notes ok: 0.1.3')) fail(`release check output wrong:\n${releaseCheck}`);
|
|
1134
1853
|
const releaseNotes = run('npm', ['run', 'release:notes']);
|
|
1135
|
-
if (!releaseNotes.includes('## 0.1.
|
|
1854
|
+
if (!releaseNotes.includes('## 0.1.3') || !releaseNotes.includes('### Summary')) {
|
|
1136
1855
|
fail(`release notes output wrong:\n${releaseNotes}`);
|
|
1137
1856
|
}
|
|
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) {
|
|
1859
|
+
fail(`github release dry run output wrong:\n${JSON.stringify(githubRelease, null, 2)}`);
|
|
1860
|
+
}
|
|
1861
|
+
const docsIndex = fs.readFileSync(path.join(repoRoot, 'docs', 'README.md'), 'utf8');
|
|
1862
|
+
const docsIndexZh = fs.readFileSync(path.join(repoRoot, 'docs', 'README.zh-CN.md'), 'utf8');
|
|
1863
|
+
if (!docsIndex.includes('github-release.yml') || !docsIndex.includes('workflow_dispatch') || !docsIndexZh.includes('github-release.yml') || !docsIndexZh.includes('workflow_dispatch')) {
|
|
1864
|
+
fail('docs index missing GitHub Release publishing command');
|
|
1865
|
+
}
|
|
1866
|
+
const workflow = fs.readFileSync(path.join(repoRoot, '.github', 'workflows', 'github-release.yml'), 'utf8');
|
|
1867
|
+
for (const expected of [
|
|
1868
|
+
'Publish GitHub Release',
|
|
1869
|
+
"tags:\n - 'v*'",
|
|
1870
|
+
'workflow_dispatch:',
|
|
1871
|
+
'contents: write',
|
|
1872
|
+
'actions/checkout@v4',
|
|
1873
|
+
'actions/setup-node@v4',
|
|
1874
|
+
'node-version: 24',
|
|
1875
|
+
'npm run release:check',
|
|
1876
|
+
'npm run release:github:dry-run',
|
|
1877
|
+
'npm run release:github',
|
|
1878
|
+
'GITHUB_TOKEN: ${{ github.token }}'
|
|
1879
|
+
]) {
|
|
1880
|
+
if (!workflow.includes(expected)) fail(`github release workflow missing ${expected}`);
|
|
1881
|
+
}
|
|
1138
1882
|
}
|
|
1139
1883
|
|
|
1140
1884
|
function oldNameScan() {
|
|
@@ -1173,9 +1917,33 @@ function syntaxAndHelp() {
|
|
|
1173
1917
|
if (!mainHelp.includes(' update Update the global npm install of hello-cc')) {
|
|
1174
1918
|
fail(`main help missing update command:\n${mainHelp}`);
|
|
1175
1919
|
}
|
|
1920
|
+
if (!mainHelp.includes(' state [--peer ID] Show timeline and next coordination action')) {
|
|
1921
|
+
fail(`main help missing state command:\n${mainHelp}`);
|
|
1922
|
+
}
|
|
1923
|
+
if (!mainHelp.includes(' team <subcommand> Plan, start, and inspect explicit task teams')) {
|
|
1924
|
+
fail(`main help missing team command:\n${mainHelp}`);
|
|
1925
|
+
}
|
|
1176
1926
|
if (!mainHelp.includes(' uninstall Remove hooks, shims, and optional project data')) {
|
|
1177
1927
|
fail(`main help missing uninstall command:\n${mainHelp}`);
|
|
1178
1928
|
}
|
|
1929
|
+
const msgHelp = run(process.execPath, [hccBin, 'msg', '--help']);
|
|
1930
|
+
if (!msgHelp.includes('msg reply') || !msgHelp.includes('msg thread')) {
|
|
1931
|
+
fail(`msg help missing reply/thread commands:\n${msgHelp}`);
|
|
1932
|
+
}
|
|
1933
|
+
const taskHelp = run(process.execPath, [hccBin, 'task', '--help']);
|
|
1934
|
+
if (!taskHelp.includes('task next [--peer ID] [--force]') ||
|
|
1935
|
+
!taskHelp.includes('existing claimed/running/review/blocked task') ||
|
|
1936
|
+
!taskHelp.includes('task create --title TEXT --parent N')) {
|
|
1937
|
+
fail(`task help missing current-task task next semantics:\n${taskHelp}`);
|
|
1938
|
+
}
|
|
1939
|
+
const teamHelp = run(process.execPath, [hccBin, 'team', '--help']);
|
|
1940
|
+
if (!teamHelp.includes('hcc team') || !teamHelp.includes('team plan') || !teamHelp.includes('team start') || !teamHelp.includes('team status')) {
|
|
1941
|
+
fail(`team help missing expected content:\n${teamHelp}`);
|
|
1942
|
+
}
|
|
1943
|
+
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')) {
|
|
1945
|
+
fail(`state help missing expected content:\n${stateHelp}`);
|
|
1946
|
+
}
|
|
1179
1947
|
const updateHelp = run(process.execPath, [hccBin, 'update', '--help']);
|
|
1180
1948
|
if (!updateHelp.includes('hcc update') || !updateHelp.includes('npm install -g @logicseek/hello-cc@TAG')) {
|
|
1181
1949
|
fail(`update help missing expected content:\n${updateHelp}`);
|
|
@@ -1200,7 +1968,7 @@ function syntaxAndHelp() {
|
|
|
1200
1968
|
if (runHelp.includes('--web-managed')) fail(`run help exposes removed --web-managed:\n${runHelp}`);
|
|
1201
1969
|
const subcommandHelpCases = [
|
|
1202
1970
|
['task', 'done', 'hcc task done'],
|
|
1203
|
-
['msg', '
|
|
1971
|
+
['msg', 'reply', 'hcc msg reply'],
|
|
1204
1972
|
['peer', 'attach', 'peer attach'],
|
|
1205
1973
|
['lock', 'release', 'hcc lock release'],
|
|
1206
1974
|
['handoff', 'create', 'hcc handoff create'],
|
|
@@ -1233,8 +2001,12 @@ function uninstallWorkflow() {
|
|
|
1233
2001
|
|
|
1234
2002
|
run(process.execPath, [hccBin, '--root', uninstallRoot, 'setup', '--quiet'], { env: uninstallEnv });
|
|
1235
2003
|
ensureFile(path.join(uninstallRoot, '.hello-cc', 'mesh.db'));
|
|
2004
|
+
ensureFile(path.join(uninstallRoot, '.hello-cc', 'HCC.md'));
|
|
1236
2005
|
ensureFile(path.join(uninstallRoot, 'CLAUDE.md'));
|
|
1237
2006
|
ensureFile(path.join(uninstallRoot, 'AGENTS.md'));
|
|
2007
|
+
assertGuidanceLockPolicy(path.join(uninstallRoot, '.hello-cc', 'HCC.md'));
|
|
2008
|
+
assertGuidanceLockPolicy(path.join(uninstallRoot, 'CLAUDE.md'));
|
|
2009
|
+
assertGuidanceLockPolicy(path.join(uninstallRoot, 'AGENTS.md'));
|
|
1238
2010
|
ensureFile(path.join(uninstallHome, '.claude', 'settings.json'));
|
|
1239
2011
|
ensureFile(path.join(uninstallHome, '.codex', 'hooks.json'));
|
|
1240
2012
|
ensureFile(path.join(uninstallHome, '.hcc-shims', 'claude'));
|