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.
@@ -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: sessionId,
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, `${sessionId}.json`);
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
- const res = spawnSync(command, processedArgs, {
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,
@@ -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
+
@@ -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
- const charCount = content.length;
49
- const lineCount = content.split("\n").length;
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");