jules-orchestrator-kit 0.38.2 → 0.41.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.
@@ -25,6 +25,38 @@
25
25
 
26
26
  /** @type {CommandDescriptor[]} */
27
27
  export const COMMAND_REGISTRY = [
28
+ {
29
+ id: "assert",
30
+ path: ["assert"],
31
+ title: "assert",
32
+ description: "Run declarative zero-dependency verification assertion primitives",
33
+ category: "Inspect",
34
+ mutates: false,
35
+ risk: "low",
36
+ interactive: "never",
37
+ requiresRepository: false,
38
+ shortcuts: ["ast"],
39
+ examples: [
40
+ "agentctl assert --dir dist --max-mb 10 --gzip",
41
+ "agentctl assert --file dist/server.js --max-kb 500",
42
+ 'agentctl assert --patterns "console.log" --targets "src/**/*.js"',
43
+ "agentctl assert --config assert.json --json",
44
+ ],
45
+ flags: [
46
+ { name: "dir", type: "string", description: "Target directory path for dir-size assertion" },
47
+ { name: "file", type: "string", description: "Target file path for file-size assertion" },
48
+ { name: "targets", type: "string", description: "Target glob/path for pattern matching" },
49
+ { name: "patterns", type: "string", description: "Comma-separated patterns or regex to ban" },
50
+ { name: "patterns-file", type: "string", description: "Path to JSON file containing banned patterns" },
51
+ { name: "max-bytes", type: "string", description: "Maximum byte limit" },
52
+ { name: "max-kb", type: "string", description: "Maximum KiB limit" },
53
+ { name: "max-mb", type: "string", description: "Maximum MiB limit" },
54
+ { name: "gzip", type: "boolean", description: "Measure gzip compressed byte size" },
55
+ { name: "config", type: "string", description: "Path to assertion JSON/YAML config" },
56
+ { name: "json", type: "boolean", description: "Output structured JSON assertion result" },
57
+ { name: "json-report", type: "string", description: "Write structured JSON report to target path" },
58
+ ],
59
+ },
28
60
  {
29
61
  id: "doctor",
30
62
  path: ["doctor"],
@@ -1,6 +1,7 @@
1
1
  import { join, resolve } from "node:path";
2
2
  import { existsSync, readdirSync, readFileSync } from "node:fs";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
+ import { detectStack } from "../config.mjs";
4
5
 
5
6
  /**
6
7
  * @typedef {import("../ux/capabilities.mjs").ActionPlan} ActionPlan
@@ -72,12 +73,17 @@ export async function planDiagnosticFixes(context = {}) {
72
73
 
73
74
  if (fix.id === "config.create-default") {
74
75
  const configPath = ".agent/config.yml";
76
+ // Detected, not assumed. Writing `npm test` into a Rust or Python
77
+ // repository's config produced a manifest whose first verification run
78
+ // could only fail, and the stack detector already has the answer.
79
+ const detected = detectStack(root);
75
80
  const newContent = `# jules-orchestrator-kit configuration manifest
76
81
  version: 1
77
- tier: pro
82
+ # Free-tier limits until you state your plan (free | pro | ultra).
83
+ tier: free
78
84
  verify:
79
- test: "npm test"
80
- build: "npm run build"
85
+ test: ${JSON.stringify(detected.testCmd || "")}
86
+ build: ${JSON.stringify(detected.buildCmd || "")}
81
87
  `;
82
88
  const diff = createDiffPreview(configPath, "", newContent);
83
89
  const planId = `PLAN-${Date.now()}-${randomUUID().slice(0, 8)}`;
@@ -0,0 +1,349 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { classifyRiskTier, RISK_TIERS } from "../risk.mjs";
3
+ import { checkSafetyGate } from "../../scripts/jules-merge-swarm.mjs";
4
+ import { normalizePath, loadConfig } from "../config.mjs";
5
+
6
+ /**
7
+ * Checks if GitHub CLI statusCheckRollup indicates all CI checks are green/successful.
8
+ *
9
+ * An empty rollup is NOT passing. It means one of two things, and neither is
10
+ * "verified": the repository has no CI at all — the common case for the repos
11
+ * this kit is installed into — or the workflows are queued and have not
12
+ * registered yet, so a harvest run seconds after the PR opens would merge ahead
13
+ * of the checks it claims to be waiting for. Treating "no evidence" as "good
14
+ * evidence" is the wrong default anywhere, and this value feeds the only
15
+ * irreversible action the kit takes.
16
+ *
17
+ * Operators who genuinely have no CI and want the triage anyway pass
18
+ * `--allow-no-checks`, which is a stated choice rather than a silent one.
19
+ *
20
+ * @param {Array<object>} checks
21
+ * @returns {{ passing: boolean, pending: boolean, failing: boolean, noChecks: boolean, summary: string }}
22
+ */
23
+ export function evaluateStatusCheckRollup(checks = []) {
24
+ if (!Array.isArray(checks) || checks.length === 0) {
25
+ return { passing: false, pending: false, failing: false, noChecks: true, summary: "NO_CHECKS" };
26
+ }
27
+
28
+ let failingCount = 0;
29
+ let pendingCount = 0;
30
+ let successCount = 0;
31
+
32
+ for (const check of checks) {
33
+ const status = String(check.status || check.state || "").toUpperCase();
34
+ const conclusion = String(check.conclusion || "").toUpperCase();
35
+
36
+ if (conclusion === "SUCCESS" || conclusion === "NEUTRAL" || conclusion === "SKIPPED") {
37
+ successCount++;
38
+ } else if (
39
+ conclusion === "FAILURE" ||
40
+ conclusion === "TIMED_OUT" ||
41
+ conclusion === "ACTION_REQUIRED" ||
42
+ status === "FAILURE" ||
43
+ status === "ERROR"
44
+ ) {
45
+ failingCount++;
46
+ } else if (status === "IN_PROGRESS" || status === "QUEUED" || status === "PENDING" || !conclusion) {
47
+ pendingCount++;
48
+ }
49
+ }
50
+
51
+ const failing = failingCount > 0;
52
+ const pending = pendingCount > 0;
53
+ const passing = !failing && !pending;
54
+
55
+ const summary = failing ? `FAILED (${failingCount})` : pending ? `PENDING (${pendingCount})` : `PASSING (${successCount})`;
56
+ return { passing, pending, failing, noChecks: false, summary };
57
+ }
58
+
59
+ /**
60
+ * Reads the changed-file list off a `gh pr list --json files` entry.
61
+ *
62
+ * The distinction between "this PR changes nothing" and "gh did not tell us
63
+ * what it changes" is load-bearing: `classifyRiskTier([])` answers R0, which is
64
+ * auto-merge eligible, so a missing or truncated `files` array used to
65
+ * downgrade an unknown PR to the lowest risk tier. GitHub also caps the
66
+ * per-PR file list, so a large PR can arrive complete-looking but partial.
67
+ *
68
+ * @param {object} pr
69
+ * @param {number} [fileCap=100] - Page size at which the list is assumed truncated.
70
+ * @returns {{ known: boolean, truncated: boolean, files: string[], diffLines: number }}
71
+ */
72
+ export function readPrFiles(pr = {}, fileCap = 100) {
73
+ if (!Array.isArray(pr.files)) {
74
+ return { known: false, truncated: false, files: [], diffLines: 0 };
75
+ }
76
+
77
+ const files = pr.files.map((f) => normalizePath(typeof f === "string" ? f : f.path || ""))
78
+ .filter(Boolean);
79
+
80
+ // Only summed when the entries carry line counts; a PR whose files lack them
81
+ // reports 0, and the caller must not read that as "a small diff".
82
+ let diffLines = 0;
83
+ let hasLineCounts = false;
84
+ for (const f of pr.files) {
85
+ if (f && typeof f === "object" && (Number.isFinite(f.additions) || Number.isFinite(f.deletions))) {
86
+ hasLineCounts = true;
87
+ diffLines += (Number(f.additions) || 0) + (Number(f.deletions) || 0);
88
+ }
89
+ }
90
+
91
+ return {
92
+ known: files.length > 0,
93
+ truncated: pr.files.length >= fileCap,
94
+ files,
95
+ diffLines: hasLineCounts ? diffLines : 0,
96
+ hasLineCounts,
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Parses risk tier option string or array and maps shorthand names (R0, R1, R2, R3).
102
+ * @param {string|string[]} tierOption
103
+ * @returns {Set<string>}
104
+ */
105
+ export function parseTierFilter(tierOption) {
106
+ const mapShorthand = (t) => {
107
+ const upper = String(t).toUpperCase().trim();
108
+ if (upper === "R0" || upper === "R0_COSMETIC") return RISK_TIERS.R0;
109
+ if (upper === "R1" || upper === "R1_ROUTINE") return RISK_TIERS.R1;
110
+ if (upper === "R2" || upper === "R2_CONSEQUENTIAL") return RISK_TIERS.R2;
111
+ if (upper === "R3" || upper === "R3_RESTRICTED") return RISK_TIERS.R3;
112
+ return upper;
113
+ };
114
+
115
+ if (!tierOption) return new Set([RISK_TIERS.R0, RISK_TIERS.R1]);
116
+ if (Array.isArray(tierOption)) return new Set(tierOption.map(mapShorthand));
117
+ return new Set(
118
+ String(tierOption)
119
+ .split(",")
120
+ .map(mapShorthand)
121
+ .filter(Boolean)
122
+ );
123
+ }
124
+
125
+ /**
126
+ * Automated PR Harvester: Discovers open PRs, evaluates CI status, risk tiers, and safety locks,
127
+ * and optionally merges/squashes green, low-risk changes autonomously.
128
+ *
129
+ * @param {string} root - Repository root path
130
+ * @param {object} options
131
+ * @returns {Promise<{ prs: Array<object>, harvested: Array<object>, summary: object }>}
132
+ */
133
+ export async function harvestPullRequests(root = process.cwd(), options = {}) {
134
+ const limit = options.limit || 50;
135
+ const state = options.state || "open";
136
+ const allowedTiers = parseTierFilter(options.tier);
137
+ const isAuto = Boolean(options.auto || options.merge);
138
+ const isDryRun = Boolean(options.dryRun);
139
+ const allowNoChecks = Boolean(options.allowNoChecks);
140
+ // Loaded once: the repository's own risk paths decide whether a PR is
141
+ // auto-merge eligible, and re-reading the config per PR would be pure I/O.
142
+ let riskConfig = options.config;
143
+ if (!riskConfig) {
144
+ try {
145
+ riskConfig = loadConfig(root);
146
+ } catch (_) {
147
+ riskConfig = {};
148
+ }
149
+ }
150
+
151
+ let rawPrs = [];
152
+ if (typeof options.execGh === "function") {
153
+ rawPrs = await options.execGh("pr", ["list", "--state", state, "--limit", String(limit), "--json", "number,title,headRefName,statusCheckRollup,mergeable,labels,files"]);
154
+ } else {
155
+ const res = spawnSync(
156
+ "gh",
157
+ [
158
+ "pr",
159
+ "list",
160
+ "--state",
161
+ state,
162
+ "--limit",
163
+ String(limit),
164
+ "--json",
165
+ "number,title,headRefName,statusCheckRollup,mergeable,labels,files",
166
+ ],
167
+ {
168
+ cwd: root,
169
+ encoding: "utf-8",
170
+ timeout: 30000,
171
+ }
172
+ );
173
+
174
+ if (res.error) {
175
+ if (res.error.code === "ENOENT") {
176
+ throw new Error("GitHub CLI (gh) not found in PATH. Install gh or use JULES_DRY_RUN=1.");
177
+ }
178
+ throw new Error(`Failed to list pull requests: ${res.error.message}`);
179
+ }
180
+
181
+ if (res.status !== 0) {
182
+ throw new Error(`gh pr list failed (exit ${res.status}): ${(res.stderr || "").trim()}`);
183
+ }
184
+
185
+ try {
186
+ rawPrs = JSON.parse(res.stdout || "[]");
187
+ } catch (err) {
188
+ throw new Error(`Invalid JSON output from gh pr list: ${err.message}`);
189
+ }
190
+ }
191
+
192
+ const evaluatedPrs = [];
193
+ const harvested = [];
194
+
195
+ for (const pr of rawPrs) {
196
+ const fileInfo = readPrFiles(pr);
197
+ const files = fileInfo.files;
198
+ // diffLines was never passed before, which left the 400-line R2 rule in
199
+ // risk.mjs unreachable from the harvest path: a 5,000-line change to one
200
+ // unremarkable file classified R1 and auto-merged.
201
+ const risk = classifyRiskTier(files, { diffLines: fileInfo.diffLines, config: riskConfig });
202
+ const checks = evaluateStatusCheckRollup(pr.statusCheckRollup || []);
203
+ const gate = checkSafetyGate(pr.headRefName || "", root);
204
+ const tierMatches = allowedTiers.has(risk.tier);
205
+ // "UNKNOWN" means GitHub is still computing mergeability. Only "MERGEABLE"
206
+ // is an answer; anything else is either a conflict or an absence of one.
207
+ const isMergeable = String(pr.mergeable || "").toUpperCase() === "MERGEABLE";
208
+ const checksUsable = checks.passing || (checks.noChecks && allowNoChecks);
209
+ const filesUsable = fileInfo.known && !fileInfo.truncated;
210
+
211
+ const eligible = checksUsable && filesUsable && gate.safe && tierMatches && isMergeable;
212
+ let actionStatus = "SKIPPED";
213
+ let actionReason = "";
214
+
215
+ if (!filesUsable) {
216
+ actionReason = fileInfo.truncated
217
+ ? `Changed-file list truncated at ${files.length} entries — risk tier cannot be trusted`
218
+ : "Changed-file list unavailable from gh — risk tier cannot be determined";
219
+ } else if (!tierMatches) {
220
+ actionReason = `Risk Tier ${risk.tier} excluded by filter (${Array.from(allowedTiers).join(",")})`;
221
+ } else if (!checksUsable) {
222
+ actionReason = checks.noChecks
223
+ ? "No CI checks reported — pass --allow-no-checks to harvest unverified PRs"
224
+ : `CI Checks not clean: ${checks.summary}`;
225
+ } else if (!gate.safe) {
226
+ actionReason = `Safety gate locked: ${gate.reason}`;
227
+ } else if (!isMergeable) {
228
+ actionReason = pr.mergeable ? `Not mergeable (${pr.mergeable})` : "Mergeability unknown";
229
+ } else {
230
+ actionStatus = "ELIGIBLE";
231
+ actionReason = checks.noChecks ? "Ready for harvest (UNVERIFIED — no CI checks)" : "Ready for harvest";
232
+ }
233
+
234
+ let merged = false;
235
+ let mergeError = null;
236
+
237
+ if (eligible && isAuto && !isDryRun) {
238
+ if (typeof options.mergeGh === "function") {
239
+ const mergeRes = await options.mergeGh(pr.number);
240
+ merged = mergeRes.ok;
241
+ if (!merged) mergeError = mergeRes.error;
242
+ } else {
243
+ const mergeCmd = spawnSync("gh", ["pr", "merge", String(pr.number), "--squash", "--auto"], {
244
+ cwd: root,
245
+ encoding: "utf-8",
246
+ timeout: 30000,
247
+ });
248
+ if (mergeCmd.status === 0) {
249
+ merged = true;
250
+ actionStatus = "MERGED";
251
+ actionReason = "Auto-squashed and merged successfully";
252
+ } else {
253
+ mergeError = (mergeCmd.stderr || mergeCmd.stdout || "Merge failed").trim();
254
+ actionStatus = "FAILED";
255
+ actionReason = `Merge error: ${mergeError}`;
256
+ }
257
+ }
258
+
259
+ if (merged) {
260
+ harvested.push({
261
+ number: pr.number,
262
+ title: pr.title,
263
+ headRefName: pr.headRefName,
264
+ tier: risk.tier,
265
+ });
266
+ }
267
+ }
268
+
269
+ evaluatedPrs.push({
270
+ number: pr.number,
271
+ title: pr.title,
272
+ branch: pr.headRefName,
273
+ filesCount: files.length,
274
+ riskTier: risk.tier,
275
+ riskReason: risk.reason,
276
+ diffLines: fileInfo.diffLines,
277
+ ciStatus: checks.summary,
278
+ ciPassing: checks.passing,
279
+ // True when this PR was allowed through without any CI evidence. Kept on
280
+ // the record so a report can distinguish "verified green" from "nobody
281
+ // checked", which the ciStatus string alone no longer conveys.
282
+ unverified: checks.noChecks && allowNoChecks,
283
+ safetySafe: gate.safe,
284
+ eligible,
285
+ status: actionStatus,
286
+ reason: actionReason,
287
+ merged,
288
+ mergeError,
289
+ });
290
+ }
291
+
292
+ const summary = {
293
+ total: evaluatedPrs.length,
294
+ eligible: evaluatedPrs.filter((p) => p.eligible).length,
295
+ merged: harvested.length,
296
+ skipped: evaluatedPrs.filter((p) => !p.eligible).length,
297
+ unverified: evaluatedPrs.filter((p) => p.unverified && p.eligible).length,
298
+ };
299
+
300
+ return {
301
+ prs: evaluatedPrs,
302
+ harvested,
303
+ summary,
304
+ };
305
+ }
306
+
307
+ /**
308
+ * Formats PR harvest results into a human-readable CLI terminal table.
309
+ * @param {object} harvestResult
310
+ * @returns {string}
311
+ */
312
+ export function formatHarvestTable(harvestResult) {
313
+ const { prs, summary } = harvestResult;
314
+ if (!prs || prs.length === 0) {
315
+ return "No open pull requests found for harvest.";
316
+ }
317
+
318
+ const lines = [];
319
+ lines.push("🌾 Pull Request Harvest & Triage Report");
320
+ lines.push("═".repeat(80));
321
+ lines.push(
322
+ `${"PR #".padEnd(8)} | ${"Tier".padEnd(6)} | ${"CI Checks".padEnd(16)} | ${"Status".padEnd(10)} | ${"Title"}`
323
+ );
324
+ lines.push("-".repeat(80));
325
+
326
+ for (const pr of prs) {
327
+ const prNum = `#${pr.number}`.padEnd(8);
328
+ const tier = pr.riskTier.padEnd(6);
329
+ const ci = pr.ciStatus.slice(0, 16).padEnd(16);
330
+ const status = pr.status.padEnd(10);
331
+ const title = pr.title.length > 32 ? `${pr.title.slice(0, 29)}...` : pr.title;
332
+ lines.push(`${prNum} | ${tier} | ${ci} | ${status} | ${title}`);
333
+ if (pr.reason && pr.status !== "MERGED" && pr.status !== "ELIGIBLE") {
334
+ lines.push(` └─ Reason: ${pr.reason}`);
335
+ }
336
+ }
337
+
338
+ lines.push("═".repeat(80));
339
+ lines.push(
340
+ `Summary: ${summary.total} total PRs · ${summary.eligible} eligible · ${summary.merged} merged · ${summary.skipped} skipped`
341
+ );
342
+ if (summary.unverified > 0) {
343
+ lines.push(
344
+ `⚠️ ${summary.unverified} of the eligible PRs carry no CI checks and were admitted by --allow-no-checks.`
345
+ );
346
+ }
347
+
348
+ return lines.join("\n");
349
+ }