@jjlabsio/claude-crew 0.1.30 → 0.1.32
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 +2 -1
- package/README.md +11 -1
- package/THIRD_PARTY_NOTICES.md +14 -0
- package/data/provider-catalog.json +36 -14
- package/package.json +2 -1
- package/scripts/crew-codex/LICENSE +201 -0
- package/scripts/crew-codex/NOTICE +16 -0
- package/scripts/crew-codex/app-server-broker.mjs +254 -0
- package/scripts/crew-codex/lib/app-server.mjs +352 -0
- package/scripts/crew-codex/lib/args.mjs +130 -0
- package/scripts/crew-codex/lib/broker-endpoint.mjs +43 -0
- package/scripts/crew-codex/lib/broker-lifecycle.mjs +213 -0
- package/scripts/crew-codex/lib/codex.mjs +1090 -0
- package/scripts/crew-codex/lib/fs.mjs +42 -0
- package/scripts/crew-codex/lib/git.mjs +348 -0
- package/scripts/crew-codex/lib/job-control.mjs +310 -0
- package/scripts/crew-codex/lib/process.mjs +137 -0
- package/scripts/crew-codex/lib/prompts.mjs +15 -0
- package/scripts/crew-codex/lib/render.mjs +466 -0
- package/scripts/crew-codex/lib/state.mjs +424 -0
- package/scripts/crew-codex/lib/tracked-jobs.mjs +279 -0
- package/scripts/crew-codex/lib/workspace.mjs +11 -0
- package/scripts/crew-codex-companion.mjs +863 -0
- package/skills/crew-dev/SKILL.md +68 -18
- package/skills/crew-interview/SKILL.md +34 -6
- package/skills/crew-plan/SKILL.md +43 -15
- package/skills/crew-setup/SKILL.md +38 -34
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Derived from @openai/codex-plugin-cc and modified for claude-crew.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
export function ensureAbsolutePath(cwd, maybePath) {
|
|
8
|
+
return path.isAbsolute(maybePath) ? maybePath : path.resolve(cwd, maybePath);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createTempDir(prefix = "codex-plugin-") {
|
|
12
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function readJsonFile(filePath) {
|
|
16
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function writeJsonFile(filePath, value) {
|
|
20
|
+
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function safeReadFile(filePath) {
|
|
24
|
+
return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isProbablyText(buffer) {
|
|
28
|
+
const sample = buffer.subarray(0, Math.min(buffer.length, 4096));
|
|
29
|
+
for (const value of sample) {
|
|
30
|
+
if (value === 0) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function readStdinIfPiped() {
|
|
38
|
+
if (process.stdin.isTTY) {
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
return fs.readFileSync(0, "utf8");
|
|
42
|
+
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Derived from @openai/codex-plugin-cc and modified for claude-crew.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { isProbablyText } from "./fs.mjs";
|
|
7
|
+
import { formatCommandFailure, runCommand, runCommandChecked } from "./process.mjs";
|
|
8
|
+
|
|
9
|
+
const MAX_UNTRACKED_BYTES = 24 * 1024;
|
|
10
|
+
const DEFAULT_INLINE_DIFF_MAX_FILES = 2;
|
|
11
|
+
const DEFAULT_INLINE_DIFF_MAX_BYTES = 256 * 1024;
|
|
12
|
+
|
|
13
|
+
function git(cwd, args, options = {}) {
|
|
14
|
+
return runCommand("git", args, { cwd, ...options });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function gitChecked(cwd, args, options = {}) {
|
|
18
|
+
return runCommandChecked("git", args, { cwd, ...options });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function listUniqueFiles(...groups) {
|
|
22
|
+
return [...new Set(groups.flat().filter(Boolean))].sort();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeMaxInlineFiles(value) {
|
|
26
|
+
const parsed = Number(value);
|
|
27
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
28
|
+
return DEFAULT_INLINE_DIFF_MAX_FILES;
|
|
29
|
+
}
|
|
30
|
+
return Math.floor(parsed);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeMaxInlineDiffBytes(value) {
|
|
34
|
+
const parsed = Number(value);
|
|
35
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
36
|
+
return DEFAULT_INLINE_DIFF_MAX_BYTES;
|
|
37
|
+
}
|
|
38
|
+
return Math.floor(parsed);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function measureGitOutputBytes(cwd, args, maxBytes) {
|
|
42
|
+
const result = git(cwd, args, { maxBuffer: maxBytes + 1 });
|
|
43
|
+
if (result.error && /** @type {NodeJS.ErrnoException} */ (result.error).code === "ENOBUFS") {
|
|
44
|
+
return maxBytes + 1;
|
|
45
|
+
}
|
|
46
|
+
if (result.error) {
|
|
47
|
+
throw result.error;
|
|
48
|
+
}
|
|
49
|
+
if (result.status !== 0) {
|
|
50
|
+
throw new Error(formatCommandFailure(result));
|
|
51
|
+
}
|
|
52
|
+
return Buffer.byteLength(result.stdout, "utf8");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function measureCombinedGitOutputBytes(cwd, argSets, maxBytes) {
|
|
56
|
+
let totalBytes = 0;
|
|
57
|
+
for (const args of argSets) {
|
|
58
|
+
const remainingBytes = maxBytes - totalBytes;
|
|
59
|
+
if (remainingBytes < 0) {
|
|
60
|
+
return maxBytes + 1;
|
|
61
|
+
}
|
|
62
|
+
totalBytes += measureGitOutputBytes(cwd, args, remainingBytes);
|
|
63
|
+
if (totalBytes > maxBytes) {
|
|
64
|
+
return totalBytes;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return totalBytes;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function buildBranchComparison(cwd, baseRef) {
|
|
71
|
+
const mergeBase = gitChecked(cwd, ["merge-base", "HEAD", baseRef]).stdout.trim();
|
|
72
|
+
return {
|
|
73
|
+
mergeBase,
|
|
74
|
+
commitRange: `${mergeBase}..HEAD`,
|
|
75
|
+
reviewRange: `${baseRef}...HEAD`
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function ensureGitRepository(cwd) {
|
|
80
|
+
const result = git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
81
|
+
const errorCode = result.error && "code" in result.error ? result.error.code : null;
|
|
82
|
+
if (errorCode === "ENOENT") {
|
|
83
|
+
throw new Error("git is not installed. Install Git and retry.");
|
|
84
|
+
}
|
|
85
|
+
if (result.status !== 0) {
|
|
86
|
+
throw new Error("This command must run inside a Git repository.");
|
|
87
|
+
}
|
|
88
|
+
return result.stdout.trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function getRepoRoot(cwd) {
|
|
92
|
+
return gitChecked(cwd, ["rev-parse", "--show-toplevel"]).stdout.trim();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function detectDefaultBranch(cwd) {
|
|
96
|
+
const symbolic = git(cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"]);
|
|
97
|
+
if (symbolic.status === 0) {
|
|
98
|
+
const remoteHead = symbolic.stdout.trim();
|
|
99
|
+
if (remoteHead.startsWith("refs/remotes/origin/")) {
|
|
100
|
+
return remoteHead.replace("refs/remotes/origin/", "");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const candidates = ["main", "master", "trunk"];
|
|
105
|
+
for (const candidate of candidates) {
|
|
106
|
+
const local = git(cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${candidate}`]);
|
|
107
|
+
if (local.status === 0) {
|
|
108
|
+
return candidate;
|
|
109
|
+
}
|
|
110
|
+
const remote = git(cwd, ["show-ref", "--verify", "--quiet", `refs/remotes/origin/${candidate}`]);
|
|
111
|
+
if (remote.status === 0) {
|
|
112
|
+
return `origin/${candidate}`;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
throw new Error("Unable to detect the repository default branch. Pass --base <ref> or use --scope working-tree.");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function getCurrentBranch(cwd) {
|
|
120
|
+
return gitChecked(cwd, ["branch", "--show-current"]).stdout.trim() || "HEAD";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function getWorkingTreeState(cwd) {
|
|
124
|
+
const staged = gitChecked(cwd, ["diff", "--cached", "--name-only"]).stdout.trim().split("\n").filter(Boolean);
|
|
125
|
+
const unstaged = gitChecked(cwd, ["diff", "--name-only"]).stdout.trim().split("\n").filter(Boolean);
|
|
126
|
+
const untracked = gitChecked(cwd, ["ls-files", "--others", "--exclude-standard"]).stdout.trim().split("\n").filter(Boolean);
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
staged,
|
|
130
|
+
unstaged,
|
|
131
|
+
untracked,
|
|
132
|
+
isDirty: staged.length > 0 || unstaged.length > 0 || untracked.length > 0
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function resolveReviewTarget(cwd, options = {}) {
|
|
137
|
+
ensureGitRepository(cwd);
|
|
138
|
+
|
|
139
|
+
const requestedScope = options.scope ?? "auto";
|
|
140
|
+
const baseRef = options.base ?? null;
|
|
141
|
+
const state = getWorkingTreeState(cwd);
|
|
142
|
+
const supportedScopes = new Set(["auto", "working-tree", "branch"]);
|
|
143
|
+
|
|
144
|
+
if (baseRef) {
|
|
145
|
+
return {
|
|
146
|
+
mode: "branch",
|
|
147
|
+
label: `branch diff against ${baseRef}`,
|
|
148
|
+
baseRef,
|
|
149
|
+
explicit: true
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (requestedScope === "working-tree") {
|
|
154
|
+
return {
|
|
155
|
+
mode: "working-tree",
|
|
156
|
+
label: "working tree diff",
|
|
157
|
+
explicit: true
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!supportedScopes.has(requestedScope)) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`Unsupported review scope "${requestedScope}". Use one of: auto, working-tree, branch, or pass --base <ref>.`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (requestedScope === "branch") {
|
|
168
|
+
const detectedBase = detectDefaultBranch(cwd);
|
|
169
|
+
return {
|
|
170
|
+
mode: "branch",
|
|
171
|
+
label: `branch diff against ${detectedBase}`,
|
|
172
|
+
baseRef: detectedBase,
|
|
173
|
+
explicit: true
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (state.isDirty) {
|
|
178
|
+
return {
|
|
179
|
+
mode: "working-tree",
|
|
180
|
+
label: "working tree diff",
|
|
181
|
+
explicit: false
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const detectedBase = detectDefaultBranch(cwd);
|
|
186
|
+
return {
|
|
187
|
+
mode: "branch",
|
|
188
|
+
label: `branch diff against ${detectedBase}`,
|
|
189
|
+
baseRef: detectedBase,
|
|
190
|
+
explicit: false
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function formatSection(title, body) {
|
|
195
|
+
return [`## ${title}`, "", body.trim() ? body.trim() : "(none)", ""].join("\n");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function formatUntrackedFile(cwd, relativePath) {
|
|
199
|
+
const absolutePath = path.join(cwd, relativePath);
|
|
200
|
+
let stat;
|
|
201
|
+
try {
|
|
202
|
+
stat = fs.statSync(absolutePath);
|
|
203
|
+
} catch {
|
|
204
|
+
return `### ${relativePath}\n(skipped: broken symlink or unreadable file)`;
|
|
205
|
+
}
|
|
206
|
+
if (stat.isDirectory()) {
|
|
207
|
+
return `### ${relativePath}\n(skipped: directory)`;
|
|
208
|
+
}
|
|
209
|
+
if (stat.size > MAX_UNTRACKED_BYTES) {
|
|
210
|
+
return `### ${relativePath}\n(skipped: ${stat.size} bytes exceeds ${MAX_UNTRACKED_BYTES} byte limit)`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let buffer;
|
|
214
|
+
try {
|
|
215
|
+
buffer = fs.readFileSync(absolutePath);
|
|
216
|
+
} catch {
|
|
217
|
+
return `### ${relativePath}\n(skipped: broken symlink or unreadable file)`;
|
|
218
|
+
}
|
|
219
|
+
if (!isProbablyText(buffer)) {
|
|
220
|
+
return `### ${relativePath}\n(skipped: binary file)`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return [`### ${relativePath}`, "```", buffer.toString("utf8").trimEnd(), "```"].join("\n");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function collectWorkingTreeContext(cwd, state, options = {}) {
|
|
227
|
+
const includeDiff = options.includeDiff !== false;
|
|
228
|
+
const status = gitChecked(cwd, ["status", "--short", "--untracked-files=all"]).stdout.trim();
|
|
229
|
+
const changedFiles = listUniqueFiles(state.staged, state.unstaged, state.untracked);
|
|
230
|
+
|
|
231
|
+
let parts;
|
|
232
|
+
if (includeDiff) {
|
|
233
|
+
const stagedDiff = gitChecked(cwd, ["diff", "--cached", "--binary", "--no-ext-diff", "--submodule=diff"]).stdout;
|
|
234
|
+
const unstagedDiff = gitChecked(cwd, ["diff", "--binary", "--no-ext-diff", "--submodule=diff"]).stdout;
|
|
235
|
+
const untrackedBody = state.untracked.map((file) => formatUntrackedFile(cwd, file)).join("\n\n");
|
|
236
|
+
parts = [
|
|
237
|
+
formatSection("Git Status", status),
|
|
238
|
+
formatSection("Staged Diff", stagedDiff),
|
|
239
|
+
formatSection("Unstaged Diff", unstagedDiff),
|
|
240
|
+
formatSection("Untracked Files", untrackedBody)
|
|
241
|
+
];
|
|
242
|
+
} else {
|
|
243
|
+
const stagedStat = gitChecked(cwd, ["diff", "--shortstat", "--cached"]).stdout.trim();
|
|
244
|
+
const unstagedStat = gitChecked(cwd, ["diff", "--shortstat"]).stdout.trim();
|
|
245
|
+
const untrackedBody = state.untracked.map((file) => formatUntrackedFile(cwd, file)).join("\n\n");
|
|
246
|
+
parts = [
|
|
247
|
+
formatSection("Git Status", status),
|
|
248
|
+
formatSection("Staged Diff Stat", stagedStat),
|
|
249
|
+
formatSection("Unstaged Diff Stat", unstagedStat),
|
|
250
|
+
formatSection("Changed Files", changedFiles.join("\n")),
|
|
251
|
+
formatSection("Untracked Files", untrackedBody)
|
|
252
|
+
];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
mode: "working-tree",
|
|
257
|
+
summary: `Reviewing ${state.staged.length} staged, ${state.unstaged.length} unstaged, and ${state.untracked.length} untracked file(s).`,
|
|
258
|
+
content: parts.join("\n"),
|
|
259
|
+
changedFiles
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function collectBranchContext(cwd, baseRef, options = {}) {
|
|
264
|
+
const includeDiff = options.includeDiff !== false;
|
|
265
|
+
const comparison = options.comparison ?? buildBranchComparison(cwd, baseRef);
|
|
266
|
+
const currentBranch = getCurrentBranch(cwd);
|
|
267
|
+
const changedFiles = gitChecked(cwd, ["diff", "--name-only", comparison.commitRange]).stdout.trim().split("\n").filter(Boolean);
|
|
268
|
+
const logOutput = gitChecked(cwd, ["log", "--oneline", "--decorate", comparison.commitRange]).stdout.trim();
|
|
269
|
+
const diffStat = gitChecked(cwd, ["diff", "--stat", comparison.commitRange]).stdout.trim();
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
mode: "branch",
|
|
273
|
+
summary: `Reviewing branch ${currentBranch} against ${baseRef} from merge-base ${comparison.mergeBase}.`,
|
|
274
|
+
content: includeDiff
|
|
275
|
+
? [
|
|
276
|
+
formatSection("Commit Log", logOutput),
|
|
277
|
+
formatSection("Diff Stat", diffStat),
|
|
278
|
+
formatSection(
|
|
279
|
+
"Branch Diff",
|
|
280
|
+
gitChecked(cwd, ["diff", "--binary", "--no-ext-diff", "--submodule=diff", comparison.commitRange]).stdout
|
|
281
|
+
)
|
|
282
|
+
].join("\n")
|
|
283
|
+
: [
|
|
284
|
+
formatSection("Commit Log", logOutput),
|
|
285
|
+
formatSection("Diff Stat", diffStat),
|
|
286
|
+
formatSection("Changed Files", changedFiles.join("\n"))
|
|
287
|
+
].join("\n"),
|
|
288
|
+
changedFiles,
|
|
289
|
+
comparison
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function buildAdversarialCollectionGuidance(options = {}) {
|
|
294
|
+
if (options.includeDiff !== false) {
|
|
295
|
+
return "Use the repository context below as primary evidence.";
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return "The repository context below is a lightweight summary. Inspect the target diff yourself with read-only git commands before finalizing findings.";
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function collectReviewContext(cwd, target, options = {}) {
|
|
302
|
+
const repoRoot = getRepoRoot(cwd);
|
|
303
|
+
const currentBranch = getCurrentBranch(repoRoot);
|
|
304
|
+
const maxInlineFiles = normalizeMaxInlineFiles(options.maxInlineFiles);
|
|
305
|
+
const maxInlineDiffBytes = normalizeMaxInlineDiffBytes(options.maxInlineDiffBytes);
|
|
306
|
+
let details;
|
|
307
|
+
let includeDiff;
|
|
308
|
+
let diffBytes;
|
|
309
|
+
|
|
310
|
+
if (target.mode === "working-tree") {
|
|
311
|
+
const state = getWorkingTreeState(repoRoot);
|
|
312
|
+
diffBytes = measureCombinedGitOutputBytes(
|
|
313
|
+
repoRoot,
|
|
314
|
+
[
|
|
315
|
+
["diff", "--cached", "--binary", "--no-ext-diff", "--submodule=diff"],
|
|
316
|
+
["diff", "--binary", "--no-ext-diff", "--submodule=diff"]
|
|
317
|
+
],
|
|
318
|
+
maxInlineDiffBytes
|
|
319
|
+
);
|
|
320
|
+
includeDiff =
|
|
321
|
+
options.includeDiff ??
|
|
322
|
+
(listUniqueFiles(state.staged, state.unstaged, state.untracked).length <= maxInlineFiles &&
|
|
323
|
+
diffBytes <= maxInlineDiffBytes);
|
|
324
|
+
details = collectWorkingTreeContext(repoRoot, state, { includeDiff });
|
|
325
|
+
} else {
|
|
326
|
+
const comparison = buildBranchComparison(repoRoot, target.baseRef);
|
|
327
|
+
const fileCount = gitChecked(repoRoot, ["diff", "--name-only", comparison.commitRange]).stdout.trim().split("\n").filter(Boolean).length;
|
|
328
|
+
diffBytes = measureGitOutputBytes(
|
|
329
|
+
repoRoot,
|
|
330
|
+
["diff", "--binary", "--no-ext-diff", "--submodule=diff", comparison.commitRange],
|
|
331
|
+
maxInlineDiffBytes
|
|
332
|
+
);
|
|
333
|
+
includeDiff = options.includeDiff ?? (fileCount <= maxInlineFiles && diffBytes <= maxInlineDiffBytes);
|
|
334
|
+
details = collectBranchContext(repoRoot, target.baseRef, { includeDiff, comparison });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
cwd: repoRoot,
|
|
339
|
+
repoRoot,
|
|
340
|
+
branch: currentBranch,
|
|
341
|
+
target,
|
|
342
|
+
fileCount: details.changedFiles.length,
|
|
343
|
+
diffBytes,
|
|
344
|
+
inputMode: includeDiff ? "inline-diff" : "self-collect",
|
|
345
|
+
collectionGuidance: buildAdversarialCollectionGuidance({ includeDiff }),
|
|
346
|
+
...details
|
|
347
|
+
};
|
|
348
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Derived from @openai/codex-plugin-cc and modified for claude-crew.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
|
|
5
|
+
import { getSessionRuntimeStatus } from "./codex.mjs";
|
|
6
|
+
import { getConfig, listJobs, readJobFile, resolveJobFile } from "./state.mjs";
|
|
7
|
+
import { SESSION_ID_ENV } from "./tracked-jobs.mjs";
|
|
8
|
+
import { resolveWorkspaceRoot } from "./workspace.mjs";
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_MAX_STATUS_JOBS = 8;
|
|
11
|
+
export const DEFAULT_MAX_PROGRESS_LINES = 4;
|
|
12
|
+
|
|
13
|
+
export function sortJobsNewestFirst(jobs) {
|
|
14
|
+
return [...jobs].sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getCurrentSessionId(options = {}) {
|
|
18
|
+
return options.env?.[SESSION_ID_ENV] ?? process.env[SESSION_ID_ENV] ?? null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function filterJobsForCurrentSession(jobs, options = {}) {
|
|
22
|
+
const sessionId = getCurrentSessionId(options);
|
|
23
|
+
if (!sessionId) {
|
|
24
|
+
return jobs;
|
|
25
|
+
}
|
|
26
|
+
return jobs.filter((job) => job.sessionId === sessionId);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getJobTypeLabel(job) {
|
|
30
|
+
if (typeof job.kindLabel === "string" && job.kindLabel) {
|
|
31
|
+
return job.kindLabel;
|
|
32
|
+
}
|
|
33
|
+
if (job.kind === "adversarial-review") {
|
|
34
|
+
return "adversarial-review";
|
|
35
|
+
}
|
|
36
|
+
if (job.jobClass === "review") {
|
|
37
|
+
return "review";
|
|
38
|
+
}
|
|
39
|
+
if (job.jobClass === "task") {
|
|
40
|
+
return "task";
|
|
41
|
+
}
|
|
42
|
+
if (job.kind === "review") {
|
|
43
|
+
return "review";
|
|
44
|
+
}
|
|
45
|
+
if (job.kind === "task") {
|
|
46
|
+
return "task";
|
|
47
|
+
}
|
|
48
|
+
return "job";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function stripLogPrefix(line) {
|
|
52
|
+
return line.replace(/^\[[^\]]+\]\s*/, "").trim();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isProgressBlockTitle(line) {
|
|
56
|
+
return (
|
|
57
|
+
["Final output", "Assistant message", "Reasoning summary", "Review output"].includes(line) ||
|
|
58
|
+
/^Subagent .+ message$/.test(line) ||
|
|
59
|
+
/^Subagent .+ reasoning summary$/.test(line)
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function readJobProgressPreview(logFile, maxLines = DEFAULT_MAX_PROGRESS_LINES) {
|
|
64
|
+
if (!logFile || !fs.existsSync(logFile)) {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const lines = fs
|
|
69
|
+
.readFileSync(logFile, "utf8")
|
|
70
|
+
.split(/\r?\n/)
|
|
71
|
+
.map((line) => line.trimEnd())
|
|
72
|
+
.filter(Boolean)
|
|
73
|
+
.filter((line) => line.startsWith("["))
|
|
74
|
+
.map(stripLogPrefix)
|
|
75
|
+
.filter((line) => line && !isProgressBlockTitle(line));
|
|
76
|
+
|
|
77
|
+
return lines.slice(-maxLines);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function formatElapsedDuration(startValue, endValue = null) {
|
|
81
|
+
const start = Date.parse(startValue ?? "");
|
|
82
|
+
if (!Number.isFinite(start)) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const end = endValue ? Date.parse(endValue) : Date.now();
|
|
87
|
+
if (!Number.isFinite(end) || end < start) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const totalSeconds = Math.max(0, Math.round((end - start) / 1000));
|
|
92
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
93
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
94
|
+
const seconds = totalSeconds % 60;
|
|
95
|
+
|
|
96
|
+
if (hours > 0) {
|
|
97
|
+
return `${hours}h ${minutes}m`;
|
|
98
|
+
}
|
|
99
|
+
if (minutes > 0) {
|
|
100
|
+
return `${minutes}m ${seconds}s`;
|
|
101
|
+
}
|
|
102
|
+
return `${seconds}s`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function looksLikeVerificationCommand(line) {
|
|
106
|
+
return /\b(test|tests|lint|build|typecheck|type-check|check|verify|validate|pytest|jest|vitest|cargo test|npm test|pnpm test|yarn test|go test|mvn test|gradle test|tsc|eslint|ruff)\b/i.test(
|
|
107
|
+
line
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function inferLegacyJobPhase(job, progressPreview = []) {
|
|
112
|
+
switch (job.status) {
|
|
113
|
+
case "queued":
|
|
114
|
+
return "queued";
|
|
115
|
+
case "cancelled":
|
|
116
|
+
return "cancelled";
|
|
117
|
+
case "failed":
|
|
118
|
+
return "failed";
|
|
119
|
+
case "completed":
|
|
120
|
+
return "done";
|
|
121
|
+
default:
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (let index = progressPreview.length - 1; index >= 0; index -= 1) {
|
|
126
|
+
const line = progressPreview[index].toLowerCase();
|
|
127
|
+
if (line.startsWith("starting codex") || line.startsWith("thread ready") || line.startsWith("turn started")) {
|
|
128
|
+
return "starting";
|
|
129
|
+
}
|
|
130
|
+
if (line.startsWith("reviewer started") || line.includes("review mode")) {
|
|
131
|
+
return "reviewing";
|
|
132
|
+
}
|
|
133
|
+
if (line.startsWith("searching:") || line.startsWith("calling ") || line.startsWith("running tool:")) {
|
|
134
|
+
return "investigating";
|
|
135
|
+
}
|
|
136
|
+
if (line.startsWith("starting collaboration tool:")) {
|
|
137
|
+
return "investigating";
|
|
138
|
+
}
|
|
139
|
+
if (line.startsWith("running command:")) {
|
|
140
|
+
return looksLikeVerificationCommand(line)
|
|
141
|
+
? "verifying"
|
|
142
|
+
: job.jobClass === "review"
|
|
143
|
+
? "reviewing"
|
|
144
|
+
: "investigating";
|
|
145
|
+
}
|
|
146
|
+
if (line.startsWith("command completed:")) {
|
|
147
|
+
return looksLikeVerificationCommand(line) ? "verifying" : "running";
|
|
148
|
+
}
|
|
149
|
+
if (line.startsWith("applying ") || line.startsWith("file changes ")) {
|
|
150
|
+
return "editing";
|
|
151
|
+
}
|
|
152
|
+
if (line.startsWith("turn completed")) {
|
|
153
|
+
return "finalizing";
|
|
154
|
+
}
|
|
155
|
+
if (line.startsWith("codex error:") || line.startsWith("failed:")) {
|
|
156
|
+
return "failed";
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return job.jobClass === "review" ? "reviewing" : "running";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function enrichJob(job, options = {}) {
|
|
164
|
+
const maxProgressLines = options.maxProgressLines ?? DEFAULT_MAX_PROGRESS_LINES;
|
|
165
|
+
const enriched = {
|
|
166
|
+
...job,
|
|
167
|
+
kindLabel: getJobTypeLabel(job),
|
|
168
|
+
progressPreview:
|
|
169
|
+
job.status === "queued" || job.status === "running" || job.status === "failed"
|
|
170
|
+
? readJobProgressPreview(job.logFile, maxProgressLines)
|
|
171
|
+
: [],
|
|
172
|
+
elapsed: formatElapsedDuration(job.startedAt ?? job.createdAt, job.completedAt ?? null),
|
|
173
|
+
duration:
|
|
174
|
+
job.status === "completed" || job.status === "failed" || job.status === "cancelled"
|
|
175
|
+
? formatElapsedDuration(job.startedAt ?? job.createdAt, job.completedAt ?? job.updatedAt)
|
|
176
|
+
: null
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
...enriched,
|
|
181
|
+
phase: enriched.phase ?? inferLegacyJobPhase(enriched, enriched.progressPreview)
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function readStoredJob(workspaceRoot, jobId) {
|
|
186
|
+
const jobFile = resolveJobFile(workspaceRoot, jobId);
|
|
187
|
+
if (!fs.existsSync(jobFile)) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
return readJobFile(jobFile);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function matchJobReference(jobs, reference, predicate = () => true) {
|
|
194
|
+
const filtered = jobs.filter(predicate);
|
|
195
|
+
if (!reference) {
|
|
196
|
+
return filtered[0] ?? null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const exact = filtered.find((job) => job.id === reference);
|
|
200
|
+
if (exact) {
|
|
201
|
+
return exact;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const prefixMatches = filtered.filter((job) => job.id.startsWith(reference));
|
|
205
|
+
if (prefixMatches.length === 1) {
|
|
206
|
+
return prefixMatches[0];
|
|
207
|
+
}
|
|
208
|
+
if (prefixMatches.length > 1) {
|
|
209
|
+
throw new Error(`Job reference "${reference}" is ambiguous. Use a longer job id.`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
throw new Error(`No job found for "${reference}". Run crew-codex status to list known jobs.`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function buildStatusSnapshot(cwd, options = {}) {
|
|
216
|
+
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
|
217
|
+
const config = getConfig(workspaceRoot);
|
|
218
|
+
const jobs = sortJobsNewestFirst(filterJobsForCurrentSession(listJobs(workspaceRoot), options));
|
|
219
|
+
const maxJobs = options.maxJobs ?? DEFAULT_MAX_STATUS_JOBS;
|
|
220
|
+
const maxProgressLines = options.maxProgressLines ?? DEFAULT_MAX_PROGRESS_LINES;
|
|
221
|
+
|
|
222
|
+
const running = jobs
|
|
223
|
+
.filter((job) => job.status === "queued" || job.status === "running")
|
|
224
|
+
.map((job) => enrichJob(job, { maxProgressLines }));
|
|
225
|
+
|
|
226
|
+
const latestFinishedRaw = jobs.find((job) => job.status !== "queued" && job.status !== "running") ?? null;
|
|
227
|
+
const latestFinished = latestFinishedRaw ? enrichJob(latestFinishedRaw, { maxProgressLines }) : null;
|
|
228
|
+
|
|
229
|
+
const recent = (options.all ? jobs : jobs.slice(0, maxJobs))
|
|
230
|
+
.filter((job) => job.status !== "queued" && job.status !== "running" && job.id !== latestFinished?.id)
|
|
231
|
+
.map((job) => enrichJob(job, { maxProgressLines }));
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
workspaceRoot,
|
|
235
|
+
config,
|
|
236
|
+
sessionRuntime: getSessionRuntimeStatus(options.env, workspaceRoot),
|
|
237
|
+
running,
|
|
238
|
+
latestFinished,
|
|
239
|
+
recent,
|
|
240
|
+
needsReview: Boolean(config.stopReviewGate)
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function buildSingleJobSnapshot(cwd, reference, options = {}) {
|
|
245
|
+
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
|
246
|
+
const jobs = sortJobsNewestFirst(listJobs(workspaceRoot));
|
|
247
|
+
const selected = matchJobReference(jobs, reference);
|
|
248
|
+
if (!selected) {
|
|
249
|
+
throw new Error(`No job found for "${reference}". Run crew-codex status to inspect known jobs.`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
workspaceRoot,
|
|
254
|
+
job: enrichJob(selected, { maxProgressLines: options.maxProgressLines })
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function resolveResultJob(cwd, reference) {
|
|
259
|
+
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
|
260
|
+
const jobs = sortJobsNewestFirst(reference ? listJobs(workspaceRoot) : filterJobsForCurrentSession(listJobs(workspaceRoot)));
|
|
261
|
+
const selected = matchJobReference(
|
|
262
|
+
jobs,
|
|
263
|
+
reference,
|
|
264
|
+
(job) => job.status === "completed" || job.status === "failed" || job.status === "cancelled"
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
if (selected) {
|
|
268
|
+
return { workspaceRoot, job: selected };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const active = matchJobReference(jobs, reference, (job) => job.status === "queued" || job.status === "running");
|
|
272
|
+
if (active) {
|
|
273
|
+
throw new Error(`Job ${active.id} is still ${active.status}. Check crew-codex status and try again once it finishes.`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (reference) {
|
|
277
|
+
throw new Error(`No finished job found for "${reference}". Run crew-codex status to inspect active jobs.`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
throw new Error("No finished Codex jobs found for this repository yet.");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function resolveCancelableJob(cwd, reference, options = {}) {
|
|
284
|
+
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
|
285
|
+
const jobs = sortJobsNewestFirst(listJobs(workspaceRoot));
|
|
286
|
+
const activeJobs = jobs.filter((job) => job.status === "queued" || job.status === "running");
|
|
287
|
+
|
|
288
|
+
if (reference) {
|
|
289
|
+
const selected = matchJobReference(activeJobs, reference);
|
|
290
|
+
if (!selected) {
|
|
291
|
+
throw new Error(`No active job found for "${reference}".`);
|
|
292
|
+
}
|
|
293
|
+
return { workspaceRoot, job: selected };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const sessionScopedActiveJobs = filterJobsForCurrentSession(activeJobs, options);
|
|
297
|
+
|
|
298
|
+
if (sessionScopedActiveJobs.length === 1) {
|
|
299
|
+
return { workspaceRoot, job: sessionScopedActiveJobs[0] };
|
|
300
|
+
}
|
|
301
|
+
if (sessionScopedActiveJobs.length > 1) {
|
|
302
|
+
throw new Error("Multiple Codex jobs are active. Pass a job id to crew-codex cancel.");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (getCurrentSessionId(options)) {
|
|
306
|
+
throw new Error("No active Codex jobs to cancel for this session.");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
throw new Error("No active Codex jobs to cancel.");
|
|
310
|
+
}
|