@lovrozagar/grunt 0.2.0 → 0.3.1
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/.agents/agents/grunt.md +4 -2
- package/.agents/agents/orchestrator.md +2 -2
- package/.agents/hooks.json +12 -1
- package/.agents/skills/commit/SKILL.md +21 -0
- package/.agents/skills/commit-and-push/SKILL.md +15 -0
- package/.agents/skills/explain/SKILL.md +14 -0
- package/.agents/skills/handoff/SKILL.md +84 -0
- package/.agents/skills/parent/SKILL.md +11 -0
- package/.agents/skills/solo/SKILL.md +21 -0
- package/.claude/agents/grunt.md +2 -0
- package/.claude/agents/orchestrator.md +3 -3
- package/.claude/settings.json +34 -1
- package/.claude/skills/commit/SKILL.md +21 -0
- package/.claude/skills/commit-and-push/SKILL.md +15 -0
- package/.claude/skills/explain/SKILL.md +14 -0
- package/.claude/skills/handoff/SKILL.md +84 -0
- package/.claude/skills/parent/SKILL.md +11 -0
- package/.claude/skills/solo/SKILL.md +21 -0
- package/.codex/agents/grunt.toml +4 -2
- package/.codex/agents/orchestrator.toml +2 -2
- package/.codex/hooks.json +34 -1
- package/.grok/agents/grunt.md +4 -2
- package/.grok/agents/orchestrator.md +2 -2
- package/.grok/hooks/orchestrate-parent.js +263 -29
- package/.grok/skills/commit/SKILL.md +21 -0
- package/.grok/skills/commit-and-push/SKILL.md +15 -0
- package/.grok/skills/handoff/SKILL.md +84 -0
- package/.grok/skills/solo/SKILL.md +21 -0
- package/.rulesync/hooks.jsonc +11 -3
- package/.rulesync/reference/cascade.md +4 -3
- package/.rulesync/reference/hooks.md +5 -1
- package/.rulesync/reference/map.md +18 -3
- package/.rulesync/rules/CLAUDE.md +2 -1
- package/.rulesync/rules/overview.md +2 -1
- package/.rulesync/skills/commit/SKILL.md +22 -0
- package/.rulesync/skills/commit-and-push/SKILL.md +16 -0
- package/.rulesync/skills/handoff/SKILL.md +84 -0
- package/.rulesync/skills/solo/SKILL.md +21 -0
- package/.rulesync/subagents/grunt.md +6 -2
- package/.rulesync/subagents/implementer.md +2 -0
- package/.rulesync/subagents/orchestrator.md +4 -2
- package/.rulesync/subagents/thinker.md +2 -0
- package/AGENTS.md +2 -1
- package/CLAUDE.md +2 -1
- package/README.md +2 -2
- package/cli/init.mjs +105 -6
- package/cli/init.test.ts +303 -6
- package/package.json +12 -6
- package/scripts/emit-agent-shell-tools.mjs +118 -0
- package/scripts/emit-gemini.mjs +219 -0
- package/scripts/emit-mcp-policy.mjs +23 -0
- package/scripts/gate-fat-tools.mjs +64 -0
- package/scripts/hooks-union.mjs +391 -0
- package/scripts/persist-handoff.mjs +231 -0
- package/scripts/scrub-spawn-prompt.mjs +37 -17
|
@@ -1,30 +1,54 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/** Parent-orchestrator gate: spawn/peek/kill/todo + persistPlan writes.
|
|
2
|
+
/** Parent-orchestrator gate: spawn/peek/kill/todo + persistPlan/persistHandoff writes.
|
|
3
3
|
Parent Read/Grep/Glob/Bash/Web denied unless parent-escape (fat-gate still).
|
|
4
4
|
SubagentStop intercepts need: search|exec with grunt-job verdicts.
|
|
5
|
-
Stop: [
|
|
6
|
-
|
|
5
|
+
Stop: any-line [orchestrator]:/[grunt]:/[implementer]:/[thinker]:/[handoff]: recap
|
|
6
|
+
| parent-escape once; else block. MAX_STOP=3. No isCheap/trivia.
|
|
7
|
+
Empty lastAssistantMessage → transcript_path tail-scan. Fail-open: parse/crash → empty stdout, exit 0.
|
|
7
8
|
*/
|
|
8
9
|
import fs from "node:fs";
|
|
9
10
|
import path from "node:path";
|
|
10
11
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
|
-
import { rewriteSpawnToolInput } from "../../scripts/scrub-spawn-prompt.mjs";
|
|
12
12
|
import {
|
|
13
|
+
MAX_PROMPT_CHARS,
|
|
14
|
+
rewriteSpawnToolInput,
|
|
15
|
+
spawnCapReason,
|
|
16
|
+
} from "../../scripts/scrub-spawn-prompt.mjs";
|
|
17
|
+
import {
|
|
18
|
+
denyResponse,
|
|
13
19
|
fatHookOutput,
|
|
14
20
|
fileSizeBytes,
|
|
15
21
|
isWorkspaceGruntJob,
|
|
16
22
|
processFatTools,
|
|
17
23
|
resolveReadPath,
|
|
24
|
+
rewriteGruntScratchPath,
|
|
18
25
|
subagentTypeOf,
|
|
19
26
|
} from "../../scripts/gate-fat-tools.mjs";
|
|
20
27
|
import { persistPlan } from "../../scripts/persist-plan.mjs";
|
|
28
|
+
import { persistHandoff } from "../../scripts/persist-handoff.mjs";
|
|
21
29
|
import { parseNeed } from "../../scripts/parse-need.mjs";
|
|
22
30
|
import { resolveJobCwd, runJob } from "../../scripts/grunt-job.mjs";
|
|
23
31
|
import { logTelemetry, ORCHESTRATOR_LOGS_DIR } from "../../scripts/telemetry.mjs";
|
|
24
32
|
|
|
25
33
|
const DENY_REASON = "parent is orchestrator; spawn grunt|implementer|thinker";
|
|
26
|
-
const
|
|
27
|
-
"parent
|
|
34
|
+
export const STOP_REASONS = [
|
|
35
|
+
"Violation: parent replied without a recap tag.\n" +
|
|
36
|
+
"DO NOT stop. Continue IN THIS RESPONSE:\n" +
|
|
37
|
+
"1. Spawn grunt|implementer|thinker if work remains.\n" +
|
|
38
|
+
"2. Recap with `[orchestrator]:` or the child role tag (`[grunt]:` `[implementer]:` `[thinker]:` `[handoff]:`).\n" +
|
|
39
|
+
"If every spawned child already returned and the turn is done, recap now.",
|
|
40
|
+
"Second violation: still no recap tag.\n" +
|
|
41
|
+
"DO NOT stop. Right now, in this same response:\n" +
|
|
42
|
+
"1. Spawn grunt|implementer|thinker if needed.\n" +
|
|
43
|
+
"2. Then recap only with `[orchestrator]:` or the matching child role tag.\n" +
|
|
44
|
+
"Already done and every child returned? Send that recap immediately.",
|
|
45
|
+
"Third violation: recap still missing.\n" +
|
|
46
|
+
"This is the last check before fail-open. DO NOT stop:\n" +
|
|
47
|
+
"1. Spawn the correct agent for remaining work if needed.\n" +
|
|
48
|
+
"2. Reply with `[orchestrator]:` or the child role tag only, nothing else.\n" +
|
|
49
|
+
"Complete with all children returned? Send the recap now.",
|
|
50
|
+
];
|
|
51
|
+
const TRANSCRIPT_TAIL_BYTES = 512 * 1024;
|
|
28
52
|
const PARENT_TOOLS = new Set([
|
|
29
53
|
"todowrite",
|
|
30
54
|
"getcommandorsubagentoutput",
|
|
@@ -43,6 +67,10 @@ const READ_TOOLS = new Set(["readfile", "read"]);
|
|
|
43
67
|
const INTERCEPT_JOBS = new Set(["search", "exec"]);
|
|
44
68
|
const MAX_STOP = 3;
|
|
45
69
|
const MAX_INTERCEPT = 3;
|
|
70
|
+
/** `/solo` enters single-agent mode; `/cascade` restores the orchestrator. */
|
|
71
|
+
const SOLO_RE = /^\s*\/solo\s*$/;
|
|
72
|
+
const CASCADE_RE = /^\s*\/cascade\s*$/;
|
|
73
|
+
const SOLO_STAMP = "grunt-off";
|
|
46
74
|
|
|
47
75
|
|
|
48
76
|
function main() {
|
|
@@ -76,8 +104,16 @@ function preToolUse(data) {
|
|
|
76
104
|
const code = emitFat(data);
|
|
77
105
|
return code == null ? 0 : code;
|
|
78
106
|
}
|
|
107
|
+
if (isSoloMode(data)) {
|
|
108
|
+
// Single-agent session: no parent-deny, no spawn rewrite. Fat gate still applies.
|
|
109
|
+
const fatCode = emitFat(data);
|
|
110
|
+
if (fatCode !== null) return fatCode;
|
|
111
|
+
emit({ decision: "allow" });
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
79
114
|
if (SPAWN_TOOLS.has(toolKey)) {
|
|
80
|
-
const updated = rewriteSpawn(toolInput);
|
|
115
|
+
const updated = rewriteSpawn(toolInput, data);
|
|
116
|
+
if (updated && updated.__denied) return 0;
|
|
81
117
|
if (updated) {
|
|
82
118
|
emit({
|
|
83
119
|
hookSpecificOutput: {
|
|
@@ -107,6 +143,16 @@ function preToolUse(data) {
|
|
|
107
143
|
return 0;
|
|
108
144
|
}
|
|
109
145
|
|
|
146
|
+
/** Session flag, not one-turn. Fail-closed: an unreadable stamp keeps grunt on. */
|
|
147
|
+
export function isSoloMode(data) {
|
|
148
|
+
try {
|
|
149
|
+
const p = soloStampPath(data);
|
|
150
|
+
return Boolean(p && fs.existsSync(p));
|
|
151
|
+
} catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
110
156
|
function hasParentEscape(data) {
|
|
111
157
|
const p = stampPath(data, "parent-escape");
|
|
112
158
|
return Boolean(p && fs.existsSync(p));
|
|
@@ -146,21 +192,32 @@ function emitFat(data) {
|
|
|
146
192
|
function workspaceRootOf(data) {
|
|
147
193
|
return (
|
|
148
194
|
process.env.GROK_WORKSPACE_ROOT ||
|
|
149
|
-
(data && (data.workspaceRoot || data.workspace_root
|
|
195
|
+
(data && (data.workspaceRoot || data.workspace_root)) ||
|
|
196
|
+
(data && data.cwd) ||
|
|
197
|
+
process.env.CLAUDE_PROJECT_DIR ||
|
|
198
|
+
process.cwd() ||
|
|
150
199
|
""
|
|
151
200
|
);
|
|
152
201
|
}
|
|
153
202
|
|
|
154
|
-
|
|
203
|
+
function isUnderDir(filePath, workspaceRoot, segments) {
|
|
155
204
|
if (!filePath || !workspaceRoot) return false;
|
|
156
205
|
const abs = path.isAbsolute(filePath)
|
|
157
206
|
? path.resolve(filePath)
|
|
158
207
|
: path.resolve(workspaceRoot, filePath);
|
|
159
|
-
const
|
|
160
|
-
const rel = path.relative(
|
|
208
|
+
const dir = path.resolve(workspaceRoot, ...segments);
|
|
209
|
+
const rel = path.relative(dir, abs);
|
|
161
210
|
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
162
211
|
}
|
|
163
212
|
|
|
213
|
+
export function isUnderPlans(filePath, workspaceRoot) {
|
|
214
|
+
return isUnderDir(filePath, workspaceRoot, [".tmp", "plans"]);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function isUnderHandoffs(filePath, workspaceRoot) {
|
|
218
|
+
return isUnderDir(filePath, workspaceRoot, [".tmp", "grunt", "handoffs"]);
|
|
219
|
+
}
|
|
220
|
+
|
|
164
221
|
export function isAllowedParentGruntJob(command, workspaceRoot) {
|
|
165
222
|
return isWorkspaceGruntJob(command, workspaceRoot, ["search", "exec"]);
|
|
166
223
|
}
|
|
@@ -171,22 +228,26 @@ function parentWrite(data, toolInput) {
|
|
|
171
228
|
return 0;
|
|
172
229
|
}
|
|
173
230
|
const ws = workspaceRootOf(data);
|
|
174
|
-
|
|
231
|
+
let rawPath =
|
|
175
232
|
toolInput.file_path ||
|
|
176
233
|
toolInput.filePath ||
|
|
177
234
|
toolInput.path ||
|
|
178
235
|
toolInput.target_file ||
|
|
179
236
|
"";
|
|
180
|
-
|
|
237
|
+
const rewritten = rewriteGruntScratchPath(rawPath, ws);
|
|
238
|
+
if (rewritten) rawPath = rewritten;
|
|
239
|
+
const handoff = isUnderHandoffs(rawPath, ws);
|
|
240
|
+
if (!handoff && !isUnderPlans(rawPath, ws)) {
|
|
181
241
|
emit({ decision: "deny", reason: DENY_REASON });
|
|
182
242
|
return 0;
|
|
183
243
|
}
|
|
184
244
|
const content = typeof toolInput.content === "string" ? toolInput.content : "";
|
|
185
|
-
const
|
|
245
|
+
const persist = handoff ? persistHandoff : persistPlan;
|
|
246
|
+
const result = persist({ workspaceRoot: ws, content });
|
|
186
247
|
if (!result.ok) {
|
|
187
248
|
emit({
|
|
188
249
|
decision: "deny",
|
|
189
|
-
reason: result.error || "invalid plan",
|
|
250
|
+
reason: result.error || (handoff ? "invalid handoff" : "invalid plan"),
|
|
190
251
|
});
|
|
191
252
|
return 0;
|
|
192
253
|
}
|
|
@@ -203,9 +264,20 @@ function parentWrite(data, toolInput) {
|
|
|
203
264
|
return 0;
|
|
204
265
|
}
|
|
205
266
|
|
|
206
|
-
function rewriteSpawn(toolInput) {
|
|
267
|
+
function rewriteSpawn(toolInput, data) {
|
|
207
268
|
// Type default + intent-scrub in one updatedInput (last-wins with scrub-spawn).
|
|
208
|
-
|
|
269
|
+
const updated = rewriteSpawnToolInput(toolInput, { defaultGrunt: true });
|
|
270
|
+
const prompt =
|
|
271
|
+
updated && typeof updated.prompt === "string"
|
|
272
|
+
? updated.prompt
|
|
273
|
+
: toolInput && typeof toolInput.prompt === "string"
|
|
274
|
+
? toolInput.prompt
|
|
275
|
+
: "";
|
|
276
|
+
if (prompt.length > MAX_PROMPT_CHARS) {
|
|
277
|
+
emit(denyResponse(spawnCapReason(workspaceRootOf(data))));
|
|
278
|
+
return { __denied: true };
|
|
279
|
+
}
|
|
280
|
+
return updated;
|
|
209
281
|
}
|
|
210
282
|
|
|
211
283
|
function postToolUse(data) {
|
|
@@ -229,7 +301,18 @@ function isParentEscapePrompt(prompt) {
|
|
|
229
301
|
function userPromptSubmit(data) {
|
|
230
302
|
unlinkQuiet(stampPath(data, "tools-used"));
|
|
231
303
|
unlinkQuiet(stampPath(data, "stop-block"));
|
|
232
|
-
|
|
304
|
+
const prompt = userPromptOf(data);
|
|
305
|
+
// Sticky: only /solo and /cascade move it. Every other prompt leaves it alone.
|
|
306
|
+
if (SOLO_RE.test(prompt)) {
|
|
307
|
+
const solo = soloStampPath(data);
|
|
308
|
+
if (solo) {
|
|
309
|
+
fs.mkdirSync(path.dirname(solo), { recursive: true });
|
|
310
|
+
fs.writeFileSync(solo, "1");
|
|
311
|
+
}
|
|
312
|
+
} else if (CASCADE_RE.test(prompt)) {
|
|
313
|
+
unlinkQuiet(soloStampPath(data));
|
|
314
|
+
}
|
|
315
|
+
if (isParentEscapePrompt(prompt)) {
|
|
233
316
|
const p = stampPath(data, "parent-escape");
|
|
234
317
|
if (p) {
|
|
235
318
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
@@ -241,25 +324,147 @@ function userPromptSubmit(data) {
|
|
|
241
324
|
return 0;
|
|
242
325
|
}
|
|
243
326
|
|
|
244
|
-
|
|
245
|
-
|
|
327
|
+
const RECAP_TAG_RE =
|
|
328
|
+
/^\[(?:orchestrator|grunt|implementer|thinker|handoff)\]:/;
|
|
329
|
+
|
|
330
|
+
export function isRecap(msg) {
|
|
331
|
+
const lines = String(msg || "").split("\n");
|
|
332
|
+
for (const line of lines) {
|
|
333
|
+
const stripped = line.replace(/^[\s`*_>]+/, "");
|
|
334
|
+
if (RECAP_TAG_RE.test(stripped)) return true;
|
|
335
|
+
}
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function payloadAssistantMessage(data) {
|
|
340
|
+
if (!data || typeof data !== "object") return "";
|
|
341
|
+
const camel = data.lastAssistantMessage;
|
|
342
|
+
if (camel != null && String(camel) !== "") return String(camel);
|
|
343
|
+
const snake = data.last_assistant_message;
|
|
344
|
+
if (snake != null && String(snake) !== "") return String(snake);
|
|
345
|
+
return "";
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function isAssistantRecord(obj) {
|
|
349
|
+
if (!obj || typeof obj !== "object") return false;
|
|
350
|
+
const type = String(obj.type || "").toLowerCase();
|
|
351
|
+
if (type === "user" || type === "human" || type === "tool" || type === "tool_result") {
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
if (type === "assistant") return true;
|
|
355
|
+
const role = String(
|
|
356
|
+
obj.role || (obj.message && obj.message.role) || "",
|
|
357
|
+
).toLowerCase();
|
|
358
|
+
if (role === "user" || role === "human") return false;
|
|
359
|
+
return role === "assistant";
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function assistantTextFromRecord(obj) {
|
|
363
|
+
if (!isAssistantRecord(obj)) return null;
|
|
364
|
+
const msg = obj.message && typeof obj.message === "object" ? obj.message : obj;
|
|
365
|
+
const content = msg.content ?? obj.content;
|
|
366
|
+
if (Array.isArray(content)) {
|
|
367
|
+
const texts = [];
|
|
368
|
+
for (const part of content) {
|
|
369
|
+
if (!part || typeof part !== "object") continue;
|
|
370
|
+
const pt = String(part.type || "");
|
|
371
|
+
if (pt === "tool_use" || pt === "tool_result" || pt === "function_call") continue;
|
|
372
|
+
if (typeof part.text === "string" && part.text) texts.push(part.text);
|
|
373
|
+
}
|
|
374
|
+
const joined = texts.join("\n").trim();
|
|
375
|
+
return joined ? joined : null;
|
|
376
|
+
}
|
|
377
|
+
if (typeof content === "string" && content.trim()) return content;
|
|
378
|
+
if (typeof obj.text === "string" && obj.text.trim()) return obj.text;
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function lastAssistantFromTranscript(filePath) {
|
|
383
|
+
try {
|
|
384
|
+
if (!filePath) return "";
|
|
385
|
+
const st = fs.statSync(filePath);
|
|
386
|
+
const start = Math.max(0, st.size - TRANSCRIPT_TAIL_BYTES);
|
|
387
|
+
const fd = fs.openSync(filePath, "r");
|
|
388
|
+
try {
|
|
389
|
+
const buf = Buffer.alloc(st.size - start);
|
|
390
|
+
fs.readSync(fd, buf, 0, buf.length, start);
|
|
391
|
+
const text = buf.toString("utf8");
|
|
392
|
+
const lines = text.split("\n");
|
|
393
|
+
if (start > 0 && lines.length) lines.shift();
|
|
394
|
+
let last = "";
|
|
395
|
+
for (const line of lines) {
|
|
396
|
+
const t = line.trim();
|
|
397
|
+
if (!t) continue;
|
|
398
|
+
let rec;
|
|
399
|
+
try {
|
|
400
|
+
rec = JSON.parse(t);
|
|
401
|
+
} catch {
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
const extracted = assistantTextFromRecord(rec);
|
|
405
|
+
if (extracted) last = extracted;
|
|
406
|
+
}
|
|
407
|
+
return last;
|
|
408
|
+
} finally {
|
|
409
|
+
fs.closeSync(fd);
|
|
410
|
+
}
|
|
411
|
+
} catch {
|
|
412
|
+
return "";
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function logParentStop(data, fields) {
|
|
417
|
+
logTelemetry("parent-stop", fields, workspaceRootOf(data));
|
|
246
418
|
}
|
|
247
419
|
|
|
248
420
|
function stop(data) {
|
|
249
421
|
if (data.subagentType || data.subagent_type) {
|
|
250
422
|
return interceptNeed(data, "Stop");
|
|
251
423
|
}
|
|
424
|
+
if (data.stopHookActive || data.stop_hook_active) {
|
|
425
|
+
logParentStop(data, {
|
|
426
|
+
recap: false,
|
|
427
|
+
recapSource: "none",
|
|
428
|
+
attempt: 0,
|
|
429
|
+
failOpen: true,
|
|
430
|
+
stopHookActive: true,
|
|
431
|
+
});
|
|
432
|
+
return 0;
|
|
433
|
+
}
|
|
252
434
|
const reason = String(data.reason || "");
|
|
253
435
|
if (reason && reason !== "end_turn") return 0;
|
|
254
436
|
|
|
437
|
+
// Before the parent-escape consume: solo must never burn the one-turn stamp.
|
|
438
|
+
if (isSoloMode(data)) return 0;
|
|
439
|
+
|
|
255
440
|
const escapeStamp = stampPath(data, "parent-escape");
|
|
256
441
|
if (escapeStamp && fs.existsSync(escapeStamp)) {
|
|
257
442
|
unlinkQuiet(escapeStamp);
|
|
258
443
|
return 0;
|
|
259
444
|
}
|
|
260
445
|
|
|
261
|
-
const
|
|
262
|
-
|
|
446
|
+
const payloadMsg = payloadAssistantMessage(data);
|
|
447
|
+
let recapSource = "none";
|
|
448
|
+
let msg = "";
|
|
449
|
+
if (payloadMsg) {
|
|
450
|
+
msg = payloadMsg;
|
|
451
|
+
recapSource = "payload";
|
|
452
|
+
} else {
|
|
453
|
+
const tp = data.transcript_path || data.transcriptPath || "";
|
|
454
|
+
msg = lastAssistantFromTranscript(tp);
|
|
455
|
+
if (msg) recapSource = "transcript";
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (isRecap(msg)) {
|
|
459
|
+
logParentStop(data, {
|
|
460
|
+
recap: true,
|
|
461
|
+
recapSource,
|
|
462
|
+
attempt: 0,
|
|
463
|
+
failOpen: false,
|
|
464
|
+
stopHookActive: false,
|
|
465
|
+
});
|
|
466
|
+
return 0;
|
|
467
|
+
}
|
|
263
468
|
|
|
264
469
|
const stopStamp = stampPath(data, "stop-block");
|
|
265
470
|
let n = 0;
|
|
@@ -267,12 +472,29 @@ function stop(data) {
|
|
|
267
472
|
n = parseInt(fs.readFileSync(stopStamp, "utf8"), 10);
|
|
268
473
|
if (!Number.isFinite(n) || n < 0) n = 0;
|
|
269
474
|
}
|
|
270
|
-
if (n >= MAX_STOP)
|
|
475
|
+
if (n >= MAX_STOP) {
|
|
476
|
+
logParentStop(data, {
|
|
477
|
+
recap: false,
|
|
478
|
+
recapSource,
|
|
479
|
+
attempt: n,
|
|
480
|
+
failOpen: true,
|
|
481
|
+
stopHookActive: false,
|
|
482
|
+
});
|
|
483
|
+
return 0;
|
|
484
|
+
}
|
|
271
485
|
if (stopStamp) {
|
|
272
486
|
fs.mkdirSync(path.dirname(stopStamp), { recursive: true });
|
|
273
487
|
fs.writeFileSync(stopStamp, String(n + 1));
|
|
274
488
|
}
|
|
275
|
-
|
|
489
|
+
logParentStop(data, {
|
|
490
|
+
recap: false,
|
|
491
|
+
recapSource,
|
|
492
|
+
attempt: n + 1,
|
|
493
|
+
failOpen: false,
|
|
494
|
+
stopHookActive: false,
|
|
495
|
+
});
|
|
496
|
+
const reasonText = STOP_REASONS[Math.min(n, STOP_REASONS.length - 1)];
|
|
497
|
+
emit({ decision: "block", reason: reasonText });
|
|
276
498
|
return 0;
|
|
277
499
|
}
|
|
278
500
|
|
|
@@ -368,7 +590,6 @@ function interceptNeed(data, hookEventName) {
|
|
|
368
590
|
);
|
|
369
591
|
emit({
|
|
370
592
|
decision: "block",
|
|
371
|
-
reason,
|
|
372
593
|
hookSpecificOutput: {
|
|
373
594
|
hookEventName,
|
|
374
595
|
additionalContext: reason,
|
|
@@ -379,14 +600,27 @@ function interceptNeed(data, hookEventName) {
|
|
|
379
600
|
|
|
380
601
|
function stampPath(data, prefix) {
|
|
381
602
|
const root = workspaceRootOf(data);
|
|
382
|
-
const sid =
|
|
383
|
-
process.env.GROK_SESSION_ID ||
|
|
384
|
-
(data && (data.sessionId || data.session_id)) ||
|
|
385
|
-
"default";
|
|
603
|
+
const sid = sessionIdOf(data) || "default";
|
|
386
604
|
if (!root) return null;
|
|
387
605
|
return path.join(root, ORCHESTRATOR_LOGS_DIR, prefix + "-" + sid);
|
|
388
606
|
}
|
|
389
607
|
|
|
608
|
+
function sessionIdOf(data) {
|
|
609
|
+
return String(
|
|
610
|
+
process.env.GROK_SESSION_ID ||
|
|
611
|
+
(data && (data.sessionId || data.session_id)) ||
|
|
612
|
+
"",
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Solo is a mode: never share a `default` stamp across sid-less sessions. */
|
|
617
|
+
function soloStampPath(data) {
|
|
618
|
+
const root = workspaceRootOf(data);
|
|
619
|
+
const sid = sessionIdOf(data);
|
|
620
|
+
if (!root || !sid) return null;
|
|
621
|
+
return path.join(root, ORCHESTRATOR_LOGS_DIR, SOLO_STAMP + "-" + sid);
|
|
622
|
+
}
|
|
623
|
+
|
|
390
624
|
function unlinkQuiet(p) {
|
|
391
625
|
if (!p) return;
|
|
392
626
|
try {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: commit
|
|
3
|
+
description: >
|
|
4
|
+
Inspect the diff and commit with a Conventional Commits subject line. Use for
|
|
5
|
+
/commit, "commit this", "commit these changes". Never pushes.
|
|
6
|
+
---
|
|
7
|
+
Inspect `git status` + `git diff` (staged and unstaged) before the message. Never push.
|
|
8
|
+
|
|
9
|
+
Stage only user intent. Never blind `git add -A` / `git add .`. Split unrelated concerns into multiple commits.
|
|
10
|
+
|
|
11
|
+
Message = Conventional Commits **subject only**: `type(scope): subject` (scope optional).
|
|
12
|
+
|
|
13
|
+
Types: feat fix docs style refactor perf test build ci chore revert.
|
|
14
|
+
|
|
15
|
+
Subject: imperative lowercase no trailing period.
|
|
16
|
+
|
|
17
|
+
NO body. NO footer. NO `Co-Authored-By`. NO `Generated with`. NO 🤖. NO trailers. No AI attribution. Work belongs to the human.
|
|
18
|
+
|
|
19
|
+
Never `git push`.
|
|
20
|
+
|
|
21
|
+
Protocol: `.rulesync/reference/cascade.md`. Do not paste cascade.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: commit-and-push
|
|
3
|
+
description: >
|
|
4
|
+
Commit like /commit, then push to the current branch's upstream. Use for
|
|
5
|
+
/commit-and-push, "commit and push", "commit this and push".
|
|
6
|
+
---
|
|
7
|
+
Run commit skill rules first (`.rulesync/skills/commit/SKILL.md`). Then push.
|
|
8
|
+
|
|
9
|
+
If on default branch, create/switch to a feature branch first — never push default directly.
|
|
10
|
+
|
|
11
|
+
Push upstream; if no upstream `git push -u origin <current-branch>`.
|
|
12
|
+
|
|
13
|
+
Never force-push.
|
|
14
|
+
|
|
15
|
+
Protocol: `.rulesync/reference/cascade.md`. Do not paste cascade.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: handoff
|
|
3
|
+
description: >
|
|
4
|
+
Parent writes a session handoff under .tmp/grunt/handoffs/ and tells the user
|
|
5
|
+
to continue in a fresh session. Use for /handoff, "context is getting long",
|
|
6
|
+
"hand this off", "start a new session". Not a plan, not a design doc.
|
|
7
|
+
---
|
|
8
|
+
# handoff
|
|
9
|
+
|
|
10
|
+
One turn, in-parent. Parent authors from its own transcript — no child sees this session, so **do not spawn**. Write once, report path, stop.
|
|
11
|
+
|
|
12
|
+
## Invocation
|
|
13
|
+
|
|
14
|
+
`/handoff [focus]`
|
|
15
|
+
|
|
16
|
+
Focus narrows what the handoff carries. Empty → whole session.
|
|
17
|
+
|
|
18
|
+
## When
|
|
19
|
+
|
|
20
|
+
Context large + work unfinished → write handoff, then advise a new session. Also on host/tool churn, or before a long unrelated detour.
|
|
21
|
+
|
|
22
|
+
Nothing done yet (no edits, no findings) → `nothing to hand off` and stop. Do not write an empty file.
|
|
23
|
+
|
|
24
|
+
## Path
|
|
25
|
+
|
|
26
|
+
`.tmp/grunt/handoffs/{serial}-{slug}-{YYYYMMDDTHHMMSSZ}.md`
|
|
27
|
+
|
|
28
|
+
- serial: unpadded int ≥ 1, next after existing handoffs (own counter; not plan serials)
|
|
29
|
+
- slug: lower, non `[a-z0-9]` → `-`, collapse, trim 50
|
|
30
|
+
- stamp: `created` with `-` and `:` dropped (`20260827T143000Z`)
|
|
31
|
+
|
|
32
|
+
## Format
|
|
33
|
+
|
|
34
|
+
```yaml
|
|
35
|
+
---
|
|
36
|
+
serial: 1
|
|
37
|
+
name: <slug>
|
|
38
|
+
status: open # open | resumed | done
|
|
39
|
+
created: 2026-08-27T14:30:00Z # UTC, seconds, no ms
|
|
40
|
+
source: "<≤120 chars of the session task>"
|
|
41
|
+
---
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Headings, this order only: `# {slug}`, `## Goal`, `## State`, `## Context`, `## Next`, `## Watch-outs`.
|
|
45
|
+
|
|
46
|
+
| Section | Amount |
|
|
47
|
+
|---|---|
|
|
48
|
+
| Goal | 1–3 sentences. What the session is trying to land. |
|
|
49
|
+
| State | bullets. Done / changed / green-red, with absolute paths. |
|
|
50
|
+
| Context | bullets. Abs paths, decisions, verdict facts the next session needs. |
|
|
51
|
+
| Next | checkbox steps, `N [ ]` phase + `N.M [ ]` leaf, all `[ ]` fresh |
|
|
52
|
+
| Watch-outs | bullets. Footguns. `(none)` allowed. |
|
|
53
|
+
|
|
54
|
+
Boxes: `^{id} [ ] {text}$`, id `N` / `N.M` / `N.M.K`. Not `- [ ]`, not `1. [ ]`. Every phase has ≥1 leaf. Fresh handoff has zero `[x]`.
|
|
55
|
+
|
|
56
|
+
Facts only. Absolute paths. No transcript paste, no dump, no secrets/tokens.
|
|
57
|
+
|
|
58
|
+
## Write
|
|
59
|
+
|
|
60
|
+
Line 1 of the body: `HANDOFF_NAME: <3-6 word name>`, then the body only.
|
|
61
|
+
|
|
62
|
+
Parent `write`s under `.tmp/grunt/handoffs/` (any filename in that dir). Grok `orchestrate-parent.js` runs `scripts/persist-handoff.mjs`: serial/slug from `HANDOFF_NAME:` / heading, injects frontmatter, rewrites path + content. Invalid handoff or a write outside that dir is denied — fix the body, do not retry elsewhere.
|
|
63
|
+
|
|
64
|
+
Host without that hook: name the file per **Path** yourself, or pipe the body to `node scripts/persist-handoff.mjs --workspace {repo}` and use its `path`.
|
|
65
|
+
|
|
66
|
+
## Report
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
[handoff]: serial={int} path=.tmp/grunt/handoffs/{serial}-{slug}-{stamp}.md
|
|
70
|
+
next: start a new session; first action = read that path
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Do not dump the handoff body.
|
|
74
|
+
|
|
75
|
+
## Picking one up
|
|
76
|
+
|
|
77
|
+
New session, `/handoff` not needed: read the newest `.tmp/grunt/handoffs/*.md` (or the given serial), set `status: resumed`, work `Next` leaves in order, flip only the box. All `[x]` → `status: done`. Do not re-plan; a stale leaf → `/write-plan` a follow-up.
|
|
78
|
+
|
|
79
|
+
## Rules
|
|
80
|
+
|
|
81
|
+
- No spawn. One write. `/handoff` is not a mode.
|
|
82
|
+
- Handoff ≠ plan: no Steps/Verify phases, no `.tmp/plans/` write.
|
|
83
|
+
- Never rewrite or renumber an existing handoff; new session = new serial.
|
|
84
|
+
- Protocol: `.rulesync/reference/cascade.md`. Do not paste it here.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: solo
|
|
3
|
+
description: >
|
|
4
|
+
Session mode. /solo disables the grunt cascade for this session — one normal
|
|
5
|
+
agent, no spawn, no orchestrator. /cascade restores it. Unlike /parent and
|
|
6
|
+
/explain this IS a mode: it persists until /cascade or session end.
|
|
7
|
+
---
|
|
8
|
+
Session mode, not one-turn. Persists until `/cascade` or session end.
|
|
9
|
+
|
|
10
|
+
This session: cascade suspended. You are one normal agent.
|
|
11
|
+
|
|
12
|
+
- Do not spawn. No grunt|implementer|thinker.
|
|
13
|
+
- No recap tags. Do not recap `[orchestrator]:`. Answer the user directly.
|
|
14
|
+
- No spawn-first rule. No `need:`/`verdict:` grammar.
|
|
15
|
+
- Voice unchanged: `.rulesync/reference/output.md`. `/explain` is still the voice escape.
|
|
16
|
+
|
|
17
|
+
Fat-tool caps stay on — token guards, not cascade.
|
|
18
|
+
|
|
19
|
+
Claude/Codex/Grok: `/solo` via submit hook writes `.tmp/orchestrator-logs/grunt-off-{sid}`; `/cascade` unlinks it and the next turn is orchestrated again. Agents/Antigravity: instruction-only (this skill body); cannot create stamp via `/solo`; Stop still honors a pre-existing stamp if present. Session-scoped, never global.
|
|
20
|
+
|
|
21
|
+
For advisory/design turns, debugging the cascade itself, or back-and-forth where spawning costs more than it saves.
|
package/.rulesync/hooks.jsonc
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
"timeout": 5
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
|
-
// Fat Read/Grep/Glob/Bash
|
|
15
|
-
|
|
14
|
+
// Fat Read/Grep/Glob/Bash + Write/Edit scratch rewrite (never deny writes).
|
|
15
|
+
// Parent-deny stays Grok-only (orchestrate-parent.js).
|
|
16
|
+
"matcher": "Read|read_file|Grep|grep|Glob|list_dir|Bash|run_terminal_command|Write|Edit|write|search_replace",
|
|
16
17
|
"type": "command",
|
|
17
18
|
"command": "node \"${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-.}}/scripts/gate-fat-tools.mjs\"",
|
|
18
19
|
"timeout": 5
|
|
@@ -20,7 +21,7 @@
|
|
|
20
21
|
],
|
|
21
22
|
// Parent Stop + /parent stamp. PreToolUse parent-deny stays Grok-only
|
|
22
23
|
// (child subagentType/agent_type unverified on these hosts).
|
|
23
|
-
"
|
|
24
|
+
"beforeSubmitPrompt": [
|
|
24
25
|
{
|
|
25
26
|
"type": "command",
|
|
26
27
|
"command": "node \"${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-.}}/.grok/hooks/orchestrate-parent.js\"",
|
|
@@ -33,6 +34,13 @@
|
|
|
33
34
|
"command": "node \"${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-.}}/.grok/hooks/orchestrate-parent.js\"",
|
|
34
35
|
"timeout": 30
|
|
35
36
|
}
|
|
37
|
+
],
|
|
38
|
+
"subagentStop": [
|
|
39
|
+
{
|
|
40
|
+
"type": "command",
|
|
41
|
+
"command": "node \"${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-.}}/.grok/hooks/orchestrate-parent.js\"",
|
|
42
|
+
"timeout": 30
|
|
43
|
+
}
|
|
36
44
|
]
|
|
37
45
|
}
|
|
38
46
|
}
|
|
@@ -28,10 +28,11 @@ Every 60s peek is real host status (no `sleep` then guess). `timeout_ms=60000` e
|
|
|
28
28
|
Quote host fields only (`status`, elapsed since spawn, last activity/output snippet) or quote the error. Never invent.
|
|
29
29
|
Classify `done` | `alive` | `stuck` vs prior peek: **done** (completed/failed), **alive** (running + fields/output changed), **stuck** (running + unchanged). Unchanged snippet = stuck, not “still working”.
|
|
30
30
|
Loop until done. No auto-kill. Kill only if the user asks and the host has a kill tool.
|
|
31
|
-
Then print recap as `[
|
|
31
|
+
Then print recap as `[orchestrator]: …` (or the child role tag) with real child output. Do not redo the child's work.
|
|
32
32
|
|
|
33
|
-
Parent Stop (`MAX_STOP=3`): do not waive fenced/impl finals for `tools-used` stamp. Allow
|
|
34
|
-
`/
|
|
33
|
+
Parent Stop (`MAX_STOP=3`): do not waive fenced/impl finals for `tools-used` stamp. Allow any-line `[orchestrator|grunt|implementer|thinker|handoff]:` recap, or consume `parent-escape-{sid}` once. Else block. No `isCheap` / trivia / long definitions.
|
|
34
|
+
`/handoff` is the other in-parent turn: context large + work unfinished → parent writes one `.tmp/grunt/handoffs/` file from its own transcript (no child sees the session), recaps `[handoff]: serial=… path=…`, tells the user to continue in a new session. No spawn, one write, not a mode.
|
|
35
|
+
`/solo` is the session mode (not one-turn): `beforeSubmitPrompt` writes `.tmp/orchestrator-logs/grunt-off-{sid}`, every gate short-circuits while it exists, `/cascade` unlinks it. Everything above resumes next turn. Antigravity: skill-only, no stamp create (see hooks.md). `/parent` is one-turn (not a mode): UserPromptSubmit writes `.tmp/orchestrator-logs/parent-escape-{sid}`; Stop consumes+unlinks once. Next Stop resumes spawn/recap rules. Skills point here; do not paste this file.
|
|
35
36
|
|
|
36
37
|
Host mapping (in-tree only; do not invent peek/kill APIs). GAP rows: no fake peeks, no auto-kill.
|
|
37
38
|
|
|
@@ -12,7 +12,11 @@ Claude / Codex / Antigravity PreToolUse still run `scripts/scrub-spawn-prompt.mj
|
|
|
12
12
|
|
|
13
13
|
Parent `run_terminal_command` is deny-by-default except `node` (optional leading `rtk`) invoking `{workspace}/scripts/grunt-job.mjs` with `--job search|exec` and `--query`. Optional `--path` `--glob` (repeatable) `--cwd`. Not `web|test`, not other scripts. META strips `--query` value first so `|` inside query is allowed; pipes/`&&` outside query still deny. Never `cd &&`. Exec shell-meta in query is grunt-job FALLBACK (exit 2), not a parent-bash allow. Unknown flags → FALLBACK.
|
|
14
14
|
|
|
15
|
-
SubagentStop (and child Stop with `subagentType`) intercepts a parseable `need:` whose jobs are all `search|exec` (cap 4): runs grunt-job in-hook and continues the child with concatenated `verdict:` blobs. Mixed/web/test, parse fail, FALLBACK, `stopHookActive`, or more than 3 intercepts per child session → empty stdout (allow stop; parent LLM path). Parent Stop (`MAX_STOP=3`): do not waive impl finals for `tools-used`. Allow
|
|
15
|
+
SubagentStop (and child Stop with `subagentType`) intercepts a parseable `need:` whose jobs are all `search|exec` (cap 4): runs grunt-job in-hook and continues the child with concatenated `verdict:` blobs. Mixed/web/test, parse fail, FALLBACK, `stopHookActive`, or more than 3 intercepts per child session → empty stdout (allow stop; parent LLM path). Parent `write` is deny-by-default except `.tmp/plans/` (`persist-plan.mjs`) and `.tmp/grunt/handoffs/` (`persist-handoff.mjs`); both validate + rewrite path/content, invalid body denies. Parent Stop (`MAX_STOP=3`): do not waive impl finals for `tools-used`. Allow any-line `[orchestrator|grunt|implementer|thinker|handoff]:` recap, or consume `parent-escape-{sid}` once (`/parent`). No `isCheap` / trivia. Stamps: `tools-used` / `stop-block` / `need-intercept` / `parent-escape`. Skills point to cascade; do not paste protocol here.
|
|
16
|
+
|
|
17
|
+
`/solo` writes `grunt-off-{sid}`; `/cascade` unlinks it (`beforeSubmitPrompt`, exact-match only). While set: PreToolUse allows parent tools (fat gate still runs) and skips the `defaultGrunt` spawn rewrite; Stop returns 0 **before** the `parent-escape` consume, so solo never burns the one-turn stamp. Session-scoped, fail-closed.
|
|
18
|
+
|
|
19
|
+
rulesync canonical event is `beforeSubmitPrompt` (→ Claude/Codex `UserPromptSubmit`), not `userPromptSubmit` — an unknown key makes Zod reject the whole `.rulesync/hooks.jsonc`, emitting nothing while `--check` still reports up to date. Antigravity supports neither `beforeSubmitPrompt` nor `subagentStop`; `/solo` degrades there to the skill body alone.
|
|
16
20
|
|
|
17
21
|
Stop and SubagentStop timeout is 30s (grunt-job cap is 20s). PreToolUse / PostToolUse / UserPromptSubmit stay at 5s. If the intercept times out, the hook fail-opens and the child stops without verdicts.
|
|
18
22
|
|