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