jules-orchestrator-kit 0.29.1 → 0.32.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.
Files changed (46) hide show
  1. package/README.md +169 -86
  2. package/bin/agentctl.mjs +267 -4
  3. package/bin/mcp-server.mjs +0 -0
  4. package/index.mjs +21 -1
  5. package/package.json +1 -1
  6. package/scripts/stale-base-check.mjs +2 -2
  7. package/src/config.mjs +18 -2
  8. package/src/dag-engine.mjs +76 -0
  9. package/src/engine.mjs +331 -32
  10. package/src/envelope.mjs +32 -2
  11. package/src/execution_envelope.mjs +9 -1
  12. package/src/git.mjs +41 -0
  13. package/src/mcp.mjs +34 -0
  14. package/src/memory.mjs +148 -0
  15. package/src/ops/checkpoint.mjs +167 -0
  16. package/src/ops/command-registry.mjs +274 -0
  17. package/src/ops/doctor-planner.mjs +214 -0
  18. package/src/ops/doctor-registry.mjs +442 -0
  19. package/src/ops/ide-scaffold.mjs +139 -0
  20. package/src/ops/receipts.mjs +171 -0
  21. package/src/ops/swarm-actions.mjs +156 -0
  22. package/src/ops/task-actions.mjs +214 -0
  23. package/src/ops/tdd-generator.mjs +115 -0
  24. package/src/ops/transaction.mjs +278 -0
  25. package/src/prompt-guard.mjs +8 -1
  26. package/src/provider.mjs +164 -8
  27. package/src/remediation.mjs +164 -0
  28. package/src/security.mjs +14 -6
  29. package/src/stack-detector.mjs +15 -5
  30. package/src/state.mjs +71 -7
  31. package/src/task-optimizer.mjs +306 -0
  32. package/src/telemetry.mjs +35 -2
  33. package/src/tui.mjs +2 -6
  34. package/src/ux/capabilities.mjs +180 -0
  35. package/src/ux/diff-viewer.mjs +289 -0
  36. package/src/ux/key-decoder.mjs +281 -0
  37. package/src/ux/layout.mjs +195 -0
  38. package/src/ux/log-viewer.mjs +110 -0
  39. package/src/ux/palette.mjs +179 -0
  40. package/src/ux/queue-model.mjs +206 -0
  41. package/src/ux/renderer.mjs +188 -0
  42. package/src/ux/swarm-model.mjs +146 -0
  43. package/src/ux/terminal-session.mjs +215 -0
  44. package/src/ux/widgets.mjs +323 -0
  45. package/src/webhook.mjs +120 -0
  46. package/src/wizard-task.mjs +6 -0
@@ -0,0 +1,214 @@
1
+ import { join, resolve } from "node:path";
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+
5
+ /**
6
+ * @typedef {import("../ux/capabilities.mjs").ActionPlan} ActionPlan
7
+ * @typedef {import("./doctor-registry.mjs").DoctorReport} DoctorReport
8
+ *
9
+ * @typedef {Object} FixPlanningContext
10
+ * @property {string} root
11
+ * @property {DoctorReport} report
12
+ * @property {string[]} selectedFixIds
13
+ * @property {Record<string, unknown>} [answers]
14
+ */
15
+
16
+ /**
17
+ * Generate unified diff preview string for file creation/replacement.
18
+ * @param {string} path
19
+ * @param {string} oldContent
20
+ * @param {string} newContent
21
+ * @returns {string}
22
+ */
23
+ function createDiffPreview(path, oldContent, newContent) {
24
+ const oldLines = oldContent ? oldContent.split("\n") : [];
25
+ const newLines = newContent ? newContent.split("\n") : [];
26
+ const lines = [];
27
+
28
+ lines.push(`--- a/${path}`);
29
+ lines.push(`+++ b/${path}`);
30
+ lines.push(`@@ -1,${oldLines.length || 0} +1,${newLines.length || 0} @@`);
31
+
32
+ for (const line of oldLines) {
33
+ lines.push(`-${line}`);
34
+ }
35
+ for (const line of newLines) {
36
+ lines.push(`+${line}`);
37
+ }
38
+
39
+ return lines.join("\n");
40
+ }
41
+
42
+ function deepFreeze(value) {
43
+ if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
44
+ for (const child of Object.values(value)) deepFreeze(child);
45
+ return Object.freeze(value);
46
+ }
47
+
48
+ function finalizePlan(plan) {
49
+ const { planHash: _oldHash, ...unsigned } = plan;
50
+ const planHash = "sha256:" + createHash("sha256").update(JSON.stringify(unsigned)).digest("hex");
51
+ return deepFreeze({ ...unsigned, planHash });
52
+ }
53
+
54
+ /**
55
+ * Plan diagnostic fixes into immutable ActionPlan objects.
56
+ * @param {FixPlanningContext} context
57
+ * @returns {Promise<ActionPlan[]>}
58
+ */
59
+ export async function planDiagnosticFixes(context = {}) {
60
+ const root = resolve(context.root || process.cwd());
61
+ const report = context.report || { results: [] };
62
+ const selectedFixIds = Array.isArray(context.selectedFixIds) ? context.selectedFixIds : ["safe"];
63
+
64
+ const wantSafeOnly = selectedFixIds.includes("safe");
65
+ /** @type {ActionPlan[]} */
66
+ const plans = [];
67
+
68
+ for (const result of report.results || []) {
69
+ for (const fix of result.fixes || []) {
70
+ const isSelected = selectedFixIds.includes(fix.id) || (wantSafeOnly && fix.automatic);
71
+ if (!isSelected) continue;
72
+
73
+ if (fix.id === "config.create-default") {
74
+ const configPath = ".agent/config.yml";
75
+ const newContent = `# jules-orchestrator-kit configuration manifest
76
+ version: 1
77
+ tier: pro
78
+ verify:
79
+ test: "npm test"
80
+ build: "npm run build"
81
+ `;
82
+ const diff = createDiffPreview(configPath, "", newContent);
83
+ const planId = `PLAN-${Date.now()}-${randomUUID().slice(0, 8)}`;
84
+
85
+ plans.push({
86
+ schema: "agentctl/action-plan-v1",
87
+ id: planId,
88
+ kind: "config.create-default",
89
+ title: "Create default .agent/config.yml",
90
+ summary: "Generate initial configuration manifest for jules-orchestrator-kit",
91
+ risk: "low",
92
+ repository: root,
93
+ createdAt: new Date().toISOString(),
94
+ preconditions: [],
95
+ fileMutations: [
96
+ {
97
+ operation: "create",
98
+ path: configPath,
99
+ newContent,
100
+ },
101
+ ],
102
+ commandEffects: [],
103
+ stateTransitions: [],
104
+ preview: {
105
+ unifiedDiff: diff,
106
+ warnings: [],
107
+ estimatedImpact: ["Creates .agent/config.yml"],
108
+ },
109
+ confirmation: {
110
+ mode: "none",
111
+ prompt: "Apply default configuration?",
112
+ },
113
+ planHash: "sha256:" + createHash("sha256").update(planId).digest("hex"),
114
+ });
115
+ } else if (fix.id === "state.rebuild-head") {
116
+ const dateStr = new Date().toISOString().split("T")[0];
117
+ const stateDir = join(root, ".agent", "state");
118
+ const headPath = `.agent/state/telemetry-${dateStr}.head`;
119
+ let tailHash = "0".repeat(64);
120
+ let segment = 0;
121
+ try {
122
+ const telemetryFiles = readdirSync(stateDir)
123
+ .filter((name) => name.startsWith(`telemetry-${dateStr}`) && name.endsWith(".jsonl"))
124
+ .sort((a, b) => {
125
+ const index = (name) => Number(name.match(/-(\d+)\.jsonl$/)?.[1] || 0);
126
+ return index(a) - index(b);
127
+ });
128
+ const lastFile = telemetryFiles.at(-1);
129
+ if (lastFile) {
130
+ const lines = readFileSync(join(stateDir, lastFile), "utf-8").split("\n").filter(Boolean);
131
+ const lastEntry = JSON.parse(lines.at(-1));
132
+ if (typeof lastEntry?.hash === "string") tailHash = lastEntry.hash;
133
+ const segmentMatch = lastFile.match(/-(\d+)\.jsonl$/);
134
+ segment = segmentMatch ? Number(segmentMatch[1]) : 0;
135
+ }
136
+ } catch (_) {}
137
+ const newContent = JSON.stringify({ v: 1, hash: tailHash, segment, ts: new Date().toISOString() });
138
+ const planId = `PLAN-${Date.now()}-${randomUUID().slice(0, 8)}`;
139
+
140
+ plans.push({
141
+ schema: "agentctl/action-plan-v1",
142
+ id: planId,
143
+ kind: "state.rebuild-head",
144
+ title: "Rebuild telemetry spine head pointer",
145
+ summary: "Re-initialize .head pointer for telemetry spine ledger",
146
+ risk: "low",
147
+ repository: root,
148
+ createdAt: new Date().toISOString(),
149
+ preconditions: [],
150
+ fileMutations: [
151
+ {
152
+ operation: "create",
153
+ path: headPath,
154
+ newContent,
155
+ },
156
+ ],
157
+ commandEffects: [],
158
+ stateTransitions: [],
159
+ preview: {
160
+ unifiedDiff: createDiffPreview(headPath, "", newContent),
161
+ warnings: [],
162
+ estimatedImpact: [`Creates ${headPath} pointer`],
163
+ },
164
+ confirmation: {
165
+ mode: "none",
166
+ prompt: "Rebuild telemetry head pointer?",
167
+ },
168
+ planHash: "sha256:" + createHash("sha256").update(planId).digest("hex"),
169
+ });
170
+ } else if (fix.id === "locks.prune-stale") {
171
+ const locksDir = join(root, ".agent", "state", "locks");
172
+ if (existsSync(locksDir)) {
173
+ const lockFiles = readdirSync(locksDir).filter((f) => f.endsWith(".json"));
174
+ for (const lockFile of lockFiles) {
175
+ const lockRelPath = `.agent/state/locks/${lockFile}`;
176
+ const planId = `PLAN-${Date.now()}-${randomUUID().slice(0, 8)}`;
177
+
178
+ plans.push({
179
+ schema: "agentctl/action-plan-v1",
180
+ id: planId,
181
+ kind: "locks.prune-stale",
182
+ title: `Remove stale lock ${lockFile}`,
183
+ summary: `Prune inactive VFS lock file ${lockRelPath}`,
184
+ risk: "low",
185
+ repository: root,
186
+ createdAt: new Date().toISOString(),
187
+ preconditions: [],
188
+ fileMutations: [
189
+ {
190
+ operation: "delete",
191
+ path: lockRelPath,
192
+ },
193
+ ],
194
+ commandEffects: [],
195
+ stateTransitions: [],
196
+ preview: {
197
+ unifiedDiff: `--- a/${lockRelPath}\n+++ /dev/null\n`,
198
+ warnings: [],
199
+ estimatedImpact: [`Removes ${lockRelPath}`],
200
+ },
201
+ confirmation: {
202
+ mode: "none",
203
+ prompt: `Remove lock ${lockFile}?`,
204
+ },
205
+ planHash: "sha256:" + createHash("sha256").update(planId).digest("hex"),
206
+ });
207
+ }
208
+ }
209
+ }
210
+ }
211
+ }
212
+
213
+ return plans.map(finalizePlan);
214
+ }
@@ -0,0 +1,442 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { execFileSync } from "node:child_process";
5
+
6
+ /**
7
+ * @typedef {"pass" | "warn" | "fail" | "skip" | "unknown"} DiagnosticStatus
8
+ * @typedef {"info" | "low" | "medium" | "high" | "critical"} DiagnosticSeverity
9
+ *
10
+ * @typedef {Object} DiagnosticEvidence
11
+ * @property {string} label
12
+ * @property {string | number | boolean} value
13
+ * @property {boolean} sensitive
14
+ *
15
+ * @typedef {Object} FixDescriptor
16
+ * @property {string} id
17
+ * @property {string} title
18
+ * @property {string} summary
19
+ * @property {"low" | "moderate" | "high" | "destructive"} risk
20
+ * @property {boolean} automatic
21
+ * @property {boolean} requiresProbe
22
+ *
23
+ * @typedef {Object} DiagnosticResult
24
+ * @property {"agentctl/diagnostic-result-v1"} schema
25
+ * @property {string} id
26
+ * @property {string} category
27
+ * @property {string} title
28
+ * @property {DiagnosticStatus} status
29
+ * @property {DiagnosticSeverity} severity
30
+ * @property {string} summary
31
+ * @property {number} durationMs
32
+ * @property {boolean} passive
33
+ * @property {DiagnosticEvidence[]} evidence
34
+ * @property {FixDescriptor[]} fixes
35
+ * @property {{ code: string, message: string }} [error]
36
+ *
37
+ * @typedef {Object} DoctorReport
38
+ * @property {"agentctl/doctor-report-v1"} schema
39
+ * @property {string} repository
40
+ * @property {string} [headSha]
41
+ * @property {string} generatedAt
42
+ * @property {boolean} activeProbe
43
+ * @property {Record<DiagnosticStatus, number>} summary
44
+ * @property {DiagnosticResult[]} results
45
+ * @property {string} reportHash
46
+ */
47
+
48
+ /**
49
+ * Compute SHA-256 report hash for DoctorReport.
50
+ * @param {Omit<DoctorReport, "reportHash">} report
51
+ * @returns {string}
52
+ */
53
+ export function computeReportHash(report) {
54
+ const payload = JSON.stringify({
55
+ schema: report.schema,
56
+ repository: report.repository,
57
+ generatedAt: report.generatedAt,
58
+ summary: report.summary,
59
+ results: report.results.map((r) => ({ id: r.id, status: r.status, summary: r.summary })),
60
+ });
61
+ return "sha256:" + createHash("sha256").update(payload).digest("hex");
62
+ }
63
+
64
+ /**
65
+ * Run diagnostic check suite across DAG.
66
+ * @param {Object} options
67
+ * @param {string} options.root
68
+ * @param {boolean} [options.activeProbe=false]
69
+ * @param {string[]} [options.selectedChecks]
70
+ * @returns {Promise<DoctorReport>}
71
+ */
72
+ export async function runDoctorChecks(options = {}) {
73
+ const root = resolve(options.root || process.cwd());
74
+ const activeProbe = Boolean(options.activeProbe);
75
+ const _startTime = Date.now();
76
+
77
+ /** @type {DiagnosticResult[]} */
78
+ const results = [];
79
+
80
+ // Helper to append result
81
+ const addResult = (res) => {
82
+ results.push({
83
+ schema: "agentctl/diagnostic-result-v1",
84
+ durationMs: 0,
85
+ passive: !activeProbe,
86
+ evidence: [],
87
+ fixes: [],
88
+ ...res,
89
+ });
90
+ };
91
+
92
+ // 1. System Checks
93
+ // runtime.node
94
+ const nodeVersion = process.version;
95
+ const nodeMajor = parseInt(nodeVersion.slice(1).split(".")[0], 10);
96
+ if (nodeMajor >= 20) {
97
+ addResult({
98
+ id: "runtime.node",
99
+ category: "System",
100
+ title: "Node.js Engine Version",
101
+ status: "pass",
102
+ severity: "info",
103
+ summary: `Node.js ${nodeVersion} satisfies runtime requirements (v20+)`,
104
+ evidence: [{ label: "nodeVersion", value: nodeVersion, sensitive: false }],
105
+ });
106
+ } else {
107
+ addResult({
108
+ id: "runtime.node",
109
+ category: "System",
110
+ title: "Node.js Engine Version",
111
+ status: "fail",
112
+ severity: "critical",
113
+ summary: `Node.js ${nodeVersion} is below required minimum v20.0.0`,
114
+ evidence: [{ label: "nodeVersion", value: nodeVersion, sensitive: false }],
115
+ });
116
+ }
117
+
118
+ // runtime.git
119
+ let gitPassed = false;
120
+ try {
121
+ const gitOut = execFileSync("git", ["--version"], { encoding: "utf-8", timeout: 5000 });
122
+ gitPassed = true;
123
+ addResult({
124
+ id: "runtime.git",
125
+ category: "System",
126
+ title: "Git Command Available",
127
+ status: "pass",
128
+ severity: "info",
129
+ summary: gitOut.trim(),
130
+ evidence: [{ label: "gitVersion", value: gitOut.trim(), sensitive: false }],
131
+ });
132
+ } catch (err) {
133
+ addResult({
134
+ id: "runtime.git",
135
+ category: "System",
136
+ title: "Git Command Available",
137
+ status: "fail",
138
+ severity: "critical",
139
+ summary: "Git executable not found or failed to run",
140
+ error: { code: "ENOENT", message: err.message },
141
+ });
142
+ }
143
+
144
+ // 2. Repository Checks
145
+
146
+ if (!gitPassed) {
147
+ addResult({
148
+ id: "repo.root",
149
+ category: "Repository",
150
+ title: "Repository Root Verification",
151
+ status: "skip",
152
+ severity: "info",
153
+ summary: "Skipped due to failed runtime.git check",
154
+ });
155
+ addResult({
156
+ id: "repo.dirty",
157
+ category: "Repository",
158
+ title: "Working Directory Cleanliness",
159
+ status: "skip",
160
+ severity: "info",
161
+ summary: "Skipped due to failed runtime.git check",
162
+ });
163
+ } else {
164
+ let isRepo = false;
165
+ let headSha = "";
166
+ try {
167
+ headSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf-8" }).trim();
168
+ isRepo = true;
169
+ addResult({
170
+ id: "repo.root",
171
+ category: "Repository",
172
+ title: "Repository Root Verification",
173
+ status: "pass",
174
+ severity: "info",
175
+ summary: `Valid repository at ${root} (HEAD: ${headSha.slice(0, 8)})`,
176
+ evidence: [{ label: "headSha", value: headSha, sensitive: false }],
177
+ });
178
+ } catch {
179
+ addResult({
180
+ id: "repo.root",
181
+ category: "Repository",
182
+ title: "Repository Root Verification",
183
+ status: "fail",
184
+ severity: "high",
185
+ summary: `Directory ${root} is not a valid Git repository`,
186
+ });
187
+ }
188
+
189
+ if (isRepo) {
190
+ try {
191
+ const statusOut = execFileSync("git", ["status", "--porcelain"], { cwd: root, encoding: "utf-8" }).trim();
192
+ const modifiedCount = statusOut ? statusOut.split("\n").length : 0;
193
+ if (modifiedCount === 0) {
194
+ addResult({
195
+ id: "repo.dirty",
196
+ category: "Repository",
197
+ title: "Working Directory Cleanliness",
198
+ status: "pass",
199
+ severity: "info",
200
+ summary: "Working tree is clean",
201
+ evidence: [{ label: "uncommittedFiles", value: 0, sensitive: false }],
202
+ });
203
+ } else {
204
+ addResult({
205
+ id: "repo.dirty",
206
+ category: "Repository",
207
+ title: "Working Directory Cleanliness",
208
+ status: "warn",
209
+ severity: "low",
210
+ summary: `Working tree has ${modifiedCount} uncommitted changes`,
211
+ evidence: [{ label: "uncommittedFiles", value: modifiedCount, sensitive: false }],
212
+ });
213
+ }
214
+ } catch {
215
+ addResult({
216
+ id: "repo.dirty",
217
+ category: "Repository",
218
+ title: "Working Directory Cleanliness",
219
+ status: "warn",
220
+ severity: "low",
221
+ summary: "Could not query git status",
222
+ });
223
+ }
224
+ }
225
+ }
226
+
227
+ // 3. Config Checks
228
+ const configCandidates = [
229
+ [".agent/config.yml", join(root, ".agent", "config.yml")],
230
+ [".agent/jules.yml", join(root, ".agent", "jules.yml")],
231
+ ];
232
+ const existingConfig = configCandidates.find(([, path]) => existsSync(path));
233
+ if (existingConfig) {
234
+ addResult({
235
+ id: "config.present",
236
+ category: "Config",
237
+ title: "Agent Configuration File",
238
+ status: "pass",
239
+ severity: "info",
240
+ summary: `${existingConfig[0]} exists`,
241
+ evidence: [{ label: "configPath", value: existingConfig[0], sensitive: false }],
242
+ });
243
+ } else {
244
+ addResult({
245
+ id: "config.present",
246
+ category: "Config",
247
+ title: "Agent Configuration File",
248
+ status: "warn",
249
+ severity: "medium",
250
+ summary: "Neither .agent/config.yml nor .agent/jules.yml exists",
251
+ fixes: [
252
+ {
253
+ id: "config.create-default",
254
+ title: "Create default config",
255
+ summary: "Initialize standard .agent/config.yml manifest",
256
+ risk: "low",
257
+ automatic: true,
258
+ requiresProbe: false,
259
+ },
260
+ ],
261
+ });
262
+ }
263
+
264
+ // 4. Verification Oracle Checks
265
+ const pkgPath = join(root, "package.json");
266
+ if (existsSync(pkgPath)) {
267
+ try {
268
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
269
+ const testScript = pkg.scripts && pkg.scripts.test;
270
+ if (testScript) {
271
+ addResult({
272
+ id: "oracle.test",
273
+ category: "Verification",
274
+ title: "Test Oracle Configuration",
275
+ status: "pass",
276
+ severity: "info",
277
+ summary: `Test oracle script found: "npm test" -> "${testScript}"`,
278
+ evidence: [{ label: "testScript", value: testScript, sensitive: false }],
279
+ });
280
+ } else {
281
+ addResult({
282
+ id: "oracle.test",
283
+ category: "Verification",
284
+ title: "Test Oracle Configuration",
285
+ status: "warn",
286
+ severity: "medium",
287
+ summary: "package.json missing test script entry",
288
+ });
289
+ }
290
+ } catch {
291
+ addResult({
292
+ id: "oracle.test",
293
+ category: "Verification",
294
+ title: "Test Oracle Configuration",
295
+ status: "warn",
296
+ severity: "medium",
297
+ summary: "Malformed package.json file",
298
+ });
299
+ }
300
+ } else {
301
+ addResult({
302
+ id: "oracle.test",
303
+ category: "Verification",
304
+ title: "Test Oracle Configuration",
305
+ status: "pass",
306
+ severity: "info",
307
+ summary: "Non-Node workspace verified",
308
+ });
309
+ }
310
+
311
+ // 5. State & Telemetry Checks
312
+ const telemetryDate = new Date().toISOString().split("T")[0];
313
+ const telemetryHeadPath = join(root, ".agent", "state", `telemetry-${telemetryDate}.head`);
314
+ if (existsSync(telemetryHeadPath)) {
315
+ addResult({
316
+ id: "state.telemetry",
317
+ category: "State",
318
+ title: "Telemetry Spine Head Integrity",
319
+ status: "pass",
320
+ severity: "info",
321
+ summary: "Telemetry .head file present"
322
+ });
323
+ } else {
324
+ addResult({
325
+ id: "state.telemetry",
326
+ category: "State",
327
+ title: "Telemetry Spine Head Integrity",
328
+ status: "warn",
329
+ severity: "low",
330
+ summary: "Telemetry .head file missing; can be rebuilt from tail log",
331
+ fixes: [
332
+ {
333
+ id: "state.rebuild-head",
334
+ title: "Rebuild telemetry head",
335
+ summary: "Recompute .head SHA-256 pointer from tail log",
336
+ risk: "low",
337
+ automatic: true,
338
+ requiresProbe: false,
339
+ },
340
+ ],
341
+ });
342
+ }
343
+
344
+ // 6. VFS Locks Check
345
+ const locksDir = join(root, ".agent", "state", "locks");
346
+ let activeLockCount = 0;
347
+ let lockInspectionFailed = false;
348
+ try {
349
+ if (existsSync(locksDir)) {
350
+ const lockFiles = readdirSync(locksDir).filter((f) => f.endsWith(".json"));
351
+ activeLockCount = lockFiles.length;
352
+ }
353
+ } catch (_) {
354
+ lockInspectionFailed = true;
355
+ addResult({
356
+ id: "locks.active",
357
+ category: "State",
358
+ title: "VFS Active Locks",
359
+ status: "unknown",
360
+ severity: "medium",
361
+ summary: "Could not inspect VFS lock directory",
362
+ });
363
+ }
364
+ if (!lockInspectionFailed && activeLockCount === 0) {
365
+ addResult({
366
+ id: "locks.active",
367
+ category: "State",
368
+ title: "VFS Active Locks",
369
+ status: "pass",
370
+ severity: "info",
371
+ summary: "No active VFS locks present",
372
+ });
373
+ } else if (!lockInspectionFailed) {
374
+ addResult({
375
+ id: "locks.active",
376
+ category: "State",
377
+ title: "VFS Active Locks",
378
+ status: "warn",
379
+ severity: "medium",
380
+ summary: `${activeLockCount} active VFS lock(s) held`,
381
+ evidence: [{ label: "lockCount", value: activeLockCount, sensitive: false }],
382
+ fixes: [
383
+ {
384
+ id: "locks.prune-stale",
385
+ title: "Prune stale locks",
386
+ summary: "Remove locks owned by dead processes",
387
+ risk: "low",
388
+ automatic: true,
389
+ requiresProbe: false,
390
+ },
391
+ ],
392
+ });
393
+ }
394
+
395
+ // 7. Jules Provider Key Check
396
+ const hasApiKey = Boolean(process.env.JULES_API_KEY || process.env.GEMINI_API_KEY);
397
+ if (hasApiKey) {
398
+ addResult({
399
+ id: "provider.key",
400
+ category: "Jules",
401
+ title: "Jules Provider API Key",
402
+ status: "pass",
403
+ severity: "info",
404
+ summary: "API key environment variable detected",
405
+ evidence: [{ label: "keyConfigured", value: true, sensitive: false }],
406
+ });
407
+ } else {
408
+ addResult({
409
+ id: "provider.key",
410
+ category: "Jules",
411
+ title: "Jules Provider API Key",
412
+ status: "warn",
413
+ severity: "high",
414
+ summary: "Neither JULES_API_KEY nor GEMINI_API_KEY environment variable is set",
415
+ evidence: [{ label: "keyConfigured", value: false, sensitive: false }],
416
+ });
417
+ }
418
+
419
+ // Summarize count by status
420
+ const summary = { pass: 0, warn: 0, fail: 0, skip: 0, unknown: 0 };
421
+ for (const r of results) {
422
+ summary[r.status] = (summary[r.status] || 0) + 1;
423
+ }
424
+
425
+ const baseReport = {
426
+ schema: /** @type {const} */ ("agentctl/doctor-report-v1"),
427
+ repository: root,
428
+ generatedAt: new Date().toISOString(),
429
+ activeProbe,
430
+ summary,
431
+ results,
432
+ };
433
+
434
+ const reportHash = computeReportHash(baseReport);
435
+ /** @type {DoctorReport} */
436
+ const report = {
437
+ ...baseReport,
438
+ reportHash,
439
+ };
440
+
441
+ return report;
442
+ }