@orchestrator-claude/definitions 3.34.0 → 3.35.0
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/ciclo-engineer.md +267 -0
- package/agents/orchestrator.md +14 -3
- package/dist/install/DevCheckoutDetector.d.ts +30 -13
- package/dist/install/DevCheckoutDetector.d.ts.map +1 -1
- package/dist/install/DevCheckoutDetector.js +33 -16
- package/dist/install/DevCheckoutDetector.js.map +1 -1
- package/hooks/agent-git-guard.mjs +118 -0
- package/hooks/agent-git-guard.ts +166 -0
- package/hooks/hooks.json +30 -15
- package/hooks/playwright-output-guard.mjs +136 -0
- package/hooks/playwright-output-guard.ts +130 -0
- package/hooks/pre-agent-dispatch.mjs +346 -0
- package/hooks/pre-agent-dispatch.ts +287 -0
- package/hooks/session-start.mjs +8 -36
- package/hooks/session-start.ts +15 -72
- package/hooks/subagent-stop.mjs +0 -18
- package/hooks/subagent-stop.ts +29 -13
- package/hooks/verification-before-completion.mjs +105 -0
- package/hooks/verification-before-completion.ts +97 -0
- package/hooks-settings-template.json +19 -4
- package/manifest.json +54 -6
- package/package.json +1 -1
- package/personas/INDEX.md +46 -0
- package/skills/ciclo-lane-dispatch/SKILL.md +158 -0
- package/skills/orchestrator/SKILL.md +3 -1
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { createRequire as __createRequireShim } from 'node:module'; const require = __createRequireShim(import.meta.url);
|
|
2
|
+
|
|
3
|
+
// hooks/pre-agent-dispatch.ts
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
// hooks/lib/api-client.ts
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync, statSync, unlinkSync, readdirSync } from "node:fs";
|
|
8
|
+
import { join as join2, dirname as dirname2 } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
// ../project-root-resolver/dist/index.js
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
function resolveProjectRoot(cwd, sentinel = ".orchestrator") {
|
|
15
|
+
let currentDir = cwd;
|
|
16
|
+
for (; ; ) {
|
|
17
|
+
if (existsSync(join(currentDir, sentinel))) {
|
|
18
|
+
return currentDir;
|
|
19
|
+
}
|
|
20
|
+
const parentDir = dirname(currentDir);
|
|
21
|
+
if (parentDir === currentDir) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
currentDir = parentDir;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// hooks/lib/api-client.ts
|
|
29
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
30
|
+
var __dirname = dirname2(__filename);
|
|
31
|
+
var PROJECT_ROOT = join2(__dirname, "..", "..", "..");
|
|
32
|
+
var STATE_DIR = join2(PROJECT_ROOT, ".orchestrator", ".state");
|
|
33
|
+
var JWT_CACHE = join2(STATE_DIR, "jwt-token");
|
|
34
|
+
var DEBUG_LOG = join2(STATE_DIR, "hook-debug.log");
|
|
35
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
36
|
+
var API_URL = process.env.ORCHESTRATOR_API_URL || "http://localhost:3001";
|
|
37
|
+
var AUTH_EMAIL = process.env.ORCHESTRATOR_ADMIN_EMAIL || process.env.ORCHESTRATOR_AUTH_EMAIL || "system@orchestrator.local";
|
|
38
|
+
var AUTH_PASSWORD = process.env.ORCHESTRATOR_ADMIN_PASSWORD || process.env.ORCHESTRATOR_AUTH_PASSWORD || "system123";
|
|
39
|
+
var PROJECT_ID = process.env.ORCHESTRATOR_PROJECT_ID || "";
|
|
40
|
+
function resolveHookProjectRoot(cwd) {
|
|
41
|
+
return resolveProjectRoot(cwd);
|
|
42
|
+
}
|
|
43
|
+
var projectIdResolutionAttempted = false;
|
|
44
|
+
async function ensureProjectIdResolved(token) {
|
|
45
|
+
if (PROJECT_ID || projectIdResolutionAttempted) return;
|
|
46
|
+
projectIdResolutionAttempted = true;
|
|
47
|
+
try {
|
|
48
|
+
const rootPath = resolveHookProjectRoot(process.cwd());
|
|
49
|
+
if (rootPath === null) {
|
|
50
|
+
log(
|
|
51
|
+
"API-CLIENT",
|
|
52
|
+
"ensureProjectIdResolved: cwd not registered (.orchestrator sentinel not found) \u2014 skipping"
|
|
53
|
+
);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const resolved = await resolveProjectIdByPath(rootPath, token);
|
|
57
|
+
if (resolved) {
|
|
58
|
+
PROJECT_ID = resolved;
|
|
59
|
+
log(
|
|
60
|
+
"API-CLIENT",
|
|
61
|
+
`ensureProjectIdResolved: resolved projectId=${resolved} rootPath=${rootPath}`
|
|
62
|
+
);
|
|
63
|
+
} else {
|
|
64
|
+
log(
|
|
65
|
+
"API-CLIENT",
|
|
66
|
+
`ensureProjectIdResolved: rootPath=${rootPath} not registered to any project \u2014 skipping`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
} catch (err) {
|
|
70
|
+
log(
|
|
71
|
+
"API-CLIENT",
|
|
72
|
+
`ensureProjectIdResolved: unexpected failure \u2014 ${err instanceof Error ? err.message : String(err)}`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function log(prefix, msg) {
|
|
77
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d+Z$/, "Z");
|
|
78
|
+
try {
|
|
79
|
+
const line = `[${ts}] ${prefix}: ${msg}
|
|
80
|
+
`;
|
|
81
|
+
writeFileSync(DEBUG_LOG, line, { flag: "a" });
|
|
82
|
+
} catch {
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function getCachedToken() {
|
|
86
|
+
try {
|
|
87
|
+
const stat = statSync(JWT_CACHE);
|
|
88
|
+
const ageMs = Date.now() - stat.mtimeMs;
|
|
89
|
+
if (ageMs < 35e5) {
|
|
90
|
+
return readFileSync(JWT_CACHE, "utf-8").trim();
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
async function login() {
|
|
97
|
+
try {
|
|
98
|
+
const resp = await fetch(`${API_URL}/api/v1/auth/login`, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: {
|
|
101
|
+
"Content-Type": "application/json",
|
|
102
|
+
"X-Project-ID": PROJECT_ID
|
|
103
|
+
},
|
|
104
|
+
body: JSON.stringify({ email: AUTH_EMAIL, password: AUTH_PASSWORD }),
|
|
105
|
+
signal: AbortSignal.timeout(5e3)
|
|
106
|
+
});
|
|
107
|
+
if (!resp.ok) return null;
|
|
108
|
+
const data = await resp.json();
|
|
109
|
+
const token = data.accessToken || data.data?.accessToken || "";
|
|
110
|
+
if (token) {
|
|
111
|
+
writeFileSync(JWT_CACHE, token);
|
|
112
|
+
}
|
|
113
|
+
return token || null;
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async function getToken() {
|
|
119
|
+
const token = getCachedToken() || await login();
|
|
120
|
+
if (token) {
|
|
121
|
+
await ensureProjectIdResolved(token);
|
|
122
|
+
}
|
|
123
|
+
return token;
|
|
124
|
+
}
|
|
125
|
+
async function apiGet(path, token, timeoutMs = 5e3) {
|
|
126
|
+
try {
|
|
127
|
+
const resp = await fetch(`${API_URL}${path}`, {
|
|
128
|
+
headers: {
|
|
129
|
+
Authorization: `Bearer ${token}`,
|
|
130
|
+
"X-Project-ID": PROJECT_ID
|
|
131
|
+
},
|
|
132
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
133
|
+
});
|
|
134
|
+
if (!resp.ok) return null;
|
|
135
|
+
return await resp.json();
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async function resolveProjectIdByPath(rootPath, token) {
|
|
141
|
+
const resp = await apiGet(
|
|
142
|
+
`/api/v1/projects/resolve?rootPath=${encodeURIComponent(rootPath)}`,
|
|
143
|
+
token
|
|
144
|
+
);
|
|
145
|
+
return resp?.projectId ?? null;
|
|
146
|
+
}
|
|
147
|
+
async function getActiveWorkflow() {
|
|
148
|
+
const token = await getToken();
|
|
149
|
+
if (!token) return null;
|
|
150
|
+
for (const status of ["in_progress", "awaiting_agent", "awaiting_approval"]) {
|
|
151
|
+
const resp = await apiGet(
|
|
152
|
+
`/api/v1/workflows?status=${status}&limit=1`,
|
|
153
|
+
token,
|
|
154
|
+
3e3
|
|
155
|
+
);
|
|
156
|
+
const list = resp?.data || (Array.isArray(resp) ? resp : null);
|
|
157
|
+
const wf = list?.[0];
|
|
158
|
+
if (wf?.id) return wf;
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
var LAST_COMPLETED_FILE = join2(STATE_DIR, "last-completed-workflow.json");
|
|
163
|
+
var LAST_COMPLETED_TTL_MS = 60 * 60 * 1e3;
|
|
164
|
+
var INVOCATIONS_DIR = join2(STATE_DIR, "invocations");
|
|
165
|
+
var INVOCATION_STATE_TTL_MS = 4 * 60 * 60 * 1e3;
|
|
166
|
+
var DISPATCH_INTENT_DIR = join2(STATE_DIR, "dispatch-intent");
|
|
167
|
+
function buildDispatchIntentPath(stateDir, workflowId) {
|
|
168
|
+
return join2(stateDir, "dispatch-intent", `${workflowId}.json`);
|
|
169
|
+
}
|
|
170
|
+
function writeDispatchIntent(workflowId) {
|
|
171
|
+
try {
|
|
172
|
+
mkdirSync(DISPATCH_INTENT_DIR, { recursive: true });
|
|
173
|
+
const path = buildDispatchIntentPath(STATE_DIR, workflowId);
|
|
174
|
+
const payload = { workflowId, stampedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
175
|
+
writeFileSync(path, JSON.stringify(payload), "utf-8");
|
|
176
|
+
return true;
|
|
177
|
+
} catch {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async function readStdin() {
|
|
182
|
+
const chunks = [];
|
|
183
|
+
for await (const chunk of process.stdin) {
|
|
184
|
+
chunks.push(chunk);
|
|
185
|
+
}
|
|
186
|
+
const raw = Buffer.concat(chunks).toString("utf-8").trim();
|
|
187
|
+
if (!raw) return {};
|
|
188
|
+
try {
|
|
189
|
+
return JSON.parse(raw);
|
|
190
|
+
} catch {
|
|
191
|
+
return {};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function getField(obj, path) {
|
|
195
|
+
const parts = path.split(".");
|
|
196
|
+
let current = obj;
|
|
197
|
+
for (const part of parts) {
|
|
198
|
+
if (current == null || typeof current !== "object") return "";
|
|
199
|
+
current = current[part];
|
|
200
|
+
}
|
|
201
|
+
return current != null ? String(current) : "";
|
|
202
|
+
}
|
|
203
|
+
function outputContext(hookEvent, context) {
|
|
204
|
+
console.log(
|
|
205
|
+
JSON.stringify({
|
|
206
|
+
hookSpecificOutput: {
|
|
207
|
+
hookEventName: hookEvent,
|
|
208
|
+
additionalContext: context
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// hooks/pre-agent-dispatch.ts
|
|
215
|
+
var HOOK = "PRE-AGENT-DISPATCH";
|
|
216
|
+
var KNOWN_AGENTS = [
|
|
217
|
+
"implementer",
|
|
218
|
+
"planner",
|
|
219
|
+
"specifier",
|
|
220
|
+
"reviewer",
|
|
221
|
+
"task-generator",
|
|
222
|
+
"researcher"
|
|
223
|
+
];
|
|
224
|
+
var KNOWN_AGENTS_REGEX = new RegExp(`\\b(${KNOWN_AGENTS.join("|")})\\b`, "i");
|
|
225
|
+
var COMMITTING_AGENTS = [
|
|
226
|
+
"implementer",
|
|
227
|
+
"azure-devops-engineer",
|
|
228
|
+
"presentation-engineer",
|
|
229
|
+
"operational-artifact-builder",
|
|
230
|
+
"cicd-platform-engineer",
|
|
231
|
+
"python-data-engineer",
|
|
232
|
+
"frontend-engineer"
|
|
233
|
+
];
|
|
234
|
+
var PROTECTED_BRANCHES = ["main", "master"];
|
|
235
|
+
function normalizeAgentName(subagentType) {
|
|
236
|
+
const i = subagentType.lastIndexOf(":");
|
|
237
|
+
return i === -1 ? subagentType : subagentType.slice(i + 1);
|
|
238
|
+
}
|
|
239
|
+
function isCommittingAgent(subagentType) {
|
|
240
|
+
if (!subagentType) return false;
|
|
241
|
+
const t = normalizeAgentName(subagentType).toLowerCase();
|
|
242
|
+
if (!t) return false;
|
|
243
|
+
return COMMITTING_AGENTS.some((a) => a.toLowerCase() === t);
|
|
244
|
+
}
|
|
245
|
+
function isProtectedBranch(branch) {
|
|
246
|
+
return PROTECTED_BRANCHES.includes(branch);
|
|
247
|
+
}
|
|
248
|
+
function parseBranchFromHead(headContents) {
|
|
249
|
+
const m = /^ref:\s*refs\/heads\/(.+)$/.exec(headContents.trim());
|
|
250
|
+
return m ? (m[1] ?? "").trim() : "";
|
|
251
|
+
}
|
|
252
|
+
function detectKnownAgentInPrompt(prompt) {
|
|
253
|
+
const match = KNOWN_AGENTS_REGEX.exec(prompt);
|
|
254
|
+
return match ? (match[1] ?? "").toLowerCase() : null;
|
|
255
|
+
}
|
|
256
|
+
function branchBeforeDispatchMessage(subagentType, branch) {
|
|
257
|
+
return `[BRANCH-BEFORE-DISPATCH][TD-171] About to dispatch committing agent '${subagentType}' while on protected branch '${branch}'. This agent can make LOCAL checkpoint commits, and agent-git-guard (TD-169) blocks push/merge but ALLOWS commit \u2014 so a local commit would land directly on '${branch}' (root cause of the S289 '4f1f4f37' breach). Check out a feature branch BEFORE dispatching; the orchestrator owns push after its independent assert.`;
|
|
258
|
+
}
|
|
259
|
+
function evaluateDispatch(ctx) {
|
|
260
|
+
const warnings = [];
|
|
261
|
+
const { subagentType, prompt, branch } = ctx;
|
|
262
|
+
if (!subagentType) {
|
|
263
|
+
const detected = detectKnownAgentInPrompt(prompt);
|
|
264
|
+
if (detected) {
|
|
265
|
+
warnings.push(
|
|
266
|
+
`[WARN] Agent dispatched without subagent_type. Known agent detected: "${detected}". Add subagent_type for proper frontmatter loading.`
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (isCommittingAgent(subagentType) && isProtectedBranch(branch)) {
|
|
271
|
+
warnings.push(branchBeforeDispatchMessage(subagentType, branch));
|
|
272
|
+
}
|
|
273
|
+
return warnings;
|
|
274
|
+
}
|
|
275
|
+
function readBranchFromGit() {
|
|
276
|
+
try {
|
|
277
|
+
return execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
278
|
+
encoding: "utf-8",
|
|
279
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
280
|
+
}).trim();
|
|
281
|
+
} catch {
|
|
282
|
+
return "";
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
var defaultDeps = {
|
|
286
|
+
readStdin,
|
|
287
|
+
outputContext,
|
|
288
|
+
log,
|
|
289
|
+
readBranch: readBranchFromGit,
|
|
290
|
+
getActiveWorkflow,
|
|
291
|
+
writeDispatchIntent
|
|
292
|
+
};
|
|
293
|
+
var DISPATCH_INTENT_RESOLVE_TIMEOUT_MS = 3e3;
|
|
294
|
+
async function stampDispatchIntent(deps) {
|
|
295
|
+
if (!deps.getActiveWorkflow || !deps.writeDispatchIntent) return;
|
|
296
|
+
try {
|
|
297
|
+
const workflow = await Promise.race([
|
|
298
|
+
deps.getActiveWorkflow(),
|
|
299
|
+
new Promise((resolve) => {
|
|
300
|
+
setTimeout(() => {
|
|
301
|
+
resolve(null);
|
|
302
|
+
}, DISPATCH_INTENT_RESOLVE_TIMEOUT_MS);
|
|
303
|
+
})
|
|
304
|
+
]);
|
|
305
|
+
if (workflow?.id) {
|
|
306
|
+
const stamped = deps.writeDispatchIntent(workflow.id);
|
|
307
|
+
deps.log(HOOK, `[TD-205] dispatch-intent stamped=${String(stamped)} workflow=${workflow.id}`);
|
|
308
|
+
}
|
|
309
|
+
} catch (err) {
|
|
310
|
+
deps.log(HOOK, `[TD-205] stampDispatchIntent failed (fail-open): ${String(err)}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
async function mainWithDeps(deps) {
|
|
314
|
+
const stdin = await deps.readStdin();
|
|
315
|
+
const toolInput = getField(stdin, "tool_input") || "";
|
|
316
|
+
const subagentType = getField(stdin, "tool_input.subagent_type") || getField(stdin, "tool_input.subagentType") || "";
|
|
317
|
+
const prompt = getField(stdin, "tool_input.prompt") || getField(stdin, "tool_input.description") || (typeof toolInput === "string" ? toolInput : JSON.stringify(toolInput));
|
|
318
|
+
const branch = deps.readBranch();
|
|
319
|
+
const warnings = evaluateDispatch({ subagentType, prompt, branch });
|
|
320
|
+
await stampDispatchIntent(deps);
|
|
321
|
+
if (warnings.length > 0) {
|
|
322
|
+
deps.outputContext(HOOK, warnings.join("\n"));
|
|
323
|
+
deps.log(
|
|
324
|
+
HOOK,
|
|
325
|
+
`${warnings.length} warning(s) (agent="${subagentType || "?"}" branch="${branch}")`
|
|
326
|
+
);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
deps.log(HOOK, `OK (agent="${subagentType || "?"}" branch="${branch}")`);
|
|
330
|
+
}
|
|
331
|
+
async function main() {
|
|
332
|
+
await mainWithDeps(defaultDeps);
|
|
333
|
+
}
|
|
334
|
+
main().catch(() => process.exit(0));
|
|
335
|
+
export {
|
|
336
|
+
COMMITTING_AGENTS,
|
|
337
|
+
branchBeforeDispatchMessage,
|
|
338
|
+
detectKnownAgentInPrompt,
|
|
339
|
+
evaluateDispatch,
|
|
340
|
+
isCommittingAgent,
|
|
341
|
+
isProtectedBranch,
|
|
342
|
+
mainWithDeps,
|
|
343
|
+
normalizeAgentName,
|
|
344
|
+
parseBranchFromHead,
|
|
345
|
+
stampDispatchIntent
|
|
346
|
+
};
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pre-agent-dispatch.ts — PreToolUse hook for the Agent tool
|
|
3
|
+
*
|
|
4
|
+
* NON-BLOCKING (always exits 0; only injects advisory context). Two responsibilities:
|
|
5
|
+
*
|
|
6
|
+
* 1. (RFC-027 Sprint 4) Warn when a known agent is dispatched WITHOUT subagent_type,
|
|
7
|
+
* so frontmatter (model/tools) loads correctly.
|
|
8
|
+
*
|
|
9
|
+
* 2. (TD-171) Branch-before-dispatch ALERT. When the orchestrator dispatches a
|
|
10
|
+
* COMMITTING agent — one whose tool list holds `mcp__orchestrator-tools__createCheckpoint`,
|
|
11
|
+
* so it may make local checkpoint commits — while sitting on a PROTECTED branch
|
|
12
|
+
* (main/master), warn it to check out a feature branch FIRST. Root cause of the
|
|
13
|
+
* S289 breach `4f1f4f37`: an implementer was dispatched from `main`, so its local
|
|
14
|
+
* commit landed directly on the protected branch. agent-git-guard (TD-169) blocks
|
|
15
|
+
* push/merge but intentionally ALLOWS local commit, so a local commit on `main`
|
|
16
|
+
* slips through. The real fix is branch-before-dispatch (orchestrator owns the
|
|
17
|
+
* branch); this hook is the non-blocking backstop. Per the user directive it ALERTS
|
|
18
|
+
* (not blocks) — the hard stop on the unrecoverable vector stays in agent-git-guard.
|
|
19
|
+
*
|
|
20
|
+
* 3. (TD-205) Dispatch-intent stamp. This hook fires SYNCHRONOUSLY at
|
|
21
|
+
* PreToolUse[Agent] — before the sub-agent spawns / before the turn yields — so it
|
|
22
|
+
* is the guaranteed-present signal for the bg-dispatch race: when the orchestrator
|
|
23
|
+
* dispatches a sub-agent in the BACKGROUND and yields immediately, dangling-guard's
|
|
24
|
+
* Stop hook can fire before subagent-start.ts's ASYNC startInvocation()+
|
|
25
|
+
* writeInvocationState() has landed, producing a false-positive BLOCK on a
|
|
26
|
+
* genuinely active workflow. Stamping a dispatch-intent marker for the active
|
|
27
|
+
* workflow here gives dangling-guard a short (seconds), self-expiring grace-window
|
|
28
|
+
* signal to cover that race without opening a hole in the rule-9 zombie safety net.
|
|
29
|
+
* Best-effort / fail-open in every dimension (missing deps, no active workflow,
|
|
30
|
+
* resolution timeout, filesystem error) — never blocks the dispatch.
|
|
31
|
+
*
|
|
32
|
+
* Design note (TD-171): the committing-agent set mirrors the `createCheckpoint` tool
|
|
33
|
+
* holders in `.claude/agents/*.md`. Keep it in sync with that ground truth and with the
|
|
34
|
+
* orchestrator SKILL §2c.1 Commit-Policy scope.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { execFileSync } from 'node:child_process';
|
|
38
|
+
import {
|
|
39
|
+
log,
|
|
40
|
+
readStdin,
|
|
41
|
+
getField,
|
|
42
|
+
outputContext,
|
|
43
|
+
getActiveWorkflow,
|
|
44
|
+
writeDispatchIntent,
|
|
45
|
+
} from './lib/api-client.js';
|
|
46
|
+
|
|
47
|
+
const HOOK = 'PRE-AGENT-DISPATCH';
|
|
48
|
+
|
|
49
|
+
// Known workflow agents (RFC-027 Sprint 4 — subagent_type presence check).
|
|
50
|
+
const KNOWN_AGENTS = [
|
|
51
|
+
'implementer',
|
|
52
|
+
'planner',
|
|
53
|
+
'specifier',
|
|
54
|
+
'reviewer',
|
|
55
|
+
'task-generator',
|
|
56
|
+
'researcher',
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const KNOWN_AGENTS_REGEX = new RegExp(`\\b(${KNOWN_AGENTS.join('|')})\\b`, 'i');
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Agents whose tool list includes `mcp__orchestrator-tools__createCheckpoint` (ground
|
|
63
|
+
* truth: grep `createCheckpoint` over `.claude/agents/*.md`). These may make local
|
|
64
|
+
* checkpoint commits during their phase, so dispatching them on a protected branch is
|
|
65
|
+
* the branch-before-dispatch hazard. Keep in sync with orchestrator SKILL §2c.1.
|
|
66
|
+
*/
|
|
67
|
+
export const COMMITTING_AGENTS: readonly string[] = [
|
|
68
|
+
'implementer',
|
|
69
|
+
'azure-devops-engineer',
|
|
70
|
+
'presentation-engineer',
|
|
71
|
+
'operational-artifact-builder',
|
|
72
|
+
'cicd-platform-engineer',
|
|
73
|
+
'python-data-engineer',
|
|
74
|
+
'frontend-engineer',
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
const PROTECTED_BRANCHES: readonly string[] = ['main', 'master'];
|
|
78
|
+
|
|
79
|
+
// ─────────────────────────────────────────────────────────────
|
|
80
|
+
// Pure helpers (exported for testing)
|
|
81
|
+
// ─────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Strip the plugin namespace from a dispatch name.
|
|
85
|
+
*
|
|
86
|
+
* Under plugin scope the harness namespaces every agent (`orchestrator:implementer`
|
|
87
|
+
* — measured in the S398 sandbox, RFC-040 §18.14); in project scope the same agent
|
|
88
|
+
* arrives bare (`implementer`). A guard that compares against the bare name only
|
|
89
|
+
* answers `false` for the namespaced form — and since hooks fail open, it disables
|
|
90
|
+
* itself in silence. Both forms MUST resolve to the same decision.
|
|
91
|
+
*/
|
|
92
|
+
export function normalizeAgentName(subagentType: string): string {
|
|
93
|
+
const i = subagentType.lastIndexOf(':');
|
|
94
|
+
return i === -1 ? subagentType : subagentType.slice(i + 1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function isCommittingAgent(subagentType: string): boolean {
|
|
98
|
+
if (!subagentType) return false;
|
|
99
|
+
const t = normalizeAgentName(subagentType).toLowerCase();
|
|
100
|
+
if (!t) return false;
|
|
101
|
+
return COMMITTING_AGENTS.some((a) => a.toLowerCase() === t);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function isProtectedBranch(branch: string): boolean {
|
|
105
|
+
return PROTECTED_BRANCHES.includes(branch);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Parse `.git/HEAD` contents → branch name, or '' for detached HEAD / junk. */
|
|
109
|
+
export function parseBranchFromHead(headContents: string): string {
|
|
110
|
+
const m = /^ref:\s*refs\/heads\/(.+)$/.exec(headContents.trim());
|
|
111
|
+
return m ? (m[1] ?? '').trim() : '';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function detectKnownAgentInPrompt(prompt: string): string | null {
|
|
115
|
+
const match = KNOWN_AGENTS_REGEX.exec(prompt);
|
|
116
|
+
return match ? (match[1] ?? '').toLowerCase() : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function branchBeforeDispatchMessage(subagentType: string, branch: string): string {
|
|
120
|
+
return (
|
|
121
|
+
`[BRANCH-BEFORE-DISPATCH][TD-171] About to dispatch committing agent '${subagentType}' while on ` +
|
|
122
|
+
`protected branch '${branch}'. This agent can make LOCAL checkpoint commits, and agent-git-guard ` +
|
|
123
|
+
`(TD-169) blocks push/merge but ALLOWS commit — so a local commit would land directly on '${branch}' ` +
|
|
124
|
+
`(root cause of the S289 '4f1f4f37' breach). Check out a feature branch BEFORE dispatching; the ` +
|
|
125
|
+
`orchestrator owns push after its independent assert.`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface DispatchContext {
|
|
130
|
+
subagentType: string;
|
|
131
|
+
prompt: string;
|
|
132
|
+
branch: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Pure decision. Returns the list of advisory warning lines (possibly empty).
|
|
137
|
+
* - missing subagent_type + a known agent named in the prompt → RFC-027 warn
|
|
138
|
+
* - committing agent dispatched on a protected branch → TD-171 branch-before-dispatch warn
|
|
139
|
+
*/
|
|
140
|
+
export function evaluateDispatch(ctx: DispatchContext): string[] {
|
|
141
|
+
const warnings: string[] = [];
|
|
142
|
+
const { subagentType, prompt, branch } = ctx;
|
|
143
|
+
|
|
144
|
+
if (!subagentType) {
|
|
145
|
+
const detected = detectKnownAgentInPrompt(prompt);
|
|
146
|
+
if (detected) {
|
|
147
|
+
warnings.push(
|
|
148
|
+
`[WARN] Agent dispatched without subagent_type. Known agent detected: "${detected}". ` +
|
|
149
|
+
`Add subagent_type for proper frontmatter loading.`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (isCommittingAgent(subagentType) && isProtectedBranch(branch)) {
|
|
155
|
+
warnings.push(branchBeforeDispatchMessage(subagentType, branch));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return warnings;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ─────────────────────────────────────────────────────────────
|
|
162
|
+
// Dependency injection interface (enables testing without IO)
|
|
163
|
+
// ─────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
export interface HookDeps {
|
|
166
|
+
readStdin: () => Promise<Record<string, unknown>>;
|
|
167
|
+
outputContext: (hookEvent: string, context: string) => void;
|
|
168
|
+
log: (prefix: string, msg: string) => void;
|
|
169
|
+
readBranch: () => string;
|
|
170
|
+
/**
|
|
171
|
+
* TD-205: resolves the active workflow so the dispatch-intent marker can be
|
|
172
|
+
* workflow-scoped. Optional so pre-TD-205 test doubles keep working —
|
|
173
|
+
* stampDispatchIntent() no-ops (fail-open) when this is absent. Production
|
|
174
|
+
* defaultDeps below always provides it.
|
|
175
|
+
*/
|
|
176
|
+
getActiveWorkflow?: () => Promise<{ id: string } | null>;
|
|
177
|
+
/** TD-205: stamps the dispatch-intent marker for workflowId. See getActiveWorkflow doc. */
|
|
178
|
+
writeDispatchIntent?: (workflowId: string) => boolean;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function readBranchFromGit(): string {
|
|
182
|
+
try {
|
|
183
|
+
// Fixed args, no shell (execFile, not exec) — no interpolation, no injection surface.
|
|
184
|
+
return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
185
|
+
encoding: 'utf-8',
|
|
186
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
187
|
+
}).trim();
|
|
188
|
+
} catch {
|
|
189
|
+
return '';
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const defaultDeps: HookDeps = {
|
|
194
|
+
readStdin,
|
|
195
|
+
outputContext,
|
|
196
|
+
log,
|
|
197
|
+
readBranch: readBranchFromGit,
|
|
198
|
+
getActiveWorkflow,
|
|
199
|
+
writeDispatchIntent,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* TD-205: internal resolve timeout for stampDispatchIntent's getActiveWorkflow()
|
|
204
|
+
* call — seconds, bounded well within this hook's own 5000ms PreToolUse
|
|
205
|
+
* timeout (settings.json) so a slow/unavailable API degrades gracefully
|
|
206
|
+
* (marker simply isn't stamped for that dispatch) rather than ever blocking
|
|
207
|
+
* the dispatch itself.
|
|
208
|
+
*/
|
|
209
|
+
const DISPATCH_INTENT_RESOLVE_TIMEOUT_MS = 3000;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* TD-205: stamps a dispatch-intent marker for the currently active workflow,
|
|
213
|
+
* if one is resolvable within DISPATCH_INTENT_RESOLVE_TIMEOUT_MS. Fail-open
|
|
214
|
+
* in every dimension — missing deps (pre-TD-205 test doubles), no active
|
|
215
|
+
* workflow, a rejected/never-resolving getActiveWorkflow(), or a filesystem
|
|
216
|
+
* failure inside writeDispatchIntent() all silently skip the stamp. This is
|
|
217
|
+
* a best-effort advisory signal, never a blocking step (matches this hook's
|
|
218
|
+
* existing non-blocking posture).
|
|
219
|
+
*/
|
|
220
|
+
export async function stampDispatchIntent(
|
|
221
|
+
deps: Pick<HookDeps, 'getActiveWorkflow' | 'writeDispatchIntent' | 'log'>
|
|
222
|
+
): Promise<void> {
|
|
223
|
+
if (!deps.getActiveWorkflow || !deps.writeDispatchIntent) return;
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const workflow = await Promise.race([
|
|
227
|
+
deps.getActiveWorkflow(),
|
|
228
|
+
new Promise<null>((resolve) => {
|
|
229
|
+
setTimeout(() => {
|
|
230
|
+
resolve(null);
|
|
231
|
+
}, DISPATCH_INTENT_RESOLVE_TIMEOUT_MS);
|
|
232
|
+
}),
|
|
233
|
+
]);
|
|
234
|
+
if (workflow?.id) {
|
|
235
|
+
const stamped = deps.writeDispatchIntent(workflow.id);
|
|
236
|
+
deps.log(HOOK, `[TD-205] dispatch-intent stamped=${String(stamped)} workflow=${workflow.id}`);
|
|
237
|
+
}
|
|
238
|
+
} catch (err) {
|
|
239
|
+
deps.log(HOOK, `[TD-205] stampDispatchIntent failed (fail-open): ${String(err)}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ─────────────────────────────────────────────────────────────
|
|
244
|
+
// Core logic (exported for testing)
|
|
245
|
+
// ─────────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
export async function mainWithDeps(deps: HookDeps): Promise<void> {
|
|
248
|
+
const stdin = await deps.readStdin();
|
|
249
|
+
|
|
250
|
+
const toolInput = getField(stdin, 'tool_input') || '';
|
|
251
|
+
const subagentType =
|
|
252
|
+
getField(stdin, 'tool_input.subagent_type') || getField(stdin, 'tool_input.subagentType') || '';
|
|
253
|
+
const prompt =
|
|
254
|
+
getField(stdin, 'tool_input.prompt') ||
|
|
255
|
+
getField(stdin, 'tool_input.description') ||
|
|
256
|
+
(typeof toolInput === 'string' ? toolInput : JSON.stringify(toolInput));
|
|
257
|
+
|
|
258
|
+
const branch = deps.readBranch();
|
|
259
|
+
|
|
260
|
+
const warnings = evaluateDispatch({ subagentType, prompt, branch });
|
|
261
|
+
|
|
262
|
+
// TD-205: stamp the dispatch-intent marker regardless of whether a warning
|
|
263
|
+
// fires — a dispatch is happening either way, and the marker exists purely
|
|
264
|
+
// to cover the bg-dispatch race for dangling-guard's Stop hook.
|
|
265
|
+
await stampDispatchIntent(deps);
|
|
266
|
+
|
|
267
|
+
if (warnings.length > 0) {
|
|
268
|
+
deps.outputContext(HOOK, warnings.join('\n'));
|
|
269
|
+
deps.log(
|
|
270
|
+
HOOK,
|
|
271
|
+
`${warnings.length} warning(s) (agent="${subagentType || '?'}" branch="${branch}")`
|
|
272
|
+
);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
deps.log(HOOK, `OK (agent="${subagentType || '?'}" branch="${branch}")`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ─────────────────────────────────────────────────────────────
|
|
280
|
+
// Entry point (production use)
|
|
281
|
+
// ─────────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
async function main(): Promise<void> {
|
|
284
|
+
await mainWithDeps(defaultDeps);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
main().catch(() => process.exit(0));
|