@logicseek/hello-cc 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,115 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export function guidanceMarkdown() {
5
+ return `# hello-cc Coordination Rules
6
+
7
+ This project may be edited by multiple Claude Code and Codex CLI sessions.
8
+
9
+ Use \`hcc\` as the source of truth for cross-session coordination. If the user
10
+ asks what other sessions are doing, do not answer from generic model knowledge.
11
+ Run the project-local commands below and summarize the result.
12
+
13
+ Each terminal session must use a stable peer id, for example codex-a,
14
+ codex-b, claude-a, claude-b, or gpu-runner-a.
15
+
16
+ Status checks:
17
+ - \`hcc peers\` shows known Claude/Codex/shell sessions.
18
+ - \`hcc task list\` shows all open tasks. Tasks stay visible until \`done\` or
19
+ \`abandoned\`; they are not read/unread items.
20
+ - \`hcc msg inbox\` shows unread messages for this session.
21
+ - \`hcc lock list\` shows active advisory locks.
22
+ - \`hcc status\` summarizes peers, tasks, locks, inbox, and recent events.
23
+ - \`hcc team status --task N\` summarizes explicit subtasks for a parent task.
24
+
25
+ Read-only work:
26
+ - Reading files, searching, inspecting \`git diff\` / \`git status\`, reviewing
27
+ uncommitted changes, and running non-mutating checks such as \`node --check\`
28
+ do not require locks.
29
+ - For read-only review, do not acquire file locks. If another live peer already
30
+ holds write locks on changed files, report that the review is only a current
31
+ snapshot and not a final commit-ready verdict.
32
+ - Use tasks, messages, events, or handoffs to make read-only review visible;
33
+ do not use advisory locks to reserve files you are only reading.
34
+
35
+ Review and monitoring:
36
+ - Reviewing another peer's work is a read-only activity. You may inspect current
37
+ diffs, task state, messages, handoffs, and non-mutating checks while that peer
38
+ keeps its write locks.
39
+ - If you find a concrete issue in another peer's task, proactively send that
40
+ peer an \`hcc msg\` with the task id, affected file or behavior, why it is a
41
+ problem, and a suggested fix or verification step.
42
+ - Continue monitoring until the peer fixes the issue, records a follow-up task,
43
+ or hands off. Do not silently treat a snapshot review as final approval.
44
+
45
+ Before mutating work:
46
+ - Register with hcc.
47
+ - Read current status with \`hcc status\`, \`hcc state\`, \`hcc task list\`, and \`hcc msg inbox\`.
48
+ - If \`hcc state\` shows a current task for this peer, continue that task until
49
+ handoff, done, or blocked instead of claiming a new task.
50
+ - Claim one task before editing or mutating shared state when this peer has no
51
+ current task.
52
+ - If work needs multiple peers, use \`hcc team plan\` and \`hcc team start\`
53
+ explicitly; do not create or claim hidden extra tasks.
54
+
55
+ Before editing or mutating shared resources:
56
+ - Acquire an advisory lock for the file, directory, module, or shared resource.
57
+ - If another live peer holds the lock, message that peer instead of editing.
58
+ - Acquire locks only before writes or shared-resource mutation, including file
59
+ edits, formatting, staging/committing through \`.git/index\`, intentional DB
60
+ data/schema changes, starting/stopping/restarting shared runtimes, or taking
61
+ over tmux/session/port resources.
62
+ - Commit-readiness checks are read-only until staging begins. Do not lock source
63
+ files for review. Before committing, ensure no live peer holds write locks on
64
+ the files to be committed, then lock \`.git/index\` only while staging and
65
+ committing.
66
+
67
+ During work:
68
+ - Keep changes scoped to the claimed task.
69
+ - Send progress messages when another session needs context.
70
+
71
+ Before stopping:
72
+ - Mark the task done or blocked.
73
+ - Create a handoff with changed files, tests, and remaining risks.
74
+ - Release locks you no longer need.
75
+ `;
76
+ }
77
+
78
+ export function writeGuidance(root) {
79
+ const guidePath = path.join(root, '.hello-cc', 'HCC.md');
80
+ fs.mkdirSync(path.dirname(guidePath), { recursive: true });
81
+ const content = guidanceMarkdown();
82
+ fs.writeFileSync(guidePath, content);
83
+ const clause = `\n<!-- hello-cc:start -->\n\n${content}\n<!-- hello-cc:end -->\n`;
84
+ for (const file of ['CLAUDE.md', 'AGENTS.md']) {
85
+ const target = path.join(root, file);
86
+ if (!fs.existsSync(target)) {
87
+ fs.writeFileSync(target, clause.trimStart());
88
+ continue;
89
+ }
90
+ const existing = fs.readFileSync(target, 'utf8');
91
+ const updated = existing.includes('<!-- hello-cc:start -->')
92
+ ? existing.replace(/<!-- hello-cc:start -->[\s\S]*?<!-- hello-cc:end -->/, clause.trim())
93
+ : `${existing.trimEnd()}\n${clause}`;
94
+ fs.writeFileSync(target, updated);
95
+ }
96
+ return guidePath;
97
+ }
98
+
99
+ export function removeGuidanceBlocks(root) {
100
+ const changed = [];
101
+ for (const file of ['CLAUDE.md', 'AGENTS.md']) {
102
+ const target = path.join(root, file);
103
+ if (!fs.existsSync(target)) continue;
104
+ const existing = fs.readFileSync(target, 'utf8');
105
+ const updated = existing
106
+ .replace(/\n?<!-- hello-cc:start -->[\s\S]*?<!-- hello-cc:end -->\n?/g, '\n')
107
+ .replace(/\n{3,}/g, '\n\n')
108
+ .trimEnd();
109
+ if (updated !== existing) {
110
+ fs.writeFileSync(target, updated ? `${updated}\n` : '');
111
+ changed.push(target);
112
+ }
113
+ }
114
+ return changed;
115
+ }
@@ -0,0 +1,24 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ export function packageRoot(fromUrl = import.meta.url) {
6
+ return path.resolve(fileURLToPath(fromUrl), '..', '..');
7
+ }
8
+
9
+ export function readJson(file) {
10
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
11
+ }
12
+
13
+ export function readPackageJson(root) {
14
+ return readJson(path.join(root, 'package.json'));
15
+ }
16
+
17
+ export function readPackageMeta(root) {
18
+ const pkg = readPackageJson(root);
19
+ return {
20
+ name: pkg.name,
21
+ version: pkg.version,
22
+ packageJson: pkg
23
+ };
24
+ }
@@ -0,0 +1,40 @@
1
+ export function normalizeVersion(version) {
2
+ return String(version || '').replace(/^v/, '');
3
+ }
4
+
5
+ export function releaseSection(markdown, version) {
6
+ const lines = markdown.split(/\r?\n/);
7
+ const heading = `## ${version}`;
8
+ const start = lines.findIndex((line) => line.trim() === heading);
9
+ if (start < 0) return null;
10
+
11
+ let end = lines.length;
12
+ for (let i = start + 1; i < lines.length; i += 1) {
13
+ if (/^##\s+/.test(lines[i])) {
14
+ end = i;
15
+ break;
16
+ }
17
+ }
18
+ return lines.slice(start, end).join('\n').trimEnd();
19
+ }
20
+
21
+ export function validateReleaseSection(section, version) {
22
+ const problems = [];
23
+ if (!section) {
24
+ problems.push(`CHANGELOG.md is missing section ## ${version}`);
25
+ return problems;
26
+ }
27
+ if (!section.includes('### Summary')) problems.push(`## ${version} is missing ### Summary`);
28
+ if (!section.includes('### Highlights')) problems.push(`## ${version} is missing ### Highlights`);
29
+ if (section.includes('TODO') || section.includes('TBD')) problems.push(`## ${version} still contains TODO/TBD text`);
30
+ const bulletCount = (section.match(/^- /gm) || []).length;
31
+ if (bulletCount < 2) problems.push(`## ${version} should include at least two release-note bullets`);
32
+ return problems;
33
+ }
34
+
35
+ export function repoFromPackage(pkg) {
36
+ const repo = typeof pkg.repository === 'string' ? pkg.repository : pkg.repository?.url;
37
+ if (!repo) return null;
38
+ const match = repo.match(/github\.com[:/](.+?\/.+?)(?:\.git)?$/);
39
+ return match ? match[1] : null;
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logicseek/hello-cc",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Local control plane for coordinating Claude Code, Codex, and other coding CLI sessions.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,8 +3,15 @@
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import process from 'node:process';
6
-
7
- const repoRoot = path.resolve(import.meta.dirname, '..');
6
+ import { packageRoot, readPackageJson } from '../lib/package-meta.mjs';
7
+ import {
8
+ normalizeVersion,
9
+ releaseSection,
10
+ repoFromPackage,
11
+ validateReleaseSection
12
+ } from '../lib/release-notes.mjs';
13
+
14
+ const repoRoot = packageRoot(import.meta.url);
8
15
  const githubApi = 'https://api.github.com';
9
16
 
10
17
  function usage() {
@@ -21,10 +28,6 @@ Authentication:
21
28
  `);
22
29
  }
23
30
 
24
- function readJson(file) {
25
- return JSON.parse(fs.readFileSync(file, 'utf8'));
26
- }
27
-
28
31
  function parseArgs(argv) {
29
32
  const opts = { dryRun: false, help: false, repo: null, version: null };
30
33
  for (let i = 0; i < argv.length; i += 1) {
@@ -50,47 +53,6 @@ function parseArgs(argv) {
50
53
  return opts;
51
54
  }
52
55
 
53
- function normalizeVersion(version) {
54
- return String(version || '').replace(/^v/, '');
55
- }
56
-
57
- function releaseSection(markdown, version) {
58
- const lines = markdown.split(/\r?\n/);
59
- const heading = `## ${version}`;
60
- const start = lines.findIndex((line) => line.trim() === heading);
61
- if (start < 0) return null;
62
-
63
- let end = lines.length;
64
- for (let i = start + 1; i < lines.length; i += 1) {
65
- if (/^##\s+/.test(lines[i])) {
66
- end = i;
67
- break;
68
- }
69
- }
70
- return lines.slice(start, end).join('\n').trimEnd();
71
- }
72
-
73
- function validateSection(section, version) {
74
- const problems = [];
75
- if (!section) {
76
- problems.push(`CHANGELOG.md is missing section ## ${version}`);
77
- return problems;
78
- }
79
- if (!section.includes('### Summary')) problems.push(`## ${version} is missing ### Summary`);
80
- if (!section.includes('### Highlights')) problems.push(`## ${version} is missing ### Highlights`);
81
- if (section.includes('TODO') || section.includes('TBD')) problems.push(`## ${version} still contains TODO/TBD text`);
82
- const bulletCount = (section.match(/^- /gm) || []).length;
83
- if (bulletCount < 2) problems.push(`## ${version} should include at least two release-note bullets`);
84
- return problems;
85
- }
86
-
87
- function repoFromPackage(pkg) {
88
- const repo = typeof pkg.repository === 'string' ? pkg.repository : pkg.repository?.url;
89
- if (!repo) return null;
90
- const match = repo.match(/github\.com[:/](.+?\/.+?)(?:\.git)?$/);
91
- return match ? match[1] : null;
92
- }
93
-
94
56
  function envToken() {
95
57
  return process.env.GH_TOKEN || process.env.GITHUB_TOKEN || '';
96
58
  }
@@ -124,7 +86,7 @@ async function main() {
124
86
  return;
125
87
  }
126
88
 
127
- const pkg = readJson(path.join(repoRoot, 'package.json'));
89
+ const pkg = readPackageJson(repoRoot);
128
90
  const version = normalizeVersion(opts.version || pkg.version);
129
91
  const tag = `v${version}`;
130
92
  const repo = opts.repo || process.env.GITHUB_REPOSITORY || repoFromPackage(pkg);
@@ -132,7 +94,7 @@ async function main() {
132
94
 
133
95
  const changelog = fs.readFileSync(path.join(repoRoot, 'CHANGELOG.md'), 'utf8');
134
96
  const body = releaseSection(changelog, version);
135
- const problems = validateSection(body, version);
97
+ const problems = validateReleaseSection(body, version);
136
98
  if (problems.length) {
137
99
  console.error(problems.join('\n'));
138
100
  process.exitCode = 1;
@@ -809,6 +809,39 @@ async function expectResizeReplaceSnapshot(peer, marker) {
809
809
  });
810
810
  }
811
811
 
812
+ async function expectWebSocketInputVisible(peer, marker) {
813
+ await new Promise((resolve, reject) => {
814
+ let sawSnapshot = false;
815
+ let sent = false;
816
+ let sawMarkerAfterInput = false;
817
+ let sawFrameAfterInput = false;
818
+ const ws = new WebSocket(runtimeWsUrl(peer));
819
+ const timer = setTimeout(() => reject(new Error(`${peer} websocket input visibility timeout`)), 5000);
820
+ ws.on('message', (raw) => {
821
+ const msg = JSON.parse(String(raw));
822
+ const data = String(msg.data || '');
823
+ if (msg.type === 'snapshot') {
824
+ sawSnapshot = true;
825
+ if (!sent) {
826
+ sent = true;
827
+ ws.send(JSON.stringify({ type: 'input', data: `echo ${marker}\r` }));
828
+ }
829
+ return;
830
+ }
831
+ if (sent && ['data', 'replace'].includes(msg.type)) {
832
+ sawFrameAfterInput = true;
833
+ if (data.includes(marker)) sawMarkerAfterInput = true;
834
+ }
835
+ if (sawSnapshot && sawFrameAfterInput && sawMarkerAfterInput) {
836
+ clearTimeout(timer);
837
+ ws.close();
838
+ resolve();
839
+ }
840
+ });
841
+ ws.on('error', reject);
842
+ });
843
+ }
844
+
812
845
  function tmuxStreamNodes() {
813
846
  const dir = path.join(root, '.hello-cc', 'bufs');
814
847
  if (!fs.existsSync(dir)) return [];
@@ -1436,13 +1469,56 @@ async function multiProjectWebWorkflow() {
1436
1469
  ]) {
1437
1470
  if (!html.includes(expected)) fail(`web layout missing resizable sidebar support: ${expected}`);
1438
1471
  }
1439
- if (!html.includes('state-card') || !html.includes('peerStateView') || !html.includes('savedCardScroll') || !html.includes('lastStateRoot') || !html.includes('data-section="peers"')) {
1472
+ if (!html.includes('state-card') || !html.includes('peerStateView') || !html.includes('savedCardScroll') || !html.includes('lastStateRoot') || !html.includes("stateCardHtml('peers'")) {
1440
1473
  fail('web state panel missing scrollable peer state UI');
1441
1474
  }
1442
- if (!html.includes('data-section="timeline"') || !html.includes('renderTimelineItem') || !html.includes('bodyPinned') || !html.includes('refreshCurrentState')) {
1475
+ for (const expected of [
1476
+ 'function stateCardCollapsed(section)',
1477
+ "localStorage.getItem('hcc.stateCard.' + section + '.collapsed')",
1478
+ 'function stateCardHtml(section, title, count, bodyHtml)',
1479
+ 'state-card-toggle',
1480
+ 'aria-expanded=',
1481
+ 'state-card-collapsed',
1482
+ 'function bindStateCardToggles()',
1483
+ "localStorage.setItem('hcc.stateCard.' + section + '.collapsed'",
1484
+ "stateCardHtml('automation'",
1485
+ "stateCardHtml('timeline'",
1486
+ "stateCardHtml('messages'",
1487
+ "stateCardHtml('peers'",
1488
+ "stateCardHtml('tasks'",
1489
+ "stateCardHtml('locks'",
1490
+ 'bindStateCardToggles();'
1491
+ ]) {
1492
+ if (!html.includes(expected)) fail(`web state panel missing collapsible card support: ${expected}`);
1493
+ }
1494
+ for (const expected of [
1495
+ 'id="actionResult"',
1496
+ 'data-action="state"',
1497
+ 'data-action="status"',
1498
+ 'data-action="inbox"',
1499
+ 'data-action="task-next"',
1500
+ 'data-action="heartbeat"',
1501
+ 'data-action="register"',
1502
+ 'data-terminal-action="status"',
1503
+ 'function runPeerAction(action)',
1504
+ "'/api/peers/' + encodeURIComponent(info.peerId) + '/actions/' + encodeURIComponent(action)",
1505
+ 'showActionResult(result)'
1506
+ ]) {
1507
+ if (!html.includes(expected)) fail(`web actions missing API result UI: ${expected}`);
1508
+ }
1509
+ if (html.includes('data-send=') || html.includes("document.querySelectorAll('[data-send]')")) {
1510
+ fail('web actions still use implicit terminal command injection');
1511
+ }
1512
+ for (const expected of [
1513
+ 'term.onData((data) => {',
1514
+ "ws.send(JSON.stringify({ type: 'input', data }))"
1515
+ ]) {
1516
+ if (!html.includes(expected)) fail(`web terminal input forwarding missing: ${expected}`);
1517
+ }
1518
+ if (!html.includes("stateCardHtml('timeline'") || !html.includes('renderTimelineItem') || !html.includes('bodyPinned') || !html.includes('refreshCurrentState')) {
1443
1519
  fail('web state panel missing collaboration timeline or refresh routing');
1444
1520
  }
1445
- if (!html.includes('data-section="messages"') || !html.includes('messagesData.length')) {
1521
+ if (!html.includes("stateCardHtml('messages'") || !html.includes('messagesData.length')) {
1446
1522
  fail('web state panel missing dedicated messages card');
1447
1523
  }
1448
1524
 
@@ -1463,6 +1539,40 @@ async function multiProjectWebWorkflow() {
1463
1539
  fail(`second project API did not see its session:\n${JSON.stringify(otherSessions)}`);
1464
1540
  }
1465
1541
 
1542
+ hcc(['register', '--peer', 'web-action-peer', '--kind', 'codex', '--role', 'peer']);
1543
+ hcc(['msg', 'send', '--from', 'human', '--to', 'web-action-peer', '--body', 'web action inbox ok']);
1544
+ const actionStatus = await (await runtimeFetch('/api/peers/web-action-peer/actions/status', {}, { root })).json();
1545
+ if (!actionStatus.ok || actionStatus.action !== 'status' || actionStatus.peer !== 'web-action-peer' || !actionStatus.data || typeof actionStatus.data.unread !== 'number') {
1546
+ fail(`web status action did not return structured status:\n${JSON.stringify(actionStatus, null, 2)}`);
1547
+ }
1548
+ const actionInbox = await (await runtimeFetch('/api/peers/web-action-peer/actions/inbox', {}, { root })).json();
1549
+ if (!actionInbox.ok || actionInbox.action !== 'inbox' || !(actionInbox.data?.messages || []).some((m) => m.body === 'web action inbox ok')) {
1550
+ fail(`web inbox action did not return unread messages:\n${JSON.stringify(actionInbox, null, 2)}`);
1551
+ }
1552
+ const actionState = await (await runtimeFetch('/api/peers/web-action-peer/actions/state', {}, { root })).json();
1553
+ if (!actionState.ok || actionState.action !== 'state' || actionState.data?.automation?.peer?.id !== 'web-action-peer') {
1554
+ fail(`web state action did not return peer automation:\n${JSON.stringify(actionState, null, 2)}`);
1555
+ }
1556
+ const taskOutput = hcc(['task', 'create', '--title', 'web action task', '--body', 'claim through web action']);
1557
+ const taskMatch = taskOutput.match(/created task #(\d+):/);
1558
+ if (!taskMatch) fail(`cannot parse web action task id:\n${taskOutput}`);
1559
+ const actionNext = await (await runtimeFetch('/api/peers/web-action-peer/actions/task-next', {
1560
+ method: 'POST',
1561
+ headers: { 'Content-Type': 'application/json' },
1562
+ body: '{}'
1563
+ }, { root })).json();
1564
+ if (!actionNext.ok || actionNext.action !== 'task-next' || String(actionNext.data?.task?.id) !== taskMatch[1]) {
1565
+ fail(`web task-next action did not claim pending task #${taskMatch[1]}:\n${JSON.stringify(actionNext, null, 2)}`);
1566
+ }
1567
+ const actionHeartbeat = await (await runtimeFetch('/api/peers/web-action-peer/actions/heartbeat', {
1568
+ method: 'POST',
1569
+ headers: { 'Content-Type': 'application/json' },
1570
+ body: JSON.stringify({ renew_locks: true })
1571
+ }, { root })).json();
1572
+ if (!actionHeartbeat.ok || actionHeartbeat.action !== 'heartbeat' || actionHeartbeat.data?.peer !== 'web-action-peer') {
1573
+ fail(`web heartbeat action did not return structured result:\n${JSON.stringify(actionHeartbeat, null, 2)}`);
1574
+ }
1575
+
1466
1576
  const startProvider = async (payload) => {
1467
1577
  const response = await runtimeFetch('/api/sessions', {
1468
1578
  method: 'POST',
@@ -1599,6 +1709,7 @@ async function tmuxBackedStartWorkflow() {
1599
1709
  await waitForFile(file, 'PTY_OK', 'pty injection');
1600
1710
  await expectWebSocketMarker('shell-a', 'WS_PTY_OK');
1601
1711
  await expectResizeReplaceSnapshot('shell-a', 'WS_RESIZE_OK');
1712
+ await expectWebSocketInputVisible('shell-a', 'WS_INPUT_VISIBLE_OK');
1602
1713
  await expectBoundedTmuxStream('tmux-backed FIFO stream');
1603
1714
 
1604
1715
  await stopRuntime();
@@ -1845,17 +1956,31 @@ async function downGcPackWorkflow() {
1845
1956
  hcc(['gc', '--older-than', '0', '--yes']);
1846
1957
  const pack = JSON.parse(run('npm', ['pack', '--dry-run', '--json']));
1847
1958
  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']) {
1959
+ for (const file of [
1960
+ 'assets/logo.svg',
1961
+ 'CHANGELOG.md',
1962
+ 'docs/commands.md',
1963
+ 'docs/commands.zh-CN.md',
1964
+ 'lib/guidance.mjs',
1965
+ 'lib/package-meta.mjs',
1966
+ 'lib/release-notes.mjs',
1967
+ 'scripts/github-release.mjs'
1968
+ ]) {
1849
1969
  if (!files.has(file)) fail(`npm package missing ${file}`);
1850
1970
  }
1971
+ const packageVersion = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')).version;
1851
1972
  const releaseCheck = run('npm', ['run', 'release:check']);
1852
- if (!releaseCheck.includes('release notes ok: 0.1.3')) fail(`release check output wrong:\n${releaseCheck}`);
1973
+ if (!releaseCheck.includes(`release notes ok: ${packageVersion}`)) fail(`release check output wrong:\n${releaseCheck}`);
1853
1974
  const releaseNotes = run('npm', ['run', 'release:notes']);
1854
- if (!releaseNotes.includes('## 0.1.3') || !releaseNotes.includes('### Summary')) {
1975
+ if (!releaseNotes.includes(`## ${packageVersion}`) || !releaseNotes.includes('### Summary')) {
1855
1976
  fail(`release notes output wrong:\n${releaseNotes}`);
1856
1977
  }
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) {
1978
+ const releaseNotesWithV = run(process.execPath, [path.join(repoRoot, 'scripts', 'release-notes.mjs'), '--version', `v${packageVersion}`]);
1979
+ if (!releaseNotesWithV.includes(`## ${packageVersion}`) || releaseNotesWithV.includes(`## v${packageVersion}`)) {
1980
+ fail(`release notes v-prefixed version output wrong:\n${releaseNotesWithV}`);
1981
+ }
1982
+ const githubRelease = JSON.parse(run(process.execPath, [path.join(repoRoot, 'scripts', 'github-release.mjs'), '--dry-run', '--version', packageVersion]));
1983
+ if (!githubRelease.ok || !githubRelease.dry_run || githubRelease.repo !== 'Dullne/hello-cc' || githubRelease.tag !== `v${packageVersion}` || githubRelease.body_length < 100) {
1859
1984
  fail(`github release dry run output wrong:\n${JSON.stringify(githubRelease, null, 2)}`);
1860
1985
  }
1861
1986
  const docsIndex = fs.readFileSync(path.join(repoRoot, 'docs', 'README.md'), 'utf8');
@@ -1910,6 +2035,41 @@ function syntaxAndHelp() {
1910
2035
  run(process.execPath, ['--check', path.join(repoRoot, 'bin', 'hcc.mjs')]);
1911
2036
  run(process.execPath, ['--check', path.join(repoRoot, 'lib', 'setup.mjs')]);
1912
2037
  run(process.execPath, ['--check', path.join(repoRoot, 'lib', 'discover.mjs')]);
2038
+ run(process.execPath, ['--check', path.join(repoRoot, 'lib', 'guidance.mjs')]);
2039
+ run(process.execPath, ['--check', path.join(repoRoot, 'lib', 'package-meta.mjs')]);
2040
+ run(process.execPath, ['--check', path.join(repoRoot, 'lib', 'release-notes.mjs')]);
2041
+ const hccSource = fs.readFileSync(hccBin, 'utf8');
2042
+ for (const expected of [
2043
+ 'function scheduleTmuxInputRefresh(session)',
2044
+ "runTmux(['pipe-pane', '-t', session.pane]);",
2045
+ 'if (session.inputRefreshTimer) return;',
2046
+ 'session.inputRefreshTimer = setTimeout',
2047
+ 'scheduleTmuxInputRefresh(session)',
2048
+ "if (session.inputRefreshTimer) { clearTimeout(session.inputRefreshTimer); session.inputRefreshTimer = null; }",
2049
+ "broadcast(session, { type: 'replace', data: refreshTmuxSnapshot(session) });"
2050
+ ]) {
2051
+ if (!hccSource.includes(expected)) fail(`web terminal input refresh support missing: ${expected}`);
2052
+ }
2053
+ for (const expected of [
2054
+ 'function webPeerAction(projectCtx, peer, action, input = {})',
2055
+ 'function claimNextTaskForPeer(db, peer, force = false)',
2056
+ 'function statusSummary(ctx, peer = null, identity = null)',
2057
+ 'const peerActionMatch = url.pathname.match(/^\\/api\\/peers\\/([^/]+)\\/actions\\/([^/]+)$/)',
2058
+ "const readOnly = ['status', 'state', 'inbox'].includes(action)",
2059
+ "sendJson(res, 200, webPeerAction(reqCtx, peer, action, input));"
2060
+ ]) {
2061
+ if (!hccSource.includes(expected)) fail(`web peer action API support missing: ${expected}`);
2062
+ }
2063
+ const packageVersion = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')).version;
2064
+ const cliVersion = run(process.execPath, [hccBin, '--version']).trim();
2065
+ if (cliVersion !== packageVersion) {
2066
+ fail(`CLI version ${cliVersion} does not match package.json ${packageVersion}`);
2067
+ }
2068
+ if (!hccSource.includes("import { readPackageMeta } from '../lib/package-meta.mjs'") ||
2069
+ !hccSource.includes('const VERSION = PACKAGE_META.version') ||
2070
+ !hccSource.includes('writeGuidanceForRoot(ctx.root)')) {
2071
+ fail('CLI still has duplicated package metadata or guidance wiring');
2072
+ }
1913
2073
  const mainHelp = run(process.execPath, [hccBin, '--help']);
1914
2074
  if (mainHelp.includes('setup') || mainHelp.includes('--web-managed')) {
1915
2075
  fail(`public help exposes maintenance or removed commands:\n${mainHelp}`);
@@ -3,8 +3,10 @@
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import process from 'node:process';
6
+ import { packageRoot, readPackageJson } from '../lib/package-meta.mjs';
7
+ import { normalizeVersion, releaseSection, validateReleaseSection } from '../lib/release-notes.mjs';
6
8
 
7
- const repoRoot = path.resolve(import.meta.dirname, '..');
9
+ const repoRoot = packageRoot(import.meta.url);
8
10
 
9
11
  function usage() {
10
12
  console.log(`release-notes
@@ -24,10 +26,6 @@ package release descriptions.
24
26
  `);
25
27
  }
26
28
 
27
- function readJson(file) {
28
- return JSON.parse(fs.readFileSync(file, 'utf8'));
29
- }
30
-
31
29
  function parseArgs(argv) {
32
30
  const opts = { check: false, help: false, version: null };
33
31
  for (let i = 0; i < argv.length; i += 1) {
@@ -48,36 +46,6 @@ function parseArgs(argv) {
48
46
  return opts;
49
47
  }
50
48
 
51
- function releaseSection(markdown, version) {
52
- const lines = markdown.split(/\r?\n/);
53
- const heading = `## ${version}`;
54
- const start = lines.findIndex((line) => line.trim() === heading);
55
- if (start < 0) return null;
56
-
57
- let end = lines.length;
58
- for (let i = start + 1; i < lines.length; i += 1) {
59
- if (/^##\s+/.test(lines[i])) {
60
- end = i;
61
- break;
62
- }
63
- }
64
- return lines.slice(start, end).join('\n').trimEnd();
65
- }
66
-
67
- function validateSection(section, version) {
68
- const problems = [];
69
- if (!section) {
70
- problems.push(`CHANGELOG.md is missing section ## ${version}`);
71
- return problems;
72
- }
73
- if (!section.includes('### Summary')) problems.push(`## ${version} is missing ### Summary`);
74
- if (!section.includes('### Highlights')) problems.push(`## ${version} is missing ### Highlights`);
75
- if (section.includes('TODO') || section.includes('TBD')) problems.push(`## ${version} still contains TODO/TBD text`);
76
- const bulletCount = (section.match(/^- /gm) || []).length;
77
- if (bulletCount < 2) problems.push(`## ${version} should include at least two release-note bullets`);
78
- return problems;
79
- }
80
-
81
49
  function main() {
82
50
  const opts = parseArgs(process.argv.slice(2));
83
51
  if (opts.help) {
@@ -85,11 +53,11 @@ function main() {
85
53
  return;
86
54
  }
87
55
 
88
- const pkg = readJson(path.join(repoRoot, 'package.json'));
89
- const version = opts.version || pkg.version;
56
+ const pkg = readPackageJson(repoRoot);
57
+ const version = normalizeVersion(opts.version || pkg.version);
90
58
  const changelog = fs.readFileSync(path.join(repoRoot, 'CHANGELOG.md'), 'utf8');
91
59
  const section = releaseSection(changelog, version);
92
- const problems = validateSection(section, version);
60
+ const problems = validateReleaseSection(section, version);
93
61
 
94
62
  if (opts.check) {
95
63
  if (problems.length) {