@jjlabsio/claude-crew 0.1.33 → 0.1.35
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +8 -7
- package/README.md +22 -0
- package/agents/code-reviewer.md +7 -0
- package/agents/dev.md +7 -0
- package/agents/explorer.md +7 -0
- package/agents/plan-evaluator.md +7 -0
- package/agents/planner.md +7 -0
- package/agents/pm.md +7 -0
- package/agents/qa.md +8 -1
- package/agents/researcher.md +7 -0
- package/agents/techlead.md +7 -0
- package/data/agent-contracts.json +350 -0
- package/data/agent-instructions/code-reviewer.md +47 -0
- package/data/agent-instructions/dev.md +48 -0
- package/data/agent-instructions/explorer.md +14 -0
- package/data/agent-instructions/plan-evaluator.md +68 -0
- package/data/agent-instructions/planner.md +73 -0
- package/data/agent-instructions/pm.md +47 -0
- package/data/agent-instructions/qa.md +65 -0
- package/data/agent-instructions/researcher.md +15 -0
- package/data/agent-instructions/techlead.md +66 -0
- package/hooks/enforce-delegation.mjs +51 -0
- package/package.json +8 -3
- package/scripts/crew-agent-runner.mjs +382 -0
- package/scripts/lib/build.mjs +213 -0
- package/scripts/lib/cli.mjs +30 -0
- package/scripts/lib/config.mjs +33 -0
- package/scripts/lib/contracts.mjs +146 -0
- package/scripts/lib/dispatch.mjs +241 -0
- package/scripts/lib/installHooks.mjs +136 -0
- package/scripts/lib/pluginRoot.mjs +10 -0
- package/scripts/lib/prepare.mjs +37 -0
- package/scripts/lib/render.mjs +138 -0
- package/scripts/lib/renderFollowup.mjs +51 -0
- package/scripts/lib/resolve.mjs +72 -0
- package/scripts/lib/skillDispatchContract.mjs +93 -0
- package/scripts/lib/validate.mjs +104 -0
- package/skills/crew-agent-runner/SKILL.md +113 -0
- package/skills/crew-dev/SKILL.md +171 -776
- package/skills/crew-interview/SKILL.md +137 -57
- package/skills/crew-plan/SKILL.md +224 -460
- package/skills/crew-setup/SKILL.md +32 -19
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { pluginPath } from "./pluginRoot.mjs";
|
|
5
|
+
|
|
6
|
+
const WORKSPACE_ACCESS_VALUES = new Set(["read-only", "workspace-write"]);
|
|
7
|
+
|
|
8
|
+
export function loadContracts(filePath) {
|
|
9
|
+
const resolvedPath =
|
|
10
|
+
filePath === undefined
|
|
11
|
+
? pluginPath("data", "agent-contracts.json")
|
|
12
|
+
: resolve(process.cwd(), filePath);
|
|
13
|
+
const contracts = JSON.parse(readFileSync(resolvedPath, "utf8"));
|
|
14
|
+
validateContracts(contracts);
|
|
15
|
+
return contracts;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function validateContracts(obj) {
|
|
19
|
+
const diagnostics = [];
|
|
20
|
+
|
|
21
|
+
if (!isPlainObject(obj)) {
|
|
22
|
+
throw new Error("Invalid agent contracts:\n- <root>: expected object");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (typeof obj.version !== "number") {
|
|
26
|
+
diagnostics.push("<root>: version must be a number");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!Array.isArray(obj.roles)) {
|
|
30
|
+
diagnostics.push("<root>: roles must be an array");
|
|
31
|
+
throwContractsError(diagnostics);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (obj.roles.length === 0) {
|
|
35
|
+
diagnostics.push("<root>: roles must not be empty");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const seenRoles = new Set();
|
|
39
|
+
for (const [index, contract] of obj.roles.entries()) {
|
|
40
|
+
if (isPlainObject(contract) && typeof contract.role === "string") {
|
|
41
|
+
if (seenRoles.has(contract.role)) {
|
|
42
|
+
diagnostics.push(`${contract.role}: duplicate role name`);
|
|
43
|
+
}
|
|
44
|
+
seenRoles.add(contract.role);
|
|
45
|
+
}
|
|
46
|
+
validateRole(contract, index, diagnostics);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (diagnostics.length > 0) {
|
|
50
|
+
throwContractsError(diagnostics);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function validateRole(contract, index, diagnostics) {
|
|
55
|
+
const role = getRoleLabel(contract, index);
|
|
56
|
+
|
|
57
|
+
if (!isPlainObject(contract)) {
|
|
58
|
+
diagnostics.push(`${role}: role contract must be an object`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
requireField(contract, "role", role, diagnostics);
|
|
63
|
+
requireField(contract, "inputs", role, diagnostics);
|
|
64
|
+
requireField(contract, "outputs", role, diagnostics);
|
|
65
|
+
requireField(contract, "capabilities", role, diagnostics);
|
|
66
|
+
requireField(contract, "policy", role, diagnostics);
|
|
67
|
+
requireField(contract, "claudeSubagent", role, diagnostics);
|
|
68
|
+
|
|
69
|
+
if (typeof contract.role !== "string") {
|
|
70
|
+
diagnostics.push(`${role}: role must be a string`);
|
|
71
|
+
}
|
|
72
|
+
validateCapabilities(contract.capabilities, role, diagnostics);
|
|
73
|
+
validatePolicy(contract.policy, role, diagnostics);
|
|
74
|
+
validateClaudeSubagent(contract.claudeSubagent, role, diagnostics);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function validateCapabilities(capabilities, role, diagnostics) {
|
|
78
|
+
if (!isPlainObject(capabilities)) {
|
|
79
|
+
diagnostics.push(`${role}: capabilities must be an object`);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!WORKSPACE_ACCESS_VALUES.has(capabilities.workspaceAccess)) {
|
|
84
|
+
diagnostics.push(
|
|
85
|
+
`${role}: capabilities.workspaceAccess must be one of ${Array.from(WORKSPACE_ACCESS_VALUES).join(", ")}`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function validatePolicy(policy, role, diagnostics) {
|
|
91
|
+
if (!isPlainObject(policy)) {
|
|
92
|
+
diagnostics.push(`${role}: policy must be an object`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!Number.isInteger(policy.maxAttempts) || policy.maxAttempts < 1) {
|
|
97
|
+
diagnostics.push(`${role}: policy.maxAttempts must be an integer >= 1`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function validateClaudeSubagent(claudeSubagent, role, diagnostics) {
|
|
102
|
+
if (!isPlainObject(claudeSubagent)) {
|
|
103
|
+
diagnostics.push(`${role}: claudeSubagent must be an object`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
validateStringArray(
|
|
108
|
+
claudeSubagent.tools,
|
|
109
|
+
`${role}: claudeSubagent.tools`,
|
|
110
|
+
diagnostics
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function validateStringArray(value, field, diagnostics) {
|
|
115
|
+
if (!Array.isArray(value)) {
|
|
116
|
+
diagnostics.push(`${field} must be a string array`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const [index, item] of value.entries()) {
|
|
121
|
+
if (typeof item !== "string") {
|
|
122
|
+
diagnostics.push(`${field}[${index}] must be a string`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function requireField(contract, field, role, diagnostics) {
|
|
128
|
+
if (!Object.hasOwn(contract, field)) {
|
|
129
|
+
diagnostics.push(`${role}: missing required field ${field}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function throwContractsError(diagnostics) {
|
|
134
|
+
throw new Error(`Invalid agent contracts:\n- ${diagnostics.join("\n- ")}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function getRoleLabel(contract, index) {
|
|
138
|
+
if (isPlainObject(contract) && typeof contract.role === "string") {
|
|
139
|
+
return contract.role;
|
|
140
|
+
}
|
|
141
|
+
return `<role-${index}>`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isPlainObject(value) {
|
|
145
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
146
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import { renderPrompt } from "./render.mjs";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_COMPANION = fileURLToPath(
|
|
10
|
+
new URL("../crew-codex-companion.mjs", import.meta.url)
|
|
11
|
+
);
|
|
12
|
+
const AGENT_RESULT_STATUSES = new Set([
|
|
13
|
+
"complete",
|
|
14
|
+
"blocked_on_user",
|
|
15
|
+
"needs_agent",
|
|
16
|
+
"needs_tool",
|
|
17
|
+
"failed"
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
export class DispatchError extends Error {
|
|
21
|
+
constructor(message, options = {}) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "DispatchError";
|
|
24
|
+
this.agentResult = options.agentResult ?? null;
|
|
25
|
+
this.companionPayload = options.companionPayload ?? null;
|
|
26
|
+
this.exitCode = options.exitCode ?? 1;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function formatDispatchProviderGuardMessage(role, provider) {
|
|
31
|
+
return `dispatch is for Codex provider only. Resolved provider for role '${role}' is '${provider}'. Use 'render' + Agent tool for Claude provider (see crew-agent-runner SKILL.md).`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function dispatch(input) {
|
|
35
|
+
if (input.resolved?.provider !== "codex") {
|
|
36
|
+
throw new DispatchError(
|
|
37
|
+
formatDispatchProviderGuardMessage(
|
|
38
|
+
input.role,
|
|
39
|
+
input.resolved?.provider ?? "unknown"
|
|
40
|
+
),
|
|
41
|
+
{ exitCode: 2 }
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const companion = resolveCompanion(input);
|
|
46
|
+
await assertResumeCandidate(input.resumeHandle, companion);
|
|
47
|
+
|
|
48
|
+
const tmpDir = await mkdtemp(join(tmpdir(), "claude-crew-dispatch-"));
|
|
49
|
+
const promptFile = join(tmpDir, `${input.role}-prompt.md`);
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
await writeFile(
|
|
53
|
+
promptFile,
|
|
54
|
+
renderPrompt({
|
|
55
|
+
role: input.role,
|
|
56
|
+
request: input.request,
|
|
57
|
+
contract: input.contract
|
|
58
|
+
}),
|
|
59
|
+
"utf8"
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const args = buildTaskArgs(input, promptFile);
|
|
63
|
+
const execution = await runCompanion(companion, args);
|
|
64
|
+
const payload = parseCompanionJson(execution.stdout, "task");
|
|
65
|
+
|
|
66
|
+
if (payload.crewAgentResultError) {
|
|
67
|
+
throw new DispatchError(
|
|
68
|
+
`Companion returned crewAgentResultError: ${payload.crewAgentResultError}`,
|
|
69
|
+
{ companionPayload: payload }
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const agentResult = normalizeAgentResult(payload.crewAgentResult, payload.threadId);
|
|
74
|
+
if (!agentResult) {
|
|
75
|
+
throw new DispatchError("Companion did not return crewAgentResult.", {
|
|
76
|
+
companionPayload: payload
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (execution.status !== 0 && agentResult.status !== "failed") {
|
|
81
|
+
throw new DispatchError(`Companion exited with status ${execution.status}.`, {
|
|
82
|
+
agentResult,
|
|
83
|
+
companionPayload: payload
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return agentResult;
|
|
88
|
+
} finally {
|
|
89
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function runCompanion(companion, args) {
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
const child = spawn(companion.command, [...companion.prefixArgs, ...args], {
|
|
96
|
+
cwd: process.cwd(),
|
|
97
|
+
env: process.env,
|
|
98
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
99
|
+
windowsHide: true
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
let stdout = "";
|
|
103
|
+
let stderr = "";
|
|
104
|
+
child.stdout.setEncoding("utf8");
|
|
105
|
+
child.stderr.setEncoding("utf8");
|
|
106
|
+
child.stdout.on("data", (chunk) => {
|
|
107
|
+
stdout += chunk;
|
|
108
|
+
});
|
|
109
|
+
child.stderr.on("data", (chunk) => {
|
|
110
|
+
stderr += chunk;
|
|
111
|
+
});
|
|
112
|
+
child.on("error", reject);
|
|
113
|
+
child.on("close", (status, signal) => {
|
|
114
|
+
resolve({
|
|
115
|
+
status: status ?? 1,
|
|
116
|
+
signal,
|
|
117
|
+
stdout,
|
|
118
|
+
stderr
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function assertResumeCandidate(resumeHandle, companion) {
|
|
125
|
+
if (!resumeHandle) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const execution = await runCompanion(companion, [
|
|
130
|
+
"task-resume-candidate",
|
|
131
|
+
"--json"
|
|
132
|
+
]);
|
|
133
|
+
const payload = parseCompanionJson(execution.stdout, "task-resume-candidate");
|
|
134
|
+
const candidateThreadId = payload?.candidate?.threadId ?? null;
|
|
135
|
+
|
|
136
|
+
if (execution.status !== 0) {
|
|
137
|
+
throw new DispatchError(
|
|
138
|
+
`Companion resume candidate lookup failed with status ${execution.status}.`,
|
|
139
|
+
{ companionPayload: payload }
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!candidateThreadId) {
|
|
144
|
+
throw new DispatchError(`No resume candidate found for ${resumeHandle}.`, {
|
|
145
|
+
companionPayload: payload
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (candidateThreadId !== resumeHandle) {
|
|
150
|
+
throw new DispatchError(
|
|
151
|
+
`Resume handle ${resumeHandle} does not match candidate ${candidateThreadId}.`,
|
|
152
|
+
{ companionPayload: payload }
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function buildTaskArgs(input, promptFile) {
|
|
158
|
+
const args = [
|
|
159
|
+
"task",
|
|
160
|
+
"--json",
|
|
161
|
+
"--expect-crew-result",
|
|
162
|
+
"--prompt-file",
|
|
163
|
+
promptFile
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
if (input.resumeHandle) {
|
|
167
|
+
args.push("--resume-last");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (input.resolved?.codex_sandbox === "workspace-write") {
|
|
171
|
+
args.push("--write");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (input.resolved?.model) {
|
|
175
|
+
args.push("--model", input.resolved.model);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (input.resolved?.reasoning) {
|
|
179
|
+
args.push("--effort", input.resolved.reasoning);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return args;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function normalizeAgentResult(value, threadId) {
|
|
186
|
+
if (value == null || typeof value !== "object" || Array.isArray(value)) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
if (!AGENT_RESULT_STATUSES.has(value.status)) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
...value,
|
|
195
|
+
questions: Array.isArray(value.questions) ? value.questions : [],
|
|
196
|
+
requests: Array.isArray(value.requests) ? value.requests : [],
|
|
197
|
+
error: value.error ?? null,
|
|
198
|
+
agent_handle: threadId ?? value.agent_handle ?? null
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function parseCompanionJson(stdout, command) {
|
|
203
|
+
try {
|
|
204
|
+
return JSON.parse(stdout);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
throw new DispatchError(
|
|
207
|
+
`Companion ${command} did not return JSON: ${
|
|
208
|
+
error instanceof Error ? error.message : String(error)
|
|
209
|
+
}`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function resolveCompanion(input = {}) {
|
|
215
|
+
const nodeScript =
|
|
216
|
+
input.companionBin ?? process.env.CREW_COMPANION_NODE_BIN ?? null;
|
|
217
|
+
if (nodeScript) {
|
|
218
|
+
return {
|
|
219
|
+
command: process.execPath,
|
|
220
|
+
prefixArgs: [nodeScript]
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const binary = process.env.CREW_COMPANION_BIN ?? DEFAULT_COMPANION;
|
|
225
|
+
if (isNodeScript(binary)) {
|
|
226
|
+
return {
|
|
227
|
+
command: process.execPath,
|
|
228
|
+
prefixArgs: [binary]
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
command: binary,
|
|
234
|
+
prefixArgs: []
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function isNodeScript(value) {
|
|
239
|
+
const name = basename(String(value));
|
|
240
|
+
return name.endsWith(".mjs") || name.endsWith(".js");
|
|
241
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const MANAGED_BLOCK = [
|
|
6
|
+
"# >>> crew-agent-runner managed >>>",
|
|
7
|
+
"node scripts/crew-agent-runner.mjs validate || {",
|
|
8
|
+
" echo \"crew-agent-runner: validate failed. Run 'node scripts/crew-agent-runner.mjs build' to fix drift.\" >&2",
|
|
9
|
+
" exit 1",
|
|
10
|
+
"}",
|
|
11
|
+
"# <<< crew-agent-runner managed <<<"
|
|
12
|
+
].join("\n");
|
|
13
|
+
|
|
14
|
+
const START_MARKER = "# >>> crew-agent-runner managed >>>";
|
|
15
|
+
const END_MARKER = "# <<< crew-agent-runner managed <<<";
|
|
16
|
+
const BASH_SHEBANG = "#!/usr/bin/env bash";
|
|
17
|
+
|
|
18
|
+
export async function installHooks({ root = process.cwd() } = {}) {
|
|
19
|
+
const projectRoot = resolve(root);
|
|
20
|
+
if (!(await isPluginSourceRepo(projectRoot))) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
"install-hooks is for claude-crew plugin developers only. " +
|
|
23
|
+
"End users do not need this command (build/validate are dev tools). " +
|
|
24
|
+
"If you are developing claude-crew, run from the plugin source repo root."
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const hooksDir = await resolveHooksDir(projectRoot);
|
|
29
|
+
const hookPath = join(hooksDir, "pre-commit");
|
|
30
|
+
await mkdir(hooksDir, { recursive: true });
|
|
31
|
+
|
|
32
|
+
const existing = await readUtf8OrNull(hookPath);
|
|
33
|
+
const base = ensureShebang(existing ?? "");
|
|
34
|
+
const next = upsertManagedBlock(base, MANAGED_BLOCK);
|
|
35
|
+
|
|
36
|
+
if (next !== existing) {
|
|
37
|
+
await writeFile(hookPath, next, "utf8");
|
|
38
|
+
}
|
|
39
|
+
await chmod(hookPath, 0o755);
|
|
40
|
+
|
|
41
|
+
return { hookPath };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function isPluginSourceRepo(root) {
|
|
45
|
+
try {
|
|
46
|
+
const pluginJsonPath = join(root, ".claude-plugin", "plugin.json");
|
|
47
|
+
const packageJsonPath = join(root, "package.json");
|
|
48
|
+
const pluginJsonStat = await stat(pluginJsonPath);
|
|
49
|
+
const packageJsonStat = await stat(packageJsonPath);
|
|
50
|
+
if (!pluginJsonStat.isFile() || !packageJsonStat.isFile()) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const pkg = JSON.parse(await readFile(packageJsonPath, "utf8"));
|
|
55
|
+
return pkg.name === "@jjlabsio/claude-crew";
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function ensureShebang(content) {
|
|
62
|
+
if (content.startsWith("#!")) {
|
|
63
|
+
return content;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return `${BASH_SHEBANG}\n${content}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function upsertManagedBlock(content, block) {
|
|
70
|
+
const start = content.indexOf(START_MARKER);
|
|
71
|
+
if (start !== -1) {
|
|
72
|
+
const end = content.indexOf(END_MARKER, start + START_MARKER.length);
|
|
73
|
+
if (end !== -1) {
|
|
74
|
+
const afterEnd = end + END_MARKER.length;
|
|
75
|
+
return `${content.slice(0, start)}${block}${content.slice(afterEnd)}`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const separator = content.length === 0 || content.endsWith("\n") ? "" : "\n";
|
|
80
|
+
return `${content}${separator}${block}\n`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function resolveHooksDir(projectRoot) {
|
|
84
|
+
const configuredHooksPath = readConfiguredHooksPath(projectRoot);
|
|
85
|
+
if (configuredHooksPath) {
|
|
86
|
+
return isAbsolute(configuredHooksPath)
|
|
87
|
+
? configuredHooksPath
|
|
88
|
+
: resolve(projectRoot, configuredHooksPath);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const dotGit = join(projectRoot, ".git");
|
|
92
|
+
try {
|
|
93
|
+
const dotGitStat = await stat(dotGit);
|
|
94
|
+
if (dotGitStat.isDirectory()) {
|
|
95
|
+
return join(dotGit, "hooks");
|
|
96
|
+
}
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error?.code === "ENOENT") {
|
|
99
|
+
return join(dotGit, "hooks");
|
|
100
|
+
}
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const gitFile = await readFile(dotGit, "utf8");
|
|
105
|
+
const match = /^gitdir:\s*(.+)\s*$/m.exec(gitFile);
|
|
106
|
+
if (!match) {
|
|
107
|
+
throw new Error(".git is not a directory or gitdir file");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const gitDir = match[1];
|
|
111
|
+
return join(isAbsolute(gitDir) ? gitDir : resolve(dirname(dotGit), gitDir), "hooks");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function readConfiguredHooksPath(projectRoot) {
|
|
115
|
+
try {
|
|
116
|
+
const hooksPath = execFileSync(
|
|
117
|
+
"git",
|
|
118
|
+
["-C", projectRoot, "config", "--get", "core.hooksPath"],
|
|
119
|
+
{ encoding: "utf8" }
|
|
120
|
+
).trim();
|
|
121
|
+
return hooksPath || null;
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function readUtf8OrNull(path) {
|
|
128
|
+
try {
|
|
129
|
+
return await readFile(path, "utf8");
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (error?.code === "ENOENT") {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { dirname, resolve } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
|
|
6
|
+
export const PLUGIN_ROOT = resolve(HERE, "..", "..");
|
|
7
|
+
|
|
8
|
+
export function pluginPath(...segments) {
|
|
9
|
+
return resolve(PLUGIN_ROOT, ...segments);
|
|
10
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { renderPrompt } from "./render.mjs";
|
|
2
|
+
import { pluginPath } from "./pluginRoot.mjs";
|
|
3
|
+
|
|
4
|
+
export function prepareDispatch({ role, requestFile, request, resolved }) {
|
|
5
|
+
if (resolved.provider === "codex") {
|
|
6
|
+
return {
|
|
7
|
+
role,
|
|
8
|
+
provider: "codex",
|
|
9
|
+
action: "dispatch",
|
|
10
|
+
command: [
|
|
11
|
+
"node",
|
|
12
|
+
pluginPath("scripts", "crew-agent-runner.mjs"),
|
|
13
|
+
"dispatch",
|
|
14
|
+
"--role",
|
|
15
|
+
role,
|
|
16
|
+
"--request-file",
|
|
17
|
+
requestFile,
|
|
18
|
+
"--json"
|
|
19
|
+
],
|
|
20
|
+
resolved
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
role,
|
|
26
|
+
provider: "claude",
|
|
27
|
+
action: "agent",
|
|
28
|
+
subagent_type: role,
|
|
29
|
+
model: resolved.model,
|
|
30
|
+
prompt: renderPrompt({
|
|
31
|
+
role,
|
|
32
|
+
request,
|
|
33
|
+
contract: resolved.contract
|
|
34
|
+
}),
|
|
35
|
+
resolved
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
export function renderPrompt(input) {
|
|
2
|
+
const { role, request = {}, contract = {} } = input;
|
|
3
|
+
const parts = [
|
|
4
|
+
`# ${titleCase(role)}`,
|
|
5
|
+
section("Capability", renderCapability(contract)),
|
|
6
|
+
section("Inputs", renderInputs(request.inputs, contract.inputs?.denied)),
|
|
7
|
+
section("Outputs", renderJson(contract.outputs)),
|
|
8
|
+
section("Instructions", request.instruction, { required: true }),
|
|
9
|
+
section("Success Gate", request.successGate),
|
|
10
|
+
section("Failure Handling", request.failureHandling),
|
|
11
|
+
section("AgentResult Contract", renderAgentResultContract())
|
|
12
|
+
].filter(Boolean);
|
|
13
|
+
|
|
14
|
+
return `${parts.join("\n\n")}\n`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function section(title, body, options = {}) {
|
|
18
|
+
const renderedBody = normalizeBody(body);
|
|
19
|
+
if (renderedBody.length === 0) {
|
|
20
|
+
if (options.required) {
|
|
21
|
+
return `## ${title}`;
|
|
22
|
+
}
|
|
23
|
+
return "";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return `## ${title}\n${renderedBody}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function fenceBlock(body, fence = "---") {
|
|
30
|
+
return `${fence}\n${normalizeBlockBody(body)}\n${fence}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function renderCapability(contract) {
|
|
34
|
+
const tools = Array.isArray(contract.claudeSubagent?.tools)
|
|
35
|
+
? contract.claudeSubagent.tools
|
|
36
|
+
: [];
|
|
37
|
+
const outputs = Array.isArray(contract.outputs) ? contract.outputs : [];
|
|
38
|
+
|
|
39
|
+
return [
|
|
40
|
+
`workspaceAccess: ${contract.capabilities?.workspaceAccess ?? "unknown"}`,
|
|
41
|
+
`canAskUser: ${String(tools.includes("AskUserQuestion"))}`,
|
|
42
|
+
`canRequestAgent: ${String(tools.includes("Agent"))}`,
|
|
43
|
+
`canUseShell: ${String(tools.includes("Bash"))}`,
|
|
44
|
+
`canWriteCrewFiles: ${String(canWriteCrewFiles(outputs))}`
|
|
45
|
+
].join("\n");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function renderInputs(inputs, denied) {
|
|
49
|
+
const lines = [];
|
|
50
|
+
|
|
51
|
+
if (Array.isArray(inputs) && inputs.length > 0) {
|
|
52
|
+
for (const item of inputs) {
|
|
53
|
+
lines.push(`### ${item.path}`);
|
|
54
|
+
lines.push(item.content ?? "");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (Array.isArray(denied) && denied.length > 0) {
|
|
59
|
+
lines.push("### Denied Inputs");
|
|
60
|
+
for (const item of denied) {
|
|
61
|
+
lines.push(`- ${item}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return lines.join("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function renderJson(value) {
|
|
69
|
+
if (value === undefined || value === null) {
|
|
70
|
+
return "";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return JSON.stringify(value, null, 2);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function renderAgentResultContract() {
|
|
77
|
+
return [
|
|
78
|
+
"Return exactly one final AgentResult JSON object wrapped in these tags:",
|
|
79
|
+
"",
|
|
80
|
+
"```text",
|
|
81
|
+
"<crew-agent-result>",
|
|
82
|
+
"{",
|
|
83
|
+
' "status": "complete | blocked_on_user | needs_agent | needs_tool | failed",',
|
|
84
|
+
' "artifact": null,',
|
|
85
|
+
' "questions": [],',
|
|
86
|
+
' "requests": [],',
|
|
87
|
+
' "summary": "short summary",',
|
|
88
|
+
' "error": null',
|
|
89
|
+
"}",
|
|
90
|
+
"</crew-agent-result>",
|
|
91
|
+
"```",
|
|
92
|
+
"",
|
|
93
|
+
"Rules:",
|
|
94
|
+
"- The wrapper tags are mandatory.",
|
|
95
|
+
"- The JSON inside the tags must be valid JSON.",
|
|
96
|
+
"- Use complete when the requested artifact is ready.",
|
|
97
|
+
"- Use blocked_on_user only with a non-empty questions array.",
|
|
98
|
+
"- Use needs_agent or needs_tool only with a non-empty requests array.",
|
|
99
|
+
"- Use failed with an error string when the task cannot continue."
|
|
100
|
+
].join("\n");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function normalizeBody(body) {
|
|
104
|
+
if (body === undefined || body === null) {
|
|
105
|
+
return "";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return String(body).trim();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeBlockBody(body) {
|
|
112
|
+
if (body === undefined || body === null) {
|
|
113
|
+
return "";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return String(body)
|
|
117
|
+
.replace(/^\uFEFF/, "")
|
|
118
|
+
.replace(/\r\n?/g, "\n")
|
|
119
|
+
.replace(/\n+$/g, "");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function titleCase(value) {
|
|
123
|
+
return String(value)
|
|
124
|
+
.split("-")
|
|
125
|
+
.filter(Boolean)
|
|
126
|
+
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
|
|
127
|
+
.join("-");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function canWriteCrewFiles(outputs) {
|
|
131
|
+
return outputs.some((output) => {
|
|
132
|
+
return (
|
|
133
|
+
output?.type === "artifact" &&
|
|
134
|
+
typeof output.target === "string" &&
|
|
135
|
+
output.target.startsWith(".crew/")
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
}
|