@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.
- package/CHANGELOG.md +59 -0
- package/bin/hcc.mjs +569 -280
- package/lib/guidance.mjs +115 -0
- package/lib/package-meta.mjs +24 -0
- package/lib/release-notes.mjs +40 -0
- package/package.json +1 -1
- package/scripts/github-release.mjs +11 -49
- package/scripts/regression.mjs +168 -8
- package/scripts/release-notes.mjs +6 -38
package/lib/guidance.mjs
ADDED
|
@@ -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
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
97
|
+
const problems = validateReleaseSection(body, version);
|
|
136
98
|
if (problems.length) {
|
|
137
99
|
console.error(problems.join('\n'));
|
|
138
100
|
process.exitCode = 1;
|
package/scripts/regression.mjs
CHANGED
|
@@ -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('
|
|
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
|
-
|
|
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('
|
|
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 [
|
|
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(
|
|
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(
|
|
1975
|
+
if (!releaseNotes.includes(`## ${packageVersion}`) || !releaseNotes.includes('### Summary')) {
|
|
1855
1976
|
fail(`release notes output wrong:\n${releaseNotes}`);
|
|
1856
1977
|
}
|
|
1857
|
-
const
|
|
1858
|
-
if (!
|
|
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 =
|
|
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 =
|
|
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 =
|
|
60
|
+
const problems = validateReleaseSection(section, version);
|
|
93
61
|
|
|
94
62
|
if (opts.check) {
|
|
95
63
|
if (problems.length) {
|