create-harness-vibe-coding 0.8.2 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-CN.md +2 -2
- package/README.md +2 -3
- package/package.json +1 -2
- package/src/generator.js +4 -5
- package/templates/common/.claude/agents/context-master.md +3 -2
- package/templates/common/.claude/agents/memory-master.md +3 -2
- package/templates/common/.claude/agents/tdd-guide.md +45 -19
- package/templates/common/.claude/agents/test-writer.md +52 -38
- package/templates/common/.claude/commands/wf-help.md +21 -0
- package/templates/common/.claude/rules/ecc/common.md +1 -1
- package/templates/common/.claude/settings.json +33 -90
- package/templates/common/.claude/skills/tdd/SKILL.md +18 -11
- package/templates/common/.claude/skills/wf-auto/SKILL.md +1 -1
- package/templates/common/.codex/config.toml +0 -2
- package/templates/common/.harness-version +55 -37
- package/templates/common/AGENTS.md +7 -6
- package/templates/common/CLAUDE.md +3 -4
- package/templates/common/Harness/ACCEPTANCE_PROTOCOL.md +170 -0
- package/templates/common/Harness/AGENT_ISOLATION.md +73 -0
- package/templates/common/Harness/DEBUG_PROTOCOL.md +64 -0
- package/templates/common/Harness/ECC-GUIDE.md +4 -4
- package/templates/common/Harness/HARNESS_BRIDGE.md +128 -0
- package/templates/common/Harness/MEMORY_PROTOCOL.md +114 -0
- package/templates/common/Harness/README.md +31 -16
- package/templates/common/Harness/TDD-GUIDE.md +138 -56
- package/templates/common/Harness/WF-AUTO.md +77 -16
- package/templates/common/Harness/WF-MAX.md +81 -22
- package/templates/common/Harness/WF.md +38 -18
- package/templates/common/Harness/agent-workflow.md +47 -22
- package/templates/common/Harness/context-loading.md +33 -32
- package/templates/common/Harness/dispatch.md +10 -4
- package/templates/common/Harness/extension.md +3 -3
- package/templates/common/Harness/lifecycle.md +19 -13
- package/templates/common/Harness/research/PRD.md +18 -9
- package/templates/common/Harness/subagents.md +41 -14
- package/templates/common/Harness/tasks/_template/NAMING.md +2 -2
- package/templates/common/Harness/tasks/_template/PLAN.md +53 -11
- package/templates/common/Harness/templates/ACCEPTANCE.template.md +20 -0
- package/templates/common/Harness/templates/API_CONTRACT.template.md +40 -0
- package/templates/common/Harness/templates/PLAYWRIGHT_SPEC.template.ts +21 -0
- package/templates/common/Harness/templates/PRD.template.md +45 -0
- package/templates/common/Harness/templates/TEST_PLAN.template.md +14 -0
- package/templates/common/Harness/templates/UI_CONTRACT.template.md +11 -0
- package/templates/common/Harness/templates/VALIDATION_REPORT.template.md +20 -0
- package/templates/common/MEMORY.md +14 -3
- package/templates/common/README.md +1 -1
- package/templates/common/SETUP.md +24 -20
- package/templates/common/scripts/validate-harness.mjs +134 -41
- package/templates/common/scripts/wf-remove.mjs +6 -2
- package/templates/optional/skills/browser-e2e/Harness/workflows/browser-e2e.md +11 -3
- package/templates/optional/skills/ts-react-frontend/Harness/workflows/ts-react-frontend.md +1 -1
- package/templates/common/.codex/hooks.json +0 -59
- package/templates/common/scripts/wf-mode-hook.mjs +0 -895
- package/templates/common/scripts/wf-statusline.ps1 +0 -62
- package/templates/common/scripts/wf-statusline.sh +0 -67
|
@@ -1,895 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* wf-mode-hook.mjs — Claude Code hook for WF-MAX / WF-REVIEW enforcement
|
|
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.
|
|
26
|
-
*/
|
|
27
|
-
|
|
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';
|
|
30
|
-
import { fileURLToPath } from 'node:url';
|
|
31
|
-
import { randomBytes } from 'node:crypto';
|
|
32
|
-
|
|
33
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
34
|
-
const RUNTIME_DIR = join(__dirname, '..', '.runtime');
|
|
35
|
-
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
|
-
const GOALS_FILE = join(RUNTIME_DIR, 'goals.json');
|
|
39
|
-
const MAX_GOAL_BYTES = 65536;
|
|
40
|
-
const MAX_GOAL_DESC = 500;
|
|
41
|
-
|
|
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'];
|
|
48
|
-
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
|
-
}
|
|
65
|
-
}
|
|
66
|
-
const normalized = resolved.replace(/\\/g, '/');
|
|
67
|
-
if (!normalized.includes('Harness/')) return null;
|
|
68
|
-
return resolved;
|
|
69
|
-
} catch {
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function safeReadJSON(filePath, maxBytes = MAX_MODE_BYTES) {
|
|
75
|
-
try {
|
|
76
|
-
const safe = safeResolveHarnessPath(filePath);
|
|
77
|
-
if (!safe) return null;
|
|
78
|
-
|
|
79
|
-
const st = lstatSync(safe);
|
|
80
|
-
if (!st.isFile()) return null;
|
|
81
|
-
if (st.size > maxBytes) return null;
|
|
82
|
-
|
|
83
|
-
const O_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
|
|
84
|
-
let fd;
|
|
85
|
-
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();
|
|
90
|
-
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;
|
|
126
|
-
} finally {
|
|
127
|
-
if (fd !== undefined) closeSync(fd);
|
|
128
|
-
}
|
|
129
|
-
} catch {
|
|
130
|
-
return null;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function safeWriteJSON(filePath, obj) {
|
|
135
|
-
try {
|
|
136
|
-
mkdirSync(dirname(filePath), { recursive: true });
|
|
137
|
-
|
|
138
|
-
try {
|
|
139
|
-
if (lstatSync(filePath).isSymbolicLink()) return;
|
|
140
|
-
} catch (e) {
|
|
141
|
-
if (e.code !== 'ENOENT') return;
|
|
142
|
-
}
|
|
143
|
-
|
|
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;
|
|
147
|
-
let fd;
|
|
148
|
-
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);
|
|
152
|
-
} finally {
|
|
153
|
-
if (fd !== undefined) closeSync(fd);
|
|
154
|
-
}
|
|
155
|
-
renameSync(tmp, filePath);
|
|
156
|
-
} catch {
|
|
157
|
-
// Silent-fail
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// ── Goal persistence (survives Claude Code goal auto-clear) ───────────────
|
|
162
|
-
|
|
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 {}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function getActiveGoals() {
|
|
199
|
-
const data = readGoals();
|
|
200
|
-
return data.goals.filter(g => g.status === 'active');
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// ── Path normalization (fixed for Windows absolute paths) ──────────────────
|
|
204
|
-
|
|
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;
|
|
214
|
-
}
|
|
215
|
-
} catch {}
|
|
216
|
-
// Fallback: assume Harness/scripts/ is two levels below root
|
|
217
|
-
return resolve(__dirname, '..', '..');
|
|
218
|
-
}
|
|
219
|
-
|
|
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;
|
|
247
|
-
}
|
|
248
|
-
|
|
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; }
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// ── stdin ─────────────────────────────────────────────────────────────────
|
|
269
|
-
|
|
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'));
|
|
294
|
-
}
|
|
295
|
-
|
|
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;
|
|
305
|
-
}
|
|
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;
|
|
316
|
-
}
|
|
317
|
-
|
|
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';
|
|
322
|
-
|
|
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
|
-
}
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
function handleUserPromptSubmit(event) {
|
|
419
|
-
const prompt = (event.prompt || event.input || '').toString();
|
|
420
|
-
const lower = prompt.toLowerCase();
|
|
421
|
-
|
|
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);
|
|
426
|
-
|
|
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
|
-
}
|
|
434
|
-
|
|
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
|
-
}
|
|
441
|
-
|
|
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
|
-
}
|
|
448
|
-
|
|
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);
|
|
452
|
-
|
|
453
|
-
if (escalateMatch) {
|
|
454
|
-
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 {}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
if (deescalateMatch) {
|
|
473
|
-
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
|
-
}
|
|
510
|
-
}
|
|
511
|
-
} catch {}
|
|
512
|
-
|
|
513
|
-
// ── Per-turn reinforcement ──
|
|
514
|
-
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 {}
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
function handlePreToolUse(event) {
|
|
594
|
-
let mode;
|
|
595
|
-
try { mode = safeReadJSON(MODE_FILE); } catch {}
|
|
596
|
-
if (!mode?.active) return;
|
|
597
|
-
|
|
598
|
-
const toolName = event.tool_name || event.tool || '';
|
|
599
|
-
if (!BLOCKED_TOOLS.includes(toolName)) return;
|
|
600
|
-
|
|
601
|
-
const input = event.tool_input || event.input || {};
|
|
602
|
-
const filePath = input.file_path || '';
|
|
603
|
-
const command = input.command || '';
|
|
604
|
-
|
|
605
|
-
const agentRole = mode.agentRole || mode.role || null;
|
|
606
|
-
|
|
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;
|
|
613
|
-
}
|
|
614
|
-
process.stderr.write(`[WF-MAX BLOCK] ${toolName} blocked: no agentRole set. Define agentRole and writeSet before editing.\n`);
|
|
615
|
-
process.exit(2);
|
|
616
|
-
}
|
|
617
|
-
|
|
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
|
-
}
|
|
633
|
-
|
|
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);
|
|
640
|
-
}
|
|
641
|
-
|
|
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)
|
|
668
|
-
return;
|
|
669
|
-
}
|
|
670
|
-
|
|
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
|
-
}
|
|
686
|
-
|
|
687
|
-
if (filePath) {
|
|
688
|
-
if (isTaskFile(filePath) || isHarnessMeta(filePath, agentRole)) return;
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
process.stderr.write(`[WF-MAX MANAGER BLOCK] ${toolName} on source files is forbidden for Manager. Delegate to Workers.\n`);
|
|
692
|
-
process.exit(2);
|
|
693
|
-
}
|
|
694
|
-
|
|
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;
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
if (filePath) {
|
|
708
|
-
if (isTaskFile(filePath) || isHarnessMeta(filePath, agentRole)) return;
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
process.stderr.write(`[WF-MAX REVIEWER BLOCK] ${toolName} is forbidden for Reviewer. Read and report only.\n`);
|
|
712
|
-
process.exit(2);
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
// ── CEO-Escalated: can write escalated files only ──
|
|
716
|
-
if (agentRole === 'ceo-escalated') {
|
|
717
|
-
const writeSet = mode.writeSet || [];
|
|
718
|
-
|
|
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;
|
|
731
|
-
}
|
|
732
|
-
|
|
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);
|
|
738
|
-
}
|
|
739
|
-
return;
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
// ── Memory auto-capture (PostToolUse) ──────────────────────────────────────
|
|
744
|
-
|
|
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
|
|
754
|
-
|
|
755
|
-
// Captured in UserPromptSubmit: last user prompt text
|
|
756
|
-
let _lastPrompt = '';
|
|
757
|
-
|
|
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));
|
|
766
|
-
}
|
|
767
|
-
|
|
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);
|
|
771
|
-
}
|
|
772
|
-
|
|
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
|
-
}
|
|
781
|
-
|
|
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
|
-
}
|
|
792
|
-
|
|
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
|
-
}
|
|
800
|
-
|
|
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
|
-
}
|
|
820
|
-
|
|
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
|
-
}
|
|
829
|
-
|
|
830
|
-
// ── Flush buffer when full ──
|
|
831
|
-
if (EPISODE_BUFFER.length >= EPISODE_FLUSH_SIZE) {
|
|
832
|
-
flushEpisodeBuffer();
|
|
833
|
-
}
|
|
834
|
-
|
|
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 {}
|
|
843
|
-
}
|
|
844
|
-
|
|
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 {}
|
|
863
|
-
}
|
|
864
|
-
|
|
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();
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
switch (eventType) {
|
|
876
|
-
case 'SessionStart':
|
|
877
|
-
handleSessionStart();
|
|
878
|
-
break;
|
|
879
|
-
case 'UserPromptSubmit':
|
|
880
|
-
handleUserPromptSubmit(event);
|
|
881
|
-
break;
|
|
882
|
-
case 'PreToolUse':
|
|
883
|
-
handlePreToolUse(event);
|
|
884
|
-
break;
|
|
885
|
-
case 'PostToolUse':
|
|
886
|
-
handlePostToolUse(event);
|
|
887
|
-
break;
|
|
888
|
-
case 'Stop':
|
|
889
|
-
handleStop(event);
|
|
890
|
-
break;
|
|
891
|
-
default:
|
|
892
|
-
break;
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
process.exit(0);
|