@flavor-code/superharness 1.0.0
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/HARNESS.md +56 -0
- package/flavor-plugin.json +25 -0
- package/index.js +238 -0
- package/package.json +23 -0
- package/scripts/ralph-lib.ps1 +297 -0
- package/scripts/ralph-lib.sh +366 -0
- package/skills/brainstorm/SKILL.md +179 -0
- package/skills/brainstorm/scripts/layout.js +76 -0
- package/skills/brainstorm/scripts/mindmap.html +249 -0
- package/skills/brainstorm/scripts/server.cjs +208 -0
- package/skills/brainstorm/scripts/start-server.ps1 +57 -0
- package/skills/brainstorm/scripts/stop-server.ps1 +17 -0
- package/skills/finishing-a-development-branch/SKILL.md +112 -0
- package/skills/go/SKILL.md +169 -0
- package/skills/light/SKILL.md +85 -0
- package/skills/requesting-code-review/SKILL.md +103 -0
- package/skills/requesting-code-review/code-reviewer.md +168 -0
- package/skills/subagent-driven-development/SKILL.md +125 -0
- package/skills/systematic-debugging/SKILL.md +296 -0
- package/skills/systematic-debugging/condition-based-waiting-example.ts +158 -0
- package/skills/systematic-debugging/condition-based-waiting.md +115 -0
- package/skills/systematic-debugging/defense-in-depth.md +122 -0
- package/skills/systematic-debugging/find-polluter.sh +63 -0
- package/skills/systematic-debugging/root-cause-tracing.md +169 -0
- package/skills/test-driven-development/SKILL.md +371 -0
- package/skills/test-driven-development/testing-anti-patterns.md +299 -0
- package/skills/using-git-worktrees/SKILL.md +91 -0
- package/skills/verification-before-completion/SKILL.md +139 -0
- package/skills/writing-plans/SKILL.md +138 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Superharness Brainstorm — Mind Map</title>
|
|
6
|
+
<style>
|
|
7
|
+
:root { --bg: #1e1f26; --panel: #2a2c35; --text: #e8e8ef; --muted: #9aa0b0; }
|
|
8
|
+
* { box-sizing: border-box; }
|
|
9
|
+
body { margin: 0; font-family: system-ui, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); overflow: hidden; }
|
|
10
|
+
#topbar { position: fixed; top: 0; left: 0; right: 0; height: 48px; display: flex; align-items: center; gap: 12px; padding: 0 16px; background: var(--panel); border-bottom: 1px solid #3a3d4a; z-index: 10; }
|
|
11
|
+
#topic { font-weight: 600; font-size: 15px; }
|
|
12
|
+
.badge { font-size: 12px; padding: 2px 10px; border-radius: 10px; background: #3a3d4a; color: var(--muted); }
|
|
13
|
+
#status.designing { background: #4d3d1f; color: #e3c77b; }
|
|
14
|
+
#status.approved { background: #1f4d2e; color: #7be3a0; }
|
|
15
|
+
#conn.ok { background: #1f4d2e; color: #7be3a0; }
|
|
16
|
+
#conn.bad { background: #4d1f1f; color: #e37b7b; }
|
|
17
|
+
#canvas { position: absolute; left: 0; right: 0; top: 48px; bottom: 0; width: 100%; height: calc(100% - 48px); cursor: grab; }
|
|
18
|
+
#canvas.dragging { cursor: grabbing; }
|
|
19
|
+
.node { cursor: pointer; }
|
|
20
|
+
.node text { font-size: 13px; fill: var(--text); }
|
|
21
|
+
.node.rejected text { fill: var(--muted); text-decoration: line-through; }
|
|
22
|
+
.link { fill: none; stroke: #4a4e5e; stroke-width: 1.5; }
|
|
23
|
+
#hint { position: fixed; bottom: 10px; right: 14px; color: var(--muted); font-size: 12px; z-index: 10; }
|
|
24
|
+
</style>
|
|
25
|
+
</head>
|
|
26
|
+
<body>
|
|
27
|
+
<div id="topbar">
|
|
28
|
+
<span id="topic">等待快照…</span>
|
|
29
|
+
<span class="badge" id="status">exploring</span>
|
|
30
|
+
<span class="badge" id="rev">rev 0</span>
|
|
31
|
+
<span class="badge bad" id="conn">连接中…</span>
|
|
32
|
+
<button id="submitBtn" disabled style="margin-left:auto; background:#3d5afe; color:#fff; border:0; padding:6px 14px; border-radius:6px; cursor:pointer; font-size:13px;">提交 (0)</button>
|
|
33
|
+
</div>
|
|
34
|
+
<svg id="canvas"><g id="viewport"></g></svg>
|
|
35
|
+
<div id="hint">拖拽平移 · 滚轮缩放 · 双击空白复位 · 单击反馈选择 · 双击节点编辑</div>
|
|
36
|
+
<div id="editPanel" style="display:none; position:fixed; z-index:20; top:50%; left:50%; transform:translate(-50%,-50%); background:var(--panel); border:1px solid #3a3d4a; border-radius:10px; padding:16px; width:320px; box-shadow:0 8px 30px rgba(0,0,0,.5);">
|
|
37
|
+
<div style="font-size:13px; color:var(--muted); margin-bottom:6px;">编辑节点</div>
|
|
38
|
+
<input id="editLabel" type="text" placeholder="标签" style="width:100%; margin-bottom:8px; padding:6px 8px; background:var(--bg); color:var(--text); border:1px solid #3a3d4a; border-radius:6px;">
|
|
39
|
+
<textarea id="editNote" rows="4" placeholder="备注 (note)" style="width:100%; padding:6px 8px; background:var(--bg); color:var(--text); border:1px solid #3a3d4a; border-radius:6px; resize:vertical;"></textarea>
|
|
40
|
+
<div style="margin-top:10px; text-align:right;">
|
|
41
|
+
<button id="editCancel" style="background:#3a3d4a; color:var(--text); border:0; padding:6px 12px; border-radius:6px; cursor:pointer;">取消</button>
|
|
42
|
+
<button id="editSave" style="background:#2a9d8f; color:#fff; border:0; padding:6px 12px; border-radius:6px; cursor:pointer;">保存</button>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
<script src="/layout.js"></script>
|
|
46
|
+
<script>
|
|
47
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
48
|
+
const KIND_COLORS = {
|
|
49
|
+
topic: '#3d5afe', question: '#8e6cf0', option: '#2a9d8f', decision: '#e9933a',
|
|
50
|
+
requirement: '#4fa3e3', risk: '#e35d6a', note: '#6c757d',
|
|
51
|
+
};
|
|
52
|
+
const svg = document.getElementById('canvas');
|
|
53
|
+
const viewport = document.getElementById('viewport');
|
|
54
|
+
let view = { x: 0, y: 0, k: 1 };
|
|
55
|
+
let currentRev = -1;
|
|
56
|
+
let lastSnap = null;
|
|
57
|
+
let selectedId = null;
|
|
58
|
+
const pendingEdits = {}; // id -> { label, note } saved-but-not-submitted
|
|
59
|
+
let editingId = null;
|
|
60
|
+
const panel = document.getElementById('editPanel');
|
|
61
|
+
const submitBtn = document.getElementById('submitBtn');
|
|
62
|
+
|
|
63
|
+
function refreshSubmit() {
|
|
64
|
+
const n = Object.keys(pendingEdits).length;
|
|
65
|
+
submitBtn.textContent = '提交 (' + n + ')';
|
|
66
|
+
submitBtn.disabled = n === 0;
|
|
67
|
+
}
|
|
68
|
+
function clearPending() {
|
|
69
|
+
for (const k of Object.keys(pendingEdits)) delete pendingEdits[k];
|
|
70
|
+
refreshSubmit();
|
|
71
|
+
}
|
|
72
|
+
function openEditor(n) {
|
|
73
|
+
editingId = n.id;
|
|
74
|
+
const pend = pendingEdits[n.id];
|
|
75
|
+
document.getElementById('editLabel').value = pend ? pend.label : n.label;
|
|
76
|
+
document.getElementById('editNote').value = pend ? (pend.note || '') : (n.note || '');
|
|
77
|
+
panel.style.display = 'block';
|
|
78
|
+
}
|
|
79
|
+
function closeEditor() { panel.style.display = 'none'; editingId = null; }
|
|
80
|
+
document.getElementById('editCancel').addEventListener('click', closeEditor);
|
|
81
|
+
document.getElementById('editSave').addEventListener('click', () => {
|
|
82
|
+
if (!editingId) return;
|
|
83
|
+
const label = document.getElementById('editLabel').value;
|
|
84
|
+
const note = document.getElementById('editNote').value;
|
|
85
|
+
pendingEdits[editingId] = { label, note };
|
|
86
|
+
fetch('/event', {
|
|
87
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
88
|
+
body: JSON.stringify({ type: 'node:edit', id: editingId, label, note,
|
|
89
|
+
timestamp: Math.floor(Date.now() / 1000) }),
|
|
90
|
+
}).catch(() => {});
|
|
91
|
+
closeEditor();
|
|
92
|
+
refreshSubmit();
|
|
93
|
+
if (lastSnap) render(lastSnap);
|
|
94
|
+
});
|
|
95
|
+
submitBtn.addEventListener('click', () => {
|
|
96
|
+
fetch('/event', {
|
|
97
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
98
|
+
body: JSON.stringify({ type: 'submit', timestamp: Math.floor(Date.now() / 1000) }),
|
|
99
|
+
}).catch(() => {});
|
|
100
|
+
// Keep the optimistic overlay until the agent's merged snapshot lands (cleared in
|
|
101
|
+
// ws.onmessage). Just lock the button so the same batch can't be re-submitted.
|
|
102
|
+
submitBtn.disabled = true;
|
|
103
|
+
submitBtn.textContent = '已提交,等待合并…';
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
function applyView() {
|
|
107
|
+
viewport.setAttribute('transform', 'translate(' + view.x + ',' + view.y + ') scale(' + view.k + ')');
|
|
108
|
+
}
|
|
109
|
+
function resetView() {
|
|
110
|
+
view = { x: svg.clientWidth / 2, y: svg.clientHeight / 2, k: 1 };
|
|
111
|
+
applyView();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- pan ---
|
|
115
|
+
let drag = null;
|
|
116
|
+
svg.addEventListener('pointerdown', e => {
|
|
117
|
+
if (e.target.closest('.node')) return;
|
|
118
|
+
drag = { x: e.clientX, y: e.clientY };
|
|
119
|
+
svg.classList.add('dragging');
|
|
120
|
+
svg.setPointerCapture(e.pointerId);
|
|
121
|
+
});
|
|
122
|
+
svg.addEventListener('pointermove', e => {
|
|
123
|
+
if (!drag) return;
|
|
124
|
+
view.x += e.clientX - drag.x;
|
|
125
|
+
view.y += e.clientY - drag.y;
|
|
126
|
+
drag = { x: e.clientX, y: e.clientY };
|
|
127
|
+
applyView();
|
|
128
|
+
});
|
|
129
|
+
svg.addEventListener('pointerup', () => { drag = null; svg.classList.remove('dragging'); });
|
|
130
|
+
svg.addEventListener('dblclick', e => { if (!e.target.closest('.node')) resetView(); });
|
|
131
|
+
|
|
132
|
+
// --- zoom around the cursor ---
|
|
133
|
+
svg.addEventListener('wheel', e => {
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
|
|
136
|
+
const k2 = Math.min(4, Math.max(0.2, view.k * factor));
|
|
137
|
+
const rect = svg.getBoundingClientRect();
|
|
138
|
+
const px = e.clientX - rect.left;
|
|
139
|
+
const py = e.clientY - rect.top;
|
|
140
|
+
view.x = px - (px - view.x) * (k2 / view.k);
|
|
141
|
+
view.y = py - (py - view.y) * (k2 / view.k);
|
|
142
|
+
view.k = k2;
|
|
143
|
+
applyView();
|
|
144
|
+
}, { passive: false });
|
|
145
|
+
|
|
146
|
+
function nodeWidth(label) {
|
|
147
|
+
let w = 0;
|
|
148
|
+
for (const ch of label) w += ch.charCodeAt(0) > 255 ? 13 : 7.5;
|
|
149
|
+
return Math.max(60, w + 24);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function el(name, attrs) {
|
|
153
|
+
const node = document.createElementNS(SVG_NS, name);
|
|
154
|
+
for (const [k, v] of Object.entries(attrs || {})) node.setAttribute(k, v);
|
|
155
|
+
return node;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function sendClick(n) {
|
|
159
|
+
selectedId = n.id;
|
|
160
|
+
fetch('/event', {
|
|
161
|
+
method: 'POST',
|
|
162
|
+
headers: { 'Content-Type': 'application/json' },
|
|
163
|
+
body: JSON.stringify({
|
|
164
|
+
type: 'node:click', id: n.id, label: n.label, kind: n.kind,
|
|
165
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
166
|
+
}),
|
|
167
|
+
}).catch(() => {});
|
|
168
|
+
if (lastSnap) render(lastSnap);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function render(snap) {
|
|
172
|
+
document.getElementById('topic').textContent = snap.topic || '(无主题)';
|
|
173
|
+
const st = document.getElementById('status');
|
|
174
|
+
st.textContent = snap.status;
|
|
175
|
+
st.className = 'badge ' + snap.status;
|
|
176
|
+
document.getElementById('rev').textContent = 'rev ' + snap.rev;
|
|
177
|
+
viewport.innerHTML = '';
|
|
178
|
+
if (!snap.root) return;
|
|
179
|
+
const { nodes, links } = MindmapLayout.layout(snap.root);
|
|
180
|
+
const byId = {};
|
|
181
|
+
nodes.forEach(n => { byId[n.id] = n; });
|
|
182
|
+
for (const l of links) {
|
|
183
|
+
const a = byId[l.from];
|
|
184
|
+
const b = byId[l.to];
|
|
185
|
+
if (!a || !b) continue;
|
|
186
|
+
const mx = (a.x + b.x) / 2;
|
|
187
|
+
viewport.appendChild(el('path', {
|
|
188
|
+
class: 'link',
|
|
189
|
+
d: 'M ' + a.x + ' ' + a.y + ' C ' + mx + ' ' + a.y + ', ' + mx + ' ' + b.y + ', ' + b.x + ' ' + b.y,
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
for (const n of nodes) {
|
|
193
|
+
const pend = pendingEdits[n.id];
|
|
194
|
+
const g = el('g', { class: 'node ' + (n.state || 'open') });
|
|
195
|
+
const shownLabel = pend ? pend.label : n.label;
|
|
196
|
+
const label = (n.state === 'resolved' ? '✓ ' : '') + shownLabel;
|
|
197
|
+
const w = nodeWidth(label);
|
|
198
|
+
const h = 32;
|
|
199
|
+
const color = KIND_COLORS[n.kind] || KIND_COLORS.note;
|
|
200
|
+
const rect = el('rect', {
|
|
201
|
+
x: n.x - w / 2, y: n.y - h / 2, width: w, height: h, rx: 8,
|
|
202
|
+
fill: n.state === 'rejected' ? '#2a2c35' : color + '33',
|
|
203
|
+
stroke: n.state === 'chosen' ? '#ffd166' : color,
|
|
204
|
+
'stroke-width': n.state === 'chosen' ? 3 : (n.id === selectedId ? 2.5 : 1.5),
|
|
205
|
+
});
|
|
206
|
+
if (n.id === selectedId) rect.setAttribute('stroke-dasharray', '4 2');
|
|
207
|
+
if (pend) { rect.setAttribute('stroke', '#7be3a0'); rect.setAttribute('stroke-dasharray', '5 3'); }
|
|
208
|
+
const text = el('text', { x: n.x, y: n.y + 4.5, 'text-anchor': 'middle' });
|
|
209
|
+
text.textContent = label;
|
|
210
|
+
g.appendChild(rect);
|
|
211
|
+
g.appendChild(text);
|
|
212
|
+
if (n.note) {
|
|
213
|
+
const tip = document.createElementNS(SVG_NS, 'title');
|
|
214
|
+
tip.textContent = n.note;
|
|
215
|
+
g.appendChild(tip);
|
|
216
|
+
}
|
|
217
|
+
g.addEventListener('click', () => sendClick(n));
|
|
218
|
+
g.addEventListener('dblclick', e => { e.stopPropagation(); openEditor(n); });
|
|
219
|
+
viewport.appendChild(g);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function connect() {
|
|
224
|
+
const conn = document.getElementById('conn');
|
|
225
|
+
const ws = new WebSocket('ws://' + location.host + '/');
|
|
226
|
+
ws.onopen = () => { conn.textContent = '已连接'; conn.className = 'badge ok'; };
|
|
227
|
+
ws.onmessage = ev => {
|
|
228
|
+
let snap;
|
|
229
|
+
try { snap = JSON.parse(ev.data); } catch { return; }
|
|
230
|
+
if (snap.type !== 'mindmap:snapshot' || snap.rev <= currentRev) return;
|
|
231
|
+
currentRev = snap.rev;
|
|
232
|
+
lastSnap = snap;
|
|
233
|
+
selectedId = null;
|
|
234
|
+
clearPending(); // authoritative snapshot now reflects any merged edits
|
|
235
|
+
render(snap);
|
|
236
|
+
};
|
|
237
|
+
ws.onclose = () => {
|
|
238
|
+
conn.textContent = '已断开,重连中…';
|
|
239
|
+
conn.className = 'badge bad';
|
|
240
|
+
setTimeout(connect, 2000);
|
|
241
|
+
};
|
|
242
|
+
ws.onerror = () => ws.close();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
resetView();
|
|
246
|
+
connect();
|
|
247
|
+
</script>
|
|
248
|
+
</body>
|
|
249
|
+
</html>
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Superharness brainstorm mind-map server. Zero-dependency Node.
|
|
2
|
+
// Serves mindmap.html, watches content/mindmap.json and pushes snapshots to the
|
|
3
|
+
// browser over WebSocket; records browser interactions to state/events (JSONL).
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const http = require('http');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
// ---------- config ----------
|
|
10
|
+
const SESSION_DIR = process.env.SUPERHARNESS_SESSION_DIR;
|
|
11
|
+
if (!SESSION_DIR) {
|
|
12
|
+
console.error('SUPERHARNESS_SESSION_DIR is required');
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
const CONTENT_DIR = path.join(SESSION_DIR, 'content');
|
|
16
|
+
const STATE_DIR = path.join(SESSION_DIR, 'state');
|
|
17
|
+
const SNAPSHOT_FILE = path.join(CONTENT_DIR, 'mindmap.json');
|
|
18
|
+
const EVENTS_FILE = path.join(STATE_DIR, 'events');
|
|
19
|
+
const EDITS_FILE = path.join(STATE_DIR, 'edits');
|
|
20
|
+
const INFO_FILE = path.join(STATE_DIR, 'server-info');
|
|
21
|
+
const STOPPED_FILE = path.join(STATE_DIR, 'server-stopped');
|
|
22
|
+
const PORT = Number(process.env.SUPERHARNESS_PORT) || (49152 + Math.floor(Math.random() * 16383));
|
|
23
|
+
const HOST = process.env.SUPERHARNESS_HOST || '127.0.0.1';
|
|
24
|
+
const IDLE_TIMEOUT_MS = Number(process.env.SUPERHARNESS_IDLE_TIMEOUT_MS) || 30 * 60 * 1000;
|
|
25
|
+
const IDLE_CHECK_MS = Math.min(5000, IDLE_TIMEOUT_MS);
|
|
26
|
+
|
|
27
|
+
fs.mkdirSync(CONTENT_DIR, { recursive: true });
|
|
28
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
29
|
+
|
|
30
|
+
let lastActivity = Date.now();
|
|
31
|
+
const touch = () => { lastActivity = Date.now(); };
|
|
32
|
+
|
|
33
|
+
// ---------- websocket protocol (RFC 6455) ----------
|
|
34
|
+
const OPCODES = { TEXT: 0x01, CLOSE: 0x08, PING: 0x09, PONG: 0x0A };
|
|
35
|
+
const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
36
|
+
|
|
37
|
+
function computeAcceptKey(clientKey) {
|
|
38
|
+
return crypto.createHash('sha1').update(clientKey + WS_MAGIC).digest('base64');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function encodeFrame(opcode, payload) {
|
|
42
|
+
const fin = 0x80;
|
|
43
|
+
const len = payload.length;
|
|
44
|
+
let header;
|
|
45
|
+
if (len < 126) {
|
|
46
|
+
header = Buffer.alloc(2);
|
|
47
|
+
header[0] = fin | opcode;
|
|
48
|
+
header[1] = len;
|
|
49
|
+
} else if (len < 65536) {
|
|
50
|
+
header = Buffer.alloc(4);
|
|
51
|
+
header[0] = fin | opcode;
|
|
52
|
+
header[1] = 126;
|
|
53
|
+
header.writeUInt16BE(len, 2);
|
|
54
|
+
} else {
|
|
55
|
+
header = Buffer.alloc(10);
|
|
56
|
+
header[0] = fin | opcode;
|
|
57
|
+
header[1] = 127;
|
|
58
|
+
header.writeBigUInt64BE(BigInt(len), 2);
|
|
59
|
+
}
|
|
60
|
+
return Buffer.concat([header, payload]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function decodeFrame(buffer) {
|
|
64
|
+
if (buffer.length < 2) return null;
|
|
65
|
+
const opcode = buffer[0] & 0x0f;
|
|
66
|
+
const masked = (buffer[1] & 0x80) !== 0;
|
|
67
|
+
let payloadLen = buffer[1] & 0x7f;
|
|
68
|
+
let offset = 2;
|
|
69
|
+
if (!masked) throw new Error('Client frames must be masked');
|
|
70
|
+
if (payloadLen === 126) {
|
|
71
|
+
if (buffer.length < 4) return null;
|
|
72
|
+
payloadLen = buffer.readUInt16BE(2);
|
|
73
|
+
offset = 4;
|
|
74
|
+
} else if (payloadLen === 127) {
|
|
75
|
+
if (buffer.length < 10) return null;
|
|
76
|
+
payloadLen = Number(buffer.readBigUInt64BE(2));
|
|
77
|
+
offset = 10;
|
|
78
|
+
}
|
|
79
|
+
const dataOffset = offset + 4;
|
|
80
|
+
const totalLen = dataOffset + payloadLen;
|
|
81
|
+
if (buffer.length < totalLen) return null;
|
|
82
|
+
const mask = buffer.slice(offset, dataOffset);
|
|
83
|
+
const data = Buffer.alloc(payloadLen);
|
|
84
|
+
for (let i = 0; i < payloadLen; i++) data[i] = buffer[dataOffset + i] ^ mask[i % 4];
|
|
85
|
+
return { opcode, payload: data, bytesConsumed: totalLen };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const clients = new Set();
|
|
89
|
+
function broadcast(text) {
|
|
90
|
+
const frame = encodeFrame(OPCODES.TEXT, Buffer.from(text));
|
|
91
|
+
for (const socket of clients) socket.write(frame);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------- snapshot ----------
|
|
95
|
+
const DEFAULT_SNAPSHOT = JSON.stringify({
|
|
96
|
+
type: 'mindmap:snapshot', rev: 0, topic: '', status: 'exploring', root: null,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
function currentSnapshot() {
|
|
100
|
+
try { return fs.readFileSync(SNAPSHOT_FILE, 'utf-8'); }
|
|
101
|
+
catch { return DEFAULT_SNAPSHOT; }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------- http ----------
|
|
105
|
+
function serveFile(res, name, type) {
|
|
106
|
+
res.writeHead(200, { 'Content-Type': type });
|
|
107
|
+
res.end(fs.readFileSync(path.join(__dirname, name)));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const server = http.createServer((req, res) => {
|
|
111
|
+
touch();
|
|
112
|
+
if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
|
|
113
|
+
serveFile(res, 'mindmap.html', 'text/html; charset=utf-8');
|
|
114
|
+
} else if (req.method === 'GET' && req.url === '/layout.js') {
|
|
115
|
+
serveFile(res, 'layout.js', 'application/javascript; charset=utf-8');
|
|
116
|
+
} else if (req.method === 'GET' && req.url === '/mindmap.json') {
|
|
117
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
118
|
+
res.end(currentSnapshot());
|
|
119
|
+
} else if (req.method === 'POST' && req.url === '/event') {
|
|
120
|
+
let body = '';
|
|
121
|
+
req.on('data', chunk => { body += chunk; });
|
|
122
|
+
req.on('end', () => {
|
|
123
|
+
try {
|
|
124
|
+
const msg = JSON.parse(body);
|
|
125
|
+
const file = (msg.type === 'node:edit' || msg.type === 'submit') ? EDITS_FILE : EVENTS_FILE;
|
|
126
|
+
fs.appendFileSync(file, body.trim() + '\n');
|
|
127
|
+
res.writeHead(204);
|
|
128
|
+
res.end();
|
|
129
|
+
} catch {
|
|
130
|
+
res.writeHead(400);
|
|
131
|
+
res.end('invalid JSON');
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
} else {
|
|
135
|
+
res.writeHead(404);
|
|
136
|
+
res.end('not found');
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ---------- websocket upgrade ----------
|
|
141
|
+
server.on('upgrade', (req, socket) => {
|
|
142
|
+
const key = req.headers['sec-websocket-key'];
|
|
143
|
+
if (!key) { socket.destroy(); return; }
|
|
144
|
+
socket.write(
|
|
145
|
+
'HTTP/1.1 101 Switching Protocols\r\n' +
|
|
146
|
+
'Upgrade: websocket\r\nConnection: Upgrade\r\n' +
|
|
147
|
+
'Sec-WebSocket-Accept: ' + computeAcceptKey(key) + '\r\n\r\n');
|
|
148
|
+
clients.add(socket);
|
|
149
|
+
touch();
|
|
150
|
+
socket.write(encodeFrame(OPCODES.TEXT, Buffer.from(currentSnapshot())));
|
|
151
|
+
let buf = Buffer.alloc(0);
|
|
152
|
+
socket.on('data', data => {
|
|
153
|
+
touch();
|
|
154
|
+
buf = Buffer.concat([buf, data]);
|
|
155
|
+
while (true) {
|
|
156
|
+
let frame;
|
|
157
|
+
try { frame = decodeFrame(buf); } catch { socket.destroy(); return; }
|
|
158
|
+
if (!frame) break;
|
|
159
|
+
buf = buf.slice(frame.bytesConsumed);
|
|
160
|
+
if (frame.opcode === OPCODES.CLOSE) { socket.end(); return; }
|
|
161
|
+
if (frame.opcode === OPCODES.PING) socket.write(encodeFrame(OPCODES.PONG, frame.payload));
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
const drop = () => clients.delete(socket);
|
|
165
|
+
socket.on('close', drop);
|
|
166
|
+
socket.on('error', drop);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ---------- snapshot file watch ----------
|
|
170
|
+
let lastMtime = 0;
|
|
171
|
+
try { lastMtime = fs.statSync(SNAPSHOT_FILE).mtimeMs; } catch {}
|
|
172
|
+
setInterval(() => {
|
|
173
|
+
let stat;
|
|
174
|
+
try { stat = fs.statSync(SNAPSHOT_FILE); } catch { return; }
|
|
175
|
+
if (stat.mtimeMs === lastMtime) return;
|
|
176
|
+
lastMtime = stat.mtimeMs;
|
|
177
|
+
touch();
|
|
178
|
+
try { fs.writeFileSync(EVENTS_FILE, ''); } catch {}
|
|
179
|
+
broadcast(currentSnapshot());
|
|
180
|
+
}, 500).unref();
|
|
181
|
+
|
|
182
|
+
// ---------- lifecycle ----------
|
|
183
|
+
function shutdown(code) {
|
|
184
|
+
try { fs.writeFileSync(STOPPED_FILE, ''); } catch {}
|
|
185
|
+
try { fs.unlinkSync(INFO_FILE); } catch {}
|
|
186
|
+
process.exit(code);
|
|
187
|
+
}
|
|
188
|
+
process.on('SIGINT', () => shutdown(0));
|
|
189
|
+
process.on('SIGTERM', () => shutdown(0));
|
|
190
|
+
|
|
191
|
+
setInterval(() => {
|
|
192
|
+
if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) shutdown(0);
|
|
193
|
+
}, IDLE_CHECK_MS).unref();
|
|
194
|
+
|
|
195
|
+
server.listen(PORT, HOST, () => {
|
|
196
|
+
try { fs.unlinkSync(STOPPED_FILE); } catch {}
|
|
197
|
+
const urlHost = HOST === '127.0.0.1' ? 'localhost' : HOST;
|
|
198
|
+
const info = {
|
|
199
|
+
type: 'server-started',
|
|
200
|
+
port: PORT,
|
|
201
|
+
url: 'http://' + urlHost + ':' + PORT,
|
|
202
|
+
pid: process.pid,
|
|
203
|
+
content_dir: CONTENT_DIR,
|
|
204
|
+
state_dir: STATE_DIR,
|
|
205
|
+
};
|
|
206
|
+
fs.writeFileSync(INFO_FILE, JSON.stringify(info));
|
|
207
|
+
console.log(JSON.stringify(info));
|
|
208
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Starts the brainstorm mind-map server for a project.
|
|
2
|
+
# Creates <project>/<state-root>/superharness/brainstorm/<session-id>/{content,state}
|
|
3
|
+
# where <state-root> follows the host (.claude under Claude Code, .flavor under
|
|
4
|
+
# flavor-code), launches node server.cjs detached, waits for state/server-info,
|
|
5
|
+
# prints it and exits.
|
|
6
|
+
param(
|
|
7
|
+
[string]$ProjectDir = (Get-Location).Path,
|
|
8
|
+
[int]$TimeoutSec = 10
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
$ErrorActionPreference = 'Stop'
|
|
12
|
+
|
|
13
|
+
$node = Get-Command node -ErrorAction SilentlyContinue
|
|
14
|
+
if (-not $node) { Write-Error 'node not found on PATH - brainstorm mind map unavailable'; exit 1 }
|
|
15
|
+
|
|
16
|
+
# State root detection (mirrors ralph-lib Get-RalphStateRoot):
|
|
17
|
+
# $env:SUPERHARNESS_STATE_ROOT ('.claude' | '.flavor') forces a choice for unusual
|
|
18
|
+
# layouts; otherwise the install location of this script names the host, with a
|
|
19
|
+
# fallback to the project's .flavor install. Claude Code is the historical default.
|
|
20
|
+
$forced = $env:SUPERHARNESS_STATE_ROOT
|
|
21
|
+
if ($forced -eq '.flavor' -or $forced -eq 'flavor') {
|
|
22
|
+
$stateRoot = '.flavor'
|
|
23
|
+
} elseif ($forced -eq '.claude' -or $forced -eq 'claude') {
|
|
24
|
+
$stateRoot = '.claude'
|
|
25
|
+
} elseif ($PSScriptRoot -match '[/\\]\.flavor[/\\]plugins[/\\]superharness') {
|
|
26
|
+
$stateRoot = '.flavor'
|
|
27
|
+
} elseif (Test-Path (Join-Path $ProjectDir '.flavor\plugins\superharness')) {
|
|
28
|
+
$stateRoot = '.flavor'
|
|
29
|
+
} else {
|
|
30
|
+
$stateRoot = '.claude'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
$sessionId = (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + $PID
|
|
34
|
+
$sessionDir = Join-Path $ProjectDir "$stateRoot\superharness\brainstorm\$sessionId"
|
|
35
|
+
New-Item -ItemType Directory -Force (Join-Path $sessionDir 'content') | Out-Null
|
|
36
|
+
New-Item -ItemType Directory -Force (Join-Path $sessionDir 'state') | Out-Null
|
|
37
|
+
|
|
38
|
+
$serverJs = Join-Path $PSScriptRoot 'server.cjs'
|
|
39
|
+
$env:SUPERHARNESS_SESSION_DIR = $sessionDir
|
|
40
|
+
try {
|
|
41
|
+
Start-Process -FilePath $node.Source -ArgumentList ('"' + $serverJs + '"') -WindowStyle Hidden
|
|
42
|
+
} finally {
|
|
43
|
+
Remove-Item Env:SUPERHARNESS_SESSION_DIR -ErrorAction SilentlyContinue
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
$infoPath = Join-Path $sessionDir 'state\server-info'
|
|
47
|
+
$deadline = (Get-Date).AddSeconds($TimeoutSec)
|
|
48
|
+
while ((Get-Date) -lt $deadline) {
|
|
49
|
+
if (Test-Path $infoPath) { break }
|
|
50
|
+
Start-Sleep -Milliseconds 200
|
|
51
|
+
}
|
|
52
|
+
if (-not (Test-Path $infoPath)) {
|
|
53
|
+
Write-Error "server did not start within $TimeoutSec seconds"
|
|
54
|
+
exit 1
|
|
55
|
+
}
|
|
56
|
+
Get-Content $infoPath -Raw -Encoding UTF8
|
|
57
|
+
exit 0
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Stops the brainstorm mind-map server for a session directory.
|
|
2
|
+
param(
|
|
3
|
+
[Parameter(Mandatory = $true)][string]$SessionDir
|
|
4
|
+
)
|
|
5
|
+
|
|
6
|
+
$ErrorActionPreference = 'SilentlyContinue'
|
|
7
|
+
|
|
8
|
+
$infoPath = Join-Path $SessionDir 'state\server-info'
|
|
9
|
+
if (Test-Path $infoPath) {
|
|
10
|
+
$info = Get-Content $infoPath -Raw | ConvertFrom-Json
|
|
11
|
+
if ($info.pid) { Stop-Process -Id $info.pid -Force -Confirm:$false }
|
|
12
|
+
Remove-Item $infoPath -Force
|
|
13
|
+
}
|
|
14
|
+
$stateDir = Join-Path $SessionDir 'state'
|
|
15
|
+
if (-not (Test-Path $stateDir)) { New-Item -ItemType Directory -Force $stateDir | Out-Null }
|
|
16
|
+
New-Item -ItemType File -Force (Join-Path $stateDir 'server-stopped') | Out-Null
|
|
17
|
+
exit 0
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: finishing-a-development-branch
|
|
3
|
+
description: Use when work done in an isolated worktree or branch is complete, verified, and reviewed - merge the branch back, remove the worktree, delete the branch, and report. The closing half of using-git-worktrees (go Phase 5)
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Finishing a Development Branch
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
Close out a task that was developed in an isolated worktree or branch: merge the
|
|
11
|
+
verified, reviewed work back into the main branch, remove the disposable
|
|
12
|
+
worktree, delete the merged branch, and leave the repository clean. This is the
|
|
13
|
+
counterpart of `superharness:using-git-worktrees` — that skill isolates the
|
|
14
|
+
work, this skill closes it out.
|
|
15
|
+
|
|
16
|
+
**Announce at start:** "Closing out the isolated workspace
|
|
17
|
+
(finishing-a-development-branch)."
|
|
18
|
+
|
|
19
|
+
**Preconditions — do NOT finish until all of these hold:**
|
|
20
|
+
|
|
21
|
+
1. The full test suite passed in the worktree (`verification-before-completion`).
|
|
22
|
+
2. The review pass is done and Critical/Important issues were fixed
|
|
23
|
+
(`requesting-code-review`).
|
|
24
|
+
3. All work is committed on the feature branch (no uncommitted changes).
|
|
25
|
+
|
|
26
|
+
If any precondition is missing, go back and complete it first. Finishing is not
|
|
27
|
+
a way to skip verification.
|
|
28
|
+
|
|
29
|
+
## Step 1 — Confirm where you are
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
git rev-parse --is-inside-work-tree 2>/dev/null || { echo "Not a git repo - nothing to finish. Report and stop."; exit 0; }
|
|
33
|
+
git status --short
|
|
34
|
+
git branch --show-current
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- Not a git repo (or `go` worked in place, no branch was created): there is
|
|
38
|
+
nothing to merge or clean up. Skip straight to Step 5.
|
|
39
|
+
- `git status --short` shows uncommitted changes: commit them first (they went
|
|
40
|
+
through TDD already) or stop and ask — never merge a dirty worktree.
|
|
41
|
+
- `git rev-parse --show-superproject-working-tree` prints a path: you are in a
|
|
42
|
+
submodule; treat it as a normal repo.
|
|
43
|
+
|
|
44
|
+
## Step 2 — Merge the branch back
|
|
45
|
+
|
|
46
|
+
Return to the main workspace and merge the feature branch. A fast-forward merge
|
|
47
|
+
is the normal case because the worktree branched off the current main and no
|
|
48
|
+
parallel work happened there:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# from the MAIN workspace (git worktree list shows its path), not the worktree
|
|
52
|
+
git switch <main-branch>
|
|
53
|
+
git merge --ff-only <feature-branch>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
If `--ff-only` fails because main moved on, do a regular merge and resolve the
|
|
57
|
+
conflicts properly (this is a real engineering task — use
|
|
58
|
+
`superharness:systematic-debugging` if anything is unclear, never "just pick
|
|
59
|
+
ours"):
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
git merge <feature-branch>
|
|
63
|
+
# resolve conflicts, then verify: run the FULL test suite again in the main workspace
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**After any merge, run the full test suite in the main workspace and paste the
|
|
67
|
+
real output.** The merge can change the result even when the branch was green.
|
|
68
|
+
|
|
69
|
+
## Step 3 — Remove the worktree and branch
|
|
70
|
+
|
|
71
|
+
Once merged (fast-forward or resolved), clean up the disposable workspace:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
git worktree remove <worktree-path>
|
|
75
|
+
git branch -d <feature-branch> # -d only deletes merged branches; if it refuses, the branch was NOT merged - stop
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
- If `git worktree remove` refuses because of uncommitted changes, deal with
|
|
79
|
+
those changes first. Do not `--force` unless the human explicitly confirms the
|
|
80
|
+
work there is disposable.
|
|
81
|
+
- If `git branch -d` refuses, the branch is not fully merged — investigate
|
|
82
|
+
before discarding. Never `-D` a branch with unreviewed work.
|
|
83
|
+
- Only remove the `.worktrees/` entry from `.gitignore` if the directory is now
|
|
84
|
+
gone (add/remove is optional; leaving the ignore line is harmless).
|
|
85
|
+
|
|
86
|
+
## Step 4 — Leave the repo clean
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
git worktree list # should show only the main workspace
|
|
90
|
+
git status --short # should be clean (or only expected files)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Step 5 — Report
|
|
94
|
+
|
|
95
|
+
Report concisely:
|
|
96
|
+
|
|
97
|
+
- What was merged and into which branch (`<feature-branch>` -> `<main-branch>`)
|
|
98
|
+
- Merge method (fast-forward or merge commit) and whether any conflict resolution
|
|
99
|
+
happened
|
|
100
|
+
- Test command run in the main workspace after the merge and its real output
|
|
101
|
+
- Worktree/branch cleanup performed (or "worked in place — nothing to clean up")
|
|
102
|
+
- Push status: **you did not push** unless the human asked
|
|
103
|
+
|
|
104
|
+
## Red Flags
|
|
105
|
+
|
|
106
|
+
| Thought | Reality |
|
|
107
|
+
|---------|---------|
|
|
108
|
+
| "It passed in the worktree, no need to re-run tests here" | The merge changes the tree. Run the full suite in the main workspace and show the output. |
|
|
109
|
+
| "The worktree has leftover changes, I'll just --force remove it" | Handle the changes first, or ask. --force discards work that may be unreviewed. |
|
|
110
|
+
| "git branch -d refused, but I know it's merged" | Then prove it — check `git log <main-branch>..<feature-branch>`. If it's really empty, -d succeeds. Refusal means it is not merged. |
|
|
111
|
+
| "I'll push to origin as part of finishing" | No. Pushing is a shared/external action — report the branch and let the human push. |
|
|
112
|
+
| "This repo isn't git, but I'll clean up anyway" | Nothing to clean up. Worked in place leaves no branch/worktree. Report and stop. |
|