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
|
|
3
|
+
* wf-mode-hook.mjs - Harness workflow hook for Claude Code/Codex.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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 {
|
|
29
|
-
|
|
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
|
|
43
|
-
const
|
|
44
|
-
const
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
67
|
-
if (
|
|
68
|
-
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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(
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
const raw =
|
|
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
|
-
|
|
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
|
|
122
|
+
function writeJSONFile(filePath, value) {
|
|
135
123
|
try {
|
|
136
|
-
|
|
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(
|
|
140
|
-
} catch (
|
|
141
|
-
if (
|
|
129
|
+
if (lstatSync(absolutePath).isSymbolicLink()) return false;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (error.code !== 'ENOENT') return false;
|
|
142
132
|
}
|
|
143
133
|
|
|
144
|
-
const
|
|
145
|
-
|
|
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(
|
|
150
|
-
|
|
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(
|
|
143
|
+
renameSync(tmpPath, absolutePath);
|
|
144
|
+
return true;
|
|
156
145
|
} catch {
|
|
157
|
-
|
|
146
|
+
return false;
|
|
158
147
|
}
|
|
159
148
|
}
|
|
160
149
|
|
|
161
|
-
|
|
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
|
|
164
|
-
|
|
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
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
}
|
|
216
|
-
|
|
217
|
-
return resolve(__dirname, '..', '..');
|
|
214
|
+
}
|
|
215
|
+
return trimmed.split(/[\n,]/).map((item) => item.trim()).filter(Boolean);
|
|
218
216
|
}
|
|
219
217
|
|
|
220
|
-
function
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
297
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const phase = mode.phase || 'W0_EXPLORE';
|
|
293
|
+
function isModeActive(mode) {
|
|
294
|
+
return Boolean(mode?.active && mode.mode);
|
|
295
|
+
}
|
|
322
296
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
|
419
|
-
|
|
420
|
-
|
|
304
|
+
function isForbiddenPath(relativePath, forbidden) {
|
|
305
|
+
return forbidden.some((entry) => pathMatches(relativePath, entry));
|
|
306
|
+
}
|
|
421
307
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
333
|
+
function outputContext(hookEventName, additionalContext) {
|
|
334
|
+
process.stdout.write(JSON.stringify({
|
|
335
|
+
hookSpecificOutput: {
|
|
336
|
+
hookEventName,
|
|
337
|
+
additionalContext,
|
|
338
|
+
},
|
|
339
|
+
}));
|
|
340
|
+
}
|
|
452
341
|
|
|
453
|
-
|
|
342
|
+
function readEvent() {
|
|
343
|
+
if (process.argv[3]) {
|
|
454
344
|
try {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
}
|
|
512
|
-
|
|
513
|
-
// ── Per-turn reinforcement ──
|
|
356
|
+
}
|
|
514
357
|
try {
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
599
|
-
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
381
|
+
function writeGoals(data) {
|
|
382
|
+
writeJSONFile(GOALS_FILE, data);
|
|
383
|
+
}
|
|
604
384
|
|
|
605
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
|
|
635
|
-
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
688
|
-
|
|
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
|
-
|
|
692
|
-
|
|
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
|
-
|
|
696
|
-
if (
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
-
|
|
708
|
-
|
|
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
|
-
|
|
712
|
-
process.exit(2);
|
|
486
|
+
outputContext('UserPromptSubmit', 'CEO escalation cleared. Source writes are blocked again.');
|
|
487
|
+
return true;
|
|
713
488
|
}
|
|
714
489
|
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
const writeSet = mode.writeSet || [];
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
718
492
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
-
|
|
549
|
+
function allowTaskArtifact(agentRole, relativePath) {
|
|
550
|
+
if (!isTaskArtifact(relativePath)) return false;
|
|
551
|
+
return agentRole === 'ceo' || agentRole === 'manager' || agentRole === 'ceo-escalated';
|
|
552
|
+
}
|
|
744
553
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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
|
-
|
|
756
|
-
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
|
769
|
-
|
|
770
|
-
|
|
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
|
|
774
|
-
const
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
|
|
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
|
-
|
|
794
|
-
if (
|
|
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
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
|
|
822
|
-
if (
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
606
|
+
if (toolName === 'Bash') {
|
|
607
|
+
enforceBash(context.agentRole, command);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
834
610
|
|
|
835
|
-
|
|
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
|
|
846
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|