jules-orchestrator-kit 0.41.1 → 0.51.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/.agent/prompts/A11y.md +23 -0
- package/.agent/prompts/Alchemist.md +23 -0
- package/.agent/prompts/Scribe.md +23 -0
- package/.agent/prompts/Spectator.md +24 -0
- package/JULES_RULES_TEMPLATE.md +10 -8
- package/README.md +10 -4
- package/bin/agentctl.mjs +356 -0
- package/index.mjs +26 -1
- package/package.json +1 -1
- package/scripts/asset-integrity-check.mjs +1 -1
- package/scripts/rules-lint.mjs +1 -1
- package/src/assertions.mjs +244 -0
- package/src/config.mjs +25 -0
- package/src/coverage.mjs +328 -0
- package/src/engine.mjs +20 -4
- package/src/git.mjs +131 -9
- package/src/mutation.mjs +739 -0
- package/src/ops/checkpoint.mjs +31 -3
- package/src/perf.mjs +92 -0
- package/src/provider.mjs +10 -1
- package/src/remediation.mjs +154 -1
- package/src/rules-budget.mjs +8 -2
- package/src/scaffold.mjs +139 -2
- package/src/security.mjs +286 -28
- package/src/stability.mjs +86 -0
- package/src/web-templates.mjs +172 -0
- package/src/asset_integrity.mjs +0 -1
- package/src/execution_envelope.mjs +0 -1
- package/src/rules_budget.mjs +0 -1
package/src/ops/checkpoint.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { git, changedFiles, diffText } from "../git.mjs";
|
|
4
|
-
import { resolveRoot } from "../config.mjs";
|
|
4
|
+
import { resolveRoot, isWindowsAbsolutePath } from "../config.mjs";
|
|
5
5
|
|
|
6
6
|
export class CheckpointError extends Error {
|
|
7
7
|
constructor(message, opts = {}) {
|
|
@@ -11,6 +11,29 @@ export class CheckpointError extends Error {
|
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
// Checkpoint ids are used verbatim as the snapshot filename under
|
|
15
|
+
// `.agent/state/checkpoints/`. An id that is not a single plain filename —
|
|
16
|
+
// `../…`, `C:\…`, `\\server\share`, or any value carrying a separator — would
|
|
17
|
+
// let a restore read (or a create write) outside that directory. Ids are
|
|
18
|
+
// therefore restricted to one `[A-Za-z0-9]`-led filename component, and the
|
|
19
|
+
// drive/UNC spellings are rejected explicitly on top of the whitelist
|
|
20
|
+
// (`SEC-02` / `P-01`).
|
|
21
|
+
const CHECKPOINT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
22
|
+
|
|
23
|
+
function assertSafeCheckpointId(id) {
|
|
24
|
+
if (
|
|
25
|
+
typeof id !== "string" ||
|
|
26
|
+
id.length === 0 ||
|
|
27
|
+
isWindowsAbsolutePath(id) ||
|
|
28
|
+
!CHECKPOINT_ID_RE.test(id)
|
|
29
|
+
) {
|
|
30
|
+
throw new CheckpointError(
|
|
31
|
+
`Invalid checkpoint session id '${id}': expected a plain filename (letters, digits, '.', '_', '-') without path separators.`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return id;
|
|
35
|
+
}
|
|
36
|
+
|
|
14
37
|
/**
|
|
15
38
|
* Ensures checkpoint storage directory exists.
|
|
16
39
|
*/
|
|
@@ -29,6 +52,7 @@ export function getCheckpointDir(root = resolveRoot()) {
|
|
|
29
52
|
* @returns {object} Checkpoint metadata
|
|
30
53
|
*/
|
|
31
54
|
export function createCheckpoint(sessionId = `session-${Date.now()}`, options = {}) {
|
|
55
|
+
const safeId = assertSafeCheckpointId(sessionId);
|
|
32
56
|
const root = options.root || resolveRoot();
|
|
33
57
|
const dir = getCheckpointDir(root);
|
|
34
58
|
|
|
@@ -47,7 +71,7 @@ export function createCheckpoint(sessionId = `session-${Date.now()}`, options =
|
|
|
47
71
|
|
|
48
72
|
const snapshot = {
|
|
49
73
|
version: 1,
|
|
50
|
-
id:
|
|
74
|
+
id: safeId,
|
|
51
75
|
timestamp: new Date().toISOString(),
|
|
52
76
|
headSha,
|
|
53
77
|
branch,
|
|
@@ -55,7 +79,7 @@ export function createCheckpoint(sessionId = `session-${Date.now()}`, options =
|
|
|
55
79
|
diffContent,
|
|
56
80
|
};
|
|
57
81
|
|
|
58
|
-
const snapshotPath = join(dir, `${
|
|
82
|
+
const snapshotPath = join(dir, `${safeId}.json`);
|
|
59
83
|
writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2), "utf-8");
|
|
60
84
|
|
|
61
85
|
// Keep last 10 checkpoints
|
|
@@ -83,6 +107,10 @@ export function restoreCheckpoint(sessionId = "--latest", options = {}) {
|
|
|
83
107
|
targetId = list[0].id;
|
|
84
108
|
}
|
|
85
109
|
|
|
110
|
+
// A caller-supplied id becomes a filename here; reject anything that is not
|
|
111
|
+
// a plain filename before `join` can turn it into a path escape (`P-01`).
|
|
112
|
+
targetId = assertSafeCheckpointId(targetId);
|
|
113
|
+
|
|
86
114
|
const snapshotFile = join(dir, `${targetId}.json`);
|
|
87
115
|
if (!existsSync(snapshotFile)) {
|
|
88
116
|
throw new CheckpointError(`Checkpoint snapshot file not found for session '${targetId}'.`);
|
package/src/perf.mjs
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { monitorEventLoopDelay } from "node:perf_hooks";
|
|
2
|
+
import { execSync } from "node:child_process";
|
|
3
|
+
import { resolveVerify } from "./config.mjs";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Measures Node.js Event Loop delay during test execution or asynchronous workloads
|
|
7
|
+
* to detect synchronous blocking, catastrophic regex backtracking, or O(n^2) starvation.
|
|
8
|
+
*
|
|
9
|
+
* @param {string|Function} target - Command line string to execute or async/sync function
|
|
10
|
+
* @param {object} [options]
|
|
11
|
+
* @param {string} [options.root=process.cwd()] - Working directory
|
|
12
|
+
* @param {number} [options.maxDelayMs=50] - Maximum allowable p99/max event loop delay in milliseconds
|
|
13
|
+
* @param {number} [options.resolution=10] - Sampling resolution in milliseconds (default: 10)
|
|
14
|
+
* @param {number} [options.timeoutMs=60000] - Process timeout in milliseconds
|
|
15
|
+
* @returns {object} { ok, maxMs, meanMs, p50Ms, p90Ms, p99Ms, minMs, thresholdMs, summary, stdout, stderr, exitCode }
|
|
16
|
+
*/
|
|
17
|
+
export function measureEventLoopDelay(target, options = {}) {
|
|
18
|
+
const root = options.root || process.cwd();
|
|
19
|
+
const maxDelayMs = typeof options.maxDelayMs === "number" ? options.maxDelayMs : 50;
|
|
20
|
+
const resolution = options.resolution || 10;
|
|
21
|
+
const timeoutMs = options.timeoutMs || 60000;
|
|
22
|
+
|
|
23
|
+
const histogram = monitorEventLoopDelay({ resolution });
|
|
24
|
+
histogram.enable();
|
|
25
|
+
|
|
26
|
+
let exitCode = 0;
|
|
27
|
+
let stdout = "";
|
|
28
|
+
let stderr = "";
|
|
29
|
+
|
|
30
|
+
const startTime = Date.now();
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
if (typeof target === "function") {
|
|
34
|
+
target();
|
|
35
|
+
} else {
|
|
36
|
+
const cmd = target || resolveVerify(root).testCmd || "npm test";
|
|
37
|
+
const env = { ...process.env };
|
|
38
|
+
for (const k of Object.keys(env)) {
|
|
39
|
+
if (k.startsWith("NODE_TEST_") || k.startsWith("NODE_CHANNEL_")) {
|
|
40
|
+
delete env[k];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
stdout = execSync(cmd, {
|
|
46
|
+
cwd: root,
|
|
47
|
+
env,
|
|
48
|
+
timeout: timeoutMs,
|
|
49
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
50
|
+
encoding: "utf-8",
|
|
51
|
+
});
|
|
52
|
+
} catch (err) {
|
|
53
|
+
exitCode = err.status || 1;
|
|
54
|
+
stdout = err.stdout ? String(err.stdout) : "";
|
|
55
|
+
stderr = err.stderr ? String(err.stderr) : err.message;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} finally {
|
|
59
|
+
histogram.disable();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const durationMs = Date.now() - startTime;
|
|
63
|
+
|
|
64
|
+
// Convert nanoseconds to milliseconds
|
|
65
|
+
const minMs = Math.round((histogram.min / 1e6) * 100) / 100;
|
|
66
|
+
const maxMs = Math.round((histogram.max / 1e6) * 100) / 100;
|
|
67
|
+
const meanMs = Number.isFinite(histogram.mean) ? Math.round((histogram.mean / 1e6) * 100) / 100 : 0;
|
|
68
|
+
const p50Ms = Math.round((histogram.percentile(50) / 1e6) * 100) / 100;
|
|
69
|
+
const p90Ms = Math.round((histogram.percentile(90) / 1e6) * 100) / 100;
|
|
70
|
+
const p99Ms = Math.round((histogram.percentile(99) / 1e6) * 100) / 100;
|
|
71
|
+
|
|
72
|
+
// If p99 or max exceeds threshold, mark as lag violation
|
|
73
|
+
const ok = exitCode === 0 && (p99Ms <= maxDelayMs || maxMs <= maxDelayMs);
|
|
74
|
+
|
|
75
|
+
const summary = `Event Loop Delay: p99=${p99Ms}ms, max=${maxMs}ms, mean=${meanMs}ms (threshold: ${maxDelayMs}ms, duration: ${durationMs}ms)`;
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
ok,
|
|
79
|
+
minMs,
|
|
80
|
+
maxMs,
|
|
81
|
+
meanMs,
|
|
82
|
+
p50Ms,
|
|
83
|
+
p90Ms,
|
|
84
|
+
p99Ms,
|
|
85
|
+
thresholdMs: maxDelayMs,
|
|
86
|
+
durationMs,
|
|
87
|
+
exitCode,
|
|
88
|
+
stdout,
|
|
89
|
+
stderr,
|
|
90
|
+
summary,
|
|
91
|
+
};
|
|
92
|
+
}
|
package/src/provider.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { join, extname } from "node:path";
|
|
4
4
|
import { redactSecrets } from "./security.mjs";
|
|
5
|
+
import { resolveWindowsSpawn } from "./git.mjs";
|
|
5
6
|
|
|
6
7
|
// Extensions node --check can parse as a script. .mjs/.cjs are unambiguous;
|
|
7
8
|
// plain .js is checked as a script too — a stray top-level ESM import would
|
|
@@ -509,10 +510,18 @@ export function createProvider(spec = "jules", config = {}) {
|
|
|
509
510
|
|
|
510
511
|
const processedArgs = filteredArgs.map((arg) => interpolateString(arg, data));
|
|
511
512
|
|
|
512
|
-
|
|
513
|
+
// `claude`, `gemini` and `codex` are npm-installed CLI binaries, which on
|
|
514
|
+
// Windows are `.cmd` shims that `spawnSync(..., { shell: false })` cannot
|
|
515
|
+
// start (CreateProcess cannot run a batch file). Route them through
|
|
516
|
+
// cmd.exe with per-argument quoting instead (P-08); on other platforms
|
|
517
|
+
// this is a null and the command spawns directly as before.
|
|
518
|
+
const winSpawn = resolveWindowsSpawn(command, processedArgs, process.env);
|
|
519
|
+
|
|
520
|
+
const res = spawnSync(winSpawn ? winSpawn.file : command, winSpawn ? winSpawn.args : processedArgs, {
|
|
513
521
|
cwd: config._root || process.cwd(),
|
|
514
522
|
encoding: "utf-8",
|
|
515
523
|
shell: false,
|
|
524
|
+
windowsVerbatimArguments: Boolean(winSpawn && winSpawn.verbatim),
|
|
516
525
|
input: providerSpec.promptViaStdin ? data.prompt : undefined,
|
|
517
526
|
timeout: providerSpec.timeoutMs || 900000,
|
|
518
527
|
maxBuffer: 32 * 1024 * 1024,
|
package/src/remediation.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync, existsSync, appendFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
4
4
|
import { getStateDir, ensureDir } from "./state.mjs";
|
|
5
5
|
import { redactSecrets } from "./security.mjs";
|
|
6
6
|
|
|
@@ -162,3 +162,156 @@ export function hydrateMemory(root, options = {}) {
|
|
|
162
162
|
return `[LEARNED_REMEDIATIONS_CONTEXT]\nThe following historical failure-and-fix patterns were harvested from previous OODA runs in this repository. Avoid repeating these failure modes:\n\n${items.join("\n\n")}`;
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Creates an OODA Thrash Cycle Breaker.
|
|
167
|
+
* Tracks rolling SHA-256 fingerprints of diff hunks and failure states in repair loops.
|
|
168
|
+
* Trips circuit breaker if an identical patch or failure ping-pong cycle is observed.
|
|
169
|
+
*
|
|
170
|
+
* @param {Object} [opts]
|
|
171
|
+
* @param {number} [opts.maxHistory=10] - Maximum states to retain in ring buffer
|
|
172
|
+
* @param {number} [opts.threshold=2] - Number of occurrences before tripping (default: 2 -> A -> B -> A)
|
|
173
|
+
* @returns {{ recordAttempt: (state: { diff?: string, symptom?: string, targetFiles?: string[] }) => { thrash: boolean, cycleLength?: number, fingerprint: string, occurrences: number, reason?: string }, getHistory: () => string[], reset: () => void }}
|
|
174
|
+
*/
|
|
175
|
+
export function createThrashDetector(opts = {}) {
|
|
176
|
+
const maxHistory = opts.maxHistory || 10;
|
|
177
|
+
const threshold = opts.threshold || 2;
|
|
178
|
+
const history = [];
|
|
179
|
+
|
|
180
|
+
function computeStateFingerprint(state = {}) {
|
|
181
|
+
const normDiff = (state.diff || "").trim();
|
|
182
|
+
const normSymptom = (state.symptom || "").trim();
|
|
183
|
+
const normFiles = (state.targetFiles || []).slice().sort().join(",");
|
|
184
|
+
return createHash("sha256")
|
|
185
|
+
.update(`${normDiff}:::${normSymptom}:::${normFiles}`)
|
|
186
|
+
.digest("hex");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function recordAttempt(state = {}) {
|
|
190
|
+
const fingerprint = computeStateFingerprint(state);
|
|
191
|
+
history.push(fingerprint);
|
|
192
|
+
if (history.length > maxHistory) {
|
|
193
|
+
history.shift();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Check for duplicate occurrences in history
|
|
197
|
+
const occurrences = history.filter((h) => h === fingerprint).length;
|
|
198
|
+
if (occurrences >= threshold) {
|
|
199
|
+
const firstIdx = history.indexOf(fingerprint);
|
|
200
|
+
const lastIdx = history.lastIndexOf(fingerprint);
|
|
201
|
+
const cycleLength = lastIdx - firstIdx;
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
thrash: true,
|
|
205
|
+
cycleLength,
|
|
206
|
+
fingerprint,
|
|
207
|
+
occurrences,
|
|
208
|
+
reason: `OODA Thrash Circuit Tripped: State fingerprint ${fingerprint.slice(0, 12)} repeated ${occurrences} times across repair loop (cycle length: ${cycleLength}).`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
thrash: false,
|
|
214
|
+
fingerprint,
|
|
215
|
+
occurrences,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
recordAttempt,
|
|
221
|
+
getHistory: () => [...history],
|
|
222
|
+
reset: () => {
|
|
223
|
+
history.length = 0;
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Creates a Whack-a-Mole Test-Oscillation Detector for OODA repair loops.
|
|
230
|
+
* Detects when consecutive repair turns oscillate between different failing test sets
|
|
231
|
+
* (e.g. fixing Test A causes Test B to break, then fixing Test B re-breaks Test A).
|
|
232
|
+
*
|
|
233
|
+
* @param {Object} [opts]
|
|
234
|
+
* @param {number} [opts.maxHistory=8] - Maximum test state signatures to retain
|
|
235
|
+
* @param {number} [opts.threshold=2] - Number of repeating occurrences before triggering
|
|
236
|
+
* @returns {{ recordTestOutcome: (failingTests: string[] | string) => { whackAMole: boolean, cycleLength?: number, oscillatingTests?: string[], occurrences: number, promptDirective?: string, reason?: string }, getHistory: () => string[], reset: () => void }}
|
|
237
|
+
*/
|
|
238
|
+
export function createWhackAMoleDetector(opts = {}) {
|
|
239
|
+
const maxHistory = opts.maxHistory || 8;
|
|
240
|
+
const threshold = opts.threshold || 2;
|
|
241
|
+
const history = []; // Array of signature strings
|
|
242
|
+
const testSets = []; // Array of string[]
|
|
243
|
+
|
|
244
|
+
function normalizeTestSet(input) {
|
|
245
|
+
if (!input) return [];
|
|
246
|
+
if (Array.isArray(input)) {
|
|
247
|
+
return [...new Set(input.map((s) => String(s).trim()).filter(Boolean))].sort();
|
|
248
|
+
}
|
|
249
|
+
const str = String(input).trim();
|
|
250
|
+
return str ? [str] : [];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function recordTestOutcome(failingInput) {
|
|
254
|
+
const tests = normalizeTestSet(failingInput);
|
|
255
|
+
const signature = tests.join("||") || "__ALL_PASS__";
|
|
256
|
+
|
|
257
|
+
history.push(signature);
|
|
258
|
+
testSets.push(tests);
|
|
259
|
+
|
|
260
|
+
if (history.length > maxHistory) {
|
|
261
|
+
history.shift();
|
|
262
|
+
testSets.shift();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (signature === "__ALL_PASS__") {
|
|
266
|
+
return {
|
|
267
|
+
whackAMole: false,
|
|
268
|
+
occurrences: 0,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const occurrences = history.filter((h) => h === signature).length;
|
|
273
|
+
if (occurrences >= threshold) {
|
|
274
|
+
const firstIdx = history.indexOf(signature);
|
|
275
|
+
const lastIdx = history.lastIndexOf(signature);
|
|
276
|
+
const cycleLength = lastIdx - firstIdx;
|
|
277
|
+
|
|
278
|
+
// Extract all distinct failing tests observed in the cycle window
|
|
279
|
+
const cycleTests = new Set();
|
|
280
|
+
for (let i = firstIdx; i <= lastIdx; i++) {
|
|
281
|
+
for (const t of testSets[i]) {
|
|
282
|
+
cycleTests.add(t);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const oscillatingTests = [...cycleTests];
|
|
286
|
+
|
|
287
|
+
const testSummary = oscillatingTests.length > 0 ? oscillatingTests.join(" <-> ") : "tests";
|
|
288
|
+
const promptDirective = `[WHACK_A_MOLE_WARNING] You are trapped in a local optimization cycle where fixing one test breaks another (${testSummary}). Do not add more conditional edge-case band-aids. Revert recent patches and refactor the core logic cleanly.`;
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
whackAMole: true,
|
|
292
|
+
cycleLength,
|
|
293
|
+
oscillatingTests,
|
|
294
|
+
occurrences,
|
|
295
|
+
promptDirective,
|
|
296
|
+
reason: `Whack-a-Mole Test Oscillation Detected: Test failure signature repeated across repair turns (${testSummary}).`,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
whackAMole: false,
|
|
302
|
+
occurrences,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
recordTestOutcome,
|
|
308
|
+
getHistory: () => [...history],
|
|
309
|
+
reset: () => {
|
|
310
|
+
history.length = 0;
|
|
311
|
+
testSets.length = 0;
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
|
package/src/rules-budget.mjs
CHANGED
|
@@ -45,8 +45,14 @@ export function checkRulesBudget(root = process.cwd(), opts = {}) {
|
|
|
45
45
|
|
|
46
46
|
try {
|
|
47
47
|
const content = readFileSync(fullPath, "utf-8");
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
// A CRLF checkout inflates the character count by one `\r` per line, so
|
|
49
|
+
// the identical rules file passes as LF and fails as CRLF — a false
|
|
50
|
+
// budget violation that depends purely on git's autocrlf setting rather
|
|
51
|
+
// than the content. Normalise line endings before measuring (P-12) so the
|
|
52
|
+
// budget counts the rule text, not the line-ending dialect.
|
|
53
|
+
const normalized = content.replace(/\r\n/g, "\n");
|
|
54
|
+
const charCount = normalized.length;
|
|
55
|
+
const lineCount = normalized.split("\n").length;
|
|
50
56
|
|
|
51
57
|
if (charCount > maxChars) {
|
|
52
58
|
violations.push({
|
package/src/scaffold.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readdirSync, copyFileSync, readFileSync, appendFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join, dirname } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { detectPolyglotStack, detectEdgeRuntime } from "./stack-detector.mjs";
|
|
4
5
|
|
|
5
6
|
const KIT_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
6
7
|
|
|
@@ -77,6 +78,136 @@ function copyDir(srcDir, destDir, force) {
|
|
|
77
78
|
return written;
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Generate stack-tailored contract templates (SPEC.md, CONSTRAINTS.md, DESIGN.md).
|
|
83
|
+
*
|
|
84
|
+
* @param {string} root - Project root
|
|
85
|
+
* @param {{ force?: boolean }} [options]
|
|
86
|
+
* @returns {string[]} Created contract file names
|
|
87
|
+
*/
|
|
88
|
+
export function scaffoldContracts(root = process.cwd(), options = {}) {
|
|
89
|
+
const force = Boolean(options.force);
|
|
90
|
+
const created = [];
|
|
91
|
+
|
|
92
|
+
const specPath = join(root, "SPEC.md");
|
|
93
|
+
if (!existsSync(specPath) || force) {
|
|
94
|
+
const specContent = `# SPEC — System & Product Contract
|
|
95
|
+
|
|
96
|
+
## Product
|
|
97
|
+
- Brief 1–2 sentence description of the system and target users.
|
|
98
|
+
|
|
99
|
+
## Core Loop
|
|
100
|
+
- Step-by-step lifecycle from input/event to final response/artifact.
|
|
101
|
+
|
|
102
|
+
## Goals
|
|
103
|
+
- [Goal 1: Core invariant functionality that MUST work]
|
|
104
|
+
- [Goal 2: Performance, throughput, or latency targets]
|
|
105
|
+
- [Goal 3: Test coverage & reliability criteria]
|
|
106
|
+
|
|
107
|
+
## Non-Goals
|
|
108
|
+
- [Explicit out-of-scope feature or abstraction]
|
|
109
|
+
- [Out-of-scope third-party dependencies or integrations]
|
|
110
|
+
|
|
111
|
+
## Definition of Done
|
|
112
|
+
- All verification test suites pass cleanly with 0 errors
|
|
113
|
+
- Zero security vulnerabilities and zero leaked secrets
|
|
114
|
+
- Diff size stays within the configured limit (<= 75 KB)
|
|
115
|
+
`;
|
|
116
|
+
writeFileSync(specPath, specContent, "utf-8");
|
|
117
|
+
created.push("SPEC.md");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const constraintsPath = join(root, "CONSTRAINTS.md");
|
|
121
|
+
if (!existsSync(constraintsPath) || force) {
|
|
122
|
+
const edgeInfo = detectEdgeRuntime(root);
|
|
123
|
+
const stackInfo = detectPolyglotStack(root);
|
|
124
|
+
|
|
125
|
+
let constraintsContent = "";
|
|
126
|
+
if (edgeInfo.edgePlatform === "cloudflare") {
|
|
127
|
+
constraintsContent = `# CONSTRAINTS — Cloudflare Workers / workerd
|
|
128
|
+
|
|
129
|
+
## Runtime Invariants
|
|
130
|
+
- Zero unbundled \`node:*\` imports in \`src/\` (rely on standard Web APIs or polyfilled modules)
|
|
131
|
+
- Bundle size: Max 10 MB total
|
|
132
|
+
- RAM memory limit: 128 MB
|
|
133
|
+
- Database & KV: Batch operations via \`db.batch([])\`, zero unbounded query loops
|
|
134
|
+
- Static content: Zero client-side JavaScript for static content
|
|
135
|
+
`;
|
|
136
|
+
} else if (stackInfo.stack === "cargo") {
|
|
137
|
+
constraintsContent = `# CONSTRAINTS — Rust Architecture
|
|
138
|
+
|
|
139
|
+
## Runtime & Safety Invariants
|
|
140
|
+
- Zero \`unsafe\` blocks unless explicitly audited and documented
|
|
141
|
+
- No unhandled \`.unwrap()\` or \`.expect()\` in production/request-handling code paths
|
|
142
|
+
- Clippy compliance: \`cargo clippy -- -D warnings\` must pass with 0 warnings
|
|
143
|
+
- Strict error propagation using \`Result<T, E>\` / \`thiserror\` / \`anyhow\`
|
|
144
|
+
`;
|
|
145
|
+
} else if (stackInfo.stack === "go") {
|
|
146
|
+
constraintsContent = `# CONSTRAINTS — Go Architecture
|
|
147
|
+
|
|
148
|
+
## Runtime & Safety Invariants
|
|
149
|
+
- Deterministic builds: \`CGO_ENABLED=0\`
|
|
150
|
+
- Explicit error handling: Never discard \`err\` returns (\`_ = err\` is strictly prohibited)
|
|
151
|
+
- Data race free: \`go test -race ./...\` must pass with 0 failures
|
|
152
|
+
- Strict struct tagging and deterministic serialization
|
|
153
|
+
`;
|
|
154
|
+
} else if (["python", "poetry", "uv", "pipenv"].includes(stackInfo.stack)) {
|
|
155
|
+
constraintsContent = `# CONSTRAINTS — Python Architecture
|
|
156
|
+
|
|
157
|
+
## Runtime & Safety Invariants
|
|
158
|
+
- Strict type annotations on all function signatures (\`mypy --strict\` passes)
|
|
159
|
+
- Zero unpinned dependencies in production manifests
|
|
160
|
+
- Lint & format cleanly with \`ruff\` or \`flake8\`/\`black\`
|
|
161
|
+
- Pytest suite passes 100% cleanly
|
|
162
|
+
`;
|
|
163
|
+
} else {
|
|
164
|
+
constraintsContent = `# CONSTRAINTS — Technical Invariants
|
|
165
|
+
|
|
166
|
+
## Architecture & Code Quality
|
|
167
|
+
- Zero third-party runtime dependencies in core orchestration/shared packages
|
|
168
|
+
- Diff Payload Budget: Keep diffs under 75 KB to prevent truncation
|
|
169
|
+
- Strict Test Lock: Never weaken assertions or delete failing tests to force green status
|
|
170
|
+
- Cross-Platform: Normalize all filesystem paths to POSIX slashes (\`/\`)
|
|
171
|
+
`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
writeFileSync(constraintsPath, constraintsContent, "utf-8");
|
|
175
|
+
created.push("CONSTRAINTS.md");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// If web/UI stack is detected (Astro, Next, Svelte, Vue, React, Tailwind), scaffold DESIGN.md
|
|
179
|
+
const isWeb = existsSync(join(root, "astro.config.mjs")) ||
|
|
180
|
+
existsSync(join(root, "next.config.js")) ||
|
|
181
|
+
existsSync(join(root, "next.config.mjs")) ||
|
|
182
|
+
existsSync(join(root, "svelte.config.js")) ||
|
|
183
|
+
existsSync(join(root, "tailwind.config.js")) ||
|
|
184
|
+
existsSync(join(root, "tailwind.config.mjs")) ||
|
|
185
|
+
existsSync(join(root, "tailwind.config.ts"));
|
|
186
|
+
|
|
187
|
+
const designPath = join(root, "DESIGN.md");
|
|
188
|
+
if (isWeb && (!existsSync(designPath) || force)) {
|
|
189
|
+
const designContent = `# DESIGN — Visual Tokens & Design System
|
|
190
|
+
|
|
191
|
+
## Typography
|
|
192
|
+
- Headings: Clean sans-serif / geometric font
|
|
193
|
+
- Body: Readable system font / sans-serif
|
|
194
|
+
|
|
195
|
+
## Tokens (@theme)
|
|
196
|
+
- Consistent spacing scale (4px, 8px, 16px, 24px, 32px, 48px)
|
|
197
|
+
- Strict color tokens for surfaces, text, and borders
|
|
198
|
+
|
|
199
|
+
## Hard UI Rules
|
|
200
|
+
- Zero client-side JS for purely static content
|
|
201
|
+
- Accessible contrast ratios (WCAG AA minimum 4.5:1)
|
|
202
|
+
- Explicit hover, focus-visible, and active states on all interactive elements
|
|
203
|
+
`;
|
|
204
|
+
writeFileSync(designPath, designContent, "utf-8");
|
|
205
|
+
created.push("DESIGN.md");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return created;
|
|
209
|
+
}
|
|
210
|
+
|
|
80
211
|
/**
|
|
81
212
|
* Scaffold the repository assets the CLI's documented features depend on.
|
|
82
213
|
*
|
|
@@ -90,7 +221,7 @@ function copyDir(srcDir, destDir, force) {
|
|
|
90
221
|
* routine way to pick up new presets and must not overwrite local edits.
|
|
91
222
|
*
|
|
92
223
|
* @param {string} [root=process.cwd()]
|
|
93
|
-
* @param {{ force?: boolean }} [options]
|
|
224
|
+
* @param {{ force?: boolean, contracts?: boolean }} [options]
|
|
94
225
|
* @returns {{ created: string[], gitignore: string[] }}
|
|
95
226
|
*/
|
|
96
227
|
export function scaffoldRepoAssets(root = process.cwd(), options = {}) {
|
|
@@ -119,7 +250,7 @@ export function scaffoldRepoAssets(root = process.cwd(), options = {}) {
|
|
|
119
250
|
}
|
|
120
251
|
|
|
121
252
|
if (copyDir(join(KIT_ROOT, ".agent/prompts"), join(agentDir, "prompts"), force) > 0) {
|
|
122
|
-
created.push(".agent/prompts/ (Overseer, Bolt, Sentinel, Janitor)");
|
|
253
|
+
created.push(".agent/prompts/ (Overseer, Bolt, Sentinel, Janitor, A11y, Scribe, Spectator, Alchemist)");
|
|
123
254
|
}
|
|
124
255
|
if (copyDir(join(KIT_ROOT, ".agent/rules"), join(agentDir, "rules"), force) > 0) {
|
|
125
256
|
created.push(".agent/rules/");
|
|
@@ -128,6 +259,12 @@ export function scaffoldRepoAssets(root = process.cwd(), options = {}) {
|
|
|
128
259
|
created.push(".agent/workflows/");
|
|
129
260
|
}
|
|
130
261
|
|
|
262
|
+
// Scaffold contract documents (SPEC.md, CONSTRAINTS.md, DESIGN.md)
|
|
263
|
+
if (options.contracts !== false) {
|
|
264
|
+
const contracts = scaffoldContracts(root, options);
|
|
265
|
+
created.push(...contracts);
|
|
266
|
+
}
|
|
267
|
+
|
|
131
268
|
// isTaskFile() skips README.md, so the queue can carry its own explanation
|
|
132
269
|
// without the runner mistaking it for a task envelope.
|
|
133
270
|
const queueReadme = join(queueDir, "README.md");
|