create-harness-vibe-coding 0.7.2 → 0.8.1
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/README-CN.md +103 -155
- package/README.md +213 -175
- package/bin/create-harness-vibe-coding.js +2 -2
- package/package.json +45 -43
- package/src/generator.js +115 -77
- package/src/index.js +159 -49
- package/src/prompts.js +95 -27
- package/templates/common/.claude/agents/architect-manager.md +45 -45
- package/templates/common/.claude/agents/context-master.md +77 -77
- package/templates/common/.claude/agents/debugger.md +41 -41
- package/templates/common/.claude/agents/explore-manager.md +41 -41
- package/templates/common/.claude/agents/implement-manager.md +49 -49
- package/templates/common/.claude/agents/implementer.md +40 -40
- package/templates/common/.claude/agents/memory-master.md +66 -66
- package/templates/common/.claude/agents/planner.md +34 -34
- package/templates/common/.claude/agents/researcher.md +41 -41
- package/templates/common/.claude/agents/review-manager.md +56 -56
- package/templates/common/.claude/agents/test-writer.md +38 -38
- package/templates/common/.claude/agents/verifier.md +32 -32
- package/templates/common/.claude/rules/ecc/common.md +45 -45
- package/templates/common/.claude/settings.json +68 -68
- package/templates/common/.claude/skills/subagent-orchestrator/SKILL.md +36 -31
- package/templates/common/.claude/skills/wf/SKILL.md +36 -0
- package/templates/common/.claude/skills/wf-learn/SKILL.md +13 -28
- package/templates/common/.claude/skills/wf-max/SKILL.md +24 -88
- package/templates/common/.claude/skills/wf-readme/SKILL.md +49 -49
- package/templates/common/.claude/skills/wf-remove/SKILL.md +17 -38
- package/templates/common/.claude/skills/wf-review/SKILL.md +19 -69
- package/templates/common/.claude/skills/wf-update/SKILL.md +19 -61
- package/templates/common/.codex/config.toml +2 -2
- package/templates/common/.codex/hooks.json +37 -37
- package/templates/common/.harness-version +53 -51
- package/templates/common/AGENTS.md +30 -30
- package/templates/common/CLAUDE.md +94 -94
- package/templates/common/{docs/harness → Harness}/PROGRESS.md +17 -17
- package/templates/common/{docs → Harness}/README.md +18 -17
- package/templates/common/{docs/harness → Harness}/WF-MAX.md +223 -223
- package/templates/common/Harness/WF.md +217 -0
- package/templates/common/{docs/harness → Harness}/architecture.md +126 -126
- package/templates/common/{docs/harness → Harness}/dispatch.md +99 -99
- package/templates/common/{docs → Harness}/research/research-results.md +66 -66
- package/templates/common/{docs/harness → Harness}/subagents.md +194 -194
- package/templates/common/{docs → Harness}/tasks/_template/ARTIFACTS.md +3 -3
- package/templates/common/{docs → Harness}/tasks/_template/NOTES.md +3 -3
- package/templates/common/{docs → Harness}/tasks/_template/PLAN.md +40 -40
- package/templates/common/{docs → Harness}/tasks/_template/PROGRESS.md +29 -29
- package/templates/common/MEMORY.md +68 -66
- package/templates/common/README.md +44 -37
- package/templates/common/SETUP.md +67 -47
- package/templates/common/memory/agent-lessons-patterns.md +21 -21
- package/templates/common/memory/tool-usage-reflections.md +21 -21
- package/templates/common/memory/user-corrections-preferences.md +21 -21
- package/templates/common/scripts/scan-clean.mjs +450 -449
- package/templates/common/scripts/validate-harness.mjs +56 -34
- package/templates/common/scripts/wf-mode-hook.mjs +318 -318
- package/templates/common/scripts/wf-remove.mjs +400 -384
- package/templates/common/scripts/wf-statusline.ps1 +38 -38
- package/templates/common/scripts/wf-statusline.sh +48 -48
- package/templates/common/scripts/wf-update-check.mjs +389 -389
- package/templates/optional/catalog.json +63 -29
- package/templates/optional/skills/browser-e2e/.claude/skills/wf-browser/SKILL.md +194 -194
- package/templates/optional/skills/browser-e2e/{docs → Harness}/workflows/browser-e2e.md +75 -75
- package/templates/optional/skills/github-pr-review/{docs → Harness}/workflows/github-pr-review.md +28 -28
- package/templates/optional/skills/python-backend/{docs → Harness}/workflows/python-backend.md +34 -34
- package/templates/optional/skills/ts-react-frontend/{docs → Harness}/workflows/ts-react-frontend.md +35 -35
- package/templates/optional/skills/ui-ux-review/{docs → Harness}/workflows/ui-ux-review.md +26 -26
- package/templates/common/.claude/commands/wf-learn.md +0 -17
- package/templates/common/.claude/commands/wf-max.md +0 -49
- package/templates/common/.claude/commands/wf-remove.md +0 -23
- package/templates/common/.claude/commands/wf-review.md +0 -25
- package/templates/common/.claude/commands/wf-update.md +0 -17
- package/templates/common/.claude/commands/wf.md +0 -31
- package/templates/common/commands/wf-max.toml +0 -18
- package/templates/common/commands/wf-review.toml +0 -15
- package/templates/common/docs/harness/WF.md +0 -174
- package/templates/optional/skills/browser-e2e/.claude/commands/wf-browser.md +0 -25
- /package/templates/common/{docs/harness → Harness}/agent-workflow.md +0 -0
- /package/templates/common/{docs/harness → Harness}/context-loading.md +0 -0
- /package/templates/common/{docs/harness → Harness}/extension.md +0 -0
- /package/templates/common/{docs/harness → Harness}/lifecycle.md +0 -0
- /package/templates/common/{docs → Harness}/research/PRD.md +0 -0
- /package/templates/common/{docs → Harness}/research/README.md +0 -0
|
@@ -1,318 +1,318 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* wf-mode-hook.mjs — Claude Code hook for WF-MAX / WF-REVIEW CEO enforcement
|
|
4
|
-
*
|
|
5
|
-
* Hook events:
|
|
6
|
-
* SessionStart → reads Harness/.runtime/current-mode.json, injects CEO role
|
|
7
|
-
* UserPromptSubmit→ detects /wf-max, /wf-review, writes mode state,
|
|
8
|
-
* emits per-turn reinforcement (prevents drift after compression)
|
|
9
|
-
* PreToolUse → blocks CEO Edit/Write/MultiEdit/Bash on source files
|
|
10
|
-
*
|
|
11
|
-
* Inspired by caveman's hook architecture:
|
|
12
|
-
* - Symlink-safe I/O (O_NOFOLLOW, atomic rename, size cap, whitelist)
|
|
13
|
-
* - Per-turn reinforcement (model drifts without it post-compression)
|
|
14
|
-
* - Fail-silent (hook must never block session start)
|
|
15
|
-
*
|
|
16
|
-
* Designed to be invoked from .claude/settings.json hooks section.
|
|
17
|
-
* Exit 0 = allow/continue. Exit 2 = block with stderr message.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import { constants, openSync, readSync, writeSync, closeSync, readFileSync, writeFileSync, existsSync, mkdirSync, lstatSync, renameSync, unlinkSync } from 'node:fs';
|
|
21
|
-
import { join, dirname, basename } from 'node:path';
|
|
22
|
-
import { fileURLToPath } from 'node:url';
|
|
23
|
-
import { randomBytes } from 'node:crypto';
|
|
24
|
-
|
|
25
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
26
|
-
const RUNTIME_DIR = join(__dirname, '..', '.runtime');
|
|
27
|
-
const MODE_FILE = join(RUNTIME_DIR, 'current-mode.json');
|
|
28
|
-
const MAX_MODE_BYTES = 4096; // JSON config, not a tiny flag — enough for the struct
|
|
29
|
-
|
|
30
|
-
const VALID_MODES = ['wf-max', 'wf-review', null];
|
|
31
|
-
const VALID_ROLES = ['ceo', null];
|
|
32
|
-
const VALID_PHASES = ['W0_EXPLORE', 'W1_ARCHITECTURE', 'W2_IMPLEMENT', 'W2R_REVIEW', 'W3_DEPENDENT', 'INTEGRATION', 'CLOSEOUT', 'REVIEW', null];
|
|
33
|
-
const BLOCKED_TOOLS = ['Edit', 'Write', 'MultiEdit', 'Bash'];
|
|
34
|
-
const MAX_TASKID_BYTES = 128; // Sanitize user-controlled strings injected into context
|
|
35
|
-
|
|
36
|
-
// ── Symlink-safe I/O (from caveman: O_NOFOLLOW + atomic write + whitelist) ──
|
|
37
|
-
|
|
38
|
-
// Resolve and verify path stays within the project's Harness directory.
|
|
39
|
-
// Rejects symlinks at EVERY path component (not just the final file).
|
|
40
|
-
function safeResolveHarnessPath(filePath) {
|
|
41
|
-
try {
|
|
42
|
-
// Walk each component, resolving symlinks at every level
|
|
43
|
-
const parts = filePath.replace(/\\/g, '/').split('/').filter(Boolean);
|
|
44
|
-
let resolved = '';
|
|
45
|
-
for (const part of parts) {
|
|
46
|
-
if (part === '..') return null; // Reject traversal
|
|
47
|
-
resolved = resolved ? join(resolved, part) : (resolved || part);
|
|
48
|
-
try {
|
|
49
|
-
const st = lstatSync(resolved);
|
|
50
|
-
if (st.isSymbolicLink()) return null; // Symlink at any component = reject
|
|
51
|
-
} catch (e) {
|
|
52
|
-
if (e.code === 'ENOENT' && part !== parts[parts.length - 1]) return null;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
// Final component must be under Harness/ directory
|
|
56
|
-
const normalized = resolved.replace(/\\/g, '/');
|
|
57
|
-
if (!normalized.includes('Harness/')) return null;
|
|
58
|
-
return resolved;
|
|
59
|
-
} catch {
|
|
60
|
-
return null;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function safeReadJSON(filePath, maxBytes = MAX_MODE_BYTES) {
|
|
65
|
-
try {
|
|
66
|
-
const safe = safeResolveHarnessPath(filePath);
|
|
67
|
-
if (!safe) return null;
|
|
68
|
-
|
|
69
|
-
const st = lstatSync(safe);
|
|
70
|
-
if (!st.isFile()) return null;
|
|
71
|
-
if (st.size > maxBytes) return null;
|
|
72
|
-
|
|
73
|
-
const O_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
|
|
74
|
-
let fd;
|
|
75
|
-
try {
|
|
76
|
-
fd = openSync(safe, constants.O_RDONLY | O_NOFOLLOW);
|
|
77
|
-
const buf = Buffer.alloc(maxBytes);
|
|
78
|
-
const n = readSync(fd, buf, 0, maxBytes, 0);
|
|
79
|
-
const raw = buf.slice(0, n).toString('utf8').trim();
|
|
80
|
-
if (!raw) return null;
|
|
81
|
-
const parsed = JSON.parse(raw);
|
|
82
|
-
// Full whitelist validation — everything that touches LLM context must be validated
|
|
83
|
-
if (!VALID_MODES.includes(parsed.mode)) return null;
|
|
84
|
-
if (!VALID_ROLES.includes(parsed.role)) return null;
|
|
85
|
-
if (parsed.active !== undefined && typeof parsed.active !== 'boolean') return null;
|
|
86
|
-
if (parsed.phase !== undefined && !VALID_PHASES.includes(parsed.phase)) return null;
|
|
87
|
-
if (parsed.explicitInvocation !== undefined && typeof parsed.explicitInvocation !== 'boolean') return null;
|
|
88
|
-
// Sanitize free-text fields injected into context
|
|
89
|
-
if (parsed.taskId && typeof parsed.taskId === 'string') {
|
|
90
|
-
parsed.taskId = parsed.taskId.replace(/[^\w-]/g, '').slice(0, MAX_TASKID_BYTES);
|
|
91
|
-
if (!parsed.taskId) parsed.taskId = 'current';
|
|
92
|
-
}
|
|
93
|
-
return parsed;
|
|
94
|
-
} finally {
|
|
95
|
-
if (fd !== undefined) closeSync(fd);
|
|
96
|
-
}
|
|
97
|
-
} catch {
|
|
98
|
-
return null;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function safeWriteJSON(filePath, obj) {
|
|
103
|
-
try {
|
|
104
|
-
mkdirSync(dirname(filePath), { recursive: true });
|
|
105
|
-
|
|
106
|
-
// Refuse if target is a symlink
|
|
107
|
-
try {
|
|
108
|
-
if (lstatSync(filePath).isSymbolicLink()) return;
|
|
109
|
-
} catch (e) {
|
|
110
|
-
if (e.code !== 'ENOENT') return;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Atomic write: temp file + rename (from caveman)
|
|
114
|
-
const tmp = join(dirname(filePath),
|
|
115
|
-
`.${basename(filePath)}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`);
|
|
116
|
-
const O_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
|
|
117
|
-
let fd;
|
|
118
|
-
try {
|
|
119
|
-
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | O_NOFOLLOW, 0o600);
|
|
120
|
-
const content = JSON.stringify(obj, null, 2) + '\n';
|
|
121
|
-
writeSync(fd, content);
|
|
122
|
-
} finally {
|
|
123
|
-
if (fd !== undefined) closeSync(fd);
|
|
124
|
-
}
|
|
125
|
-
renameSync(tmp, filePath);
|
|
126
|
-
} catch {
|
|
127
|
-
// Silent-fail — mode state is best-effort
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// ── Validation helpers ────────────────────────────────────────────────────
|
|
132
|
-
|
|
133
|
-
function normalizePath(filePath) {
|
|
134
|
-
// Resolve, normalize separators, reject traversal
|
|
135
|
-
try {
|
|
136
|
-
const resolved = join('/', filePath).replace(/\\/g, '/');
|
|
137
|
-
if (resolved.includes('..')) return null; // Reject traversal
|
|
138
|
-
return resolved;
|
|
139
|
-
} catch { return null; }
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function isTaskFile(filePath) {
|
|
143
|
-
const normalized = normalizePath(filePath);
|
|
144
|
-
if (!normalized) return false;
|
|
145
|
-
// Must start with /Harness/tasks/ (after join above), end with allowed file
|
|
146
|
-
return /^\/Harness\/tasks\/[^/]+\/(PLAN|PROGRESS|ARTIFACTS|NOTES)\.md$/.test(normalized);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function isHarnessMeta(filePath) {
|
|
150
|
-
const normalized = normalizePath(filePath);
|
|
151
|
-
if (!normalized) return false;
|
|
152
|
-
return /^\/Harness\/(memory\/|MEMORY\.md$|PROGRESS\.md$|\.runtime\/)/.test(normalized);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// ── stdin ─────────────────────────────────────────────────────────────────
|
|
156
|
-
|
|
157
|
-
async function readStdin() {
|
|
158
|
-
const chunks = [];
|
|
159
|
-
for await (const chunk of process.stdin) {
|
|
160
|
-
chunks.push(chunk);
|
|
161
|
-
}
|
|
162
|
-
const raw = Buffer.concat(chunks).toString().trim();
|
|
163
|
-
if (!raw) return {};
|
|
164
|
-
try { return JSON.parse(raw); } catch { return {}; }
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// ── event handlers ────────────────────────────────────────────────────────
|
|
168
|
-
|
|
169
|
-
function handleSessionStart() {
|
|
170
|
-
let mode;
|
|
171
|
-
try { mode = safeReadJSON(MODE_FILE); } catch { /* silent-fail */ }
|
|
172
|
-
|
|
173
|
-
if (!mode?.active) return;
|
|
174
|
-
|
|
175
|
-
if (mode.mode === 'wf-max') {
|
|
176
|
-
// Plain-text stdout → injected as hidden system context (same format as caveman)
|
|
177
|
-
process.stdout.write([
|
|
178
|
-
'WF-MAX MODE ACTIVE — You are CEO, not implementer.',
|
|
179
|
-
`Task: ${mode.taskId || 'current'} | Phase: ${mode.phase || 'W0_EXPLORE'}`,
|
|
180
|
-
'',
|
|
181
|
-
'CEO RULES:',
|
|
182
|
-
'1. Spawn read-only subagents in ONE message for exploration',
|
|
183
|
-
'2. NEVER Edit/Write/MultiEdit source files — delegate to Workers via Agent tool',
|
|
184
|
-
'3. Bash: only ls/dir/tree/git status/git diff, and harness scripts',
|
|
185
|
-
'4. PLAN.md and PROGRESS.md writes are your ONLY write exceptions',
|
|
186
|
-
'5. Any temptation to Read/Edit a source file → STOP. Spawn a Worker.',
|
|
187
|
-
].join('\n'));
|
|
188
|
-
} else if (mode.mode === 'wf-review') {
|
|
189
|
-
process.stdout.write([
|
|
190
|
-
'WF-REVIEW MODE ACTIVE — Cross-model peer review.',
|
|
191
|
-
'Use Bash to invoke the OTHER CLI (codex/claude). NEVER self-review.',
|
|
192
|
-
'Your role: prepare context, invoke peer, synthesize findings.',
|
|
193
|
-
].join('\n'));
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function handleUserPromptSubmit(event) {
|
|
198
|
-
// ── Mode activation detection ──
|
|
199
|
-
try {
|
|
200
|
-
const prompt = (event.prompt || event.input || '').toString();
|
|
201
|
-
const lower = prompt.toLowerCase();
|
|
202
|
-
|
|
203
|
-
if (lower.includes('/wf-max') || lower.match(/\bwf\s+max\b/)) {
|
|
204
|
-
safeWriteJSON(MODE_FILE, {
|
|
205
|
-
active: true,
|
|
206
|
-
mode: 'wf-max',
|
|
207
|
-
role: 'ceo',
|
|
208
|
-
taskId: 'wf-max-current-task',
|
|
209
|
-
phase: 'W0_EXPLORE',
|
|
210
|
-
explicitInvocation: true,
|
|
211
|
-
startedAt: new Date().toISOString(),
|
|
212
|
-
});
|
|
213
|
-
} else if (lower.includes('/wf-review') || lower.match(/\bwf\s+review\b/)) {
|
|
214
|
-
safeWriteJSON(MODE_FILE, {
|
|
215
|
-
active: true,
|
|
216
|
-
mode: 'wf-review',
|
|
217
|
-
role: 'ceo',
|
|
218
|
-
taskId: 'wf-review-current',
|
|
219
|
-
phase: 'REVIEW',
|
|
220
|
-
explicitInvocation: true,
|
|
221
|
-
startedAt: new Date().toISOString(),
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
} catch { /* silent-fail */ }
|
|
225
|
-
|
|
226
|
-
// ── Per-turn reinforcement (from caveman: prevents drift after compression) ──
|
|
227
|
-
try {
|
|
228
|
-
const mode = safeReadJSON(MODE_FILE);
|
|
229
|
-
if (!mode?.active || mode.role !== 'ceo') return;
|
|
230
|
-
|
|
231
|
-
if (mode.mode === 'wf-max') {
|
|
232
|
-
// hookSpecificOutput = the standard Claude Code per-turn injection format
|
|
233
|
-
process.stdout.write(JSON.stringify({
|
|
234
|
-
hookSpecificOutput: {
|
|
235
|
-
hookEventName: 'UserPromptSubmit',
|
|
236
|
-
additionalContext: [
|
|
237
|
-
'WF-MAX ACTIVE — You are CEO (' + (mode.phase || 'W0_EXPLORE') + ').',
|
|
238
|
-
'Spawn Workers. NEVER Edit/Write/MultiEdit source files.',
|
|
239
|
-
'Bash only: ls/dir/tree/git. PLAN.md/PROGRESS.md writes allowed.',
|
|
240
|
-
].join(' '),
|
|
241
|
-
},
|
|
242
|
-
}));
|
|
243
|
-
} else if (mode.mode === 'wf-review') {
|
|
244
|
-
process.stdout.write(JSON.stringify({
|
|
245
|
-
hookSpecificOutput: {
|
|
246
|
-
hookEventName: 'UserPromptSubmit',
|
|
247
|
-
additionalContext: 'WF-REVIEW ACTIVE. Use Bash to invoke the OTHER CLI. NEVER self-review.',
|
|
248
|
-
},
|
|
249
|
-
}));
|
|
250
|
-
}
|
|
251
|
-
} catch { /* silent-fail — never let per-turn reinforcement block the prompt */ }
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function handlePreToolUse(event) {
|
|
255
|
-
let mode;
|
|
256
|
-
try { mode = safeReadJSON(MODE_FILE); } catch { /* silent-fail — allow */ }
|
|
257
|
-
if (!mode?.active || mode.role !== 'ceo') return;
|
|
258
|
-
|
|
259
|
-
const toolName = event.tool_name || event.tool || '';
|
|
260
|
-
if (!BLOCKED_TOOLS.includes(toolName)) return;
|
|
261
|
-
|
|
262
|
-
const input = event.tool_input || event.input || {};
|
|
263
|
-
const filePath = input.file_path || '';
|
|
264
|
-
const command = input.command || '';
|
|
265
|
-
|
|
266
|
-
// ── Bash exceptions for CEO ──
|
|
267
|
-
if (toolName === 'Bash') {
|
|
268
|
-
const trimmed = command.trim();
|
|
269
|
-
// Reject newlines/control chars — can't reliably defend against multi-line injection
|
|
270
|
-
if (/[\r\n\0]/.test(trimmed)) {
|
|
271
|
-
process.stderr.write('[CEO BLOCK] Bash command contains newlines or control characters.\n');
|
|
272
|
-
process.exit(2);
|
|
273
|
-
}
|
|
274
|
-
// Reject shell metacharacters that could chain commands or redirect output
|
|
275
|
-
if (/[;&|>`$]/.test(trimmed)) {
|
|
276
|
-
process.stderr.write('[CEO BLOCK] Bash command contains shell metacharacters. Use a Worker.\n');
|
|
277
|
-
process.exit(2);
|
|
278
|
-
}
|
|
279
|
-
// Anchored exact-command allowlist — no flexible git subcommands beyond safe ones
|
|
280
|
-
const allowed = /^(ls|dir|tree|git\s+status|git\s+diff|git\s+log|git\s+branch|node\s+Harness\/scripts\/[a-z0-9_.-]+\.mjs|which|echo|type|codex|claude)(\s+[^;&|>`$]*)?$/;
|
|
281
|
-
if (allowed.test(trimmed)) return; // allow
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// ── Write exceptions ──
|
|
285
|
-
if (filePath) {
|
|
286
|
-
if (isTaskFile(filePath) || isHarnessMeta(filePath)) return; // allow
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// ── Block ──
|
|
290
|
-
const blockMsg = mode.mode === 'wf-max'
|
|
291
|
-
? `[WF-MAX CEO BLOCK] ${toolName} on source files is forbidden for CEO. Delegate to a Worker via Agent tool. File: ${filePath || command}`
|
|
292
|
-
: `[WF-REVIEW BLOCK] ${toolName} is forbidden during peer review. Use Bash to invoke the other CLI for review.`;
|
|
293
|
-
|
|
294
|
-
process.stderr.write(blockMsg + '\n');
|
|
295
|
-
process.exit(2);
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// ── main ───────────────────────────────────────────────────────────────────
|
|
299
|
-
|
|
300
|
-
const event = await readStdin();
|
|
301
|
-
const eventType = event.hook_event_name || event.event || event.type || '';
|
|
302
|
-
|
|
303
|
-
switch (eventType) {
|
|
304
|
-
case 'SessionStart':
|
|
305
|
-
handleSessionStart();
|
|
306
|
-
break;
|
|
307
|
-
case 'UserPromptSubmit':
|
|
308
|
-
handleUserPromptSubmit(event);
|
|
309
|
-
break;
|
|
310
|
-
case 'PreToolUse':
|
|
311
|
-
handlePreToolUse(event);
|
|
312
|
-
break;
|
|
313
|
-
default:
|
|
314
|
-
// Unknown event — safe no-op
|
|
315
|
-
break;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
process.exit(0);
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* wf-mode-hook.mjs — Claude Code hook for WF-MAX / WF-REVIEW CEO enforcement
|
|
4
|
+
*
|
|
5
|
+
* Hook events:
|
|
6
|
+
* SessionStart → reads Harness/.runtime/current-mode.json, injects CEO role
|
|
7
|
+
* UserPromptSubmit→ detects /wf-max, /wf-review, writes mode state,
|
|
8
|
+
* emits per-turn reinforcement (prevents drift after compression)
|
|
9
|
+
* PreToolUse → blocks CEO Edit/Write/MultiEdit/Bash on source files
|
|
10
|
+
*
|
|
11
|
+
* Inspired by caveman's hook architecture:
|
|
12
|
+
* - Symlink-safe I/O (O_NOFOLLOW, atomic rename, size cap, whitelist)
|
|
13
|
+
* - Per-turn reinforcement (model drifts without it post-compression)
|
|
14
|
+
* - Fail-silent (hook must never block session start)
|
|
15
|
+
*
|
|
16
|
+
* Designed to be invoked from .claude/settings.json hooks section.
|
|
17
|
+
* Exit 0 = allow/continue. Exit 2 = block with stderr message.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { constants, openSync, readSync, writeSync, closeSync, readFileSync, writeFileSync, existsSync, mkdirSync, lstatSync, renameSync, unlinkSync } from 'node:fs';
|
|
21
|
+
import { join, dirname, basename } from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { randomBytes } from 'node:crypto';
|
|
24
|
+
|
|
25
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const RUNTIME_DIR = join(__dirname, '..', '.runtime');
|
|
27
|
+
const MODE_FILE = join(RUNTIME_DIR, 'current-mode.json');
|
|
28
|
+
const MAX_MODE_BYTES = 4096; // JSON config, not a tiny flag — enough for the struct
|
|
29
|
+
|
|
30
|
+
const VALID_MODES = ['wf-max', 'wf-review', null];
|
|
31
|
+
const VALID_ROLES = ['ceo', null];
|
|
32
|
+
const VALID_PHASES = ['W0_EXPLORE', 'W1_ARCHITECTURE', 'W2_IMPLEMENT', 'W2R_REVIEW', 'W3_DEPENDENT', 'INTEGRATION', 'CLOSEOUT', 'REVIEW', null];
|
|
33
|
+
const BLOCKED_TOOLS = ['Edit', 'Write', 'MultiEdit', 'Bash'];
|
|
34
|
+
const MAX_TASKID_BYTES = 128; // Sanitize user-controlled strings injected into context
|
|
35
|
+
|
|
36
|
+
// ── Symlink-safe I/O (from caveman: O_NOFOLLOW + atomic write + whitelist) ──
|
|
37
|
+
|
|
38
|
+
// Resolve and verify path stays within the project's Harness directory.
|
|
39
|
+
// Rejects symlinks at EVERY path component (not just the final file).
|
|
40
|
+
function safeResolveHarnessPath(filePath) {
|
|
41
|
+
try {
|
|
42
|
+
// Walk each component, resolving symlinks at every level
|
|
43
|
+
const parts = filePath.replace(/\\/g, '/').split('/').filter(Boolean);
|
|
44
|
+
let resolved = '';
|
|
45
|
+
for (const part of parts) {
|
|
46
|
+
if (part === '..') return null; // Reject traversal
|
|
47
|
+
resolved = resolved ? join(resolved, part) : (resolved || part);
|
|
48
|
+
try {
|
|
49
|
+
const st = lstatSync(resolved);
|
|
50
|
+
if (st.isSymbolicLink()) return null; // Symlink at any component = reject
|
|
51
|
+
} catch (e) {
|
|
52
|
+
if (e.code === 'ENOENT' && part !== parts[parts.length - 1]) return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Final component must be under Harness/ directory
|
|
56
|
+
const normalized = resolved.replace(/\\/g, '/');
|
|
57
|
+
if (!normalized.includes('Harness/')) return null;
|
|
58
|
+
return resolved;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function safeReadJSON(filePath, maxBytes = MAX_MODE_BYTES) {
|
|
65
|
+
try {
|
|
66
|
+
const safe = safeResolveHarnessPath(filePath);
|
|
67
|
+
if (!safe) return null;
|
|
68
|
+
|
|
69
|
+
const st = lstatSync(safe);
|
|
70
|
+
if (!st.isFile()) return null;
|
|
71
|
+
if (st.size > maxBytes) return null;
|
|
72
|
+
|
|
73
|
+
const O_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
|
|
74
|
+
let fd;
|
|
75
|
+
try {
|
|
76
|
+
fd = openSync(safe, constants.O_RDONLY | O_NOFOLLOW);
|
|
77
|
+
const buf = Buffer.alloc(maxBytes);
|
|
78
|
+
const n = readSync(fd, buf, 0, maxBytes, 0);
|
|
79
|
+
const raw = buf.slice(0, n).toString('utf8').trim();
|
|
80
|
+
if (!raw) return null;
|
|
81
|
+
const parsed = JSON.parse(raw);
|
|
82
|
+
// Full whitelist validation — everything that touches LLM context must be validated
|
|
83
|
+
if (!VALID_MODES.includes(parsed.mode)) return null;
|
|
84
|
+
if (!VALID_ROLES.includes(parsed.role)) return null;
|
|
85
|
+
if (parsed.active !== undefined && typeof parsed.active !== 'boolean') return null;
|
|
86
|
+
if (parsed.phase !== undefined && !VALID_PHASES.includes(parsed.phase)) return null;
|
|
87
|
+
if (parsed.explicitInvocation !== undefined && typeof parsed.explicitInvocation !== 'boolean') return null;
|
|
88
|
+
// Sanitize free-text fields injected into context
|
|
89
|
+
if (parsed.taskId && typeof parsed.taskId === 'string') {
|
|
90
|
+
parsed.taskId = parsed.taskId.replace(/[^\w-]/g, '').slice(0, MAX_TASKID_BYTES);
|
|
91
|
+
if (!parsed.taskId) parsed.taskId = 'current';
|
|
92
|
+
}
|
|
93
|
+
return parsed;
|
|
94
|
+
} finally {
|
|
95
|
+
if (fd !== undefined) closeSync(fd);
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function safeWriteJSON(filePath, obj) {
|
|
103
|
+
try {
|
|
104
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
105
|
+
|
|
106
|
+
// Refuse if target is a symlink
|
|
107
|
+
try {
|
|
108
|
+
if (lstatSync(filePath).isSymbolicLink()) return;
|
|
109
|
+
} catch (e) {
|
|
110
|
+
if (e.code !== 'ENOENT') return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Atomic write: temp file + rename (from caveman)
|
|
114
|
+
const tmp = join(dirname(filePath),
|
|
115
|
+
`.${basename(filePath)}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`);
|
|
116
|
+
const O_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
|
|
117
|
+
let fd;
|
|
118
|
+
try {
|
|
119
|
+
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | O_NOFOLLOW, 0o600);
|
|
120
|
+
const content = JSON.stringify(obj, null, 2) + '\n';
|
|
121
|
+
writeSync(fd, content);
|
|
122
|
+
} finally {
|
|
123
|
+
if (fd !== undefined) closeSync(fd);
|
|
124
|
+
}
|
|
125
|
+
renameSync(tmp, filePath);
|
|
126
|
+
} catch {
|
|
127
|
+
// Silent-fail — mode state is best-effort
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Validation helpers ────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
function normalizePath(filePath) {
|
|
134
|
+
// Resolve, normalize separators, reject traversal
|
|
135
|
+
try {
|
|
136
|
+
const resolved = join('/', filePath).replace(/\\/g, '/');
|
|
137
|
+
if (resolved.includes('..')) return null; // Reject traversal
|
|
138
|
+
return resolved;
|
|
139
|
+
} catch { return null; }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isTaskFile(filePath) {
|
|
143
|
+
const normalized = normalizePath(filePath);
|
|
144
|
+
if (!normalized) return false;
|
|
145
|
+
// Must start with /Harness/tasks/ (after join above), end with allowed file
|
|
146
|
+
return /^\/Harness\/tasks\/[^/]+\/(PLAN|PROGRESS|ARTIFACTS|NOTES)\.md$/.test(normalized);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isHarnessMeta(filePath) {
|
|
150
|
+
const normalized = normalizePath(filePath);
|
|
151
|
+
if (!normalized) return false;
|
|
152
|
+
return /^\/Harness\/(memory\/|MEMORY\.md$|PROGRESS\.md$|\.runtime\/)/.test(normalized);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── stdin ─────────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
async function readStdin() {
|
|
158
|
+
const chunks = [];
|
|
159
|
+
for await (const chunk of process.stdin) {
|
|
160
|
+
chunks.push(chunk);
|
|
161
|
+
}
|
|
162
|
+
const raw = Buffer.concat(chunks).toString().trim();
|
|
163
|
+
if (!raw) return {};
|
|
164
|
+
try { return JSON.parse(raw); } catch { return {}; }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── event handlers ────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
function handleSessionStart() {
|
|
170
|
+
let mode;
|
|
171
|
+
try { mode = safeReadJSON(MODE_FILE); } catch { /* silent-fail */ }
|
|
172
|
+
|
|
173
|
+
if (!mode?.active) return;
|
|
174
|
+
|
|
175
|
+
if (mode.mode === 'wf-max') {
|
|
176
|
+
// Plain-text stdout → injected as hidden system context (same format as caveman)
|
|
177
|
+
process.stdout.write([
|
|
178
|
+
'WF-MAX MODE ACTIVE — You are CEO, not implementer.',
|
|
179
|
+
`Task: ${mode.taskId || 'current'} | Phase: ${mode.phase || 'W0_EXPLORE'}`,
|
|
180
|
+
'',
|
|
181
|
+
'CEO RULES:',
|
|
182
|
+
'1. Spawn read-only subagents in ONE message for exploration',
|
|
183
|
+
'2. NEVER Edit/Write/MultiEdit source files — delegate to Workers via Agent tool',
|
|
184
|
+
'3. Bash: only ls/dir/tree/git status/git diff, and harness scripts',
|
|
185
|
+
'4. PLAN.md and PROGRESS.md writes are your ONLY write exceptions',
|
|
186
|
+
'5. Any temptation to Read/Edit a source file → STOP. Spawn a Worker.',
|
|
187
|
+
].join('\n'));
|
|
188
|
+
} else if (mode.mode === 'wf-review') {
|
|
189
|
+
process.stdout.write([
|
|
190
|
+
'WF-REVIEW MODE ACTIVE — Cross-model peer review.',
|
|
191
|
+
'Use Bash to invoke the OTHER CLI (codex/claude). NEVER self-review.',
|
|
192
|
+
'Your role: prepare context, invoke peer, synthesize findings.',
|
|
193
|
+
].join('\n'));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function handleUserPromptSubmit(event) {
|
|
198
|
+
// ── Mode activation detection ──
|
|
199
|
+
try {
|
|
200
|
+
const prompt = (event.prompt || event.input || '').toString();
|
|
201
|
+
const lower = prompt.toLowerCase();
|
|
202
|
+
|
|
203
|
+
if (lower.includes('/wf-max') || lower.match(/\bwf\s+max\b/)) {
|
|
204
|
+
safeWriteJSON(MODE_FILE, {
|
|
205
|
+
active: true,
|
|
206
|
+
mode: 'wf-max',
|
|
207
|
+
role: 'ceo',
|
|
208
|
+
taskId: 'wf-max-current-task',
|
|
209
|
+
phase: 'W0_EXPLORE',
|
|
210
|
+
explicitInvocation: true,
|
|
211
|
+
startedAt: new Date().toISOString(),
|
|
212
|
+
});
|
|
213
|
+
} else if (lower.includes('/wf-review') || lower.match(/\bwf\s+review\b/)) {
|
|
214
|
+
safeWriteJSON(MODE_FILE, {
|
|
215
|
+
active: true,
|
|
216
|
+
mode: 'wf-review',
|
|
217
|
+
role: 'ceo',
|
|
218
|
+
taskId: 'wf-review-current',
|
|
219
|
+
phase: 'REVIEW',
|
|
220
|
+
explicitInvocation: true,
|
|
221
|
+
startedAt: new Date().toISOString(),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
} catch { /* silent-fail */ }
|
|
225
|
+
|
|
226
|
+
// ── Per-turn reinforcement (from caveman: prevents drift after compression) ──
|
|
227
|
+
try {
|
|
228
|
+
const mode = safeReadJSON(MODE_FILE);
|
|
229
|
+
if (!mode?.active || mode.role !== 'ceo') return;
|
|
230
|
+
|
|
231
|
+
if (mode.mode === 'wf-max') {
|
|
232
|
+
// hookSpecificOutput = the standard Claude Code per-turn injection format
|
|
233
|
+
process.stdout.write(JSON.stringify({
|
|
234
|
+
hookSpecificOutput: {
|
|
235
|
+
hookEventName: 'UserPromptSubmit',
|
|
236
|
+
additionalContext: [
|
|
237
|
+
'WF-MAX ACTIVE — You are CEO (' + (mode.phase || 'W0_EXPLORE') + ').',
|
|
238
|
+
'Spawn Workers. NEVER Edit/Write/MultiEdit source files.',
|
|
239
|
+
'Bash only: ls/dir/tree/git. PLAN.md/PROGRESS.md writes allowed.',
|
|
240
|
+
].join(' '),
|
|
241
|
+
},
|
|
242
|
+
}));
|
|
243
|
+
} else if (mode.mode === 'wf-review') {
|
|
244
|
+
process.stdout.write(JSON.stringify({
|
|
245
|
+
hookSpecificOutput: {
|
|
246
|
+
hookEventName: 'UserPromptSubmit',
|
|
247
|
+
additionalContext: 'WF-REVIEW ACTIVE. Use Bash to invoke the OTHER CLI. NEVER self-review.',
|
|
248
|
+
},
|
|
249
|
+
}));
|
|
250
|
+
}
|
|
251
|
+
} catch { /* silent-fail — never let per-turn reinforcement block the prompt */ }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function handlePreToolUse(event) {
|
|
255
|
+
let mode;
|
|
256
|
+
try { mode = safeReadJSON(MODE_FILE); } catch { /* silent-fail — allow */ }
|
|
257
|
+
if (!mode?.active || mode.role !== 'ceo') return;
|
|
258
|
+
|
|
259
|
+
const toolName = event.tool_name || event.tool || '';
|
|
260
|
+
if (!BLOCKED_TOOLS.includes(toolName)) return;
|
|
261
|
+
|
|
262
|
+
const input = event.tool_input || event.input || {};
|
|
263
|
+
const filePath = input.file_path || '';
|
|
264
|
+
const command = input.command || '';
|
|
265
|
+
|
|
266
|
+
// ── Bash exceptions for CEO ──
|
|
267
|
+
if (toolName === 'Bash') {
|
|
268
|
+
const trimmed = command.trim();
|
|
269
|
+
// Reject newlines/control chars — can't reliably defend against multi-line injection
|
|
270
|
+
if (/[\r\n\0]/.test(trimmed)) {
|
|
271
|
+
process.stderr.write('[CEO BLOCK] Bash command contains newlines or control characters.\n');
|
|
272
|
+
process.exit(2);
|
|
273
|
+
}
|
|
274
|
+
// Reject shell metacharacters that could chain commands or redirect output
|
|
275
|
+
if (/[;&|>`$]/.test(trimmed)) {
|
|
276
|
+
process.stderr.write('[CEO BLOCK] Bash command contains shell metacharacters. Use a Worker.\n');
|
|
277
|
+
process.exit(2);
|
|
278
|
+
}
|
|
279
|
+
// Anchored exact-command allowlist — no flexible git subcommands beyond safe ones
|
|
280
|
+
const allowed = /^(ls|dir|tree|git\s+status|git\s+diff|git\s+log|git\s+branch|node\s+Harness\/scripts\/[a-z0-9_.-]+\.mjs|which|echo|type|codex|claude)(\s+[^;&|>`$]*)?$/;
|
|
281
|
+
if (allowed.test(trimmed)) return; // allow
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── Write exceptions ──
|
|
285
|
+
if (filePath) {
|
|
286
|
+
if (isTaskFile(filePath) || isHarnessMeta(filePath)) return; // allow
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── Block ──
|
|
290
|
+
const blockMsg = mode.mode === 'wf-max'
|
|
291
|
+
? `[WF-MAX CEO BLOCK] ${toolName} on source files is forbidden for CEO. Delegate to a Worker via Agent tool. File: ${filePath || command}`
|
|
292
|
+
: `[WF-REVIEW BLOCK] ${toolName} is forbidden during peer review. Use Bash to invoke the other CLI for review.`;
|
|
293
|
+
|
|
294
|
+
process.stderr.write(blockMsg + '\n');
|
|
295
|
+
process.exit(2);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ── main ───────────────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
const event = await readStdin();
|
|
301
|
+
const eventType = event.hook_event_name || event.event || event.type || '';
|
|
302
|
+
|
|
303
|
+
switch (eventType) {
|
|
304
|
+
case 'SessionStart':
|
|
305
|
+
handleSessionStart();
|
|
306
|
+
break;
|
|
307
|
+
case 'UserPromptSubmit':
|
|
308
|
+
handleUserPromptSubmit(event);
|
|
309
|
+
break;
|
|
310
|
+
case 'PreToolUse':
|
|
311
|
+
handlePreToolUse(event);
|
|
312
|
+
break;
|
|
313
|
+
default:
|
|
314
|
+
// Unknown event — safe no-op
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
process.exit(0);
|