jules-orchestrator-kit 0.51.0 → 0.52.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.
@@ -0,0 +1,312 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { writeFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { createProvider } from "./provider.mjs";
5
+ import { loadConfig, resolveRoot } from "./config.mjs";
6
+ import { redactSecrets } from "./security.mjs";
7
+
8
+ /**
9
+ * Parses human-readable duration strings (e.g. "7d", "24h", "30m", "60s") into milliseconds.
10
+ *
11
+ * @param {string|number} duration
12
+ * @returns {number} Milliseconds
13
+ */
14
+ export function parseAgeDuration(duration) {
15
+ if (typeof duration === "number" && Number.isFinite(duration)) return duration;
16
+ if (!duration || typeof duration !== "string") return 0;
17
+ const match = duration.trim().match(/^(\d+(?:\.\d+)?)\s*([smhdw])$/i);
18
+ if (!match) {
19
+ const num = Number(duration);
20
+ return Number.isFinite(num) ? num : 0;
21
+ }
22
+ const val = parseFloat(match[1]);
23
+ const unit = match[2].toLowerCase();
24
+ switch (unit) {
25
+ case "s": return val * 1000;
26
+ case "m": return val * 60 * 1000;
27
+ case "h": return val * 60 * 60 * 1000;
28
+ case "d": return val * 24 * 60 * 60 * 1000;
29
+ case "w": return val * 7 * 24 * 60 * 60 * 1000;
30
+ default: return 0;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Extracts git diff patch, PR details, and modified files from a Jules session.
36
+ *
37
+ * @param {string} sessionId
38
+ * @param {object} opts
39
+ * @returns {Promise<{ ok: boolean, id: string, patch: string, pr: object|null, files: string[], error?: string }>}
40
+ */
41
+ export async function extractSessionPatch(sessionId, opts = {}) {
42
+ if (!sessionId || typeof sessionId !== "string") {
43
+ throw new TypeError("extractSessionPatch requires a valid sessionId string");
44
+ }
45
+ const root = opts.root || resolveRoot();
46
+ const config = opts.config || loadConfig(root);
47
+ const provider = opts.provider || createProvider(config.provider || "jules", config);
48
+
49
+ const session = await provider.getSession(sessionId, opts);
50
+ const activitiesRes = await provider.listActivities(sessionId, opts).catch(() => ({ activities: [] }));
51
+ const activities = activitiesRes.activities || [];
52
+
53
+ let patch = "";
54
+ let pr = null;
55
+ const files = new Set();
56
+
57
+ // 1. Inspect session outputs
58
+ const outputs = session?.raw?.outputs || session?.outputs || [];
59
+ for (const out of outputs) {
60
+ if (out.pullRequest) {
61
+ pr = out.pullRequest;
62
+ }
63
+ if (out.gitPatch || out.patch || out.diff) {
64
+ patch = out.gitPatch || out.patch || out.diff;
65
+ }
66
+ }
67
+
68
+ // 2. Inspect activity artifacts and logs if patch wasn't in top-level output
69
+ for (const act of activities) {
70
+ if (act.pullRequest) pr = act.pullRequest;
71
+ if (act.gitPatch || act.patch || act.diff) {
72
+ patch = patch || act.gitPatch || act.patch || act.diff;
73
+ }
74
+ if (act.artifacts && Array.isArray(act.artifacts)) {
75
+ for (const art of act.artifacts) {
76
+ if (art.patch || art.diff || (typeof art.content === "string" && art.content.startsWith("diff --git"))) {
77
+ patch = patch || art.patch || art.diff || art.content;
78
+ }
79
+ if (art.path || art.filename) {
80
+ files.add(art.path || art.filename);
81
+ }
82
+ }
83
+ }
84
+ if (act.changedFiles && Array.isArray(act.changedFiles)) {
85
+ act.changedFiles.forEach((f) => files.add(f));
86
+ }
87
+ }
88
+
89
+ // Extract changed files from patch headers if files set is empty
90
+ if (patch && files.size === 0) {
91
+ const fileMatches = patch.matchAll(/^diff --git a\/(.+?) b\/(.+?)$/gm);
92
+ for (const m of fileMatches) {
93
+ files.add(m[2]);
94
+ }
95
+ }
96
+
97
+ return {
98
+ ok: Boolean(patch || pr),
99
+ id: sessionId,
100
+ patch: patch ? (patch.endsWith("\n") ? patch : patch + "\n") : "",
101
+ pr,
102
+ files: Array.from(files),
103
+ rawSession: session.raw,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Applies a Jules session's patch to the local working tree with git apply safety checks.
109
+ *
110
+ * @param {string} sessionId
111
+ * @param {object} opts
112
+ * @returns {Promise<{ ok: boolean, patchApplied: boolean, checkPassed: boolean, patch: string, files: string[], error?: string }>}
113
+ */
114
+ export async function applySessionPatch(sessionId, opts = {}) {
115
+ const root = opts.root || resolveRoot();
116
+ const res = await extractSessionPatch(sessionId, { ...opts, root });
117
+
118
+ if (!res.patch) {
119
+ return {
120
+ ok: false,
121
+ patchApplied: false,
122
+ checkPassed: false,
123
+ patch: "",
124
+ files: res.files || [],
125
+ error: "No git patch found in session output or activities.",
126
+ };
127
+ }
128
+
129
+ if (opts.save) {
130
+ const savePath = resolve(root, opts.save);
131
+ writeFileSync(savePath, res.patch, "utf-8");
132
+ }
133
+
134
+ // 1. Dry run: git apply --check
135
+ const checkRes = spawnSync("git", ["apply", "--check", "-"], {
136
+ cwd: root,
137
+ input: res.patch,
138
+ encoding: "utf-8",
139
+ });
140
+
141
+ if (checkRes.status !== 0) {
142
+ const stderr = (checkRes.stderr || "").trim();
143
+ return {
144
+ ok: false,
145
+ patchApplied: false,
146
+ checkPassed: false,
147
+ patch: res.patch,
148
+ files: res.files,
149
+ error: `git apply --check failed: ${stderr || "Patch does not cleanly apply to working directory"}`,
150
+ };
151
+ }
152
+
153
+ // 2. If apply requested, execute git apply
154
+ if (opts.apply) {
155
+ const applyRes = spawnSync("git", ["apply", "-"], {
156
+ cwd: root,
157
+ input: res.patch,
158
+ encoding: "utf-8",
159
+ });
160
+
161
+ if (applyRes.status !== 0) {
162
+ const stderr = (applyRes.stderr || "").trim();
163
+ return {
164
+ ok: false,
165
+ patchApplied: false,
166
+ checkPassed: true,
167
+ patch: res.patch,
168
+ files: res.files,
169
+ error: `git apply failed: ${stderr}`,
170
+ };
171
+ }
172
+
173
+ return {
174
+ ok: true,
175
+ patchApplied: true,
176
+ checkPassed: true,
177
+ patch: res.patch,
178
+ files: res.files,
179
+ };
180
+ }
181
+
182
+ return {
183
+ ok: true,
184
+ patchApplied: false,
185
+ checkPassed: true,
186
+ patch: res.patch,
187
+ files: res.files,
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Gathers failure context and error output from a failed session and synthesizes a new OODA retry session.
193
+ *
194
+ * @param {string} sessionId
195
+ * @param {object} opts
196
+ * @returns {Promise<{ ok: boolean, originalSessionId: string, newSession: object, failureReason: string }>}
197
+ */
198
+ export async function retrySession(sessionId, opts = {}) {
199
+ if (!sessionId || typeof sessionId !== "string") {
200
+ throw new TypeError("retrySession requires a valid sessionId string");
201
+ }
202
+ const root = opts.root || resolveRoot();
203
+ const config = opts.config || loadConfig(root);
204
+ const provider = opts.provider || createProvider(config.provider || "jules", config);
205
+
206
+ const session = await provider.getSession(sessionId, opts);
207
+ const activitiesRes = await provider.listActivities(sessionId, opts).catch(() => ({ activities: [] }));
208
+ const activities = activitiesRes.activities || [];
209
+
210
+ // Extract failure diagnostics
211
+ const errors = [];
212
+ for (const act of activities) {
213
+ if (act.error) errors.push(typeof act.error === "string" ? act.error : JSON.stringify(act.error));
214
+ if (act.executionOutput && (act.exitCode !== 0 || act.status === "FAILED")) {
215
+ errors.push(act.executionOutput);
216
+ }
217
+ }
218
+
219
+ const rawPrompt = session?.raw?.prompt || session?.prompt || "";
220
+ const title = session?.raw?.title || `Retry of Session ${sessionId}`;
221
+ const failureReason = errors.join("\n").slice(0, 4000) || "Previous session did not complete cleanly.";
222
+
223
+ let synthesizedPrompt = rawPrompt;
224
+ if (opts.withFailure !== false && failureReason) {
225
+ synthesizedPrompt = `${rawPrompt}\n\n[PREVIOUS_ATTEMPT_FAILURE_DIAGNOSTIC]\nThe previous automated session failed with the following error output:\n\`\`\`\n${redactSecrets(failureReason)}\n\`\`\`\nPlease analyze this failure, fix the root cause, and ensure all tests pass.\n`;
226
+ }
227
+
228
+ const source = session?.raw?.sourceContext?.source || opts.source;
229
+ const branch = session?.raw?.sourceContext?.githubRepoContext?.startingBranch || opts.branch || "main";
230
+
231
+ const dispatchRes = await provider.dispatch(
232
+ {
233
+ title: opts.title || title,
234
+ prompt: synthesizedPrompt,
235
+ role: opts.role,
236
+ source,
237
+ branch,
238
+ },
239
+ opts
240
+ );
241
+
242
+ return {
243
+ ok: true,
244
+ originalSessionId: sessionId,
245
+ newSession: dispatchRes,
246
+ failureReason,
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Queries and batch-archives old or completed Jules sessions based on age and state filters.
252
+ *
253
+ * @param {object} opts
254
+ * @returns {Promise<{ ok: boolean, matchedCount: number, archivedCount: number, sessions: Array<{ id: string, state: string, createTime: string, action: string }> }>}
255
+ */
256
+ export async function pruneSessions(opts = {}) {
257
+ const root = opts.root || resolveRoot();
258
+ const config = opts.config || loadConfig(root);
259
+ const provider = opts.provider || createProvider(config.provider || "jules", config);
260
+
261
+ const listRes = await provider.listSessions({ ...opts, pageSize: opts.pageSize || 100 });
262
+ const allSessions = listRes.sessions || [];
263
+
264
+ const maxAgeMs = opts.age ? parseAgeDuration(opts.age) : 0;
265
+ const now = Date.now();
266
+ const targetState = opts.state ? String(opts.state).toUpperCase() : null;
267
+
268
+ const matched = [];
269
+ for (const s of allSessions) {
270
+ const state = (s.state || s.status || "UNKNOWN").toUpperCase();
271
+ if (targetState && state !== targetState) continue;
272
+
273
+ if (maxAgeMs > 0) {
274
+ const timeStr = s.createTime || s.updateTime;
275
+ const sessionTime = timeStr ? new Date(timeStr).getTime() : 0;
276
+ if (sessionTime > 0 && now - sessionTime < maxAgeMs) {
277
+ continue; // Session is newer than the cutoff age
278
+ }
279
+ }
280
+
281
+ matched.push(s);
282
+ }
283
+
284
+ const results = [];
285
+ for (const s of matched) {
286
+ const id = s.id || (s.name ? s.name.split("/").pop() : "");
287
+ if (!id) continue;
288
+
289
+ if (opts.dryRun) {
290
+ results.push({ id, state: s.state || s.status, createTime: s.createTime, action: "DRY_RUN_SKIP" });
291
+ } else {
292
+ try {
293
+ if (opts.delete) {
294
+ await provider.deleteSession(id, opts);
295
+ results.push({ id, state: s.state || s.status, createTime: s.createTime, action: "DELETED" });
296
+ } else {
297
+ await provider.archiveSession(id, opts);
298
+ results.push({ id, state: s.state || s.status, createTime: s.createTime, action: "ARCHIVED" });
299
+ }
300
+ } catch (err) {
301
+ results.push({ id, state: s.state || s.status, createTime: s.createTime, action: `ERROR: ${err.message}` });
302
+ }
303
+ }
304
+ }
305
+
306
+ return {
307
+ ok: true,
308
+ matchedCount: matched.length,
309
+ archivedCount: results.filter((r) => r.action === "ARCHIVED" || r.action === "DELETED").length,
310
+ sessions: results,
311
+ };
312
+ }
@@ -210,8 +210,12 @@ export function detectPolyglotStack(projectRoot = process.cwd()) {
210
210
  setupCmd = "npx drizzle-kit push";
211
211
  }
212
212
 
213
- if (existsSync(join(projectRoot, "bunfig.toml")) || existsSync(join(projectRoot, "bun.lockb"))) {
214
- const triggerFile = existsSync(join(projectRoot, "bunfig.toml")) ? "bunfig.toml" : "bun.lockb";
213
+ if (existsSync(join(projectRoot, "bunfig.toml")) || existsSync(join(projectRoot, "bun.lockb")) || existsSync(join(projectRoot, "bun.lock"))) {
214
+ const triggerFile = existsSync(join(projectRoot, "bunfig.toml"))
215
+ ? "bunfig.toml"
216
+ : existsSync(join(projectRoot, "bun.lockb"))
217
+ ? "bun.lockb"
218
+ : "bun.lock";
215
219
  return { ...container, stack: "bun", setupCmd, testCmd: "bun test", buildCmd: "bun run build", triggerFile };
216
220
  }
217
221
  if (existsSync(join(projectRoot, "deno.json")) || existsSync(join(projectRoot, "deno.jsonc"))) {
@@ -219,20 +223,38 @@ export function detectPolyglotStack(projectRoot = process.cwd()) {
219
223
  return { ...container, stack: "deno", setupCmd, testCmd: "deno test", buildCmd: "deno task build", triggerFile };
220
224
  }
221
225
  if (existsSync(join(projectRoot, "package.json"))) {
222
- let testScript = "npm test";
223
- let buildScript = "npm run build";
226
+ const hasPnpm = existsSync(join(projectRoot, "pnpm-lock.yaml"));
227
+ const hasYarn = existsSync(join(projectRoot, "yarn.lock"));
228
+ const hasBun = existsSync(join(projectRoot, "bun.lock")) || existsSync(join(projectRoot, "bun.lockb"));
229
+ const pm = hasPnpm ? "pnpm" : hasYarn ? "yarn" : hasBun ? "bun" : "npm";
230
+ const triggerFile = hasPnpm
231
+ ? "pnpm-lock.yaml"
232
+ : hasYarn
233
+ ? "yarn.lock"
234
+ : hasBun
235
+ ? (existsSync(join(projectRoot, "bun.lock")) ? "bun.lock" : "bun.lockb")
236
+ : "package.json";
237
+
238
+ let testScript = "";
239
+ let buildScript = "";
224
240
  try {
225
241
  const pkg = JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf-8"));
226
242
  if (pkg && typeof pkg === "object") {
227
243
  const scripts = pkg.scripts || {};
228
- testScript = scripts.test ? "npm test" : "npm test";
229
- buildScript = scripts.build ? "npm run build" : "";
244
+ if (scripts.test) {
245
+ testScript = pm === "yarn" ? "yarn test" : `${pm} test`;
246
+ } else if (pm === "bun") {
247
+ testScript = "bun test";
248
+ }
249
+ if (scripts.build) {
250
+ buildScript = pm === "yarn" ? "yarn build" : `${pm} run build`;
251
+ }
230
252
  }
231
253
  } catch (_) {
232
- testScript = "npm test";
233
- buildScript = "npm run build";
254
+ testScript = "";
255
+ buildScript = "";
234
256
  }
235
- return { ...container, stack: "node", setupCmd, testCmd: testScript, buildCmd: buildScript, triggerFile: "package.json" };
257
+ return { ...container, stack: pm === "npm" ? "node" : pm, setupCmd, testCmd: testScript, buildCmd: buildScript, triggerFile };
236
258
  }
237
259
 
238
260
  // 7. .NET / C# / F# / PHP / Python root file extension fallback
@@ -1,8 +1,28 @@
1
1
  import { existsSync, readFileSync, readdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { spawnSync } from "node:child_process";
3
4
  import { detectStack, parseYaml } from "./config.mjs";
4
5
  import { runCmd } from "./git.mjs";
5
6
 
7
+ /**
8
+ * Check if a command-line binary is available on the system PATH.
9
+ * @param {string} binName
10
+ * @returns {boolean}
11
+ */
12
+ export function hasBinary(binName) {
13
+ if (!binName || typeof binName !== "string") return false;
14
+ const bin = binName.trim().split(/\s+/)[0];
15
+ if (!bin) return false;
16
+ try {
17
+ const isWin = process.platform === "win32";
18
+ const checker = isWin ? "where" : "which";
19
+ const res = spawnSync(checker, [bin], { stdio: "ignore" });
20
+ return res.status === 0;
21
+ } catch (_) {
22
+ return false;
23
+ }
24
+ }
25
+
6
26
  /**
7
27
  * Inspect repository topology, workspace structure, and manifests to infer candidate verification oracles.
8
28
  * @param {string} [root=process.cwd()]
@@ -37,18 +57,24 @@ export function detectStackOracles(root = process.cwd()) {
37
57
 
38
58
  const packages = [];
39
59
 
40
- // Check Node.json package.json scripts
60
+ // Check Node.js package.json scripts
41
61
  const pkgPath = join(root, "package.json");
42
62
  if (existsSync(pkgPath)) {
43
63
  try {
44
64
  const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
45
65
  const scripts = pkg.scripts || {};
66
+ const hasPnpm = existsSync(join(root, "pnpm-lock.yaml")) || existsSync(join(root, "pnpm-workspace.yaml"));
67
+ const hasYarn = existsSync(join(root, "yarn.lock"));
68
+ const hasBun = existsSync(join(root, "bun.lock")) || existsSync(join(root, "bun.lockb")) || existsSync(join(root, "bunfig.toml"));
69
+ const pm = hasPnpm ? "pnpm" : hasYarn ? "yarn" : hasBun ? "bun" : "npm";
46
70
 
47
- if (scripts.test) candidates.testCmd = candidates.testCmd || "npm test";
48
- if (scripts.build) candidates.buildCmd = candidates.buildCmd || "npm run build";
49
- if (scripts.lint) candidates.lintCmd = "npm run lint";
71
+ if (scripts.test) candidates.testCmd = candidates.testCmd || (pm === "yarn" ? "yarn test" : `${pm} test`);
72
+ if (scripts.build) candidates.buildCmd = candidates.buildCmd || (pm === "yarn" ? "yarn build" : `${pm} run build`);
73
+ if (scripts.lint) candidates.lintCmd = pm === "yarn" ? "yarn lint" : `${pm} run lint`;
50
74
  if (scripts.typecheck || scripts["type-check"]) {
51
- candidates.typecheckCmd = scripts.typecheck ? "npm run typecheck" : "npm run type-check";
75
+ candidates.typecheckCmd = scripts.typecheck
76
+ ? (pm === "yarn" ? "yarn typecheck" : `${pm} run typecheck`)
77
+ : (pm === "yarn" ? "yarn type-check" : `${pm} run type-check`);
52
78
  } else if (existsSync(join(root, "tsconfig.json"))) {
53
79
  candidates.typecheckCmd = "npx tsc --noEmit";
54
80
  }
@@ -69,7 +95,9 @@ export function detectStackOracles(root = process.cwd()) {
69
95
  if (stack.includes("go") || existsSync(join(root, "go.mod"))) {
70
96
  candidates.testCmd = candidates.testCmd || "go test ./...";
71
97
  candidates.buildCmd = candidates.buildCmd || "go build ./...";
72
- candidates.lintCmd = candidates.lintCmd || "golangci-lint run";
98
+ if (hasBinary("golangci-lint")) {
99
+ candidates.lintCmd = candidates.lintCmd || "golangci-lint run";
100
+ }
73
101
  candidates.typecheckCmd = candidates.typecheckCmd || "go vet ./...";
74
102
  }
75
103
 
@@ -77,8 +105,14 @@ export function detectStackOracles(root = process.cwd()) {
77
105
  if (stack.includes("pytest") || existsSync(join(root, "pyproject.toml")) || existsSync(join(root, "requirements.txt"))) {
78
106
  candidates.testCmd = candidates.testCmd || "pytest";
79
107
  candidates.buildCmd = candidates.buildCmd || "";
80
- candidates.lintCmd = candidates.lintCmd || "flake8 .";
81
- candidates.typecheckCmd = candidates.typecheckCmd || "mypy .";
108
+ if (hasBinary("flake8")) {
109
+ candidates.lintCmd = candidates.lintCmd || "flake8 .";
110
+ } else if (hasBinary("ruff")) {
111
+ candidates.lintCmd = candidates.lintCmd || "ruff check .";
112
+ }
113
+ if (hasBinary("mypy")) {
114
+ candidates.typecheckCmd = candidates.typecheckCmd || "mypy .";
115
+ }
82
116
  }
83
117
 
84
118
  // Discover subpackages if monorepo
@@ -280,14 +280,15 @@ export async function runTaskCreateWizard(root = process.cwd(), options = {}) {
280
280
  if (useTodo) {
281
281
  const selectedTodo = await select(
282
282
  todos.slice(0, 10).map((t) => ({
283
- label: `[${t.type}] ${t.text} (${t.file}:${t.line})`,
283
+ label: `[${t.type || t.tag || "TODO"}] ${t.text} (${t.file}:${t.line})`,
284
284
  value: t,
285
285
  })),
286
286
  "Select TODO Candidate",
287
287
  options
288
288
  );
289
- title = `Fix ${selectedTodo.type}: ${selectedTodo.text.slice(0, 40)}`;
290
- promptText = `Resolve ${selectedTodo.type} in ${selectedTodo.file} at line ${selectedTodo.line}: ${selectedTodo.text}`;
289
+ const todoKind = selectedTodo.type || selectedTodo.tag || "TODO";
290
+ title = `Fix ${todoKind}: ${selectedTodo.text.slice(0, 40)}`;
291
+ promptText = `Resolve ${todoKind} in ${selectedTodo.file} at line ${selectedTodo.line}: ${selectedTodo.text}`;
291
292
  }
292
293
  }
293
294