create-harness-vibe-coding 0.8.1 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README-CN.md +15 -11
  2. package/README.md +21 -12
  3. package/package.json +1 -1
  4. package/src/index.js +210 -10
  5. package/templates/common/.claude/agents/tdd-guide.md +55 -0
  6. package/templates/common/.claude/settings.json +22 -0
  7. package/templates/common/.claude/skills/tdd/SKILL.md +30 -0
  8. package/templates/common/.claude/skills/wf-auto/SKILL.md +107 -0
  9. package/templates/common/.claude/skills/wf-auto-spark/SKILL.md +39 -0
  10. package/templates/common/.claude/skills/wf-max/SKILL.md +10 -2
  11. package/templates/common/.claude/skills/wf-remove/SKILL.md +12 -5
  12. package/templates/common/.claude/skills/wf-update/SKILL.md +12 -5
  13. package/templates/common/.codex/hooks.json +59 -37
  14. package/templates/common/.harness-version +48 -25
  15. package/templates/common/AGENTS.md +5 -5
  16. package/templates/common/CLAUDE.md +12 -17
  17. package/templates/common/Harness/ECC-GUIDE.md +246 -0
  18. package/templates/common/Harness/README.md +129 -130
  19. package/templates/common/Harness/TDD-GUIDE.md +83 -0
  20. package/templates/common/Harness/WF-AUTO-SPARK.md +297 -0
  21. package/templates/common/Harness/WF-AUTO.md +508 -0
  22. package/templates/common/Harness/WF-MAX.md +24 -0
  23. package/templates/common/Harness/context-loading.md +38 -1
  24. package/templates/common/Harness/dispatch.md +40 -40
  25. package/templates/common/Harness/subagents.md +7 -21
  26. package/templates/common/Harness/tasks/_template/NAMING.md +47 -0
  27. package/templates/common/MEMORY.md +73 -66
  28. package/templates/common/SETUP.md +100 -78
  29. package/templates/common/scripts/validate-harness.mjs +92 -58
  30. package/templates/common/scripts/wf-mode-hook.mjs +642 -318
  31. package/templates/common/scripts/wf-remove.mjs +301 -81
  32. package/templates/common/scripts/wf-statusline.ps1 +62 -38
  33. package/templates/common/scripts/wf-statusline.sh +67 -48
  34. package/templates/common/scripts/wf-update-check.mjs +179 -81
@@ -1,318 +1,642 @@
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 - Harness workflow hook for Claude Code/Codex.
4
+ *
5
+ * Authority model:
6
+ * - Harness/.runtime/current-mode.json is observable workflow state only.
7
+ * - Per-agent dispatch context is the authority for source-write permissions.
8
+ * - Dispatch context may arrive on the hook event or via HARNESS_* env vars.
9
+ */
10
+
11
+ import {
12
+ closeSync,
13
+ constants,
14
+ existsSync,
15
+ lstatSync,
16
+ mkdirSync,
17
+ openSync,
18
+ readFileSync,
19
+ readSync,
20
+ renameSync,
21
+ writeFileSync,
22
+ writeSync,
23
+ } from 'node:fs';
24
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { randomBytes } from 'node:crypto';
27
+
28
+ const __dirname = dirname(fileURLToPath(import.meta.url));
29
+ const RUNTIME_DIR = join(__dirname, '..', '.runtime');
30
+ const MODE_FILE = join(RUNTIME_DIR, 'current-mode.json');
31
+ const GOALS_FILE = join(RUNTIME_DIR, 'goals.json');
32
+
33
+ const MAX_MODE_BYTES = 16 * 1024;
34
+ const MAX_GOAL_BYTES = 64 * 1024;
35
+ const MAX_GOAL_DESC = 500;
36
+ const MAX_TASKID_BYTES = 128;
37
+ const MAX_ESCALATION_FILES = 1;
38
+ const STALE_MODE_MS = 30 * 60 * 1000;
39
+
40
+ const VALID_MODES = new Set(['wf', 'wf-max', 'wf-auto', 'wf-auto-spark', 'wf-review', 'wf-learn', null]);
41
+ const VALID_ROLES = new Set(['ceo', 'ceo-escalated', 'manager', 'worker', 'reviewer', null]);
42
+ const VALID_PHASES = new Set([
43
+ 'W0_EXPLORE',
44
+ 'W1_ARCHITECTURE',
45
+ 'W2_IMPLEMENT',
46
+ 'W2R_REVIEW',
47
+ 'W3_DEPENDENT',
48
+ 'INTEGRATION',
49
+ 'CLOSEOUT',
50
+ 'REVIEW',
51
+ 'SPARK',
52
+ 'AUTO',
53
+ 'LEARN',
54
+ null,
55
+ ]);
56
+ const GUARDED_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'Bash']);
57
+ const SAFE_BASH = /^(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|npm\s+test|npm\s+run|npx\s+playwright\s+test)(\s+[^;&|>`$]*)?$/i;
58
+
59
+ const PROJECT_ROOT = findProjectRoot();
60
+
61
+ function findProjectRoot() {
62
+ let current = resolve(__dirname);
63
+ for (let i = 0; i < 12; i++) {
64
+ if (existsSync(join(current, 'CLAUDE.md')) || existsSync(join(current, 'package.json'))) {
65
+ return current;
66
+ }
67
+ const parent = dirname(current);
68
+ if (parent === current) break;
69
+ current = parent;
70
+ }
71
+ return resolve(__dirname, '..', '..');
72
+ }
73
+
74
+ function hasTraversal(rawPath) {
75
+ return String(rawPath || '')
76
+ .replace(/\\/g, '/')
77
+ .split('/')
78
+ .some((part) => part === '..');
79
+ }
80
+
81
+ function normalizeProjectPath(filePath) {
82
+ if (!filePath || typeof filePath !== 'string') return null;
83
+ if (hasTraversal(filePath)) return null;
84
+
85
+ const absolutePath = isAbsolute(filePath) ? resolve(filePath) : resolve(PROJECT_ROOT, filePath);
86
+ const relativePath = relative(PROJECT_ROOT, absolutePath).replace(/\\/g, '/');
87
+ if (!relativePath || relativePath === '.') return '';
88
+ if (relativePath === '..' || relativePath.startsWith('../') || isAbsolute(relativePath)) return null;
89
+ return relativePath;
90
+ }
91
+
92
+ function isSafeRuntimeTarget(filePath) {
93
+ const normalized = normalizeProjectPath(filePath);
94
+ return normalized === 'Harness/.runtime/current-mode.json' || normalized === 'Harness/.runtime/goals.json';
95
+ }
96
+
97
+ function readJSONFile(filePath, maxBytes = MAX_MODE_BYTES) {
98
+ try {
99
+ const normalized = normalizeProjectPath(filePath);
100
+ if (!normalized) return null;
101
+ const absolutePath = resolve(PROJECT_ROOT, normalized);
102
+ const stat = lstatSync(absolutePath);
103
+ if (!stat.isFile() || stat.size > maxBytes) return null;
104
+
105
+ const noFollow = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
106
+ let fd;
107
+ try {
108
+ fd = openSync(absolutePath, constants.O_RDONLY | noFollow);
109
+ const buffer = Buffer.alloc(maxBytes);
110
+ const bytes = readSync(fd, buffer, 0, maxBytes, 0);
111
+ const raw = buffer.subarray(0, bytes).toString('utf8').trim();
112
+ if (!raw) return null;
113
+ return JSON.parse(raw);
114
+ } finally {
115
+ if (fd !== undefined) closeSync(fd);
116
+ }
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ function writeJSONFile(filePath, value) {
123
+ try {
124
+ if (!isSafeRuntimeTarget(filePath)) return false;
125
+ const normalized = normalizeProjectPath(filePath);
126
+ const absolutePath = resolve(PROJECT_ROOT, normalized);
127
+ mkdirSync(dirname(absolutePath), { recursive: true });
128
+ try {
129
+ if (lstatSync(absolutePath).isSymbolicLink()) return false;
130
+ } catch (error) {
131
+ if (error.code !== 'ENOENT') return false;
132
+ }
133
+
134
+ const tmpPath = join(dirname(absolutePath), `.${basename(absolutePath)}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`);
135
+ const noFollow = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
136
+ let fd;
137
+ try {
138
+ fd = openSync(tmpPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow, 0o600);
139
+ writeSync(fd, JSON.stringify(value, null, 2) + '\n');
140
+ } finally {
141
+ if (fd !== undefined) closeSync(fd);
142
+ }
143
+ renameSync(tmpPath, absolutePath);
144
+ return true;
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+
150
+ function validateMode(rawMode) {
151
+ if (!rawMode || typeof rawMode !== 'object') return null;
152
+ const mode = { ...rawMode };
153
+ if (!VALID_MODES.has(mode.mode ?? null)) return null;
154
+ if (mode.active !== undefined && typeof mode.active !== 'boolean') return null;
155
+ if (!VALID_PHASES.has(mode.phase ?? null)) return null;
156
+ if (mode.agentRole !== undefined && mode.agentRole !== '' && !VALID_ROLES.has(mode.agentRole)) return null;
157
+ if (mode.role !== undefined && mode.role !== '' && !VALID_ROLES.has(mode.role)) return null;
158
+ if (mode.explicitInvocation !== undefined && typeof mode.explicitInvocation !== 'boolean') return null;
159
+
160
+ if (mode.agentRole === '') delete mode.agentRole;
161
+ if (mode.role === '') delete mode.role;
162
+ if (!mode.agentRole && mode.role) mode.agentRole = mode.role;
163
+
164
+ if (typeof mode.taskId === 'string') {
165
+ mode.taskId = sanitizeTaskId(mode.taskId);
166
+ }
167
+ if (mode.writeSet !== undefined) {
168
+ const writeSet = parseList(mode.writeSet);
169
+ if (!writeSet) return null;
170
+ mode.writeSet = writeSet;
171
+ }
172
+ if (mode.forbidden !== undefined) {
173
+ const forbidden = parseList(mode.forbidden);
174
+ if (!forbidden) return null;
175
+ mode.forbidden = forbidden;
176
+ }
177
+ return mode;
178
+ }
179
+
180
+ function readMode() {
181
+ return validateMode(readJSONFile(MODE_FILE, MAX_MODE_BYTES));
182
+ }
183
+
184
+ function clearMode() {
185
+ writeJSONFile(MODE_FILE, {
186
+ active: false,
187
+ mode: null,
188
+ agentRole: null,
189
+ phase: null,
190
+ clearedAt: new Date().toISOString(),
191
+ });
192
+ }
193
+
194
+ function sanitizeTaskId(value) {
195
+ const sanitized = String(value).replace(/[^\w-]/g, '').slice(0, MAX_TASKID_BYTES);
196
+ return sanitized || 'current';
197
+ }
198
+
199
+ function parseList(value) {
200
+ if (value === undefined || value === null || value === '') return [];
201
+ if (Array.isArray(value)) {
202
+ if (!value.every((item) => typeof item === 'string' && item.length > 0 && item.length < 1024)) return null;
203
+ return value.map((item) => item.trim()).filter(Boolean);
204
+ }
205
+ if (typeof value !== 'string' || value.length >= 8192) return null;
206
+ const trimmed = value.trim();
207
+ if (!trimmed) return [];
208
+ if (trimmed.startsWith('[')) {
209
+ try {
210
+ return parseList(JSON.parse(trimmed));
211
+ } catch {
212
+ return null;
213
+ }
214
+ }
215
+ return trimmed.split(/[\n,]/).map((item) => item.trim()).filter(Boolean);
216
+ }
217
+
218
+ function normalizeRole(role) {
219
+ if (role === undefined || role === null || role === '') return null;
220
+ const normalized = String(role).trim().toLowerCase();
221
+ return VALID_ROLES.has(normalized) ? normalized : null;
222
+ }
223
+
224
+ function pickObject(value) {
225
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
226
+ }
227
+
228
+ function readDispatchContext(event) {
229
+ const toolInput = pickObject(event.tool_input || event.input);
230
+ const candidates = [
231
+ pickObject(event.dispatch),
232
+ pickObject(event.harness),
233
+ pickObject(event.permission),
234
+ pickObject(event.context),
235
+ pickObject(toolInput.dispatch),
236
+ pickObject(toolInput.harness),
237
+ pickObject(toolInput.permission),
238
+ event,
239
+ ];
240
+
241
+ const merged = {};
242
+ for (const candidate of candidates) {
243
+ for (const key of ['agentRole', 'role', 'writeSet', 'forbidden', 'verification', 'taskId', 'phase']) {
244
+ if (candidate[key] !== undefined && merged[key] === undefined) {
245
+ merged[key] = candidate[key];
246
+ }
247
+ }
248
+ }
249
+
250
+ if (process.env.HARNESS_AGENT_ROLE || process.env.HARNESS_ROLE) {
251
+ merged.agentRole = process.env.HARNESS_AGENT_ROLE || process.env.HARNESS_ROLE;
252
+ }
253
+ if (process.env.HARNESS_WRITE_SET !== undefined) merged.writeSet = process.env.HARNESS_WRITE_SET;
254
+ if (process.env.HARNESS_FORBIDDEN !== undefined) merged.forbidden = process.env.HARNESS_FORBIDDEN;
255
+ if (process.env.HARNESS_VERIFICATION !== undefined) merged.verification = process.env.HARNESS_VERIFICATION;
256
+ if (process.env.HARNESS_TASK_ID !== undefined) merged.taskId = process.env.HARNESS_TASK_ID;
257
+ if (process.env.HARNESS_PHASE !== undefined) merged.phase = process.env.HARNESS_PHASE;
258
+
259
+ const agentRole = normalizeRole(merged.agentRole ?? merged.role);
260
+ if (!agentRole) return null;
261
+
262
+ const writeSet = parseList(merged.writeSet);
263
+ const forbidden = parseList(merged.forbidden);
264
+ const verification = parseList(merged.verification);
265
+ if (!writeSet || !forbidden || !verification) return null;
266
+
267
+ return {
268
+ agentRole,
269
+ writeSet,
270
+ forbidden,
271
+ verification,
272
+ taskId: typeof merged.taskId === 'string' ? sanitizeTaskId(merged.taskId) : undefined,
273
+ phase: VALID_PHASES.has(merged.phase ?? null) ? merged.phase : undefined,
274
+ source: 'dispatch',
275
+ };
276
+ }
277
+
278
+ function readEmergencyEscalation(mode) {
279
+ if (mode?.active && mode.agentRole === 'ceo-escalated') {
280
+ return {
281
+ agentRole: 'ceo-escalated',
282
+ writeSet: mode.writeSet || [],
283
+ forbidden: mode.forbidden || [],
284
+ verification: [],
285
+ taskId: mode.taskId,
286
+ phase: mode.phase,
287
+ source: 'escalation',
288
+ };
289
+ }
290
+ return null;
291
+ }
292
+
293
+ function isModeActive(mode) {
294
+ return Boolean(mode?.active && mode.mode);
295
+ }
296
+
297
+ function isTaskArtifact(relativePath) {
298
+ return /^Harness\/tasks\/[^/]+\/(PLAN|PROGRESS|ARTIFACTS|NOTES)\.md$/.test(relativePath)
299
+ || relativePath === 'Harness/PROGRESS.md'
300
+ || relativePath === 'Harness/MEMORY.md'
301
+ || /^Harness\/memory\/[^/]+\.md$/.test(relativePath);
302
+ }
303
+
304
+ function isForbiddenPath(relativePath, forbidden) {
305
+ return forbidden.some((entry) => pathMatches(relativePath, entry));
306
+ }
307
+
308
+ function isInWriteSet(relativePath, writeSet) {
309
+ return writeSet.some((entry) => pathMatches(relativePath, entry));
310
+ }
311
+
312
+ function pathMatches(relativePath, pattern) {
313
+ const normalizedPattern = normalizeProjectPath(pattern);
314
+ if (!normalizedPattern) return false;
315
+ return relativePath === normalizedPattern || relativePath.startsWith(normalizedPattern + '/');
316
+ }
317
+
318
+ function isGuardedSourcePath(relativePath) {
319
+ if (!relativePath) return false;
320
+ if (relativePath.startsWith('Harness/.runtime/')) return true;
321
+ if (isTaskArtifact(relativePath)) return false;
322
+ return true;
323
+ }
324
+
325
+ function isSafeBash(command) {
326
+ const trimmed = String(command || '').trim();
327
+ if (!trimmed) return true;
328
+ if (/[\r\n\0]/.test(trimmed)) return false;
329
+ if (/[;&|>`$]/.test(trimmed)) return false;
330
+ return SAFE_BASH.test(trimmed);
331
+ }
332
+
333
+ function outputContext(hookEventName, additionalContext) {
334
+ process.stdout.write(JSON.stringify({
335
+ hookSpecificOutput: {
336
+ hookEventName,
337
+ additionalContext,
338
+ },
339
+ }));
340
+ }
341
+
342
+ function readEvent() {
343
+ if (process.argv[3]) {
344
+ try {
345
+ return JSON.parse(process.argv[3]);
346
+ } catch {
347
+ return {};
348
+ }
349
+ }
350
+ if (process.argv[2] && process.argv[2].trim().startsWith('{')) {
351
+ try {
352
+ return JSON.parse(process.argv[2]);
353
+ } catch {
354
+ return {};
355
+ }
356
+ }
357
+ try {
358
+ const raw = readFileSync(0, 'utf8').trim();
359
+ return raw ? JSON.parse(raw) : {};
360
+ } catch {
361
+ return {};
362
+ }
363
+ }
364
+
365
+ function eventName(event) {
366
+ return event.hook_event_name || event.event || event.type || process.argv[2] || '';
367
+ }
368
+
369
+ function goalData() {
370
+ const parsed = readJSONFile(GOALS_FILE, MAX_GOAL_BYTES);
371
+ if (!parsed || !Array.isArray(parsed.goals)) return { goals: [] };
372
+ parsed.goals = parsed.goals.filter((goal) =>
373
+ goal
374
+ && typeof goal.id === 'string'
375
+ && typeof goal.description === 'string'
376
+ && ['active', 'completed', 'abandoned'].includes(goal.status)
377
+ );
378
+ return parsed;
379
+ }
380
+
381
+ function writeGoals(data) {
382
+ writeJSONFile(GOALS_FILE, data);
383
+ }
384
+
385
+ function activeGoalText() {
386
+ const goals = goalData().goals.filter((goal) => goal.status === 'active');
387
+ if (goals.length === 0) return '';
388
+ if (goals.length === 1) return `GOAL TRACKING: Active goal [${goals[0].id}] ${goals[0].description}.`;
389
+ return `GOAL TRACKING: Active goals ${goals.map((goal) => `[${goal.id}] ${goal.description}`).join(' | ')}.`;
390
+ }
391
+
392
+ function handleSessionStart(event) {
393
+ let mode = readMode();
394
+ if (mode?.active) {
395
+ const startedAt = Date.parse(mode.startedAt || '');
396
+ if (!startedAt || Date.now() - startedAt > STALE_MODE_MS) {
397
+ clearMode();
398
+ mode = null;
399
+ }
400
+ }
401
+ if (!isModeActive(mode)) return;
402
+
403
+ const dispatch = readDispatchContext(event);
404
+ const role = dispatch?.agentRole || mode.agentRole || mode.role || 'observer';
405
+ const phase = dispatch?.phase || mode.phase || 'W0_EXPLORE';
406
+ const taskId = dispatch?.taskId || mode.taskId || 'current';
407
+
408
+ if (mode.mode === 'wf-review') {
409
+ outputContext('SessionStart', `WF-REVIEW MODE ACTIVE\nTask: ${taskId} | Phase: ${phase}\nUse the OTHER CLI. Never self-review.`);
410
+ return;
411
+ }
412
+
413
+ if (mode.mode === 'wf-max') {
414
+ const writeSet = dispatch?.writeSet?.length ? `\nwriteSet: ${dispatch.writeSet.join(', ')}` : '';
415
+ outputContext('SessionStart', `WF-MAX ACTIVE\nTask: ${taskId} | Phase: ${phase}\nRole: ${role}${writeSet}\nMode file is status only; dispatch context grants source-write authority.`);
416
+ return;
417
+ }
418
+
419
+ outputContext('SessionStart', `${String(mode.mode).toUpperCase()} ACTIVE\nTask: ${taskId} | Phase: ${phase}`);
420
+ }
421
+
422
+ function handleGoalCommands(prompt) {
423
+ const setMatch = prompt.match(/(?:^|\n)\s*goal\s+set\s+(.+)/i);
424
+ const completeMatch = prompt.match(/(?:^|\n)\s*goal\s+complete\s+(\S+)/i);
425
+ const abandonMatch = prompt.match(/(?:^|\n)\s*goal\s+abandon\s+(\S+)/i);
426
+
427
+ if (setMatch) {
428
+ const data = goalData();
429
+ data.goals.push({
430
+ id: 'g' + Date.now().toString(36),
431
+ description: setMatch[1].trim().slice(0, MAX_GOAL_DESC),
432
+ status: 'active',
433
+ createdAt: new Date().toISOString(),
434
+ completedAt: null,
435
+ });
436
+ writeGoals(data);
437
+ }
438
+
439
+ for (const [match, status] of [[completeMatch, 'completed'], [abandonMatch, 'abandoned']]) {
440
+ if (!match) continue;
441
+ const data = goalData();
442
+ const goal = data.goals.find((item) => item.id === match[1] && item.status === 'active');
443
+ if (goal) {
444
+ goal.status = status;
445
+ goal.completedAt = new Date().toISOString();
446
+ writeGoals(data);
447
+ }
448
+ }
449
+ }
450
+
451
+ function handleEscalationCommands(prompt) {
452
+ if (/(?:^|\n)\s*ceo\s+done\b/i.test(prompt)) {
453
+ clearMode();
454
+ return true;
455
+ }
456
+
457
+ const escalateMatch = prompt.match(/(?:^|\n)\s*ceo\s+escalate\s+(\S+(?:\s*,\s*\S+)*)(?:\s+"(.+?)")?/i);
458
+ if (escalateMatch) {
459
+ const mode = readMode();
460
+ const files = parseList(escalateMatch[1]);
461
+ if (!mode?.active || !files || files.length !== MAX_ESCALATION_FILES || files.some((file) => !normalizeProjectPath(file))) {
462
+ outputContext('UserPromptSubmit', 'CEO escalation denied. Provide exactly one project-relative file path.');
463
+ return true;
464
+ }
465
+ writeJSONFile(MODE_FILE, {
466
+ ...mode,
467
+ agentRole: 'ceo-escalated',
468
+ writeSet: files,
469
+ escalationReason: (escalateMatch[2] || 'Worker retry limit exceeded').slice(0, 200),
470
+ escalatedAt: new Date().toISOString(),
471
+ });
472
+ outputContext('UserPromptSubmit', `CEO escalation active for ${files[0]}. Say "ceo deescalate" when finished.`);
473
+ return true;
474
+ }
475
+
476
+ if (/(?:^|\n)\s*ceo\s+deescalate\b/i.test(prompt)) {
477
+ const mode = readMode();
478
+ if (mode?.active) {
479
+ const { writeSet, forbidden, escalationReason, escalatedAt, ...rest } = mode;
480
+ writeJSONFile(MODE_FILE, {
481
+ ...rest,
482
+ agentRole: 'ceo',
483
+ deescalatedAt: new Date().toISOString(),
484
+ });
485
+ }
486
+ outputContext('UserPromptSubmit', 'CEO escalation cleared. Source writes are blocked again.');
487
+ return true;
488
+ }
489
+
490
+ return false;
491
+ }
492
+
493
+ function detectMode(prompt) {
494
+ const lower = prompt.toLowerCase();
495
+ const configs = [
496
+ { triggers: ['/wf-auto-spark', 'wf auto spark', 'spark mode'], mode: 'wf-auto-spark', taskId: 'wf-auto-spark-current', phase: 'SPARK' },
497
+ { triggers: ['/wf-max', 'wf max'], mode: 'wf-max', taskId: 'wf-max-current-task', phase: 'W0_EXPLORE' },
498
+ { triggers: ['/wf-auto', 'wf auto', 'auto mode'], mode: 'wf-auto', taskId: 'wf-auto-current', phase: 'AUTO' },
499
+ { triggers: ['/wf-review', 'wf review'], mode: 'wf-review', taskId: 'wf-review-current', phase: 'REVIEW' },
500
+ { triggers: ['/wf-learn', 'wf learn'], mode: 'wf-learn', taskId: 'wf-learn-current', phase: 'LEARN' },
501
+ { triggers: ['/wf', 'wf mode', 'workflow mode', 'wk mode'], mode: 'wf', taskId: 'wf-current-task', phase: 'W0_EXPLORE' },
502
+ ];
503
+
504
+ for (const config of configs) {
505
+ if (config.triggers.some((trigger) => lower.includes(trigger))) {
506
+ writeJSONFile(MODE_FILE, {
507
+ active: true,
508
+ mode: config.mode,
509
+ agentRole: 'ceo',
510
+ taskId: config.taskId,
511
+ phase: config.phase,
512
+ explicitInvocation: true,
513
+ startedAt: new Date().toISOString(),
514
+ });
515
+ return config;
516
+ }
517
+ }
518
+ return null;
519
+ }
520
+
521
+ function handleUserPromptSubmit(event) {
522
+ const prompt = String(event.prompt || event.text || '').trim();
523
+ if (!prompt) return;
524
+
525
+ handleGoalCommands(prompt);
526
+ if (handleEscalationCommands(prompt)) return;
527
+ detectMode(prompt);
528
+
529
+ const goalText = activeGoalText();
530
+ const mode = readMode();
531
+ if (!goalText && !isModeActive(mode)) return;
532
+
533
+ const dispatch = readDispatchContext(event);
534
+ const contextLines = [];
535
+ if (goalText) contextLines.push(goalText);
536
+ if (isModeActive(mode)) {
537
+ if (mode.mode === 'wf-review') {
538
+ contextLines.push('WF-REVIEW ACTIVE. Use the OTHER CLI. Never self-review.');
539
+ } else if (mode.mode === 'wf-max') {
540
+ const role = dispatch?.agentRole || mode.agentRole || 'observer';
541
+ contextLines.push(`WF-MAX ACTIVE. Role: ${role}. Source-write authority comes from dispatch context, not the mode file.`);
542
+ } else {
543
+ contextLines.push(`${String(mode.mode).toUpperCase()} ACTIVE.`);
544
+ }
545
+ }
546
+ if (contextLines.length) outputContext('UserPromptSubmit', contextLines.join('\n'));
547
+ }
548
+
549
+ function allowTaskArtifact(agentRole, relativePath) {
550
+ if (!isTaskArtifact(relativePath)) return false;
551
+ return agentRole === 'ceo' || agentRole === 'manager' || agentRole === 'ceo-escalated';
552
+ }
553
+
554
+ function block(message) {
555
+ process.stderr.write(message + '\n');
556
+ process.exit(2);
557
+ }
558
+
559
+ function enforceFileTool(agentRole, relativePath, context) {
560
+ if (!relativePath) block('[BLOCK] Invalid or unsafe file path.');
561
+ if (isForbiddenPath(relativePath, context.forbidden || [])) {
562
+ block(`[FORBIDDEN BLOCK] ${relativePath} matches forbidden scope.`);
563
+ }
564
+ if (!isGuardedSourcePath(relativePath) && allowTaskArtifact(agentRole, relativePath)) return;
565
+
566
+ if (agentRole === 'reviewer') {
567
+ block(`[REVIEWER BLOCK] ${relativePath} cannot be edited by reviewer.`);
568
+ }
569
+ if (agentRole === 'ceo') {
570
+ block(`[CEO BLOCK] ${relativePath} is a source write. Delegate to a Worker or use ceo escalate <file>.`);
571
+ }
572
+ if (agentRole === 'worker' || agentRole === 'manager' || agentRole === 'ceo-escalated') {
573
+ if (isInWriteSet(relativePath, context.writeSet || [])) return;
574
+ const label = agentRole === 'ceo-escalated' ? 'ESCALATED BLOCK' : `${agentRole.toUpperCase()} BLOCK`;
575
+ block(`[${label}] ${relativePath} is outside writeSet [${(context.writeSet || []).join(', ')}].`);
576
+ }
577
+ block(`[ROLE BLOCK] ${relativePath} blocked for role ${agentRole}.`);
578
+ }
579
+
580
+ function enforceBash(agentRole, command) {
581
+ if (isSafeBash(command)) return;
582
+ block(`[${agentRole.toUpperCase()} BLOCK] Bash command is outside the safe command policy.`);
583
+ }
584
+
585
+ function handlePreToolUse(event) {
586
+ const mode = readMode();
587
+ const dispatch = readDispatchContext(event);
588
+ const emergencyEscalation = dispatch ? null : readEmergencyEscalation(mode);
589
+ const context = dispatch || emergencyEscalation;
590
+
591
+ if (!context && !isModeActive(mode)) return;
592
+
593
+ const toolName = event.tool_name || event.tool || '';
594
+ if (!GUARDED_TOOLS.has(toolName)) return;
595
+
596
+ const toolInput = pickObject(event.tool_input || event.input);
597
+ const command = toolInput.command || '';
598
+ const relativePath = normalizeProjectPath(toolInput.file_path || '');
599
+
600
+ if (!context) {
601
+ if (toolName === 'Bash' && isSafeBash(command)) return;
602
+ if (relativePath && isTaskArtifact(relativePath)) return;
603
+ block(`[WF-MAX BLOCK] ${toolName} blocked: no dispatch role/writeSet authority is present.`);
604
+ }
605
+
606
+ if (toolName === 'Bash') {
607
+ enforceBash(context.agentRole, command);
608
+ return;
609
+ }
610
+
611
+ enforceFileTool(context.agentRole, relativePath, context);
612
+ }
613
+
614
+ function handlePostToolUse() {
615
+ return;
616
+ }
617
+
618
+ function handleStop() {
619
+ return;
620
+ }
621
+
622
+ const event = readEvent();
623
+
624
+ switch (eventName(event)) {
625
+ case 'SessionStart':
626
+ handleSessionStart(event);
627
+ break;
628
+ case 'UserPromptSubmit':
629
+ handleUserPromptSubmit(event);
630
+ break;
631
+ case 'PreToolUse':
632
+ handlePreToolUse(event);
633
+ break;
634
+ case 'PostToolUse':
635
+ handlePostToolUse(event);
636
+ break;
637
+ case 'Stop':
638
+ handleStop(event);
639
+ break;
640
+ default:
641
+ break;
642
+ }