create-harness-vibe-coding 0.8.2 → 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.
@@ -1,128 +1,116 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * wf-mode-hook.mjs Claude Code hook for WF-MAX / WF-REVIEW enforcement
3
+ * wf-mode-hook.mjs - Harness workflow hook for Claude Code/Codex.
4
4
  *
5
- * Three-layer architecture:
6
- * Layer 1 — global mode: mode = wf-max | wf-review | null
7
- * Layer 2 agent role: agentRole = ceo | manager | worker | reviewer | null
8
- * Layer 3 dispatch perm: writeSet / forbidden / verification
9
- *
10
- * Rules:
11
- * - Top-level orchestrator is CEO. CEO reads, plans, dispatches. No source edits.
12
- * - Manager scopes/reviews/coordinates. Default: no source edits.
13
- * - Worker may edit only files in dispatch.writeSet.
14
- * - Reviewer reads and reports. No writes.
15
- * - Missing role or writeSet → source edits denied by default.
16
- *
17
- * Hook events:
18
- * SessionStart → reads mode file; auto-clears stale mode (>30 min old);
19
- * injects context only for fresh/active mode
20
- * UserPromptSubmit→ detects /wf-max, /wf-review; writes mode state;
21
- * emits per-turn role-aware reinforcement
22
- * PreToolUse → enforces by agentRole + writeSet
23
- *
24
- * Security: symlink-safe I/O, atomic write, size caps, whitelist validation.
25
- * Exit 0 = allow. Exit 2 = block with stderr message.
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.
26
9
  */
27
10
 
28
- import { constants, openSync, readSync, writeSync, closeSync, readFileSync, writeFileSync, existsSync, mkdirSync, lstatSync, renameSync, unlinkSync } from 'node:fs';
29
- import { join, dirname, basename, relative, resolve } from 'node:path';
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';
30
25
  import { fileURLToPath } from 'node:url';
31
26
  import { randomBytes } from 'node:crypto';
32
27
 
33
28
  const __dirname = dirname(fileURLToPath(import.meta.url));
34
29
  const RUNTIME_DIR = join(__dirname, '..', '.runtime');
35
30
  const MODE_FILE = join(RUNTIME_DIR, 'current-mode.json');
36
- const MAX_MODE_BYTES = 16384; // Expanded for writeSet/forbidden/verification fields
37
- const STALE_MODE_MS = 30 * 60 * 1000; // 30 min — clear modes from prior sessions
38
31
  const GOALS_FILE = join(RUNTIME_DIR, 'goals.json');
39
- const MAX_GOAL_BYTES = 65536;
40
- const MAX_GOAL_DESC = 500;
41
32
 
42
- const VALID_MODES = ['wf', 'wf-max', 'wf-auto', 'wf-auto-spark', 'wf-review', 'wf-learn', null];
43
- const VALID_ROLES = ['ceo', 'ceo-escalated', 'manager', 'worker', 'reviewer', null];
44
- const MAX_ESCALATION_COUNT = 3; // Worker failures before CEO can escalate
45
- const MAX_ESCALATION_FILES = 3; // Max files CEO can touch while escalated
46
- const VALID_PHASES = ['W0_EXPLORE', 'W1_ARCHITECTURE', 'W2_IMPLEMENT', 'W2R_REVIEW', 'W3_DEPENDENT', 'INTEGRATION', 'CLOSEOUT', 'REVIEW', 'SPARK', 'AUTO', 'LEARN', null];
47
- const BLOCKED_TOOLS = ['Edit', 'Write', 'MultiEdit', 'Bash'];
33
+ const MAX_MODE_BYTES = 16 * 1024;
34
+ const MAX_GOAL_BYTES = 64 * 1024;
35
+ const MAX_GOAL_DESC = 500;
48
36
  const MAX_TASKID_BYTES = 128;
49
-
50
- // ── Symlink-safe I/O ───────────────────────────────────────────────────────
51
-
52
- function safeResolveHarnessPath(filePath) {
53
- try {
54
- const parts = filePath.replace(/\\/g, '/').split('/').filter(Boolean);
55
- let resolved = '';
56
- for (const part of parts) {
57
- if (part === '..') return null;
58
- resolved = resolved ? join(resolved, part) : (resolved || part);
59
- try {
60
- const st = lstatSync(resolved);
61
- if (st.isSymbolicLink()) return null;
62
- } catch (e) {
63
- if (e.code === 'ENOENT' && part !== parts[parts.length - 1]) return null;
64
- }
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;
65
66
  }
66
- const normalized = resolved.replace(/\\/g, '/');
67
- if (!normalized.includes('Harness/')) return null;
68
- return resolved;
69
- } catch {
70
- return null;
67
+ const parent = dirname(current);
68
+ if (parent === current) break;
69
+ current = parent;
71
70
  }
71
+ return resolve(__dirname, '..', '..');
72
72
  }
73
73
 
74
- function safeReadJSON(filePath, maxBytes = MAX_MODE_BYTES) {
75
- try {
76
- const safe = safeResolveHarnessPath(filePath);
77
- if (!safe) return null;
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;
78
84
 
79
- const st = lstatSync(safe);
80
- if (!st.isFile()) return null;
81
- if (st.size > maxBytes) return null;
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
+ }
82
96
 
83
- const O_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
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;
84
106
  let fd;
85
107
  try {
86
- fd = openSync(safe, constants.O_RDONLY | O_NOFOLLOW);
87
- const buf = Buffer.alloc(maxBytes);
88
- const n = readSync(fd, buf, 0, maxBytes, 0);
89
- const raw = buf.slice(0, n).toString('utf8').trim();
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();
90
112
  if (!raw) return null;
91
- const parsed = JSON.parse(raw);
92
- // Whitelist validation
93
- if (!VALID_MODES.includes(parsed.mode)) return null;
94
- // Normalize: agentRole takes precedence; fall back to legacy 'role' field
95
- if (parsed.agentRole !== undefined && parsed.agentRole !== '' && !VALID_ROLES.includes(parsed.agentRole)) return null;
96
- if (parsed.role !== undefined && parsed.role !== '' && !VALID_ROLES.includes(parsed.role)) return null;
97
- // Treat empty string agentRole as "not set"
98
- if (parsed.agentRole === '') parsed.agentRole = undefined;
99
- if (parsed.role === '') parsed.role = undefined;
100
- if (!parsed.agentRole && parsed.role) {
101
- parsed.agentRole = parsed.role;
102
- }
103
- // If no role at all, still allow the mode object — enforcement will deny source edits
104
- if (parsed.agentRole === undefined && parsed.role === undefined) {
105
- parsed.agentRole = null;
106
- }
107
- if (parsed.active !== undefined && typeof parsed.active !== 'boolean') return null;
108
- if (parsed.phase !== undefined && !VALID_PHASES.includes(parsed.phase)) return null;
109
- if (parsed.explicitInvocation !== undefined && typeof parsed.explicitInvocation !== 'boolean') return null;
110
- // Sanitize free-text fields
111
- if (parsed.taskId && typeof parsed.taskId === 'string') {
112
- parsed.taskId = parsed.taskId.replace(/[^\w-]/g, '').slice(0, MAX_TASKID_BYTES);
113
- if (!parsed.taskId) parsed.taskId = 'current';
114
- }
115
- // Validate writeSet if present
116
- if (parsed.writeSet !== undefined) {
117
- if (!Array.isArray(parsed.writeSet)) return null;
118
- if (!parsed.writeSet.every(e => typeof e === 'string' && e.length > 0 && e.length < 1024)) return null;
119
- }
120
- // Validate forbidden if present
121
- if (parsed.forbidden !== undefined) {
122
- if (!Array.isArray(parsed.forbidden)) return null;
123
- if (!parsed.forbidden.every(e => typeof e === 'string' && e.length < 1024)) return null;
124
- }
125
- return parsed;
113
+ return JSON.parse(raw);
126
114
  } finally {
127
115
  if (fd !== undefined) closeSync(fd);
128
116
  }
@@ -131,750 +119,511 @@ function safeReadJSON(filePath, maxBytes = MAX_MODE_BYTES) {
131
119
  }
132
120
  }
133
121
 
134
- function safeWriteJSON(filePath, obj) {
122
+ function writeJSONFile(filePath, value) {
135
123
  try {
136
- mkdirSync(dirname(filePath), { recursive: true });
137
-
124
+ if (!isSafeRuntimeTarget(filePath)) return false;
125
+ const normalized = normalizeProjectPath(filePath);
126
+ const absolutePath = resolve(PROJECT_ROOT, normalized);
127
+ mkdirSync(dirname(absolutePath), { recursive: true });
138
128
  try {
139
- if (lstatSync(filePath).isSymbolicLink()) return;
140
- } catch (e) {
141
- if (e.code !== 'ENOENT') return;
129
+ if (lstatSync(absolutePath).isSymbolicLink()) return false;
130
+ } catch (error) {
131
+ if (error.code !== 'ENOENT') return false;
142
132
  }
143
133
 
144
- const tmp = join(dirname(filePath),
145
- `.${basename(filePath)}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`);
146
- const O_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
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;
147
136
  let fd;
148
137
  try {
149
- fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | O_NOFOLLOW, 0o600);
150
- const content = JSON.stringify(obj, null, 2) + '\n';
151
- writeSync(fd, content);
138
+ fd = openSync(tmpPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow, 0o600);
139
+ writeSync(fd, JSON.stringify(value, null, 2) + '\n');
152
140
  } finally {
153
141
  if (fd !== undefined) closeSync(fd);
154
142
  }
155
- renameSync(tmp, filePath);
143
+ renameSync(tmpPath, absolutePath);
144
+ return true;
156
145
  } catch {
157
- // Silent-fail
146
+ return false;
158
147
  }
159
148
  }
160
149
 
161
- // ── Goal persistence (survives Claude Code goal auto-clear) ───────────────
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
+ }
162
179
 
163
- function readGoals() {
164
- try {
165
- if (!existsSync(GOALS_FILE)) return { goals: [] };
166
- const st = lstatSync(GOALS_FILE);
167
- if (!st.isFile() || st.size > MAX_GOAL_BYTES) return { goals: [] };
168
- const raw = readFileSync(GOALS_FILE, 'utf8').trim();
169
- if (!raw) return { goals: [] };
170
- const parsed = JSON.parse(raw);
171
- if (!Array.isArray(parsed.goals)) return { goals: [] };
172
- parsed.goals = parsed.goals.filter(g =>
173
- typeof g.id === 'string' && g.id.length > 0 && g.id.length < 128 &&
174
- typeof g.description === 'string' && g.description.length > 0 &&
175
- ['active', 'completed', 'abandoned'].includes(g.status)
176
- );
177
- return parsed;
178
- } catch { return { goals: [] }; }
179
- }
180
-
181
- function writeGoals(goalsObj) {
182
- try {
183
- mkdirSync(dirname(GOALS_FILE), { recursive: true });
184
- const tmp = join(dirname(GOALS_FILE),
185
- `.${basename(GOALS_FILE)}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`);
186
- const O_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
187
- let fd;
188
- try {
189
- fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | O_NOFOLLOW, 0o600);
190
- writeSync(fd, JSON.stringify(goalsObj, null, 2) + '\n');
191
- } finally {
192
- if (fd !== undefined) closeSync(fd);
193
- }
194
- renameSync(tmp, GOALS_FILE);
195
- } catch {}
180
+ function readMode() {
181
+ return validateMode(readJSONFile(MODE_FILE, MAX_MODE_BYTES));
196
182
  }
197
183
 
198
- function getActiveGoals() {
199
- const data = readGoals();
200
- return data.goals.filter(g => g.status === 'active');
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
+ });
201
192
  }
202
193
 
203
- // ── Path normalization (fixed for Windows absolute paths) ──────────────────
194
+ function sanitizeTaskId(value) {
195
+ const sanitized = String(value).replace(/[^\w-]/g, '').slice(0, MAX_TASKID_BYTES);
196
+ return sanitized || 'current';
197
+ }
204
198
 
205
- function getProjectRoot() {
206
- // Walk up from __dirname to find the repo root (where CLAUDE.md lives)
207
- try {
208
- let dir = resolve(__dirname);
209
- for (let i = 0; i < 10; i++) {
210
- if (existsSync(join(dir, 'CLAUDE.md'))) return dir;
211
- const parent = dirname(dir);
212
- if (parent === dir) break;
213
- dir = parent;
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;
214
213
  }
215
- } catch {}
216
- // Fallback: assume Harness/scripts/ is two levels below root
217
- return resolve(__dirname, '..', '..');
214
+ }
215
+ return trimmed.split(/[\n,]/).map((item) => item.trim()).filter(Boolean);
218
216
  }
219
217
 
220
- function normalizePath(filePath) {
221
- try {
222
- // Resolve to absolute, then make relative to project root
223
- const abs = resolve(filePath);
224
- const root = getProjectRoot();
225
- const rel = relative(root, abs).replace(/\\/g, '/');
226
- if (rel.startsWith('..')) return null; // Outside project
227
- if (rel.includes('..')) return null; // Traversal
228
- // Prefix with / so patterns match uniformly
229
- return '/' + rel;
230
- } catch { return null; }
231
- }
232
-
233
- function isTaskFile(filePath) {
234
- const normalized = normalizePath(filePath);
235
- if (!normalized) return false;
236
- return /^\/Harness\/tasks\/[^/]+\/(PLAN|PROGRESS|ARTIFACTS|NOTES)\.md$/.test(normalized);
237
- }
238
-
239
- function isHarnessMeta(filePath, agentRole) {
240
- const normalized = normalizePath(filePath);
241
- if (!normalized) return false;
242
- const isMeta = /^\/Harness\/(memory\/|MEMORY\.md$|PROGRESS\.md$|\.runtime\/)/.test(normalized);
243
- if (!isMeta) return false;
244
- // .runtime/ writes (current-mode.json) — CEO only (prevents privilege escalation)
245
- if (/^\/Harness\/\.runtime\//.test(normalized) && agentRole !== 'ceo') return false;
246
- return true;
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;
247
222
  }
248
223
 
249
- /**
250
- * Check if filePath is within the agent's writeSet.
251
- * writeSet entries are relative paths from project root (e.g., "CLAUDE.md", "Harness/scripts/wf-mode-hook.mjs").
252
- */
253
- function isInWriteSet(filePath, writeSet) {
254
- if (!writeSet || writeSet.length === 0) return false;
255
- try {
256
- const abs = resolve(filePath);
257
- const root = getProjectRoot();
258
- const rel = relative(root, abs).replace(/\\/g, '/');
259
- if (rel.startsWith('..')) return false;
260
- return writeSet.some(entry => {
261
- const normalizedEntry = entry.replace(/\\/g, '/');
262
- // Exact match only — list each file explicitly (e.g., both "CLAUDE.md" and "templates/common/CLAUDE.md")
263
- return rel === normalizedEntry;
264
- });
265
- } catch { return false; }
224
+ function pickObject(value) {
225
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
266
226
  }
267
227
 
268
- // ── stdin ─────────────────────────────────────────────────────────────────
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
+ ];
269
240
 
270
- async function readStdin() {
271
- const chunks = [];
272
- for await (const chunk of process.stdin) {
273
- chunks.push(chunk);
274
- }
275
- const raw = Buffer.concat(chunks).toString().trim();
276
- if (!raw) return {};
277
- try { return JSON.parse(raw); } catch { return {}; }
278
- }
279
-
280
- // ── Event handlers ─────────────────────────────────────────────────────────
281
-
282
- function handleSessionStart() {
283
- // ── Goal persistence: re-inject active goals even if Claude Code cleared them ──
284
- const activeGoals = getActiveGoals();
285
- if (activeGoals.length > 0) {
286
- const goalLines = activeGoals.map((g, i) => `${i + 1}. [${g.id}] ${g.description}`);
287
- process.stdout.write([
288
- `ACTIVE GOALS (${activeGoals.length}) — Harness-persisted, will NOT auto-clear:`,
289
- ...goalLines,
290
- 'To complete a goal: "goal complete <id>" or "goal done <id>".',
291
- 'To abandon: "goal abandon <id>".',
292
- '',
293
- ].join('\n'));
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
+ }
294
248
  }
295
249
 
296
- let mode;
297
- try { mode = safeReadJSON(MODE_FILE); } catch {}
298
-
299
- if (!mode?.active) return;
300
-
301
- // Auto-clear stale modes. Missing startedAt → treat as stale (legacy/manual mode files)
302
- if (!mode.startedAt) {
303
- safeWriteJSON(MODE_FILE, { active: false });
304
- return;
250
+ if (process.env.HARNESS_AGENT_ROLE || process.env.HARNESS_ROLE) {
251
+ merged.agentRole = process.env.HARNESS_AGENT_ROLE || process.env.HARNESS_ROLE;
305
252
  }
306
- try {
307
- const age = Date.now() - new Date(mode.startedAt).getTime();
308
- if (age > STALE_MODE_MS) {
309
- safeWriteJSON(MODE_FILE, { active: false });
310
- return;
311
- }
312
- } catch {
313
- // Invalid date → treat as stale
314
- safeWriteJSON(MODE_FILE, { active: false });
315
- return;
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
+ };
316
289
  }
290
+ return null;
291
+ }
317
292
 
318
- const agentRole = mode.agentRole || mode.role || null;
319
- // If no valid role, don't inject — enforcement will deny source edits
320
- if (!agentRole) return;
321
- const phase = mode.phase || 'W0_EXPLORE';
293
+ function isModeActive(mode) {
294
+ return Boolean(mode?.active && mode.mode);
295
+ }
322
296
 
323
- if (mode.mode === 'wf-max') {
324
- if (agentRole === 'ceo') {
325
- process.stdout.write([
326
- '═══ WF-MAX CEO MODE — YOU ARE THE ORCHESTRATOR, NOT AN IMPLEMENTER ═══',
327
- `Task: ${mode.taskId || 'current'} | Phase: ${phase}`,
328
- '',
329
- 'YOUR IDENTITY: You are CEO. You do NOT write code. You THINK, PLAN, and DELEGATE.',
330
- 'Every line of source code must be written by a Worker subagent — never by you.',
331
- '',
332
- 'WHAT YOU DO:',
333
- ' • Read docs and plan architecture',
334
- ' • Grep/Glob to scope the problem',
335
- ' • Spawn Workers via Agent tool to implement changes',
336
- ' • Write PLAN.md and PROGRESS.md (your ONLY file writes)',
337
- ' • Run verification commands via Bash',
338
- '',
339
- 'WHAT YOU NEVER DO:',
340
- ' ✗ Edit / Write / MultiEdit any source file',
341
- ' ✗ Write code directly — even "just one line"',
342
- ' ✗ Bash commands with side effects (npm install, git commit, etc.)',
343
- ' ✗ Read source files deeply (delegate to Worker)',
344
- '',
345
- 'IF YOU CATCH YOURSELF about to edit a source file → STOP IMMEDIATELY.',
346
- 'Say "Delegating to Worker" and spawn an Agent with explicit writeSet.',
347
- '',
348
- 'PreToolUse hook ENFORCES this. You WILL be blocked if you try to write code.',
349
- 'Save tokens. Save time. Delegate from the start.',
350
- ].join('\n'));
351
- } else if (agentRole === 'worker') {
352
- const ws = mode.writeSet ? ` [writeSet: ${mode.writeSet.join(', ')}]` : ' [no writeSet]';
353
- process.stdout.write([
354
- `WF-MAX WORKER ACTIVE — Edit only assigned writeSet.${ws}`,
355
- `Task: ${mode.taskId || 'current'} | Phase: ${phase}`,
356
- '',
357
- 'WORKER RULES:',
358
- '1. Edit only files in your dispatch writeSet.',
359
- '2. Do NOT edit files outside writeSet.',
360
- '3. If writeSet is empty/missing, source edits are blocked.',
361
- ].join('\n'));
362
- } else if (agentRole === 'manager') {
363
- process.stdout.write([
364
- 'WF-MAX MANAGER ACTIVE — Scope, review, coordinate.',
365
- `Task: ${mode.taskId || 'current'} | Phase: ${phase}`,
366
- '',
367
- 'MANAGER RULES:',
368
- '1. Partition domain, dispatch Workers, synthesize results.',
369
- '2. Do NOT edit source files directly.',
370
- '3. Report to CEO with synthesized findings.',
371
- ].join('\n'));
372
- } else if (agentRole === 'reviewer') {
373
- process.stdout.write([
374
- 'WF-MAX REVIEWER ACTIVE — Read and report only.',
375
- `Task: ${mode.taskId || 'current'} | Phase: ${phase}`,
376
- '',
377
- 'REVIEWER RULES:',
378
- '1. Read files, analyze, report findings.',
379
- '2. Do NOT edit any files.',
380
- ].join('\n'));
381
- } else if (agentRole === 'ceo-escalated') {
382
- const ws = mode.writeSet ? ` [writeSet: ${mode.writeSet.join(', ')}]` : '';
383
- process.stdout.write([
384
- '═══ CEO ESCALATED — Workers failed, CEO stepping in ═══',
385
- `Task: ${mode.taskId || 'current'} | Phase: ${phase}`,
386
- `Reason: ${mode.escalationReason || 'Worker retry limit exceeded'}`,
387
- '',
388
- `WRITE ONLY: ${mode.writeSet ? mode.writeSet.join(', ') : 'NONE SPECIFIED'}`,
389
- 'Fix the specific issue, then IMMEDIATELY say "ceo deescalate".',
390
- 'De-escalation returns you to normal CEO (no source writes).',
391
- ].join('\n'));
392
- }
393
- } else if (mode.mode === 'wf-review') {
394
- process.stdout.write([
395
- 'WF-REVIEW MODE ACTIVE — Cross-model peer review.',
396
- 'Use Bash to invoke the OTHER CLI (codex/claude). NEVER self-review.',
397
- 'Your role: prepare context, invoke peer, synthesize findings.',
398
- ].join('\n'));
399
- } else if (mode.mode === 'wf') {
400
- process.stdout.write([
401
- 'WF MODE ACTIVE — Task-bounded workflow with heartbeat and recovery loop.',
402
- ].join('\n'));
403
- } else if (mode.mode === 'wf-auto') {
404
- process.stdout.write([
405
- 'WF-AUTO MODE ACTIVE — Perpetual auto-optimization. Never stops until 8-angle exhaustion.',
406
- ].join('\n'));
407
- } else if (mode.mode === 'wf-auto-spark') {
408
- process.stdout.write([
409
- 'WF-AUTO-SPARK MODE ACTIVE — Perpetual inspiration. External spark search. Roadmap-anchored.',
410
- ].join('\n'));
411
- } else if (mode.mode === 'wf-learn') {
412
- process.stdout.write([
413
- 'WF-LEARN MODE ACTIVE — Force learning cycle: context-master -> memory-master.',
414
- ].join('\n'));
415
- }
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);
416
302
  }
417
303
 
418
- function handleUserPromptSubmit(event) {
419
- const prompt = (event.prompt || event.input || '').toString();
420
- const lower = prompt.toLowerCase();
304
+ function isForbiddenPath(relativePath, forbidden) {
305
+ return forbidden.some((entry) => pathMatches(relativePath, entry));
306
+ }
421
307
 
422
- // ── Goal command detection (Harness-persisted, NOT pattern-match auto-clear) ──
423
- const goalSetMatch = prompt.match(/(?:^|\n)\s*(?:goal\s+set|goal\s+add|\/goal)\s+(.+?)(?:\n|$)/i);
424
- const goalCompleteMatch = prompt.match(/(?:^|\n)\s*goal\s+(?:complete|done)\s+(\S+)/i);
425
- const goalAbandonMatch = prompt.match(/(?:^|\n)\s*goal\s+abandon\s+(\S+)/i);
308
+ function isInWriteSet(relativePath, writeSet) {
309
+ return writeSet.some((entry) => pathMatches(relativePath, entry));
310
+ }
426
311
 
427
- if (goalSetMatch) {
428
- const desc = goalSetMatch[1].trim().slice(0, MAX_GOAL_DESC);
429
- const data = readGoals();
430
- const id = 'g' + Date.now().toString(36);
431
- data.goals.push({ id, description: desc, status: 'active', createdAt: new Date().toISOString(), completedAt: null });
432
- writeGoals(data);
433
- }
312
+ function pathMatches(relativePath, pattern) {
313
+ const normalizedPattern = normalizeProjectPath(pattern);
314
+ if (!normalizedPattern) return false;
315
+ return relativePath === normalizedPattern || relativePath.startsWith(normalizedPattern + '/');
316
+ }
434
317
 
435
- if (goalCompleteMatch) {
436
- const id = goalCompleteMatch[1];
437
- const data = readGoals();
438
- const g = data.goals.find(g => g.id === id && g.status === 'active');
439
- if (g) { g.status = 'completed'; g.completedAt = new Date().toISOString(); writeGoals(data); }
440
- }
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
+ }
441
324
 
442
- if (goalAbandonMatch) {
443
- const id = goalAbandonMatch[1];
444
- const data = readGoals();
445
- const g = data.goals.find(g => g.id === id && g.status === 'active');
446
- if (g) { g.status = 'abandoned'; g.completedAt = new Date().toISOString(); writeGoals(data); }
447
- }
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
+ }
448
332
 
449
- // ── CEO escalation / de-escalation ──
450
- const escalateMatch = prompt.match(/(?:^|\n)\s*ceo\s+escalate\s+(\S+(?:\s*,\s*\S+)*)(?:\s+"(.+?)")?/i);
451
- const deescalateMatch = prompt.match(/(?:^|\n)\s*ceo\s+deescalate/i);
333
+ function outputContext(hookEventName, additionalContext) {
334
+ process.stdout.write(JSON.stringify({
335
+ hookSpecificOutput: {
336
+ hookEventName,
337
+ additionalContext,
338
+ },
339
+ }));
340
+ }
452
341
 
453
- if (escalateMatch) {
342
+ function readEvent() {
343
+ if (process.argv[3]) {
454
344
  try {
455
- const mode = safeReadJSON(MODE_FILE);
456
- if (mode && mode.agentRole === 'ceo') {
457
- const files = escalateMatch[1].split(',').map(f => f.trim()).filter(Boolean);
458
- const reason = (escalateMatch[2] || 'Worker retry limit exceeded').slice(0, 200);
459
- if (files.length <= MAX_ESCALATION_FILES) {
460
- safeWriteJSON(MODE_FILE, {
461
- ...mode,
462
- agentRole: 'ceo-escalated',
463
- writeSet: files,
464
- escalationReason: reason,
465
- escalatedAt: new Date().toISOString(),
466
- });
467
- }
468
- }
469
- } catch {}
345
+ return JSON.parse(process.argv[3]);
346
+ } catch {
347
+ return {};
348
+ }
470
349
  }
471
-
472
- if (deescalateMatch) {
350
+ if (process.argv[2] && process.argv[2].trim().startsWith('{')) {
473
351
  try {
474
- const mode = safeReadJSON(MODE_FILE);
475
- if (mode && mode.agentRole === 'ceo-escalated') {
476
- const { escalationReason, escalatedAt, ...rest } = mode;
477
- safeWriteJSON(MODE_FILE, {
478
- ...rest,
479
- agentRole: 'ceo',
480
- writeSet: undefined,
481
- });
482
- }
483
- } catch {}
484
- }
485
-
486
- // ── Mode activation detection ──
487
- try {
488
- const modeConfigs = [
489
- { trigger: ['/wf-auto-spark', 'wf auto spark', 'spark mode'], mode: 'wf-auto-spark', taskId: 'wf-auto-spark-current', phase: 'SPARK' },
490
- { trigger: ['/wf-max', 'wf max'], mode: 'wf-max', taskId: 'wf-max-current-task', phase: 'W0_EXPLORE' },
491
- { trigger: ['/wf-auto', 'wf auto', 'auto mode'], mode: 'wf-auto', taskId: 'wf-auto-current', phase: 'AUTO' },
492
- { trigger: ['/wf-review', 'wf review'], mode: 'wf-review', taskId: 'wf-review-current', phase: 'REVIEW' },
493
- { trigger: ['/wf-learn', 'wf learn'], mode: 'wf-learn', taskId: 'wf-learn-current', phase: 'LEARN' },
494
- { trigger: ['/wf', 'wf mode', 'workflow mode', 'wk mode'], mode: 'wf', taskId: 'wf-current-task', phase: 'W0_EXPLORE' },
495
- ];
496
- for (const cfg of modeConfigs) {
497
- if (cfg.trigger.some(t => lower.includes(t))) {
498
- safeWriteJSON(MODE_FILE, {
499
- active: true,
500
- mode: cfg.mode,
501
- agentRole: 'ceo',
502
- role: 'ceo',
503
- taskId: cfg.taskId,
504
- phase: cfg.phase,
505
- explicitInvocation: true,
506
- startedAt: new Date().toISOString(),
507
- });
508
- break;
509
- }
352
+ return JSON.parse(process.argv[2]);
353
+ } catch {
354
+ return {};
510
355
  }
511
- } catch {}
512
-
513
- // ── Per-turn reinforcement ──
356
+ }
514
357
  try {
515
- // Active goal reminder (always shown, independent of mode)
516
- const activeGoals = getActiveGoals();
517
- if (activeGoals.length > 0) {
518
- const goalReminder = activeGoals.length === 1
519
- ? `Active goal: [${activeGoals[0].id}] ${activeGoals[0].description}`
520
- : `Active goals (${activeGoals.length}): ` + activeGoals.map(g => `[${g.id}] ${g.description}`).join(' | ');
521
- process.stdout.write(JSON.stringify({
522
- hookSpecificOutput: {
523
- hookEventName: 'UserPromptSubmit',
524
- additionalContext: `GOAL TRACKING: ${goalReminder}. Say "goal complete <id>" when done, "goal abandon <id>" to drop.`,
525
- },
526
- }));
527
- }
528
-
529
- const mode = safeReadJSON(MODE_FILE);
530
- if (!mode?.active) return;
531
-
532
- const agentRole = mode.agentRole || mode.role;
533
- if (!agentRole) return;
534
-
535
- if (mode.mode === 'wf-max') {
536
- const roleText = agentRole === 'ceo'
537
- ? `═══ WF-MAX CEO (${mode.phase || 'W0_EXPLORE'}) ═══ YOU ARE CEO, NOT IMPLEMENTER. Do NOT write code. Do NOT edit files. Spawn Workers via Agent tool for ALL source changes. Only write: PLAN.md, PROGRESS.md. Bash: ls/dir/tree/git only. You WILL be blocked if you attempt Edit/Write/MultiEdit.`
538
- : agentRole === 'worker'
539
- ? `WF-MAX WORKER — Edit only files in dispatch writeSet${mode.writeSet ? ': ' + mode.writeSet.join(', ') : ' (none)'}.`
540
- : agentRole === 'manager'
541
- ? `WF-MAX MANAGER — Coordinate and synthesize. No source edits.`
542
- : agentRole === 'reviewer'
543
- ? `WF-MAX REVIEWER — Read and report only. No edits.`
544
- : agentRole === 'ceo-escalated'
545
- ? `═══ CEO ESCALATED — Fix the issue in ${mode.writeSet ? mode.writeSet.join(',') : 'escalated files'}, then deescalate. ═══`
546
- : `WF-MAX ACTIVE — Role: ${agentRole}.`;
547
-
548
- process.stdout.write(JSON.stringify({
549
- hookSpecificOutput: {
550
- hookEventName: 'UserPromptSubmit',
551
- additionalContext: roleText,
552
- },
553
- }));
554
- } else if (mode.mode === 'wf-review') {
555
- process.stdout.write(JSON.stringify({
556
- hookSpecificOutput: {
557
- hookEventName: 'UserPromptSubmit',
558
- additionalContext: 'WF-REVIEW ACTIVE. Use Bash to invoke the OTHER CLI. NEVER self-review.',
559
- },
560
- }));
561
- } else if (mode.mode === 'wf') {
562
- process.stdout.write(JSON.stringify({
563
- hookSpecificOutput: {
564
- hookEventName: 'UserPromptSubmit',
565
- additionalContext: 'WF MODE ACTIVE — Task-bounded workflow with heartbeat and recovery loop.',
566
- },
567
- }));
568
- } else if (mode.mode === 'wf-auto') {
569
- process.stdout.write(JSON.stringify({
570
- hookSpecificOutput: {
571
- hookEventName: 'UserPromptSubmit',
572
- additionalContext: 'WF-AUTO MODE ACTIVE — Perpetual auto-optimization. Never stops until 8-angle exhaustion.',
573
- },
574
- }));
575
- } else if (mode.mode === 'wf-auto-spark') {
576
- process.stdout.write(JSON.stringify({
577
- hookSpecificOutput: {
578
- hookEventName: 'UserPromptSubmit',
579
- additionalContext: 'WF-AUTO-SPARK MODE ACTIVE — Perpetual inspiration. External spark search. Roadmap-anchored.',
580
- },
581
- }));
582
- } else if (mode.mode === 'wf-learn') {
583
- process.stdout.write(JSON.stringify({
584
- hookSpecificOutput: {
585
- hookEventName: 'UserPromptSubmit',
586
- additionalContext: 'WF-LEARN MODE ACTIVE — Force learning cycle: context-master -> memory-master.',
587
- },
588
- }));
589
- }
590
- } catch {}
358
+ const raw = readFileSync(0, 'utf8').trim();
359
+ return raw ? JSON.parse(raw) : {};
360
+ } catch {
361
+ return {};
362
+ }
591
363
  }
592
364
 
593
- function handlePreToolUse(event) {
594
- let mode;
595
- try { mode = safeReadJSON(MODE_FILE); } catch {}
596
- if (!mode?.active) return;
365
+ function eventName(event) {
366
+ return event.hook_event_name || event.event || event.type || process.argv[2] || '';
367
+ }
597
368
 
598
- const toolName = event.tool_name || event.tool || '';
599
- if (!BLOCKED_TOOLS.includes(toolName)) return;
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
+ }
600
380
 
601
- const input = event.tool_input || event.input || {};
602
- const filePath = input.file_path || '';
603
- const command = input.command || '';
381
+ function writeGoals(data) {
382
+ writeJSONFile(GOALS_FILE, data);
383
+ }
604
384
 
605
- const agentRole = mode.agentRole || mode.role || null;
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
+ }
606
391
 
607
- // ── No role → deny source edits by default ──
608
- if (!agentRole) {
609
- if (filePath && (isTaskFile(filePath) || isHarnessMeta(filePath, agentRole))) return; // allow meta
610
- if (toolName === 'Bash') {
611
- const trimmed = command.trim();
612
- if (/^((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+[^;&|>`$]*)?)$/.test(trimmed)) return;
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;
613
399
  }
614
- process.stderr.write(`[WF-MAX BLOCK] ${toolName} blocked: no agentRole set. Define agentRole and writeSet before editing.\n`);
615
- process.exit(2);
616
400
  }
401
+ if (!isModeActive(mode)) return;
617
402
 
618
- // ── CEO: block all source edits ──
619
- if (agentRole === 'ceo') {
620
- if (toolName === 'Bash') {
621
- const trimmed = command.trim();
622
- if (/[\r\n\0]/.test(trimmed)) {
623
- process.stderr.write('[CEO BLOCK] Bash command contains newlines or control characters.\n');
624
- process.exit(2);
625
- }
626
- if (/[;&|>`$]/.test(trimmed)) {
627
- process.stderr.write('[CEO BLOCK] Bash command contains shell metacharacters. Use a Worker.\n');
628
- process.exit(2);
629
- }
630
- 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+[^;&|>`$]*)?$/;
631
- if (allowed.test(trimmed)) return;
632
- }
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';
633
407
 
634
- if (filePath) {
635
- if (isTaskFile(filePath) || isHarnessMeta(filePath, agentRole)) return;
636
- }
637
-
638
- process.stderr.write(`[WF-MAX CEO BLOCK] ${toolName} on source files is forbidden for CEO. Delegate to a Worker via Agent tool. File: ${filePath || command}\n`);
639
- process.exit(2);
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;
640
411
  }
641
412
 
642
- // ── Worker: allow writeSet files only ──
643
- if (agentRole === 'worker') {
644
- const writeSet = mode.writeSet || [];
645
-
646
- if (toolName === 'Bash') {
647
- const trimmed = command.trim();
648
- if (/[\r\n\0]/.test(trimmed)) {
649
- process.stderr.write('[WORKER BLOCK] Bash command contains control characters.\n');
650
- process.exit(2);
651
- }
652
- // Block shell chaining and redirects (same as CEO)
653
- if (/[;&|>`$]/.test(trimmed)) {
654
- process.stderr.write('[WORKER BLOCK] Bash command contains shell metacharacters.\n');
655
- process.exit(2);
656
- }
657
- return; // Allow safe bash for workers (test running, build, etc.)
658
- }
659
-
660
- if (filePath) {
661
- if (isTaskFile(filePath) || isHarnessMeta(filePath, agentRole)) return;
662
- if (isInWriteSet(filePath, writeSet)) return;
663
- process.stderr.write(`[WF-MAX WORKER BLOCK] ${toolName} on "${filePath}" is outside writeSet [${writeSet.join(', ')}].\n`);
664
- process.exit(2);
665
- }
666
-
667
- // No filePath? Allow (tool call without file target)
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.`);
668
416
  return;
669
417
  }
670
418
 
671
- // ── Manager: no source edits (like CEO but can run more bash) ──
672
- if (agentRole === 'manager') {
673
- if (toolName === 'Bash') {
674
- const trimmed = command.trim();
675
- if (/[\r\n\0]/.test(trimmed)) {
676
- process.stderr.write('[MANAGER BLOCK] Bash command contains control characters.\n');
677
- process.exit(2);
678
- }
679
- if (/[;&|>`$]/.test(trimmed)) {
680
- process.stderr.write('[MANAGER BLOCK] Bash command contains shell metacharacters.\n');
681
- process.exit(2);
682
- }
683
- 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+[^;&|>`$]*)?$/;
684
- if (allowed.test(trimmed)) return;
685
- }
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
+ }
686
438
 
687
- if (filePath) {
688
- if (isTaskFile(filePath) || isHarnessMeta(filePath, agentRole)) return;
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);
689
447
  }
448
+ }
449
+ }
690
450
 
691
- process.stderr.write(`[WF-MAX MANAGER BLOCK] ${toolName} on source files is forbidden for Manager. Delegate to Workers.\n`);
692
- process.exit(2);
451
+ function handleEscalationCommands(prompt) {
452
+ if (/(?:^|\n)\s*ceo\s+done\b/i.test(prompt)) {
453
+ clearMode();
454
+ return true;
693
455
  }
694
456
 
695
- // ── Reviewer: no edits at all ──
696
- if (agentRole === 'reviewer') {
697
- if (toolName === 'Bash') {
698
- const trimmed = command.trim();
699
- if (/[\r\n\0]/.test(trimmed)) {
700
- process.stderr.write('[REVIEWER BLOCK] Bash command contains control characters.\n');
701
- process.exit(2);
702
- }
703
- const allowed = /^(ls|dir|tree|git\s+status|git\s+diff|git\s+log|git\s+branch|which|echo|type)(\s+[^;&|>`$]*)?$/;
704
- if (allowed.test(trimmed)) return;
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;
705
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
+ }
706
475
 
707
- if (filePath) {
708
- if (isTaskFile(filePath) || isHarnessMeta(filePath, agentRole)) return;
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
+ });
709
485
  }
710
-
711
- process.stderr.write(`[WF-MAX REVIEWER BLOCK] ${toolName} is forbidden for Reviewer. Read and report only.\n`);
712
- process.exit(2);
486
+ outputContext('UserPromptSubmit', 'CEO escalation cleared. Source writes are blocked again.');
487
+ return true;
713
488
  }
714
489
 
715
- // ── CEO-Escalated: can write escalated files only ──
716
- if (agentRole === 'ceo-escalated') {
717
- const writeSet = mode.writeSet || [];
490
+ return false;
491
+ }
718
492
 
719
- if (toolName === 'Bash') {
720
- const trimmed = command.trim();
721
- if (/[\r\n\0]/.test(trimmed)) {
722
- process.stderr.write('[ESCALATED BLOCK] Bash command contains control characters.\n');
723
- process.exit(2);
724
- }
725
- if (/[;&|>`$]/.test(trimmed)) {
726
- process.stderr.write('[ESCALATED BLOCK] Bash command contains shell metacharacters.\n');
727
- process.exit(2);
728
- }
729
- 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|npm\s+test|npm\s+run)(\s+[^;&|>`$]*)?$/;
730
- if (allowed.test(trimmed)) return;
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;
731
516
  }
517
+ }
518
+ return null;
519
+ }
732
520
 
733
- if (filePath) {
734
- if (isTaskFile(filePath) || isHarnessMeta(filePath, 'ceo')) return;
735
- if (isInWriteSet(filePath, writeSet)) return;
736
- process.stderr.write(`[ESCALATED BLOCK] ${toolName} on "${filePath}" is outside escalation writeSet [${writeSet.join(', ')}]. Say "ceo deescalate" to return to normal CEO.\n`);
737
- process.exit(2);
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.`);
738
544
  }
739
- return;
740
545
  }
546
+ if (contextLines.length) outputContext('UserPromptSubmit', contextLines.join('\n'));
741
547
  }
742
548
 
743
- // ── Memory auto-capture (PostToolUse) ──────────────────────────────────────
549
+ function allowTaskArtifact(agentRole, relativePath) {
550
+ if (!isTaskArtifact(relativePath)) return false;
551
+ return agentRole === 'ceo' || agentRole === 'manager' || agentRole === 'ceo-escalated';
552
+ }
744
553
 
745
- const MEMORY_DIR = join(__dirname, '..', 'memory');
746
- const MEMORY_FILES = {
747
- toolReflections: join(MEMORY_DIR, 'tool-usage-reflections.md'),
748
- userCorrections: join(MEMORY_DIR, 'user-corrections-preferences.md'),
749
- agentLessons: join(MEMORY_DIR, 'agent-lessons-patterns.md'),
750
- };
751
- const EPISODE_BUFFER = []; // Batched significant events
752
- const EPISODE_FLUSH_SIZE = 5;
753
- const SESSION_EVENTS = []; // Tracked for Stop hook summary
554
+ function block(message) {
555
+ process.stderr.write(message + '\n');
556
+ process.exit(2);
557
+ }
754
558
 
755
- // Captured in UserPromptSubmit: last user prompt text
756
- let _lastPrompt = '';
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;
757
565
 
758
- function isSignificantError(stderr, stdout) {
759
- const combined = (stderr + stdout).toLowerCase();
760
- const patterns = [
761
- /\berror\b/, /\bfail(?:ed|ure)?\b/, /\bexception\b/, /\btraceback\b/,
762
- /\bpanic\b/, /\bfatal\b/, /\btimeout\b/, /\bdenied\b/, /\bblock(?:ed)?\b/,
763
- /\bpermission denied\b/, /\bnot found\b/i, /\bout of memory\b/i,
764
- ];
765
- return patterns.some(p => p.test(combined));
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}.`);
766
578
  }
767
579
 
768
- function isTestResult(stdout) {
769
- return /\b(tests?|pass(?:ed)?|fail(?:ed)?)\s+\d+/i.test(stdout) ||
770
- /(\d+)\s+(passed|failed)/i.test(stdout);
580
+ function enforceBash(agentRole, command) {
581
+ if (isSafeBash(command)) return;
582
+ block(`[${agentRole.toUpperCase()} BLOCK] Bash command is outside the safe command policy.`);
771
583
  }
772
584
 
773
- function isUserCorrectionIntent(prompt) {
774
- const patterns = [
775
- /记住|记下来|别忘了|don'?t\s+forget|remember\s+this/i,
776
- /我(说了|告诉过).*记住/i, /correction/i,
777
- /不要.*再|别再|以后.*不要|下次.*不要/i,
778
- ];
779
- return patterns.some(p => p.test(prompt));
780
- }
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;
781
590
 
782
- function appendMemory(filePath, entry) {
783
- try {
784
- mkdirSync(dirname(filePath), { recursive: true });
785
- const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
786
- const header = `\n### ${ts} — auto-captured by wf-mode-hook\n\n${entry}\n`;
787
- const fd = openSync(filePath, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND, 0o644);
788
- writeSync(fd, header);
789
- closeSync(fd);
790
- } catch {}
791
- }
591
+ if (!context && !isModeActive(mode)) return;
792
592
 
793
- function flushEpisodeBuffer() {
794
- if (EPISODE_BUFFER.length === 0) return;
795
- const batch = EPISODE_BUFFER.splice(0);
796
- const entry = batch.map(e => `- [${e.type}] ${e.tool}: ${e.summary.slice(0, 200)}`).join('\n');
797
- appendMemory(MEMORY_FILES.toolReflections, `**Batch (${batch.length} events)**\n${entry}`);
798
- SESSION_EVENTS.push({ type: 'batch', count: batch.length, summary: batch[0]?.summary });
799
- }
593
+ const toolName = event.tool_name || event.tool || '';
594
+ if (!GUARDED_TOOLS.has(toolName)) return;
800
595
 
801
- function handlePostToolUse(event) {
802
- try {
803
- const toolName = event.tool_name || event.tool || '';
804
- const stderr = (event.stderr || event.tool_stderr || '').toString();
805
- const stdout = (event.stdout || event.tool_stdout || '').toString();
806
-
807
- // ── Error auto-capture ──
808
- if (isSignificantError(stderr, stdout)) {
809
- const summary = stderr.slice(0, 300).replace(/\n/g, ' ').trim() ||
810
- stdout.slice(0, 300).replace(/\n/g, ' ').trim();
811
- EPISODE_BUFFER.push({ type: 'error', tool: toolName, summary, ts: Date.now() });
812
-
813
- // Immediate write for critical errors (exit code 2 = blocked by hook)
814
- if (stderr.includes('BLOCK') || stderr.includes('exit 2') || event.exit_code === 2) {
815
- appendMemory(MEMORY_FILES.toolReflections,
816
- `**Hook blocked**: \`${toolName}\` — ${summary}\n` +
817
- `> Reason: ${stderr.slice(0, 200)}\n`);
818
- }
819
- }
596
+ const toolInput = pickObject(event.tool_input || event.input);
597
+ const command = toolInput.command || '';
598
+ const relativePath = normalizeProjectPath(toolInput.file_path || '');
820
599
 
821
- // ── Test result capture ──
822
- if (isTestResult(stdout)) {
823
- const lines = stdout.split('\n').filter(l => /\b(pass|fail|test)/i.test(l));
824
- if (lines.length > 0) {
825
- appendMemory(MEMORY_FILES.agentLessons,
826
- `**Test run** (\`${toolName}\`):\n${lines.slice(0, 5).map(l => `> ${l.trim()}`).join('\n')}\n`);
827
- }
828
- }
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
+ }
829
605
 
830
- // ── Flush buffer when full ──
831
- if (EPISODE_BUFFER.length >= EPISODE_FLUSH_SIZE) {
832
- flushEpisodeBuffer();
833
- }
606
+ if (toolName === 'Bash') {
607
+ enforceBash(context.agentRole, command);
608
+ return;
609
+ }
834
610
 
835
- // ── User correction capture (checked against last prompt) ──
836
- if (_lastPrompt && isUserCorrectionIntent(_lastPrompt)) {
837
- appendMemory(MEMORY_FILES.userCorrections,
838
- `**User directive**: ${_lastPrompt.slice(0, 300)}\n` +
839
- `> Context: after \`${toolName}\` tool call\n`);
840
- _lastPrompt = ''; // Reset — one correction per trigger
841
- }
842
- } catch {}
611
+ enforceFileTool(context.agentRole, relativePath, context);
843
612
  }
844
613
 
845
- function handleStop() {
846
- try {
847
- // Flush remaining buffer
848
- flushEpisodeBuffer();
849
-
850
- // Write session summary if significant events occurred
851
- if (SESSION_EVENTS.length > 0) {
852
- const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
853
- const summary = [
854
- `## Session Summary — ${ts}`,
855
- `- Events captured: ${SESSION_EVENTS.length}`,
856
- ...SESSION_EVENTS.slice(0, 10).map(e =>
857
- ` - ${e.type}: ${(e.summary || '').slice(0, 100)}`),
858
- '',
859
- ].join('\n');
860
- appendMemory(MEMORY_FILES.toolReflections, summary);
861
- }
862
- } catch {}
614
+ function handlePostToolUse() {
615
+ return;
863
616
  }
864
617
 
865
- // ── main ───────────────────────────────────────────────────────────────────
866
-
867
- const event = await readStdin();
868
- const eventType = event.hook_event_name || event.event || event.type || '';
869
-
870
- // Capture user prompt for PostToolUse correlation
871
- if (eventType === 'UserPromptSubmit') {
872
- _lastPrompt = (event.prompt || event.input || '').toString();
618
+ function handleStop() {
619
+ return;
873
620
  }
874
621
 
875
- switch (eventType) {
622
+ const event = readEvent();
623
+
624
+ switch (eventName(event)) {
876
625
  case 'SessionStart':
877
- handleSessionStart();
626
+ handleSessionStart(event);
878
627
  break;
879
628
  case 'UserPromptSubmit':
880
629
  handleUserPromptSubmit(event);
@@ -891,5 +640,3 @@ switch (eventType) {
891
640
  default:
892
641
  break;
893
642
  }
894
-
895
- process.exit(0);