jules-orchestrator-kit 0.29.0 → 0.31.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 +174 -89
- package/bin/agentctl.mjs +205 -6
- package/index.mjs +21 -1
- package/package.json +1 -1
- package/src/config.mjs +18 -2
- package/src/dag-engine.mjs +76 -0
- package/src/dashboard.mjs +2 -2
- package/src/engine.mjs +296 -37
- package/src/envelope.mjs +24 -2
- package/src/mcp.mjs +35 -1
- 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/provider.mjs +164 -8
- package/src/security.mjs +15 -7
- package/src/stack-detector.mjs +15 -5
- package/src/state.mjs +44 -7
- package/src/task-optimizer.mjs +306 -0
- package/src/telemetry.mjs +35 -2
- package/src/tui.mjs +4 -1
- 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-init.mjs +41 -11
- package/src/wizard-task.mjs +51 -15
package/src/state.mjs
CHANGED
|
@@ -10,10 +10,20 @@ import {
|
|
|
10
10
|
fsyncSync,
|
|
11
11
|
unlinkSync,
|
|
12
12
|
} from "node:fs";
|
|
13
|
-
import { join } from "node:path";
|
|
13
|
+
import { join, basename } from "node:path";
|
|
14
14
|
import { createHash, randomUUID } from "node:crypto";
|
|
15
15
|
import { hostname } from "node:os";
|
|
16
16
|
import { resolveRoot } from "./config.mjs";
|
|
17
|
+
import { redactSecrets } from "./security.mjs";
|
|
18
|
+
|
|
19
|
+
function scrubStateValue(value) {
|
|
20
|
+
if (typeof value === "string") return redactSecrets(value);
|
|
21
|
+
if (Array.isArray(value)) return value.map(scrubStateValue);
|
|
22
|
+
if (value && typeof value === "object") {
|
|
23
|
+
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, scrubStateValue(child)]));
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
17
27
|
|
|
18
28
|
export function ensureDir(dirPath) {
|
|
19
29
|
if (!dirPath || typeof dirPath !== "string" || dirPath.includes("\0")) {
|
|
@@ -109,7 +119,7 @@ export function appendLedger(entry, rootOrOpts = resolveRoot()) {
|
|
|
109
119
|
}
|
|
110
120
|
|
|
111
121
|
const timestamp = new Date().toISOString();
|
|
112
|
-
const rawPayload = { timestamp, ...entry, prevHash };
|
|
122
|
+
const rawPayload = { timestamp, ...scrubStateValue(entry), prevHash };
|
|
113
123
|
const hash = createHash("sha256").update(JSON.stringify(rawPayload)).digest("hex");
|
|
114
124
|
const payload = { ...rawPayload, hash };
|
|
115
125
|
|
|
@@ -321,7 +331,17 @@ export async function withBudget(fn, root = resolveRoot(), limit = 300) {
|
|
|
321
331
|
if (err && typeof err === "object") {
|
|
322
332
|
err.reservationId = reservation.reservationId;
|
|
323
333
|
}
|
|
324
|
-
|
|
334
|
+
try {
|
|
335
|
+
rollbackBudgetReservation(root, reservation.reservationId);
|
|
336
|
+
if (err && typeof err === "object") err.budgetReservationRolledBack = true;
|
|
337
|
+
} catch (_) {}
|
|
338
|
+
try {
|
|
339
|
+
appendLedger({
|
|
340
|
+
event: "budget_reservation_failed",
|
|
341
|
+
reservationId: reservation.reservationId,
|
|
342
|
+
error: err?.message || String(err),
|
|
343
|
+
}, root);
|
|
344
|
+
} catch (_) {}
|
|
325
345
|
throw err;
|
|
326
346
|
}
|
|
327
347
|
}
|
|
@@ -363,8 +383,8 @@ export function isPidAlive(pid, expectedStartTime = null) {
|
|
|
363
383
|
if (!pid || typeof pid !== "number") return false;
|
|
364
384
|
try {
|
|
365
385
|
process.kill(pid, 0);
|
|
366
|
-
} catch (
|
|
367
|
-
return false;
|
|
386
|
+
} catch (err) {
|
|
387
|
+
if (err?.code !== "EPERM") return false;
|
|
368
388
|
}
|
|
369
389
|
|
|
370
390
|
if (expectedStartTime !== null && expectedStartTime !== undefined && process.platform === "linux") {
|
|
@@ -381,7 +401,14 @@ export function isPidAlive(pid, expectedStartTime = null) {
|
|
|
381
401
|
return true;
|
|
382
402
|
}
|
|
383
403
|
|
|
404
|
+
function assertSafeTaskId(taskId) {
|
|
405
|
+
if (typeof taskId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(taskId) || basename(taskId) !== taskId) {
|
|
406
|
+
throw new Error("Invalid task id for lock path");
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
384
410
|
export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveRoot(), opts = {}) {
|
|
411
|
+
assertSafeTaskId(taskId);
|
|
385
412
|
const root = typeof rootOrOpts === "string" ? rootOrOpts : (rootOrOpts?.root || resolveRoot());
|
|
386
413
|
const branch = (typeof rootOrOpts === "object" && rootOrOpts?.branch) || opts?.branch || process.env.JULES_BRANCH || process.env.BRANCH_NAME || "";
|
|
387
414
|
const lockDir = getLockDir(root);
|
|
@@ -418,11 +445,11 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
|
|
|
418
445
|
acquiredAt: new Date().toISOString(),
|
|
419
446
|
};
|
|
420
447
|
|
|
448
|
+
let fd;
|
|
421
449
|
try {
|
|
422
|
-
|
|
450
|
+
fd = openSync(lockFile, "wx");
|
|
423
451
|
writeSync(fd, JSON.stringify(payload, null, 2), "utf-8");
|
|
424
452
|
fsyncSync(fd);
|
|
425
|
-
closeSync(fd);
|
|
426
453
|
return { ok: true, lockFile };
|
|
427
454
|
} catch (err) {
|
|
428
455
|
if (err.code === "EEXIST") {
|
|
@@ -433,11 +460,21 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
|
|
|
433
460
|
return { ok: false, holder: "unknown", taskId };
|
|
434
461
|
}
|
|
435
462
|
}
|
|
463
|
+
try { unlinkSync(lockFile); } catch (_) {}
|
|
436
464
|
throw err;
|
|
465
|
+
} finally {
|
|
466
|
+
if (fd !== undefined) {
|
|
467
|
+
try { closeSync(fd); } catch (_) {}
|
|
468
|
+
}
|
|
437
469
|
}
|
|
438
470
|
}
|
|
439
471
|
|
|
440
472
|
export function releaseLock(taskId, rootOrOpts = resolveRoot()) {
|
|
473
|
+
try {
|
|
474
|
+
assertSafeTaskId(taskId);
|
|
475
|
+
} catch (_) {
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
441
478
|
const root = typeof rootOrOpts === "string" ? rootOrOpts : resolveRoot();
|
|
442
479
|
const lockDir = getLockDir(root);
|
|
443
480
|
const lockFile = join(lockDir, `${taskId}.json`);
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative, resolve } from "node:path";
|
|
3
|
+
import { checkScope } from "./security.mjs";
|
|
4
|
+
import { detectStackOracles } from "./wizard-oracle.mjs";
|
|
5
|
+
import { loadConfig } from "./config.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Calculates Levenshtein distance between two strings.
|
|
9
|
+
* Zero external dependencies.
|
|
10
|
+
*/
|
|
11
|
+
export function levenshteinDistance(a, b) {
|
|
12
|
+
if (a === b) return 0;
|
|
13
|
+
if (!a || a.length === 0) return b ? b.length : 0;
|
|
14
|
+
if (!b || b.length === 0) return a.length;
|
|
15
|
+
|
|
16
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
17
|
+
for (let i = 1; i <= a.length; i++) {
|
|
18
|
+
let prev = i;
|
|
19
|
+
for (let j = 1; j <= b.length; j++) {
|
|
20
|
+
const val = a[i - 1] === b[j - 1] ? row[j - 1] : Math.min(row[j - 1], prev, row[j]) + 1;
|
|
21
|
+
row[j - 1] = prev;
|
|
22
|
+
prev = val;
|
|
23
|
+
}
|
|
24
|
+
row[b.length] = prev;
|
|
25
|
+
}
|
|
26
|
+
return row[b.length];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Traverses local repository directory to harvest candidate relative file paths.
|
|
31
|
+
*/
|
|
32
|
+
function harvestRepoFiles(dir, maxDepth = 4, currentDepth = 0) {
|
|
33
|
+
if (currentDepth > maxDepth) return [];
|
|
34
|
+
const files = [];
|
|
35
|
+
try {
|
|
36
|
+
const entries = readdirSync(dir);
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
if (entry === "node_modules" || entry === ".git" || entry === "dist" || entry === "coverage") continue;
|
|
39
|
+
const fullPath = join(dir, entry);
|
|
40
|
+
const relPath = relative(process.cwd(), fullPath).replace(/\\/g, "/");
|
|
41
|
+
let stat;
|
|
42
|
+
try { stat = statSync(fullPath); } catch (_) { continue; }
|
|
43
|
+
if (stat.isDirectory()) {
|
|
44
|
+
files.push(...harvestRepoFiles(fullPath, maxDepth, currentDepth + 1));
|
|
45
|
+
} else if (stat.isFile()) {
|
|
46
|
+
files.push(relPath);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} catch (_) {}
|
|
50
|
+
return files;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Extracts potential file/directory path tokens from prompt text.
|
|
55
|
+
*/
|
|
56
|
+
export function extractPathTokens(promptText) {
|
|
57
|
+
if (!promptText || typeof promptText !== "string") return [];
|
|
58
|
+
const tokens = [];
|
|
59
|
+
const words = promptText.split(/\s+/);
|
|
60
|
+
for (const rawWord of words) {
|
|
61
|
+
const cleanWord = rawWord.replace(/^[`'"]+|[`'":;,.]+$|\.$/g, "");
|
|
62
|
+
if (
|
|
63
|
+
cleanWord.includes("/") &&
|
|
64
|
+
(cleanWord.includes(".") || cleanWord.startsWith(".github/") || cleanWord.startsWith(".agent/")) &&
|
|
65
|
+
!cleanWord.startsWith("http://") &&
|
|
66
|
+
!cleanWord.startsWith("https://")
|
|
67
|
+
) {
|
|
68
|
+
if (!tokens.includes(cleanWord)) {
|
|
69
|
+
tokens.push(cleanWord);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return tokens;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const VAGUE_BUZZWORDS = [
|
|
77
|
+
{ term: "clean up", penalty: 15, msg: "Vague goal: 'clean up' lacks explicit acceptance criteria." },
|
|
78
|
+
{ term: "make faster", penalty: 15, msg: "Vague goal: 'make faster' lacks quantitative benchmark criteria." },
|
|
79
|
+
{ term: "fix bug", penalty: 10, msg: "Vague description: 'fix bug' does not reference specific error code or line." },
|
|
80
|
+
{ term: "refactor", penalty: 10, msg: "Unbounded directive: 'refactor' without boundary limits risks payload blowouts." },
|
|
81
|
+
{ term: "improve", penalty: 10, msg: "Subjective goal: 'improve' is not objectively scoreable." },
|
|
82
|
+
{ term: "better", penalty: 10, msg: "Subjective wording: 'better' lacks falsifiable pass/fail criteria." },
|
|
83
|
+
{ term: "various fixes", penalty: 20, msg: "Unbounded scope: 'various fixes' triggers scope leakage and ring-buffer thrashing." },
|
|
84
|
+
{ term: "update code", penalty: 15, msg: "Generic request: 'update code' does not specify target contract or behavior." }
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const TRIVIAL_VERIFY_CMDS = ["true", "echo", ":", "false", "exit 0"];
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Scores task prompt falsifiability, scope compliance, path validity, and oracle readiness.
|
|
91
|
+
*/
|
|
92
|
+
export function scorePromptFalsifiability(promptText, options = {}) {
|
|
93
|
+
const rootDir = options.rootDir || process.cwd();
|
|
94
|
+
const rawPrompt = (promptText || "").trim();
|
|
95
|
+
const config = options.config || loadConfig(rootDir);
|
|
96
|
+
|
|
97
|
+
let score = 100;
|
|
98
|
+
const issues = [];
|
|
99
|
+
const suggestions = [];
|
|
100
|
+
|
|
101
|
+
if (!rawPrompt) {
|
|
102
|
+
return {
|
|
103
|
+
score: 0,
|
|
104
|
+
grade: "F",
|
|
105
|
+
isFalsifiable: false,
|
|
106
|
+
issues: [{ type: "EMPTY_PROMPT", message: "Task prompt is empty.", penalty: 100 }],
|
|
107
|
+
suggestions: ["Provide a clear description of the code change requested."],
|
|
108
|
+
paths: { found: [], missingCount: 0, scopeDeniedCount: 0 },
|
|
109
|
+
oracle: { command: null, autoDetected: false, isTrivial: false }
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 1. Length & Buzzword Analysis
|
|
114
|
+
if (rawPrompt.length < 15) {
|
|
115
|
+
score -= 25;
|
|
116
|
+
issues.push({ type: "SHORT_PROMPT", message: "Prompt is under 15 characters long.", penalty: 25 });
|
|
117
|
+
suggestions.push("Expand prompt with context, affected symbols, or expected output behavior.");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const promptLower = rawPrompt.toLowerCase();
|
|
121
|
+
for (const bw of VAGUE_BUZZWORDS) {
|
|
122
|
+
if (promptLower.includes(bw.term)) {
|
|
123
|
+
score -= bw.penalty;
|
|
124
|
+
issues.push({ type: "VAGUE_WORDING", message: bw.msg, penalty: bw.penalty });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 2. Concrete Evidence Indicators (Bonus/Protection)
|
|
129
|
+
const hasErrorTrace = /(?:error|exception|fail|failed|stack|traceback|line\s+\d+|exit\s+code)/i.test(rawPrompt);
|
|
130
|
+
const hasSymbolRef = /(?:`[^`]+`|\b[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+\b|\b[a-zA-Z0-9_]+\(\))/i.test(rawPrompt);
|
|
131
|
+
const hasExplicitCheck = /(?:verify|assert|should|must|returns?|expect)/i.test(rawPrompt);
|
|
132
|
+
|
|
133
|
+
if (hasErrorTrace || hasSymbolRef || hasExplicitCheck) {
|
|
134
|
+
score = Math.min(100, score + 10);
|
|
135
|
+
} else {
|
|
136
|
+
score -= 10;
|
|
137
|
+
issues.push({ type: "NO_CONCRETE_CRITERIA", message: "Lacks explicit symbol references, test names, or error tracebacks.", penalty: 10 });
|
|
138
|
+
suggestions.push("Specify exact function names, file paths, or expected test assertions.");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 3. Static Path & Scope Verification
|
|
142
|
+
const extractedPaths = extractPathTokens(rawPrompt);
|
|
143
|
+
const repoFiles = harvestRepoFiles(rootDir);
|
|
144
|
+
const pathResults = [];
|
|
145
|
+
let missingCount = 0;
|
|
146
|
+
let scopeDeniedCount = 0;
|
|
147
|
+
|
|
148
|
+
for (const token of extractedPaths) {
|
|
149
|
+
const fullPath = resolve(rootDir, token);
|
|
150
|
+
const relPath = relative(rootDir, fullPath).replace(/\\/g, "/");
|
|
151
|
+
const exists = existsSync(fullPath);
|
|
152
|
+
|
|
153
|
+
// Check scope policy
|
|
154
|
+
const scopeCheck = checkScope([relPath], config.scope || {});
|
|
155
|
+
const scopeDenied = Boolean(scopeCheck.violations && scopeCheck.violations.length > 0);
|
|
156
|
+
const denialReason = scopeDenied ? scopeCheck.violations[0].reason : null;
|
|
157
|
+
|
|
158
|
+
if (scopeDenied) {
|
|
159
|
+
score -= 30;
|
|
160
|
+
scopeDeniedCount++;
|
|
161
|
+
issues.push({ type: "SCOPE_VIOLATION", message: `Referenced path '${relPath}' violates security policy (${denialReason}).`, penalty: 30 });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let suggestion;
|
|
165
|
+
if (!exists && !scopeDenied) {
|
|
166
|
+
missingCount++;
|
|
167
|
+
score -= 15;
|
|
168
|
+
// Fuzzy search closest file
|
|
169
|
+
let bestDist = Infinity;
|
|
170
|
+
let bestFile = null;
|
|
171
|
+
for (const repoFile of repoFiles) {
|
|
172
|
+
const dist = levenshteinDistance(token, repoFile);
|
|
173
|
+
if (dist < bestDist && dist <= 4) {
|
|
174
|
+
bestDist = dist;
|
|
175
|
+
bestFile = repoFile;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (bestFile) {
|
|
179
|
+
suggestion = bestFile;
|
|
180
|
+
suggestions.push(`Referenced file '${token}' not found. Did you mean '${bestFile}'?`);
|
|
181
|
+
} else {
|
|
182
|
+
suggestions.push(`Referenced file '${token}' does not exist on disk.`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
pathResults.push({
|
|
187
|
+
token,
|
|
188
|
+
exists,
|
|
189
|
+
suggestion,
|
|
190
|
+
scopeDenied,
|
|
191
|
+
reason: denialReason
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 4. Stack Oracle Detection
|
|
196
|
+
let verifyCmd = options.verifyCmd || null;
|
|
197
|
+
let autoDetected = false;
|
|
198
|
+
let isTrivial = false;
|
|
199
|
+
|
|
200
|
+
if (!verifyCmd) {
|
|
201
|
+
const detectedOracles = detectStackOracles(rootDir);
|
|
202
|
+
if (detectedOracles.length > 0 && detectedOracles[0].testCmd) {
|
|
203
|
+
verifyCmd = detectedOracles[0].testCmd;
|
|
204
|
+
autoDetected = true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (verifyCmd && TRIVIAL_VERIFY_CMDS.includes(verifyCmd.trim())) {
|
|
209
|
+
isTrivial = true;
|
|
210
|
+
score -= 25;
|
|
211
|
+
issues.push({ type: "TRIVIAL_ORACLE", message: `Verification command '${verifyCmd}' is non-evaluable / trivial.`, penalty: 25 });
|
|
212
|
+
suggestions.push("Use a non-trivial test command like 'npm test', 'pytest', or 'cargo test'.");
|
|
213
|
+
} else if (!verifyCmd) {
|
|
214
|
+
score -= 15;
|
|
215
|
+
issues.push({ type: "MISSING_ORACLE", message: "No automated test or build verification command specified.", penalty: 15 });
|
|
216
|
+
suggestions.push("Specify a verification command using --verify (e.g. 'npm test').");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Final score clamping & letter grade assignment
|
|
220
|
+
score = Math.max(0, Math.min(100, Math.round(score)));
|
|
221
|
+
|
|
222
|
+
let grade = "F";
|
|
223
|
+
if (score >= 90) grade = "A+";
|
|
224
|
+
else if (score >= 80) grade = "A";
|
|
225
|
+
else if (score >= 70) grade = "B";
|
|
226
|
+
else if (score >= 60) grade = "C";
|
|
227
|
+
else if (score >= 50) grade = "D";
|
|
228
|
+
|
|
229
|
+
const isFalsifiable = score >= 65 && scopeDeniedCount === 0 && !isTrivial;
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
score,
|
|
233
|
+
grade,
|
|
234
|
+
isFalsifiable,
|
|
235
|
+
issues,
|
|
236
|
+
suggestions: [...new Set(suggestions)],
|
|
237
|
+
paths: {
|
|
238
|
+
found: pathResults,
|
|
239
|
+
missingCount,
|
|
240
|
+
scopeDeniedCount
|
|
241
|
+
},
|
|
242
|
+
oracle: {
|
|
243
|
+
command: verifyCmd,
|
|
244
|
+
autoDetected,
|
|
245
|
+
isTrivial
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Transforms raw prompt into an optimized, structured task envelope.
|
|
252
|
+
*/
|
|
253
|
+
export function optimizeTaskPrompt(promptText, options = {}) {
|
|
254
|
+
const analysis = scorePromptFalsifiability(promptText, options);
|
|
255
|
+
const rawPrompt = (promptText || "").trim();
|
|
256
|
+
|
|
257
|
+
if (!rawPrompt) {
|
|
258
|
+
return {
|
|
259
|
+
optimizedPrompt: "",
|
|
260
|
+
analysis
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let promptBody = rawPrompt;
|
|
265
|
+
|
|
266
|
+
// Apply suggestions & path corrections to prompt body if requested
|
|
267
|
+
for (const pathInfo of analysis.paths.found) {
|
|
268
|
+
if (!pathInfo.exists && pathInfo.suggestion) {
|
|
269
|
+
const regex = new RegExp(pathInfo.token.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"), "g");
|
|
270
|
+
promptBody = promptBody.replace(regex, pathInfo.suggestion);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Construct structured Markdown envelope
|
|
275
|
+
const lines = [];
|
|
276
|
+
lines.push(`# TASK: ${promptBody.split("\n")[0]}`);
|
|
277
|
+
lines.push("");
|
|
278
|
+
lines.push("## Objective & Acceptance Criteria");
|
|
279
|
+
lines.push(`- **Goal**: ${promptBody}`);
|
|
280
|
+
if (analysis.oracle.command) {
|
|
281
|
+
lines.push(`- **Verification Command**: \`${analysis.oracle.command}\` (Must pass cleanly with exit code 0)`);
|
|
282
|
+
}
|
|
283
|
+
lines.push("- **Falsifiability Criteria**: Zero deleted tests, zero weakened assertions, zero lint errors.");
|
|
284
|
+
lines.push("");
|
|
285
|
+
|
|
286
|
+
if (analysis.paths.found.length > 0) {
|
|
287
|
+
lines.push("## Primary Target Paths");
|
|
288
|
+
for (const p of analysis.paths.found) {
|
|
289
|
+
const pathTarget = p.exists ? p.token : (p.suggestion || p.token);
|
|
290
|
+
lines.push(`- \`${pathTarget}\` (${p.exists ? "Verified" : "Corrected"})`);
|
|
291
|
+
}
|
|
292
|
+
lines.push("");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
lines.push("## Standard Guardrails");
|
|
296
|
+
lines.push("- Do NOT modify package.json, lockfiles, or .github/ infrastructure files.");
|
|
297
|
+
lines.push("- Diff Payload Governor: Keep total diff payload under 75 KB (\`git diff | wc -c\`).");
|
|
298
|
+
lines.push("- Verify before finishing: Execute full verification command and confirm zero errors.");
|
|
299
|
+
|
|
300
|
+
const optimizedPrompt = lines.join("\n");
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
optimizedPrompt,
|
|
304
|
+
analysis
|
|
305
|
+
};
|
|
306
|
+
}
|
package/src/telemetry.mjs
CHANGED
|
@@ -7,16 +7,26 @@ import {
|
|
|
7
7
|
existsSync,
|
|
8
8
|
statSync,
|
|
9
9
|
readdirSync,
|
|
10
|
+
truncateSync,
|
|
10
11
|
} from "node:fs";
|
|
11
12
|
import { join } from "node:path";
|
|
12
13
|
import { createHash } from "node:crypto";
|
|
13
14
|
import { resolveRoot } from "./config.mjs";
|
|
14
15
|
import { getStateDir, withVfsMutex } from "./state.mjs";
|
|
15
|
-
import { safeAtomicWrite } from "./security.mjs";
|
|
16
|
+
import { safeAtomicWrite, redactSecrets } from "./security.mjs";
|
|
16
17
|
|
|
17
18
|
export const MAX_TELEMETRY_SEGMENT_BYTES = 8 * 1024 * 1024; // 8 MB log segment ceiling
|
|
18
19
|
export const TELEMETRY_GENESIS_HASH = "0".repeat(64);
|
|
19
20
|
|
|
21
|
+
function scrubTelemetryValue(value) {
|
|
22
|
+
if (typeof value === "string") return redactSecrets(value);
|
|
23
|
+
if (Array.isArray(value)) return value.map(scrubTelemetryValue);
|
|
24
|
+
if (value && typeof value === "object") {
|
|
25
|
+
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, scrubTelemetryValue(child)]));
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
/**
|
|
21
31
|
* Reads up to 64 KB from EOF of target log segment to extract true tail hash.
|
|
22
32
|
*/
|
|
@@ -48,6 +58,27 @@ function readTailHash(filePath, bufferSize = 64 * 1024) {
|
|
|
48
58
|
return null;
|
|
49
59
|
}
|
|
50
60
|
|
|
61
|
+
function discardIncompleteTail(filePath, bufferSize = 64 * 1024) {
|
|
62
|
+
if (!existsSync(filePath)) return;
|
|
63
|
+
try {
|
|
64
|
+
const stat = statSync(filePath);
|
|
65
|
+
if (stat.size === 0) return;
|
|
66
|
+
const readSize = Math.min(stat.size, bufferSize);
|
|
67
|
+
const position = stat.size - readSize;
|
|
68
|
+
const fd = openSync(filePath, "r");
|
|
69
|
+
const buf = Buffer.alloc(readSize);
|
|
70
|
+
try {
|
|
71
|
+
readSync(fd, buf, 0, readSize, position);
|
|
72
|
+
} finally {
|
|
73
|
+
closeSync(fd);
|
|
74
|
+
}
|
|
75
|
+
if (buf[buf.length - 1] === 0x0a) return;
|
|
76
|
+
const lastNewline = buf.lastIndexOf(0x0a);
|
|
77
|
+
const truncateAt = lastNewline === -1 ? position : position + lastNewline + 1;
|
|
78
|
+
truncateSync(filePath, truncateAt);
|
|
79
|
+
} catch (_) {}
|
|
80
|
+
}
|
|
81
|
+
|
|
51
82
|
/**
|
|
52
83
|
* Returns telemetry file paths for a given date string sorted by segment index.
|
|
53
84
|
*/
|
|
@@ -132,6 +163,8 @@ export function appendTelemetry(rootOrOpts = resolveRoot(), kind = "event", fiel
|
|
|
132
163
|
: `telemetry-${dateStr}-${activeSegmentIndex}.jsonl`;
|
|
133
164
|
let activeFilePath = join(stateDir, activeFileName);
|
|
134
165
|
|
|
166
|
+
// Discard a torn final JSONL record before reconciling the true tail.
|
|
167
|
+
discardIncompleteTail(activeFilePath);
|
|
135
168
|
// Reconcile .head hash against true log tail (reading last 64 KB from EOF)
|
|
136
169
|
const tailHash = readTailHash(activeFilePath);
|
|
137
170
|
if (tailHash) {
|
|
@@ -162,7 +195,7 @@ export function appendTelemetry(rootOrOpts = resolveRoot(), kind = "event", fiel
|
|
|
162
195
|
}
|
|
163
196
|
|
|
164
197
|
const ts = new Date().toISOString();
|
|
165
|
-
const payloadWithoutHash = { v: 1, ts, kind, ...fields, prevHash };
|
|
198
|
+
const payloadWithoutHash = { v: 1, ts, kind, ...scrubTelemetryValue(fields), prevHash };
|
|
166
199
|
const hash = createHash("sha256").update(JSON.stringify(payloadWithoutHash)).digest("hex");
|
|
167
200
|
const entry = { ...payloadWithoutHash, hash };
|
|
168
201
|
|
package/src/tui.mjs
CHANGED
|
@@ -41,6 +41,9 @@ export function styleText(text, style) {
|
|
|
41
41
|
return `${style}${text}${ANSI.reset}`;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
import { WizardCancelledError } from "./ux/terminal-session.mjs";
|
|
45
|
+
export { WizardCancelledError };
|
|
46
|
+
|
|
44
47
|
/**
|
|
45
48
|
* Read keypress in raw mode from TTY input stream.
|
|
46
49
|
* @param {import("node:stream").Readable} stream
|
|
@@ -60,7 +63,7 @@ async function* readKeypresses(stream = process.stdin) {
|
|
|
60
63
|
if (str === "\u0003") {
|
|
61
64
|
// Ctrl+C SIGINT
|
|
62
65
|
process.stdout.write(ANSI.showCursor + "\n");
|
|
63
|
-
|
|
66
|
+
throw new WizardCancelledError("Wizard operation cancelled by user (SIGINT).");
|
|
64
67
|
}
|
|
65
68
|
yield str;
|
|
66
69
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { isatty } from "node:tty";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {Object} TerminalCapabilities
|
|
5
|
+
* @property {boolean} inputIsTTY
|
|
6
|
+
* @property {boolean} outputIsTTY
|
|
7
|
+
* @property {number} columns
|
|
8
|
+
* @property {number} rows
|
|
9
|
+
* @property {0 | 4 | 8 | 24} colorDepth
|
|
10
|
+
* @property {boolean} ansi
|
|
11
|
+
* @property {boolean} unicode
|
|
12
|
+
* @property {boolean} alternateScreen
|
|
13
|
+
* @property {false} mouse
|
|
14
|
+
* @property {NodeJS.Platform} platform
|
|
15
|
+
* @property {string} term
|
|
16
|
+
* @property {boolean} noColor
|
|
17
|
+
* @property {boolean} forceColor
|
|
18
|
+
* @property {boolean} reducedMotion
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} TerminalSessionOptions
|
|
23
|
+
* @property {import("node:stream").Readable} [input]
|
|
24
|
+
* @property {import("node:stream").Writable} [output]
|
|
25
|
+
* @property {import("node:stream").Writable} [errorOutput]
|
|
26
|
+
* @property {boolean} [jsonMode]
|
|
27
|
+
* @property {boolean} [alternateScreen]
|
|
28
|
+
* @property {"auto" | "always" | "never"} [color]
|
|
29
|
+
* @property {"auto" | "always" | "never"} [unicode]
|
|
30
|
+
* @property {boolean} [reducedMotion]
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Detect current terminal capabilities based on streams, environment variables, and CLI options.
|
|
35
|
+
* @param {TerminalSessionOptions} [options]
|
|
36
|
+
* @returns {TerminalCapabilities}
|
|
37
|
+
*/
|
|
38
|
+
export function detectTerminalCapabilities(options = {}) {
|
|
39
|
+
const input = options.input || process.stdin;
|
|
40
|
+
const output = options.output || process.stdout;
|
|
41
|
+
const env = process.env || {};
|
|
42
|
+
const argv = process.argv || [];
|
|
43
|
+
|
|
44
|
+
const inputIsTTY = Boolean(input && (input.isTTY || (typeof input.fd === "number" && isatty(input.fd))));
|
|
45
|
+
const outputIsTTY = Boolean(output && (output.isTTY || (typeof output.fd === "number" && isatty(output.fd))));
|
|
46
|
+
|
|
47
|
+
const term = String(env.TERM || "").toLowerCase();
|
|
48
|
+
const colorTerm = String(env.COLORTERM || "").toLowerCase();
|
|
49
|
+
|
|
50
|
+
// Environment color flags
|
|
51
|
+
const envNoColor = Boolean(env.NO_COLOR !== undefined && env.NO_COLOR !== "");
|
|
52
|
+
let envForceColor = false;
|
|
53
|
+
let envDisableColor = false;
|
|
54
|
+
|
|
55
|
+
if (env.FORCE_COLOR !== undefined) {
|
|
56
|
+
if (env.FORCE_COLOR === "0" || env.FORCE_COLOR === "false") {
|
|
57
|
+
envDisableColor = true;
|
|
58
|
+
} else {
|
|
59
|
+
envForceColor = true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// CLI argument overrides
|
|
64
|
+
const cliNoColor = argv.includes("--no-color");
|
|
65
|
+
const cliColor = argv.includes("--color");
|
|
66
|
+
const cliAscii = argv.includes("--ascii");
|
|
67
|
+
const jsonMode = Boolean(options.jsonMode || argv.includes("--json"));
|
|
68
|
+
|
|
69
|
+
const noColor = envNoColor || cliNoColor || options.color === "never";
|
|
70
|
+
const forceColor = (envForceColor || cliColor || options.color === "always") && !envDisableColor;
|
|
71
|
+
|
|
72
|
+
// Priority evaluation for ANSI support:
|
|
73
|
+
// --no-color / options.color === "never" > NO_COLOR > FORCE_COLOR=0 > jsonMode > --color / FORCE_COLOR > auto TTY capability
|
|
74
|
+
let ansi = false;
|
|
75
|
+
if (jsonMode) {
|
|
76
|
+
ansi = false;
|
|
77
|
+
} else if (noColor || envDisableColor) {
|
|
78
|
+
ansi = false;
|
|
79
|
+
} else if (forceColor) {
|
|
80
|
+
ansi = true;
|
|
81
|
+
} else {
|
|
82
|
+
ansi = outputIsTTY && term !== "dumb";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Color depth calculation
|
|
86
|
+
/** @type {0 | 4 | 8 | 24} */
|
|
87
|
+
let colorDepth = 0;
|
|
88
|
+
if (ansi) {
|
|
89
|
+
if (colorTerm === "truecolor" || colorTerm === "24bit" || env.FORCE_COLOR === "3") {
|
|
90
|
+
colorDepth = 24;
|
|
91
|
+
} else if (term.includes("256color") || colorTerm === "256color" || env.FORCE_COLOR === "2") {
|
|
92
|
+
colorDepth = 8;
|
|
93
|
+
} else {
|
|
94
|
+
colorDepth = 4;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Unicode support detection
|
|
99
|
+
let unicode = true;
|
|
100
|
+
if (cliAscii || options.unicode === "never") {
|
|
101
|
+
unicode = false;
|
|
102
|
+
} else if (options.unicode === "always") {
|
|
103
|
+
unicode = true;
|
|
104
|
+
} else {
|
|
105
|
+
const lang = String(env.LANG || env.LC_ALL || env.LC_CTYPE || "").toLowerCase();
|
|
106
|
+
const isUtf8 = lang.includes("utf-8") || lang.includes("utf8");
|
|
107
|
+
// Default to true on non-windows or utf-8 windows terminals
|
|
108
|
+
unicode = isUtf8 || process.platform !== "win32" || Boolean(env.WT_SESSION);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Terminal dimensions
|
|
112
|
+
const columns = Math.max(10, output.columns || input.columns || process.stdout.columns || 80);
|
|
113
|
+
const rows = Math.max(5, output.rows || input.rows || process.stdout.rows || 24);
|
|
114
|
+
|
|
115
|
+
const reducedMotion = Boolean(
|
|
116
|
+
options.reducedMotion ||
|
|
117
|
+
env.REDUCED_MOTION === "1" ||
|
|
118
|
+
env.REDUCED_MOTION === "true"
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const alternateScreen = options.alternateScreen !== false && ansi && outputIsTTY;
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
inputIsTTY,
|
|
125
|
+
outputIsTTY,
|
|
126
|
+
columns,
|
|
127
|
+
rows,
|
|
128
|
+
colorDepth,
|
|
129
|
+
ansi,
|
|
130
|
+
unicode,
|
|
131
|
+
alternateScreen,
|
|
132
|
+
mouse: false,
|
|
133
|
+
platform: process.platform,
|
|
134
|
+
term: env.TERM || "",
|
|
135
|
+
noColor,
|
|
136
|
+
forceColor,
|
|
137
|
+
reducedMotion,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Get display symbol set based on unicode capability.
|
|
143
|
+
* @param {TerminalCapabilities} caps
|
|
144
|
+
*/
|
|
145
|
+
export function getSymbols(caps) {
|
|
146
|
+
if (caps.unicode) {
|
|
147
|
+
return {
|
|
148
|
+
select: "❯",
|
|
149
|
+
checked: "●",
|
|
150
|
+
unchecked: "○",
|
|
151
|
+
success: "✓",
|
|
152
|
+
warning: "!",
|
|
153
|
+
failure: "✗",
|
|
154
|
+
info: "ℹ",
|
|
155
|
+
bullet: "•",
|
|
156
|
+
ellipsis: "…",
|
|
157
|
+
spinner: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
|
|
158
|
+
branchMiddle: "├─",
|
|
159
|
+
branchEnd: "└─",
|
|
160
|
+
arrowUp: "↑",
|
|
161
|
+
arrowDown: "↓",
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
select: ">",
|
|
166
|
+
checked: "[x]",
|
|
167
|
+
unchecked: "[ ]",
|
|
168
|
+
success: "OK",
|
|
169
|
+
warning: "WARN",
|
|
170
|
+
failure: "ERR",
|
|
171
|
+
info: "INFO",
|
|
172
|
+
bullet: "*",
|
|
173
|
+
ellipsis: "...",
|
|
174
|
+
spinner: ["-", "\\", "|", "/"],
|
|
175
|
+
branchMiddle: "+-",
|
|
176
|
+
branchEnd: "`-",
|
|
177
|
+
arrowUp: "^",
|
|
178
|
+
arrowDown: "v",
|
|
179
|
+
};
|
|
180
|
+
}
|