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.
- package/README.md +169 -86
- package/bin/agentctl.mjs +267 -4
- package/bin/mcp-server.mjs +0 -0
- package/index.mjs +21 -1
- package/package.json +1 -1
- package/scripts/stale-base-check.mjs +2 -2
- package/src/config.mjs +18 -2
- package/src/dag-engine.mjs +76 -0
- package/src/engine.mjs +331 -32
- package/src/envelope.mjs +32 -2
- package/src/execution_envelope.mjs +9 -1
- package/src/git.mjs +41 -0
- package/src/mcp.mjs +34 -0
- package/src/memory.mjs +148 -0
- package/src/ops/checkpoint.mjs +167 -0
- package/src/ops/command-registry.mjs +274 -0
- package/src/ops/doctor-planner.mjs +214 -0
- package/src/ops/doctor-registry.mjs +442 -0
- package/src/ops/ide-scaffold.mjs +139 -0
- package/src/ops/receipts.mjs +171 -0
- package/src/ops/swarm-actions.mjs +156 -0
- package/src/ops/task-actions.mjs +214 -0
- package/src/ops/tdd-generator.mjs +115 -0
- package/src/ops/transaction.mjs +278 -0
- package/src/prompt-guard.mjs +8 -1
- package/src/provider.mjs +164 -8
- package/src/remediation.mjs +164 -0
- package/src/security.mjs +14 -6
- package/src/stack-detector.mjs +15 -5
- package/src/state.mjs +71 -7
- package/src/task-optimizer.mjs +306 -0
- package/src/telemetry.mjs +35 -2
- package/src/tui.mjs +2 -6
- package/src/ux/capabilities.mjs +180 -0
- package/src/ux/diff-viewer.mjs +289 -0
- package/src/ux/key-decoder.mjs +281 -0
- package/src/ux/layout.mjs +195 -0
- package/src/ux/log-viewer.mjs +110 -0
- package/src/ux/palette.mjs +179 -0
- package/src/ux/queue-model.mjs +206 -0
- package/src/ux/renderer.mjs +188 -0
- package/src/ux/swarm-model.mjs +146 -0
- package/src/ux/terminal-session.mjs +215 -0
- package/src/ux/widgets.mjs +323 -0
- package/src/webhook.mjs +120 -0
- 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
|
+
}
|