squad-kit 0.2.2 → 0.3.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/dist/cli.js CHANGED
@@ -172,7 +172,7 @@ function renderError(err) {
172
172
  // src/commands/init.ts
173
173
  import fs14 from "fs";
174
174
  import path6 from "path";
175
- import { input as input3, select as select3, checkbox, confirm as confirm5, password as password3 } from "@inquirer/prompts";
175
+ import { input as input2, select as select3, checkbox, confirm as confirm5, password as password3 } from "@inquirer/prompts";
176
176
 
177
177
  // src/core/paths.ts
178
178
  import path from "path";
@@ -212,8 +212,8 @@ function requireSquadRoot(startDir = process.cwd()) {
212
212
  }
213
213
  return root;
214
214
  }
215
- function slugify(input6) {
216
- return input6.toLowerCase().trim().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 80);
215
+ function slugify(input5) {
216
+ return input5.toLowerCase().trim().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 80);
217
217
  }
218
218
 
219
219
  // src/core/gitignore.ts
@@ -222,42 +222,38 @@ import path2 from "path";
222
222
  var SQUAD_SECRETS_PATTERN = ".squad/secrets.yaml";
223
223
  var SQUAD_ATTACHMENTS_PATTERN = ".squad/stories/**/attachments/";
224
224
  var SQUAD_TRASH_PATTERN = ".squad/.trash/";
225
+ var SQUAD_LAST_RUN_PATTERN = ".squad/.last-run.json";
226
+ var SQUAD_LAST_COPY_PROMPT_PATTERN = ".squad/.last-copy-prompt.md";
225
227
  var MANAGED_BLOCK_HEADER = "# Managed by squad-kit \u2014 do not edit this block";
226
228
  var MANAGED_BLOCK_FOOTER = "# End squad-kit block";
227
229
  function ensureGitignore(repoRoot) {
228
230
  const file = path2.join(repoRoot, ".gitignore");
229
231
  const text = fs2.existsSync(file) ? fs2.readFileSync(file, "utf8") : "";
230
- if (text.includes(SQUAD_SECRETS_PATTERN) && text.includes(SQUAD_TRASH_PATTERN)) {
231
- return false;
232
- }
233
- if (text.includes(SQUAD_SECRETS_PATTERN) && !text.includes(SQUAD_TRASH_PATTERN)) {
234
- let next = text;
235
- if (next.includes(SQUAD_ATTACHMENTS_PATTERN + "\n")) {
236
- next = next.replace(
237
- SQUAD_ATTACHMENTS_PATTERN + "\n",
238
- SQUAD_ATTACHMENTS_PATTERN + "\n" + SQUAD_TRASH_PATTERN + "\n"
239
- );
240
- } else if (next.includes(MANAGED_BLOCK_FOOTER)) {
241
- next = next.replace(MANAGED_BLOCK_FOOTER, SQUAD_TRASH_PATTERN + "\n" + MANAGED_BLOCK_FOOTER);
232
+ const requiredPatterns = [
233
+ SQUAD_SECRETS_PATTERN,
234
+ SQUAD_ATTACHMENTS_PATTERN,
235
+ SQUAD_TRASH_PATTERN,
236
+ SQUAD_LAST_RUN_PATTERN,
237
+ SQUAD_LAST_COPY_PROMPT_PATTERN
238
+ ];
239
+ const allPresent = requiredPatterns.every((p) => text.includes(p));
240
+ if (allPresent) return false;
241
+ if (!text.includes(SQUAD_SECRETS_PATTERN)) {
242
+ const block = ["", MANAGED_BLOCK_HEADER, ...requiredPatterns, MANAGED_BLOCK_FOOTER, ""].join("\n");
243
+ const next2 = text.length === 0 ? block.trimStart() : text.endsWith("\n") ? text + block : text + "\n" + block;
244
+ fs2.writeFileSync(file, next2, "utf8");
245
+ return true;
246
+ }
247
+ let next = text;
248
+ for (const pattern of requiredPatterns) {
249
+ if (next.includes(pattern)) continue;
250
+ if (next.includes(MANAGED_BLOCK_FOOTER)) {
251
+ next = next.replace(MANAGED_BLOCK_FOOTER, pattern + "\n" + MANAGED_BLOCK_FOOTER);
242
252
  } else {
243
- next = next.replace(/\n?$/, (m) => m + SQUAD_TRASH_PATTERN + "\n");
244
- }
245
- if (next !== text) {
246
- fs2.writeFileSync(file, next, "utf8");
247
- return true;
253
+ next = next.replace(/\n?$/, (m) => m + pattern + "\n");
248
254
  }
249
255
  }
250
- if (!text.includes(SQUAD_SECRETS_PATTERN)) {
251
- const block = [
252
- "",
253
- MANAGED_BLOCK_HEADER,
254
- SQUAD_SECRETS_PATTERN,
255
- SQUAD_ATTACHMENTS_PATTERN,
256
- SQUAD_TRASH_PATTERN,
257
- MANAGED_BLOCK_FOOTER,
258
- ""
259
- ].join("\n");
260
- const next = text.length === 0 ? block.trimStart() : text.endsWith("\n") ? text + block : text + "\n" + block;
256
+ if (next !== text) {
261
257
  fs2.writeFileSync(file, next, "utf8");
262
258
  return true;
263
259
  }
@@ -280,15 +276,15 @@ var DEFAULT_CONFIG = {
280
276
  planner: void 0
281
277
  };
282
278
  var FORBIDDEN_KEYS = ["apikey", "api_key", "token", "secret", "credential", "credentials"];
283
- function rejectSecretsInYaml(node, configFile, path25 = []) {
279
+ function rejectSecretsInYaml(node, configFile, path26 = []) {
284
280
  if (node === null || typeof node !== "object") return;
285
281
  for (const [key, value] of Object.entries(node)) {
286
282
  if (FORBIDDEN_KEYS.includes(key.toLowerCase())) {
287
283
  throw new Error(
288
- `Refusing to load ${configFile}: key "${[...path25, key].join(".")}" looks like a secret. Run \`squad config set planner\` or \`squad config set tracker\` to save it to .squad/secrets.yaml, then remove the key from config.yaml. Provider keys also work via ANTHROPIC_API_KEY / OPENAI_API_KEY / GOOGLE_API_KEY.`
284
+ `Refusing to load ${configFile}: key "${[...path26, key].join(".")}" looks like a secret. Run \`squad config set planner\` or \`squad config set tracker\` to save it to .squad/secrets.yaml, then remove the key from config.yaml. Provider keys also work via ANTHROPIC_API_KEY / OPENAI_API_KEY / GOOGLE_API_KEY.`
289
285
  );
290
286
  }
291
- if (typeof value === "object") rejectSecretsInYaml(value, configFile, [...path25, key]);
287
+ if (typeof value === "object") rejectSecretsInYaml(value, configFile, [...path26, key]);
292
288
  }
293
289
  }
294
290
  function mergePlanner(base, override) {
@@ -306,6 +302,9 @@ function mergePlanner(base, override) {
306
302
  modelOverride: {
307
303
  ...base?.modelOverride ?? {},
308
304
  ...override.modelOverride ?? {}
305
+ },
306
+ cache: {
307
+ enabled: override.cache?.enabled ?? base?.cache?.enabled ?? true
309
308
  }
310
309
  };
311
310
  if (merged.modelOverride) {
@@ -350,6 +349,20 @@ function loadConfig(configFile) {
350
349
  rejectSecretsInYaml(parsed, configFile);
351
350
  const merged = mergeConfig(DEFAULT_CONFIG, parsed);
352
351
  validateModelOverride(merged.planner?.modelOverride, configFile);
352
+ if (merged.planner?.cache !== void 0) {
353
+ const c = merged.planner.cache;
354
+ if (typeof c !== "object" || c === null || Array.isArray(c)) {
355
+ throw new Error(
356
+ `planner.cache in ${configFile} must be an object with \`enabled: true\` or \`false\`. Run \`squad config set planner\` to set prompt caching.`
357
+ );
358
+ }
359
+ const ce = c.enabled;
360
+ if (typeof ce !== "boolean") {
361
+ throw new Error(
362
+ `planner.cache.enabled in ${configFile} must be a boolean (not a quoted string). Run \`squad config set planner\` to set prompt caching, or use \`true\` or \`false\` unquoted in YAML.`
363
+ );
364
+ }
365
+ }
353
366
  if (merged.planner?.enabled) {
354
367
  if (!["anthropic", "openai", "google"].includes(merged.planner.provider)) {
355
368
  throw new Error(
@@ -696,7 +709,7 @@ async function runConfigShow(opts) {
696
709
 
697
710
  // src/commands/config/set-planner.ts
698
711
  import fs9 from "fs";
699
- import { confirm as confirm2, input as input2, password as password2, select } from "@inquirer/prompts";
712
+ import { confirm as confirm2, password as password2, select } from "@inquirer/prompts";
700
713
 
701
714
  // src/tracker/env-overlay.ts
702
715
  function overlayTrackerEnv(base) {
@@ -842,7 +855,8 @@ function newPlannerBlock(provider) {
842
855
  maxFileReads: 25,
843
856
  maxContextBytes: 5e4,
844
857
  maxDurationSeconds: 180
845
- }
858
+ },
859
+ cache: { enabled: true }
846
860
  };
847
861
  }
848
862
  function mergePlannerKeyIntoSecrets(base, provider, key) {
@@ -993,22 +1007,11 @@ async function runConfigSetPlanner(opts = {}) {
993
1007
  success("Planner key saved to .squad/secrets.yaml");
994
1008
  info(".squad/secrets.yaml updated (chmod 0600 on POSIX)");
995
1009
  }
996
- const wantOverride = await confirm2({
997
- message: "Set a custom plan-phase model id (planner.modelOverride)?",
998
- default: false
1010
+ const cacheEnabled = await confirm2({
1011
+ message: "Enable prompt caching? (Recommended \u2014 reduces billed tokens by ~70% on most providers.)",
1012
+ default: nextPlanner.cache?.enabled ?? true
999
1013
  });
1000
- if (wantOverride) {
1001
- const modelId = await input2({
1002
- message: "Plan model id (leave empty to use the default for this provider):",
1003
- default: ""
1004
- });
1005
- if (modelId.trim().length > 0 && nextPlanner) {
1006
- nextPlanner = {
1007
- ...nextPlanner,
1008
- modelOverride: { ...nextPlanner.modelOverride ?? {}, [provider]: modelId.trim() }
1009
- };
1010
- }
1011
- }
1014
+ nextPlanner = { ...nextPlanner, cache: { enabled: cacheEnabled } };
1012
1015
  }
1013
1016
  const next = { ...config2, planner: nextPlanner };
1014
1017
  saveConfig(paths.configFile, next);
@@ -1029,6 +1032,7 @@ async function runConfigSetPlanner(opts = {}) {
1029
1032
  kv("credential", `${cred.source} (${sourceText})`, 10);
1030
1033
  }
1031
1034
  if (process.env.CI === "true" || !cred) {
1035
+ printPlannerNextSteps(Boolean(cred));
1032
1036
  return;
1033
1037
  }
1034
1038
  const listed = await fetchProviderModelIds(provider, cred.value);
@@ -1042,6 +1046,23 @@ async function runConfigSetPlanner(opts = {}) {
1042
1046
  } else {
1043
1047
  info("Credential check: models API responded OK for this key.");
1044
1048
  }
1049
+ printPlannerNextSteps(true);
1050
+ }
1051
+ function printPlannerNextSteps(credentialReady) {
1052
+ blank();
1053
+ step("Next:");
1054
+ if (!credentialReady) {
1055
+ info("1) Save a planner key: re-run `squad config set planner` and paste the key when prompted.");
1056
+ info("2) Verify with `squad doctor` \u2014 all planner checks should turn green.");
1057
+ info("3) Then run `squad new-story <slug>` and `squad new-plan --api` to generate your first plan.");
1058
+ return;
1059
+ }
1060
+ info("1) Verify with `squad doctor` \u2014 every planner check should be green.");
1061
+ info("2) Create a story: squad new-story <feature-slug> (or --no-tracker for a manual story).");
1062
+ info("3) Fill the generated intake.md, then run `squad new-plan --api` to generate the plan.");
1063
+ info(
1064
+ "4) To change provider, key, or disable caching later: re-run `squad config set planner`. Model overrides are edited directly in .squad/config.yaml (planner.modelOverride)."
1065
+ );
1045
1066
  }
1046
1067
 
1047
1068
  // src/commands/config/set-tracker.ts
@@ -1526,6 +1547,7 @@ async function runConfigSetTracker(opts = {}) {
1526
1547
  kv("project", next.tracker.project, 10);
1527
1548
  }
1528
1549
  if (process.env.CI === "true") {
1550
+ printTrackerNextSteps(next.tracker.type);
1529
1551
  return;
1530
1552
  }
1531
1553
  const reloaded = loadConfig(paths.configFile);
@@ -1549,6 +1571,20 @@ async function runConfigSetTracker(opts = {}) {
1549
1571
  );
1550
1572
  }
1551
1573
  }
1574
+ printTrackerNextSteps(next.tracker.type);
1575
+ }
1576
+ function printTrackerNextSteps(type) {
1577
+ blank();
1578
+ step("Next:");
1579
+ if (type === "none") {
1580
+ info("1) Tracker is off. Create stories manually with `squad new-story <slug> --no-tracker`.");
1581
+ info("2) Re-enable any time with `squad config set tracker`.");
1582
+ return;
1583
+ }
1584
+ info("1) Verify with `squad doctor` \u2014 tracker checks should be green.");
1585
+ info(`2) Create a story: squad new-story <feature-slug> --id <${type === "jira" ? "JIRA-123" : "azure-work-item-id"}>`);
1586
+ info(" squad-kit auto-fetches the title, description, and attachments into the intake.");
1587
+ info("3) Review the generated intake.md, then run `squad new-plan --api` to generate the plan.");
1552
1588
  }
1553
1589
 
1554
1590
  // src/commands/config/unset-tracker.ts
@@ -1671,8 +1707,8 @@ async function runInit(opts) {
1671
1707
  agents: parseAgentsFlag(opts.agents)
1672
1708
  };
1673
1709
  const answers = opts.yes ? defaults : {
1674
- name: await input3({ message: "Project name", default: defaults.name }),
1675
- language: await input3({ message: "Primary language", default: defaults.language }),
1710
+ name: await input2({ message: "Project name", default: defaults.name }),
1711
+ language: await input2({ message: "Primary language", default: defaults.language }),
1676
1712
  tracker: await select3({
1677
1713
  message: "Issue tracker",
1678
1714
  choices: [
@@ -1893,7 +1929,7 @@ Plan meta-prompts (\`generate-plan.md\`, \`story-skeleton.md\`) ship inside the
1893
1929
  // src/commands/new-story.ts
1894
1930
  import fs16 from "fs";
1895
1931
  import path8 from "path";
1896
- import { input as input4, select as select4 } from "@inquirer/prompts";
1932
+ import { input as input3, select as select4 } from "@inquirer/prompts";
1897
1933
 
1898
1934
  // src/core/stories.ts
1899
1935
  import fs15 from "fs";
@@ -2024,7 +2060,7 @@ async function runNewStory(featureSlug, opts) {
2024
2060
  }
2025
2061
  }
2026
2062
  if (!featureSlugInput) {
2027
- featureSlugInput = (await input4({
2063
+ featureSlugInput = (await input3({
2028
2064
  message: "New feature slug (kebab-case):",
2029
2065
  validate: validateFeatureSlugForPrompt
2030
2066
  })).trim();
@@ -2063,7 +2099,7 @@ async function runNewStory(featureSlug, opts) {
2063
2099
  noTracker = true;
2064
2100
  trackerNeedsId = false;
2065
2101
  } else {
2066
- id = (await input4({
2102
+ id = (await input3({
2067
2103
  message: `${config2.tracker.type} work-item id:`,
2068
2104
  validate: (v) => validateTrackerId(config2.tracker.type, v.trim()) ? true : `Invalid ${config2.tracker.type} id format.`
2069
2105
  })).trim();
@@ -2082,12 +2118,12 @@ async function runNewStory(featureSlug, opts) {
2082
2118
  );
2083
2119
  }
2084
2120
  } else {
2085
- title = (await input4({ message: "Short story title:" })).trim();
2121
+ title = (await input3({ message: "Short story title:" })).trim();
2086
2122
  if (!title) throw new Error("Title cannot be empty. Run `squad new-story` again and enter a short title, or add `--yes` to fail fast in scripts.");
2087
2123
  }
2088
2124
  }
2089
2125
  if (interactive && !title) {
2090
- const maybe = (await input4({ message: "Short title (optional, press Enter to skip):", default: "" })).trim();
2126
+ const maybe = (await input3({ message: "Short title (optional, press Enter to skip):", default: "" })).trim();
2091
2127
  if (maybe) title = maybe;
2092
2128
  }
2093
2129
  const storyFolderName = id ? trackerIdForFilename(config2.tracker.type, id) : slugify(title ?? "");
@@ -2279,29 +2315,96 @@ function ensureFeatureOverview(featurePlanDir, slug) {
2279
2315
  }
2280
2316
 
2281
2317
  // src/commands/new-plan.ts
2282
- import fs21 from "fs";
2283
- import path14 from "path";
2318
+ import fs22 from "fs";
2319
+ import path15 from "path";
2284
2320
  import { confirm as confirm6, select as select5 } from "@inquirer/prompts";
2285
2321
 
2286
2322
  // src/utils/clipboard.ts
2287
- import { spawn } from "child_process";
2323
+ import { spawn, spawnSync } from "child_process";
2288
2324
  async function copyToClipboard(text) {
2289
- const cmd = clipboardCommand();
2290
- if (!cmd) return false;
2325
+ const candidates = clipboardCandidates();
2326
+ if (candidates.length === 0) {
2327
+ return { ok: false, reason: noCandidateReason() };
2328
+ }
2329
+ let lastReason;
2330
+ for (const cmd of candidates) {
2331
+ const reason = cmd.unavailableReason?.();
2332
+ if (reason) {
2333
+ lastReason = reason;
2334
+ continue;
2335
+ }
2336
+ const res = await tryCopy(cmd, text);
2337
+ if (res.ok) return res;
2338
+ lastReason = res.reason;
2339
+ }
2340
+ return { ok: false, reason: lastReason ?? "no working clipboard tool found" };
2341
+ }
2342
+ function tryCopy(cmd, text) {
2291
2343
  return new Promise((resolve) => {
2292
- const child = spawn(cmd.command, cmd.args, { stdio: ["pipe", "ignore", "ignore"] });
2293
- child.on("error", () => resolve(false));
2294
- child.on("close", (code) => resolve(code === 0));
2295
- child.stdin.write(text);
2296
- child.stdin.end();
2344
+ let child;
2345
+ try {
2346
+ child = spawn(cmd.command, cmd.args, { stdio: ["pipe", "ignore", "pipe"] });
2347
+ } catch (err) {
2348
+ resolve({ ok: false, reason: `${cmd.command} failed to spawn: ${err.message}` });
2349
+ return;
2350
+ }
2351
+ let stderr = "";
2352
+ child.stderr?.on("data", (chunk) => {
2353
+ stderr += chunk.toString();
2354
+ });
2355
+ child.on("error", (err) => {
2356
+ resolve({ ok: false, reason: `${cmd.command} spawn error: ${err.message}` });
2357
+ });
2358
+ child.on("close", (code) => {
2359
+ if (code === 0) {
2360
+ resolve({ ok: true, tool: cmd.command });
2361
+ } else {
2362
+ resolve({
2363
+ ok: false,
2364
+ reason: `${cmd.command} exited with code ${code}${stderr ? `: ${stderr.trim().slice(0, 200)}` : ""}`
2365
+ });
2366
+ }
2367
+ });
2368
+ child.stdin?.write(text);
2369
+ child.stdin?.end();
2297
2370
  });
2298
2371
  }
2299
- function clipboardCommand() {
2300
- if (process.platform === "darwin") return { command: "pbcopy", args: [] };
2301
- if (process.platform === "win32") return { command: "clip", args: [] };
2302
- if (process.env.WAYLAND_DISPLAY) return { command: "wl-copy", args: [] };
2303
- if (process.env.DISPLAY) return { command: "xclip", args: ["-selection", "clipboard"] };
2304
- return null;
2372
+ function clipboardCandidates() {
2373
+ if (process.platform === "darwin") {
2374
+ return [{ command: "pbcopy", args: [] }];
2375
+ }
2376
+ if (process.platform === "win32") {
2377
+ return [{ command: "clip", args: [] }];
2378
+ }
2379
+ const out = [];
2380
+ if (process.env.WAYLAND_DISPLAY) {
2381
+ out.push({ command: "wl-copy", args: [], unavailableReason: hasBinary("wl-copy") });
2382
+ }
2383
+ if (process.env.DISPLAY) {
2384
+ out.push({ command: "xclip", args: ["-selection", "clipboard"], unavailableReason: hasBinary("xclip") });
2385
+ out.push({ command: "xsel", args: ["--clipboard", "--input"], unavailableReason: hasBinary("xsel") });
2386
+ }
2387
+ if (process.env.TERMUX_VERSION) {
2388
+ out.push({ command: "termux-clipboard-set", args: [], unavailableReason: hasBinary("termux-clipboard-set") });
2389
+ }
2390
+ return out;
2391
+ }
2392
+ function hasBinary(bin) {
2393
+ return () => {
2394
+ const which = process.platform === "win32" ? "where" : "which";
2395
+ const res = spawnSync(which, [bin], { stdio: "ignore" });
2396
+ if (res.status === 0) return void 0;
2397
+ return `${bin} not found on PATH`;
2398
+ };
2399
+ }
2400
+ function noCandidateReason() {
2401
+ if (process.platform === "linux") {
2402
+ if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY && !process.env.TERMUX_VERSION) {
2403
+ return "no display server detected (headless / SSH) \u2014 install xclip or xsel, or use the saved file";
2404
+ }
2405
+ return "no clipboard tool detected \u2014 install xclip, xsel, or wl-copy";
2406
+ }
2407
+ return `no clipboard tool detected on ${process.platform}`;
2305
2408
  }
2306
2409
 
2307
2410
  // src/planner/loop.ts
@@ -2323,8 +2426,8 @@ var READ_FILE_TOOL = {
2323
2426
  }
2324
2427
  };
2325
2428
  var MAX_BYTES_PER_READ = 32e3;
2326
- function readFileTool(root, budget, input6) {
2327
- const raw = input6?.path;
2429
+ function readFileTool(root, budget, input5) {
2430
+ const raw = input5?.path;
2328
2431
  if (typeof raw !== "string" || raw.length === 0) {
2329
2432
  return error('read_file: missing or invalid "path" argument.');
2330
2433
  }
@@ -2455,117 +2558,16 @@ function rateLimitMessage(err) {
2455
2558
  ].join("\n");
2456
2559
  }
2457
2560
 
2458
- // src/planner/loop.ts
2459
- var MAX_RATE_LIMIT_RETRY_SEC = 90;
2460
- var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2461
- async function runPlanner(input6) {
2462
- const maxIter = input6.maxIterations ?? 40;
2463
- const turns = [{ role: "user", text: input6.userPrompt }];
2464
- let accumulatedText = "";
2465
- let iterations = 0;
2466
- let budgetExhausted = false;
2467
- let finishedNormally = false;
2468
- while (iterations < maxIter) {
2469
- iterations += 1;
2470
- if (input6.budget.timedOut()) {
2471
- return { planText: accumulatedText, budgetExhausted, timedOut: true, finishedNormally: false, iterations };
2472
- }
2473
- if (input6.budget.overCost()) {
2474
- return { planText: accumulatedText, budgetExhausted: true, timedOut: false, finishedNormally: false, iterations };
2475
- }
2476
- const request = {
2477
- systemPrompt: input6.systemPrompt,
2478
- model: input6.model,
2479
- tools: [READ_FILE_TOOL],
2480
- turns,
2481
- apiKey: input6.apiKey,
2482
- maxOutputTokens: 8192
2483
- };
2484
- let response = await input6.provider.send(request);
2485
- if (response.usage) {
2486
- input6.budget.recordUsage(response.usage);
2487
- input6.onUsage?.(response.usage);
2488
- }
2489
- let retriedRateLimit = false;
2490
- let retrySkippedReason;
2491
- if (response.stopReason === "error" && response.errorKind === "rate_limit") {
2492
- const asked = response.retryAfterSec;
2493
- if (asked !== void 0 && asked > MAX_RATE_LIMIT_RETRY_SEC) {
2494
- retrySkippedReason = "retry_after_too_long";
2495
- } else {
2496
- const waitSec = Math.min(asked ?? 10, MAX_RATE_LIMIT_RETRY_SEC);
2497
- input6.onRateLimit?.(waitSec);
2498
- await (input6.sleep ?? defaultSleep)(waitSec * 1e3);
2499
- response = await input6.provider.send(request);
2500
- if (response.usage) {
2501
- input6.budget.recordUsage(response.usage);
2502
- input6.onUsage?.(response.usage);
2503
- }
2504
- retriedRateLimit = true;
2505
- }
2506
- }
2507
- if (response.stopReason === "error") {
2508
- throw composePlannerError(input6.provider.name, response, retriedRateLimit, retrySkippedReason);
2509
- }
2510
- if (response.text) {
2511
- accumulatedText += response.text;
2512
- input6.onAssistantText?.(response.text);
2513
- }
2514
- if (response.stopReason === "max_tokens" && !response.toolCalls?.length) {
2515
- return { planText: accumulatedText, budgetExhausted, timedOut: false, finishedNormally: false, iterations };
2516
- }
2517
- if (response.stopReason === "end_turn" || !response.toolCalls?.length) {
2518
- finishedNormally = true;
2519
- return { planText: accumulatedText, budgetExhausted, timedOut: false, finishedNormally, iterations };
2520
- }
2521
- turns.push({
2522
- role: "assistant",
2523
- text: response.text,
2524
- toolCalls: response.toolCalls
2525
- });
2526
- const toolResults = [];
2527
- for (const tc of response.toolCalls) {
2528
- if (tc.name !== READ_FILE_TOOL.name) {
2529
- toolResults.push({ toolCallId: tc.id, content: `unknown tool "${tc.name}"`, isError: true });
2530
- continue;
2531
- }
2532
- const before = input6.budget.snapshot().bytes;
2533
- const result = readFileTool(input6.root, input6.budget, tc.input);
2534
- const after = input6.budget.snapshot().bytes;
2535
- input6.onToolCall?.(tc, after - before, after);
2536
- if (result.isError && (/budget/i.test(result.content) || /max file reads/i.test(result.content))) {
2537
- budgetExhausted = true;
2538
- }
2539
- toolResults.push({ toolCallId: tc.id, content: result.content, isError: result.isError });
2540
- }
2541
- turns.push({ role: "user", toolResults });
2542
- if (budgetExhausted) {
2543
- turns.push({
2544
- role: "user",
2545
- text: "Budget is exhausted. Finalise the plan with the information you already have. Do not call any more tools. Output the complete plan markdown now."
2546
- });
2547
- }
2548
- }
2549
- return { planText: accumulatedText, budgetExhausted, timedOut: false, finishedNormally: false, iterations };
2550
- }
2551
- function composePlannerError(providerName, response, retriedRateLimit, retrySkippedReason) {
2552
- if (response.errorKind === "rate_limit") {
2553
- return new Error(
2554
- rateLimitMessage({
2555
- provider: providerName,
2556
- retryAfterSec: response.retryAfterSec,
2557
- rawBody: response.rawError ?? "",
2558
- retryAlreadyAttempted: retriedRateLimit,
2559
- retrySkippedReason,
2560
- maxRetrySec: MAX_RATE_LIMIT_RETRY_SEC
2561
- })
2562
- );
2563
- }
2564
- if (response.errorKind === "model_not_found") {
2565
- return new Error(response.rawError ?? "planner: model not found");
2561
+ // src/planner/stable-json.ts
2562
+ function sortRecordKeys(v) {
2563
+ if (v === null || typeof v !== "object") return v;
2564
+ if (Array.isArray(v)) return v.map((x) => sortRecordKeys(x));
2565
+ const o = v;
2566
+ const out = {};
2567
+ for (const k of Object.keys(o).sort()) {
2568
+ out[k] = sortRecordKeys(o[k]);
2566
2569
  }
2567
- const base = response.rawError ?? "planner: provider error";
2568
- return new Error(`${base} Run \`squad doctor\` to diagnose, or retry \u2014 most 5xx errors are transient.`);
2570
+ return out;
2569
2571
  }
2570
2572
 
2571
2573
  // src/planner/providers/anthropic.ts
@@ -2577,14 +2579,39 @@ var anthropicProvider = {
2577
2579
  return callAnthropic(req);
2578
2580
  }
2579
2581
  };
2580
- async function callAnthropic(req) {
2581
- const body = {
2582
+ function buildAnthropicBody(req) {
2583
+ if (req.cacheEnabled === false) {
2584
+ const base2 = {
2585
+ model: req.model,
2586
+ max_tokens: req.maxOutputTokens ?? 4096,
2587
+ system: req.systemPrompt,
2588
+ tools: req.tools.map(toAnthropicTool)
2589
+ };
2590
+ if (req.turns.length === 0) {
2591
+ return base2;
2592
+ }
2593
+ return { ...base2, messages: req.turns.map((t) => toAnthropicMessage(t)) };
2594
+ }
2595
+ const base = {
2582
2596
  model: req.model,
2583
2597
  max_tokens: req.maxOutputTokens ?? 4096,
2584
- system: req.systemPrompt,
2585
- tools: req.tools.map(toAnthropicTool),
2586
- messages: req.turns.map(toAnthropicMessage)
2598
+ system: [
2599
+ {
2600
+ type: "text",
2601
+ text: req.systemPrompt,
2602
+ cache_control: { type: "ephemeral" }
2603
+ }
2604
+ ],
2605
+ tools: req.tools.map(toAnthropicTool)
2587
2606
  };
2607
+ if (req.turns.length === 0) {
2608
+ return base;
2609
+ }
2610
+ return { ...base, messages: toMessagesWithCacheMarkers(req.turns) };
2611
+ }
2612
+ async function callAnthropic(req) {
2613
+ const built = buildAnthropicBody(req);
2614
+ const body = { ...built, messages: built.messages ?? [] };
2588
2615
  const res = await fetch(API_URL, {
2589
2616
  method: "POST",
2590
2617
  headers: {
@@ -2624,26 +2651,73 @@ async function callAnthropic(req) {
2624
2651
  stopReason: mapStop(json.stop_reason),
2625
2652
  usage: {
2626
2653
  inputTokens: json.usage?.input_tokens ?? 0,
2627
- outputTokens: json.usage?.output_tokens ?? 0
2654
+ outputTokens: json.usage?.output_tokens ?? 0,
2655
+ cacheCreationTokens: json.usage?.cache_creation_input_tokens ?? 0,
2656
+ cacheReadTokens: json.usage?.cache_read_input_tokens ?? 0
2628
2657
  }
2629
2658
  };
2630
2659
  }
2660
+ function toMessagesWithCacheMarkers(turns) {
2661
+ let lastUserWithToolResultIdx = -1;
2662
+ for (let i = turns.length - 1; i >= 0; i -= 1) {
2663
+ const t = turns[i];
2664
+ if (t.role === "user" && hasToolResultBlock(t)) {
2665
+ lastUserWithToolResultIdx = i;
2666
+ break;
2667
+ }
2668
+ }
2669
+ return turns.map((turn, idx) => {
2670
+ const message = toAnthropicMessage(turn);
2671
+ if (idx === lastUserWithToolResultIdx) {
2672
+ return attachCacheControlToLastToolResult(message);
2673
+ }
2674
+ return message;
2675
+ });
2676
+ }
2677
+ function hasToolResultBlock(turn) {
2678
+ return (turn.toolResults?.length ?? 0) > 0;
2679
+ }
2680
+ function attachCacheControlToLastToolResult(msg) {
2681
+ const content = [...msg.content];
2682
+ for (let i = content.length - 1; i >= 0; i -= 1) {
2683
+ const block = content[i];
2684
+ if (block.type === "tool_result") {
2685
+ content[i] = { ...block, cache_control: { type: "ephemeral" } };
2686
+ break;
2687
+ }
2688
+ }
2689
+ return { ...msg, content };
2690
+ }
2631
2691
  function toAnthropicTool(t) {
2632
- return { name: t.name, description: t.description, input_schema: t.inputSchema };
2692
+ return {
2693
+ name: t.name,
2694
+ description: t.description,
2695
+ input_schema: sortRecordKeys(t.inputSchema)
2696
+ };
2633
2697
  }
2634
2698
  function toAnthropicMessage(turn) {
2635
2699
  const blocks = [];
2636
2700
  if (turn.text) blocks.push({ type: "text", text: turn.text });
2637
- if (turn.toolCalls)
2638
- for (const tc of turn.toolCalls) blocks.push({ type: "tool_use", id: tc.id, name: tc.name, input: tc.input });
2639
- if (turn.toolResults)
2640
- for (const tr of turn.toolResults)
2701
+ if (turn.toolCalls) {
2702
+ for (const tc of turn.toolCalls) {
2703
+ blocks.push({
2704
+ type: "tool_use",
2705
+ id: tc.id,
2706
+ name: tc.name,
2707
+ input: sortRecordKeys(tc.input)
2708
+ });
2709
+ }
2710
+ }
2711
+ if (turn.toolResults) {
2712
+ for (const tr of turn.toolResults) {
2641
2713
  blocks.push({
2642
2714
  type: "tool_result",
2643
2715
  tool_use_id: tr.toolCallId,
2644
2716
  content: tr.content,
2645
2717
  is_error: tr.isError ?? false
2646
2718
  });
2719
+ }
2720
+ }
2647
2721
  return { role: turn.role === "assistant" ? "assistant" : "user", content: blocks };
2648
2722
  }
2649
2723
  function mapStop(r) {
@@ -2660,15 +2734,23 @@ var openaiProvider = {
2660
2734
  return callOpenAI(req);
2661
2735
  }
2662
2736
  };
2663
- async function callOpenAI(req) {
2664
- const messages = [{ role: "system", content: req.systemPrompt }];
2665
- for (const turn of req.turns) messages.push(...toOpenAIMessages(turn));
2666
- const body = {
2737
+ function buildOpenAIBody(req) {
2738
+ const base = {
2667
2739
  model: req.model,
2668
- messages,
2669
- tools: req.tools.map(toOpenAITool),
2670
- max_completion_tokens: req.maxOutputTokens ?? 4096
2740
+ max_completion_tokens: req.maxOutputTokens ?? 4096,
2741
+ tools: req.tools.map(toOpenAITool)
2671
2742
  };
2743
+ if (req.turns.length === 0) {
2744
+ return base;
2745
+ }
2746
+ const messages = [{ role: "system", content: req.systemPrompt }];
2747
+ for (const turn of req.turns) messages.push(...toOpenAIMessages(turn));
2748
+ return { ...base, messages };
2749
+ }
2750
+ async function callOpenAI(req) {
2751
+ const built = buildOpenAIBody(req);
2752
+ const messages = built.messages && built.messages.length > 0 ? built.messages : [{ role: "system", content: req.systemPrompt }];
2753
+ const body = { ...built, messages };
2672
2754
  const res = await fetch(API_URL2, {
2673
2755
  method: "POST",
2674
2756
  headers: {
@@ -2718,7 +2800,14 @@ async function callOpenAI(req) {
2718
2800
  };
2719
2801
  }
2720
2802
  function toOpenAITool(t) {
2721
- return { type: "function", function: { name: t.name, description: t.description, parameters: t.inputSchema } };
2803
+ return {
2804
+ type: "function",
2805
+ function: {
2806
+ name: t.name,
2807
+ description: t.description,
2808
+ parameters: sortRecordKeys(t.inputSchema)
2809
+ }
2810
+ };
2722
2811
  }
2723
2812
  function toOpenAIMessages(turn) {
2724
2813
  const out = [];
@@ -2729,7 +2818,10 @@ function toOpenAIMessages(turn) {
2729
2818
  tool_calls: turn.toolCalls?.map((tc) => ({
2730
2819
  id: tc.id,
2731
2820
  type: "function",
2732
- function: { name: tc.name, arguments: JSON.stringify(tc.input) }
2821
+ function: {
2822
+ name: tc.name,
2823
+ arguments: JSON.stringify(sortRecordKeys(tc.input))
2824
+ }
2733
2825
  }))
2734
2826
  });
2735
2827
  } else {
@@ -2749,7 +2841,7 @@ function mapStop2(r) {
2749
2841
  }
2750
2842
  function safeJson(raw) {
2751
2843
  try {
2752
- return JSON.parse(raw);
2844
+ return sortRecordKeys(JSON.parse(raw));
2753
2845
  } catch {
2754
2846
  return {};
2755
2847
  }
@@ -2763,14 +2855,23 @@ var googleProvider = {
2763
2855
  return callGoogle(req);
2764
2856
  }
2765
2857
  };
2766
- async function callGoogle(req) {
2767
- const url = `${API_BASE}/${encodeURIComponent(req.model)}:generateContent?key=${encodeURIComponent(req.apiKey)}`;
2768
- const body = {
2858
+ function buildGoogleBody(req) {
2859
+ const base = {
2769
2860
  system_instruction: { parts: [{ text: req.systemPrompt }] },
2770
- contents: req.turns.map(toGoogleContent),
2771
- tools: req.tools.length ? [{ functionDeclarations: req.tools.map(toGoogleTool) }] : void 0,
2772
2861
  generationConfig: { maxOutputTokens: req.maxOutputTokens ?? 4096 }
2773
2862
  };
2863
+ if (req.tools.length) {
2864
+ base.tools = [{ functionDeclarations: req.tools.map(toGoogleTool) }];
2865
+ }
2866
+ if (req.turns.length === 0) {
2867
+ return base;
2868
+ }
2869
+ return { ...base, contents: req.turns.map(toGoogleContent) };
2870
+ }
2871
+ async function callGoogle(req) {
2872
+ const url = `${API_BASE}/${encodeURIComponent(req.model)}:generateContent?key=${encodeURIComponent(req.apiKey)}`;
2873
+ const built = buildGoogleBody(req);
2874
+ const body = { ...built, contents: built.contents ?? [] };
2774
2875
  const res = await fetch(url, {
2775
2876
  method: "POST",
2776
2877
  headers: { "content-type": "application/json" },
@@ -2805,7 +2906,7 @@ async function callGoogle(req) {
2805
2906
  const toolCalls = parts.filter((p) => "functionCall" in p && !!p.functionCall).map((p, i) => ({
2806
2907
  id: `gcall_${i}_${p.functionCall.name}`,
2807
2908
  name: p.functionCall.name,
2808
- input: p.functionCall.args ?? {}
2909
+ input: p.functionCall.args ? sortRecordKeys(p.functionCall.args) : {}
2809
2910
  }));
2810
2911
  return {
2811
2912
  text: textOut || void 0,
@@ -2818,14 +2919,17 @@ async function callGoogle(req) {
2818
2919
  };
2819
2920
  }
2820
2921
  function toGoogleTool(t) {
2821
- return { name: t.name, description: t.description, parameters: t.inputSchema };
2922
+ return { name: t.name, description: t.description, parameters: sortRecordKeys(t.inputSchema) };
2822
2923
  }
2823
2924
  function toGoogleContent(turn) {
2824
2925
  const role = turn.role === "assistant" ? "model" : "user";
2825
2926
  const parts = [];
2826
2927
  if (turn.text) parts.push({ text: turn.text });
2827
- if (turn.toolCalls)
2828
- for (const tc of turn.toolCalls) parts.push({ functionCall: { name: tc.name, args: tc.input } });
2928
+ if (turn.toolCalls) {
2929
+ for (const tc of turn.toolCalls) {
2930
+ parts.push({ functionCall: { name: tc.name, args: sortRecordKeys(tc.input) } });
2931
+ }
2932
+ }
2829
2933
  if (turn.toolResults) {
2830
2934
  for (const tr of turn.toolResults) {
2831
2935
  parts.push({
@@ -2845,6 +2949,208 @@ function mapStop3(r) {
2845
2949
  return "tool_use";
2846
2950
  }
2847
2951
 
2952
+ // src/planner/providers/prefix.ts
2953
+ function prefixOf(provider, req) {
2954
+ const trimmed = { ...req, turns: req.turns.slice(0, -1) };
2955
+ const s = (() => {
2956
+ switch (provider) {
2957
+ case "anthropic":
2958
+ return JSON.stringify(buildAnthropicBody(trimmed));
2959
+ case "openai":
2960
+ return JSON.stringify(buildOpenAIBody(trimmed));
2961
+ case "google":
2962
+ return JSON.stringify(buildGoogleBody(trimmed));
2963
+ }
2964
+ })();
2965
+ return s.length > 0 ? s.slice(0, -1) : s;
2966
+ }
2967
+
2968
+ // src/planner/loop.ts
2969
+ var MAX_RATE_LIMIT_RETRY_SEC = 90;
2970
+ var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2971
+ function buildPlannerRunStats(budget, turns, runStartedAt) {
2972
+ const u = budget.snapshot().usage;
2973
+ const cacheRead = u.cacheReadTokens ?? 0;
2974
+ const cacheCreate = u.cacheCreationTokens ?? 0;
2975
+ const inTok = u.inputTokens;
2976
+ const outTok = u.outputTokens;
2977
+ const totalInput = inTok + cacheRead;
2978
+ const cacheHitRatio = totalInput === 0 ? 0 : Math.round(cacheRead / totalInput * 100) / 100;
2979
+ return {
2980
+ turns,
2981
+ inputTokens: inTok,
2982
+ outputTokens: outTok,
2983
+ cacheCreationTokens: cacheCreate,
2984
+ cacheReadTokens: cacheRead,
2985
+ cacheHitRatio,
2986
+ durationMs: Date.now() - runStartedAt
2987
+ };
2988
+ }
2989
+ async function runPlanner(input5) {
2990
+ const runStartedAt = Date.now();
2991
+ const maxIter = input5.maxIterations ?? 40;
2992
+ const turns = [{ role: "user", text: input5.userPrompt }];
2993
+ let accumulatedText = "";
2994
+ let iterations = 0;
2995
+ let budgetExhausted = false;
2996
+ let finishedNormally = false;
2997
+ let prevRequestPrefix;
2998
+ while (iterations < maxIter) {
2999
+ iterations += 1;
3000
+ if (input5.budget.timedOut()) {
3001
+ return {
3002
+ planText: accumulatedText,
3003
+ budgetExhausted,
3004
+ timedOut: true,
3005
+ finishedNormally: false,
3006
+ iterations,
3007
+ stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt)
3008
+ };
3009
+ }
3010
+ if (input5.budget.overCost()) {
3011
+ return {
3012
+ planText: accumulatedText,
3013
+ budgetExhausted: true,
3014
+ timedOut: false,
3015
+ finishedNormally: false,
3016
+ iterations,
3017
+ stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt)
3018
+ };
3019
+ }
3020
+ const request = {
3021
+ systemPrompt: input5.systemPrompt,
3022
+ model: input5.model,
3023
+ tools: [READ_FILE_TOOL],
3024
+ turns,
3025
+ apiKey: input5.apiKey,
3026
+ maxOutputTokens: 8192,
3027
+ cacheEnabled: input5.cacheEnabled ?? true
3028
+ };
3029
+ if (process.env.NODE_ENV !== "production" && process.env.NODE_ENV !== "test" && prevRequestPrefix !== void 0) {
3030
+ const currentPrefix = prefixOf(input5.provider.name, request);
3031
+ if (!currentPrefix.startsWith(prevRequestPrefix)) {
3032
+ const byte = firstByteDiff(prevRequestPrefix, currentPrefix);
3033
+ warning(
3034
+ `planner: prefix mutation at turn ${iterations}, byte ${byte} \u2014 caching will not hit.`
3035
+ );
3036
+ }
3037
+ }
3038
+ let response = await input5.provider.send(request);
3039
+ if (response.usage) {
3040
+ input5.budget.recordUsage(response.usage);
3041
+ input5.onUsage?.(response.usage);
3042
+ }
3043
+ let retriedRateLimit = false;
3044
+ let retrySkippedReason;
3045
+ if (response.stopReason === "error" && response.errorKind === "rate_limit") {
3046
+ const asked = response.retryAfterSec;
3047
+ if (asked !== void 0 && asked > MAX_RATE_LIMIT_RETRY_SEC) {
3048
+ retrySkippedReason = "retry_after_too_long";
3049
+ } else {
3050
+ const waitSec = Math.min(asked ?? 10, MAX_RATE_LIMIT_RETRY_SEC);
3051
+ input5.onRateLimit?.(waitSec);
3052
+ await (input5.sleep ?? defaultSleep)(waitSec * 1e3);
3053
+ response = await input5.provider.send(request);
3054
+ if (response.usage) {
3055
+ input5.budget.recordUsage(response.usage);
3056
+ input5.onUsage?.(response.usage);
3057
+ }
3058
+ retriedRateLimit = true;
3059
+ }
3060
+ }
3061
+ if (response.stopReason === "error") {
3062
+ throw composePlannerError(input5.provider.name, response, retriedRateLimit, retrySkippedReason);
3063
+ }
3064
+ prevRequestPrefix = prefixOf(input5.provider.name, request);
3065
+ if (response.text) {
3066
+ accumulatedText += response.text;
3067
+ input5.onAssistantText?.(response.text);
3068
+ }
3069
+ if (response.stopReason === "max_tokens" && !response.toolCalls?.length) {
3070
+ return {
3071
+ planText: accumulatedText,
3072
+ budgetExhausted,
3073
+ timedOut: false,
3074
+ finishedNormally: false,
3075
+ iterations,
3076
+ stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt)
3077
+ };
3078
+ }
3079
+ if (response.stopReason === "end_turn" || !response.toolCalls?.length) {
3080
+ finishedNormally = true;
3081
+ return {
3082
+ planText: accumulatedText,
3083
+ budgetExhausted,
3084
+ timedOut: false,
3085
+ finishedNormally,
3086
+ iterations,
3087
+ stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt)
3088
+ };
3089
+ }
3090
+ turns.push({
3091
+ role: "assistant",
3092
+ text: response.text,
3093
+ toolCalls: response.toolCalls
3094
+ });
3095
+ const toolResults = [];
3096
+ for (const tc of response.toolCalls) {
3097
+ if (tc.name !== READ_FILE_TOOL.name) {
3098
+ toolResults.push({ toolCallId: tc.id, content: `unknown tool "${tc.name}"`, isError: true });
3099
+ continue;
3100
+ }
3101
+ const before = input5.budget.snapshot().bytes;
3102
+ const result = readFileTool(input5.root, input5.budget, tc.input);
3103
+ const after = input5.budget.snapshot().bytes;
3104
+ input5.onToolCall?.(tc, after - before, after);
3105
+ if (result.isError && (/budget/i.test(result.content) || /max file reads/i.test(result.content))) {
3106
+ budgetExhausted = true;
3107
+ }
3108
+ toolResults.push({ toolCallId: tc.id, content: result.content, isError: result.isError });
3109
+ }
3110
+ turns.push({ role: "user", toolResults });
3111
+ if (budgetExhausted) {
3112
+ turns.push({
3113
+ role: "user",
3114
+ text: "Budget is exhausted. Finalise the plan with the information you already have. Do not call any more tools. Output the complete plan markdown now."
3115
+ });
3116
+ }
3117
+ }
3118
+ return {
3119
+ planText: accumulatedText,
3120
+ budgetExhausted,
3121
+ timedOut: false,
3122
+ finishedNormally: false,
3123
+ iterations,
3124
+ stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt)
3125
+ };
3126
+ }
3127
+ function firstByteDiff(a, b) {
3128
+ const n = Math.min(a.length, b.length);
3129
+ for (let i = 0; i < n; i += 1) {
3130
+ if (a.charCodeAt(i) !== b.charCodeAt(i)) return i;
3131
+ }
3132
+ return n;
3133
+ }
3134
+ function composePlannerError(providerName, response, retriedRateLimit, retrySkippedReason) {
3135
+ if (response.errorKind === "rate_limit") {
3136
+ return new Error(
3137
+ rateLimitMessage({
3138
+ provider: providerName,
3139
+ retryAfterSec: response.retryAfterSec,
3140
+ rawBody: response.rawError ?? "",
3141
+ retryAlreadyAttempted: retriedRateLimit,
3142
+ retrySkippedReason,
3143
+ maxRetrySec: MAX_RATE_LIMIT_RETRY_SEC
3144
+ })
3145
+ );
3146
+ }
3147
+ if (response.errorKind === "model_not_found") {
3148
+ return new Error(response.rawError ?? "planner: model not found");
3149
+ }
3150
+ const base = response.rawError ?? "planner: provider error";
3151
+ return new Error(`${base} Run \`squad doctor\` to diagnose, or retry \u2014 most 5xx errors are transient.`);
3152
+ }
3153
+
2848
3154
  // src/planner/providers/index.ts
2849
3155
  function providerFor(name) {
2850
3156
  switch (name) {
@@ -2866,7 +3172,7 @@ var Budget = class {
2866
3172
  reads = 0;
2867
3173
  bytes = 0;
2868
3174
  startedAt = Date.now();
2869
- totalUsage = { inputTokens: 0, outputTokens: 0 };
3175
+ totalUsage = { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 };
2870
3176
  canRead(nextFileBytes) {
2871
3177
  if (this.reads >= this.cfg.maxFileReads) {
2872
3178
  return { ok: false, reason: `max file reads (${this.cfg.maxFileReads}) reached` };
@@ -2884,6 +3190,8 @@ var Budget = class {
2884
3190
  this.totalUsage.inputTokens += u.inputTokens;
2885
3191
  this.totalUsage.outputTokens += u.outputTokens;
2886
3192
  if (u.costUsd !== void 0) this.totalUsage.costUsd = (this.totalUsage.costUsd ?? 0) + u.costUsd;
3193
+ this.totalUsage.cacheCreationTokens = (this.totalUsage.cacheCreationTokens ?? 0) + (u.cacheCreationTokens ?? 0);
3194
+ this.totalUsage.cacheReadTokens = (this.totalUsage.cacheReadTokens ?? 0) + (u.cacheReadTokens ?? 0);
2887
3195
  }
2888
3196
  timedOut() {
2889
3197
  return (Date.now() - this.startedAt) / 1e3 > this.cfg.maxDurationSeconds;
@@ -3029,8 +3337,8 @@ function formatSequence(n) {
3029
3337
  }
3030
3338
 
3031
3339
  // src/planner/writer.ts
3032
- function writePlanFile(input6) {
3033
- const { paths, config: config2, story } = input6;
3340
+ function writePlanFile(input5) {
3341
+ const { paths, config: config2, story } = input5;
3034
3342
  const featurePlanDir = path13.join(paths.plansDir, story.feature);
3035
3343
  fs20.mkdirSync(featurePlanDir, { recursive: true });
3036
3344
  const existing = findExistingPlan(featurePlanDir, story.id);
@@ -3049,9 +3357,9 @@ function writePlanFile(input6) {
3049
3357
  const trackerPart = trackerId && trackerId !== baseSlug ? `-${trackerId}` : "";
3050
3358
  targetPath = path13.join(featurePlanDir, `${formatSequence(nn)}-story-${baseSlug}${trackerPart}.md`);
3051
3359
  }
3052
- const body = `${input6.metadataHeader.trimEnd()}
3360
+ const body = `${input5.metadataHeader.trimEnd()}
3053
3361
 
3054
- ${input6.planBodyMarkdown.trimStart()}
3362
+ ${input5.planBodyMarkdown.trimStart()}
3055
3363
  `;
3056
3364
  fs20.writeFileSync(targetPath, body, "utf8");
3057
3365
  upsertOverviewRow(featurePlanDir, story.feature, nn, path13.basename(targetPath), story);
@@ -3118,6 +3426,45 @@ function buildMetadataHeader(args) {
3118
3426
  return `<!-- squad-kit: ${parts.join(", ")} -->`;
3119
3427
  }
3120
3428
 
3429
+ // src/core/last-run.ts
3430
+ import fs21 from "fs/promises";
3431
+ import path14 from "path";
3432
+ async function writeLastRun(paths, rec) {
3433
+ const file = path14.join(paths.squadDir, ".last-run.json");
3434
+ await fs21.writeFile(file, JSON.stringify({ ...rec, version: 1 }, null, 2) + "\n", "utf8");
3435
+ }
3436
+ async function readLastRun(paths) {
3437
+ const file = path14.join(paths.squadDir, ".last-run.json");
3438
+ try {
3439
+ const raw = await fs21.readFile(file, "utf8");
3440
+ const parsed = JSON.parse(raw);
3441
+ if (parsed.version !== 1) return void 0;
3442
+ return parsed;
3443
+ } catch {
3444
+ return void 0;
3445
+ }
3446
+ }
3447
+
3448
+ // src/ui/planner-cache-summary.ts
3449
+ function formatTokenK(n) {
3450
+ return `${(n / 1e3).toFixed(1)}k`;
3451
+ }
3452
+ function formatPlannerCacheLine(opts) {
3453
+ if (!opts.cacheEnabled) {
3454
+ return "cache disabled";
3455
+ }
3456
+ const { stats } = opts;
3457
+ if (stats.cacheReadTokens > 0) {
3458
+ const pct = Math.round(stats.cacheHitRatio * 100);
3459
+ const total = stats.inputTokens + stats.cacheReadTokens;
3460
+ const read = formatTokenK(stats.cacheReadTokens);
3461
+ const tot = formatTokenK(total);
3462
+ const written = stats.cacheCreationTokens > 0 ? ` \xB7 ${formatTokenK(stats.cacheCreationTokens)} written` : "";
3463
+ return `cache hit ${pct}% (${read} read / ${tot} total${written})`;
3464
+ }
3465
+ return "cache miss 0% \u2014 run `squad doctor` to diagnose";
3466
+ }
3467
+
3121
3468
  // src/commands/new-plan.ts
3122
3469
  function decideMode(opts, config2) {
3123
3470
  if (opts.api && opts.copy) {
@@ -3149,8 +3496,8 @@ async function runNewPlan(intakePath, opts) {
3149
3496
  }
3150
3497
  }
3151
3498
  function resolveFromPath(intakePath, stories, root) {
3152
- const resolved = path14.resolve(intakePath.trim());
3153
- if (!fs21.existsSync(resolved)) {
3499
+ const resolved = path15.resolve(intakePath.trim());
3500
+ if (!fs22.existsSync(resolved)) {
3154
3501
  throw new Error(
3155
3502
  `Intake not found: ${resolved}. Run \`squad list\` to see intakes, or \`squad new-story\` to create one first.`
3156
3503
  );
@@ -3162,7 +3509,7 @@ function resolveFromPath(intakePath, stories, root) {
3162
3509
  );
3163
3510
  }
3164
3511
  function relativeStoriesDir(root) {
3165
- return path14.relative(root, path14.join(root, ".squad", "stories"));
3512
+ return path15.relative(root, path15.join(root, ".squad", "stories"));
3166
3513
  }
3167
3514
  async function pickStory(stories, opts) {
3168
3515
  const candidates = opts.all ? stories : stories.filter((s) => !s.planFile);
@@ -3193,7 +3540,7 @@ async function pickStory(stories, opts) {
3193
3540
  value: s.intakePath
3194
3541
  }))
3195
3542
  });
3196
- return candidates.find((s) => path14.resolve(s.intakePath) === path14.resolve(pick));
3543
+ return candidates.find((s) => path15.resolve(s.intakePath) === path15.resolve(pick));
3197
3544
  }
3198
3545
  function formatLabel(s) {
3199
3546
  const head = `${s.feature} / ${s.id}`;
@@ -3254,6 +3601,7 @@ async function emitViaApi(story, paths, config2, _opts) {
3254
3601
  const userPrompt = composeUserPrompt({ intakeContent: readFile(story.intakePath) });
3255
3602
  const sessionSpinner = { current: null };
3256
3603
  const startedAt = Date.now();
3604
+ const cacheEnabled = planner.cache?.enabled ?? true;
3257
3605
  const result = await runPlanner({
3258
3606
  root: paths.root,
3259
3607
  provider,
@@ -3262,6 +3610,7 @@ async function emitViaApi(story, paths, config2, _opts) {
3262
3610
  systemPrompt,
3263
3611
  userPrompt,
3264
3612
  budget,
3613
+ cacheEnabled,
3265
3614
  onToolCall: (tc, bytes, total) => {
3266
3615
  const p = tc.input.path ?? "<unknown>";
3267
3616
  sessionSpinner.current?.succeed(
@@ -3304,9 +3653,18 @@ async function emitViaApi(story, paths, config2, _opts) {
3304
3653
  planBodyMarkdown: result.planText,
3305
3654
  metadataHeader: header
3306
3655
  });
3656
+ try {
3657
+ await writeLastRun(paths, {
3658
+ stats: result.stats,
3659
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
3660
+ provider: planner.provider,
3661
+ model
3662
+ });
3663
+ } catch {
3664
+ }
3307
3665
  blank();
3308
3666
  summaryBox(" plan generated ", [
3309
- { key: "file", value: path14.relative(paths.root, planFile) },
3667
+ { key: "file", value: path15.relative(paths.root, planFile) },
3310
3668
  { key: "nn", value: String(sequenceNumber).padStart(2, "0") },
3311
3669
  { key: "model", value: `${planner.provider}/${model}` },
3312
3670
  { key: "reads", value: `${snap.reads} files \xB7 ${(snap.bytes / 1024).toFixed(1)} KB` },
@@ -3314,6 +3672,7 @@ async function emitViaApi(story, paths, config2, _opts) {
3314
3672
  key: "tokens",
3315
3673
  value: `${snap.usage.inputTokens} in \xB7 ${snap.usage.outputTokens} out${snap.usage.costUsd !== void 0 ? ` \u2248 $${snap.usage.costUsd.toFixed(2)}` : ""}`
3316
3674
  },
3675
+ { key: "cache", value: formatPlannerCacheLine({ cacheEnabled, stats: result.stats }) },
3317
3676
  { key: "time", value: `${Math.round(elapsedMs / 1e3)}s` },
3318
3677
  { key: "action", value: overwrote ? "overwrote existing plan" : "new plan" }
3319
3678
  ]);
@@ -3323,9 +3682,9 @@ async function emitViaApi(story, paths, config2, _opts) {
3323
3682
  warning("Planner did not reach end_turn. Inspect the plan carefully.");
3324
3683
  }
3325
3684
  blank();
3326
- info(`next \u2192 open a new agent chat and attach only ${path14.basename(planFile)}`);
3685
+ info(`next \u2192 open a new agent chat and attach only ${path15.basename(planFile)}`);
3327
3686
  }
3328
- async function emitCopyPrompt(story, _paths, config2, clipboard) {
3687
+ async function emitCopyPrompt(story, paths, config2, clipboard) {
3329
3688
  const intakeContent = readFile(story.intakePath);
3330
3689
  const metaPromptTemplate = readBundledPrompt("generate-plan.md");
3331
3690
  const composed = render(metaPromptTemplate, {
@@ -3334,13 +3693,44 @@ async function emitCopyPrompt(story, _paths, config2, clipboard) {
3334
3693
  trackerType: config2.tracker.type,
3335
3694
  intakeContent
3336
3695
  });
3696
+ const promptFile = path15.join(paths.squadDir, ".last-copy-prompt.md");
3697
+ fs22.writeFileSync(promptFile, composed, "utf8");
3698
+ const relPromptFile = path15.relative(paths.root, promptFile);
3699
+ const bytes = Buffer.byteLength(composed, "utf8");
3700
+ const estTokens = Math.round(bytes / 4);
3701
+ const clipResult = clipboard ? await copyToClipboard(composed) : { ok: false, reason: "clipboard disabled (--no-clipboard)" };
3702
+ blank();
3703
+ divider("planner prompt ready");
3704
+ kv("mode", "copy-paste", 10);
3705
+ kv("story", `${story.feature} / ${story.id}`, 10);
3706
+ kv("prompt", relPromptFile, 10);
3707
+ kv("size", `${formatKB(bytes)} \xB7 ~${formatCount(estTokens)} tokens (est)`, 10);
3708
+ if (clipResult.ok) {
3709
+ kv("clipboard", `\u2713 copied via ${clipResult.tool}`, 10);
3710
+ } else {
3711
+ kv("clipboard", `! ${clipResult.reason}`, 10);
3712
+ }
3337
3713
  printModelBanner(config2);
3338
- process.stdout.write(composed);
3339
- if (!composed.endsWith("\n")) process.stdout.write("\n");
3340
- if (clipboard) {
3341
- const ok = await copyToClipboard(composed);
3342
- if (ok) info("copied to clipboard");
3714
+ blank();
3715
+ step("Next:");
3716
+ info("1) Open your agent chat (Cursor / Claude Code / Copilot / Gemini).");
3717
+ info("2) Switch to a strong plan model (Opus 4.x / GPT-5.3 Codex thinking / Gemini deep-think).");
3718
+ if (clipResult.ok) {
3719
+ info("3) Paste from clipboard and let the agent work. It will read files and write the plan itself.");
3720
+ } else {
3721
+ info(`3) Open ${relPromptFile}, paste its contents into the agent chat, and let it work.`);
3343
3722
  }
3723
+ info(
3724
+ `4) The agent writes the plan to .squad/plans/${story.feature}/<nn>-story-${story.id}.md. Review it, then open a fresh chat to implement (attach only the plan file).`
3725
+ );
3726
+ }
3727
+ function formatKB(bytes) {
3728
+ if (bytes < 1024) return `${bytes} B`;
3729
+ return `${(bytes / 1024).toFixed(1)} KB`;
3730
+ }
3731
+ function formatCount(n) {
3732
+ if (n < 1e3) return String(n);
3733
+ return `${(n / 1e3).toFixed(1)}k`;
3344
3734
  }
3345
3735
  function printModelBanner(config2) {
3346
3736
  if (!isInteractive()) return;
@@ -3363,7 +3753,7 @@ function printModelBanner(config2) {
3363
3753
  }
3364
3754
 
3365
3755
  // src/commands/status.ts
3366
- import fs22 from "fs";
3756
+ import fs23 from "fs";
3367
3757
  var kw = 28;
3368
3758
  function probeTrackerCredential(config2, overlay) {
3369
3759
  const { client, error: error2 } = clientFor(config2, overlay);
@@ -3389,7 +3779,7 @@ async function runStatus() {
3389
3779
  const scan = scanPlans(paths.plansDir);
3390
3780
  const storyCount = listStories(paths).length;
3391
3781
  const planCount = scan.usedNumbers.length;
3392
- const secrets = fs22.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
3782
+ const secrets = fs23.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
3393
3783
  const overlay = overlayTrackerEnv(secrets);
3394
3784
  banner();
3395
3785
  step("squad-kit status");
@@ -3443,13 +3833,13 @@ function buildTrackerLine(config2, overlay) {
3443
3833
  }
3444
3834
 
3445
3835
  // src/commands/list.ts
3446
- import path15 from "path";
3836
+ import path16 from "path";
3447
3837
  async function runList(opts) {
3448
3838
  const root = requireSquadRoot();
3449
3839
  const paths = buildPaths(root);
3450
3840
  const records = listStories(paths, { feature: opts.feature });
3451
3841
  const rows = records.map((r) => {
3452
- const planAbs = r.planFile ? path15.join(paths.plansDir, r.feature, r.planFile) : void 0;
3842
+ const planAbs = r.planFile ? path16.join(paths.plansDir, r.feature, r.planFile) : void 0;
3453
3843
  const meta = planAbs ? readPlanMetadata(planAbs) : void 0;
3454
3844
  return {
3455
3845
  feature: r.feature,
@@ -3488,9 +3878,9 @@ function stripAnsi(s) {
3488
3878
  }
3489
3879
 
3490
3880
  // src/commands/tracker-link.ts
3491
- import fs23 from "fs";
3492
- import path16 from "path";
3493
- import { input as input5, select as select6 } from "@inquirer/prompts";
3881
+ import fs24 from "fs";
3882
+ import path17 from "path";
3883
+ import { input as input4, select as select6 } from "@inquirer/prompts";
3494
3884
  async function runTrackerLink(storyPath, trackerId, opts = {}) {
3495
3885
  const root = requireSquadRoot();
3496
3886
  const paths = buildPaths(root);
@@ -3522,7 +3912,7 @@ async function runTrackerLink(storyPath, trackerId, opts = {}) {
3522
3912
  "Usage: squad tracker link [story-path] [tracker-id]. Run `squad tracker link` without arguments in a TTY to pick from a list, or pass `--yes` to opt out of prompts."
3523
3913
  );
3524
3914
  }
3525
- resolvedTrackerId = (await input5({
3915
+ resolvedTrackerId = (await input4({
3526
3916
  message: `${config2.tracker.type} work-item id:`,
3527
3917
  validate: (v) => {
3528
3918
  const trimmed = v.trim();
@@ -3536,17 +3926,21 @@ async function runTrackerLink(storyPath, trackerId, opts = {}) {
3536
3926
  `Invalid tracker id "${resolvedTrackerId}" for tracker type "${config2.tracker.type}". Run \`squad config show\` to review the expected format, or \`squad config set tracker\` if the type is wrong.`
3537
3927
  );
3538
3928
  }
3539
- const resolved = path16.resolve(resolvedStoryPath);
3540
- const intakeFile = resolved.endsWith("intake.md") ? resolved : path16.join(resolved, "intake.md");
3541
- if (!fs23.existsSync(intakeFile)) {
3929
+ const resolved = path17.resolve(resolvedStoryPath);
3930
+ const intakeFile = resolved.endsWith("intake.md") ? resolved : path17.join(resolved, "intake.md");
3931
+ if (!fs24.existsSync(intakeFile)) {
3542
3932
  throw new Error(
3543
3933
  `intake.md not found at ${intakeFile}. Run \`squad list\` to see valid paths, or \`squad new-story\` to create a story first.`
3544
3934
  );
3545
3935
  }
3546
- const content = fs23.readFileSync(intakeFile, "utf8");
3936
+ const content = fs24.readFileSync(intakeFile, "utf8");
3547
3937
  const updated = upsertTrackerId(content, resolvedTrackerId);
3548
- fs23.writeFileSync(intakeFile, updated, "utf8");
3549
- success(`Linked tracker id ${resolvedTrackerId} \u2192 ${path16.relative(root, intakeFile)}`);
3938
+ fs24.writeFileSync(intakeFile, updated, "utf8");
3939
+ success(`Linked tracker id ${resolvedTrackerId} \u2192 ${path17.relative(root, intakeFile)}`);
3940
+ blank();
3941
+ step("Next:");
3942
+ info("1) Review the intake \u2014 the Source block at the top now carries the tracker id.");
3943
+ info("2) Run `squad new-plan --api` (or `--copy`) to generate the plan for this story.");
3550
3944
  }
3551
3945
  function upsertTrackerId(content, id) {
3552
3946
  const pattern = /(-\s*\*\*Work item id:\*\*)[^\n]*/;
@@ -3560,8 +3954,8 @@ function upsertTrackerId(content, id) {
3560
3954
  }
3561
3955
 
3562
3956
  // src/commands/doctor.ts
3563
- import fs24 from "fs";
3564
- import path17 from "path";
3957
+ import fs25 from "fs";
3958
+ import path18 from "path";
3565
3959
  async function gatherContext(paths) {
3566
3960
  const ctx = { hasLegacyPromptsDir: false };
3567
3961
  try {
@@ -3570,29 +3964,29 @@ async function gatherContext(paths) {
3570
3964
  ctx.configError = err;
3571
3965
  }
3572
3966
  try {
3573
- ctx.secrets = fs24.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
3967
+ ctx.secrets = fs25.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
3574
3968
  } catch (err) {
3575
3969
  ctx.secretsError = err;
3576
3970
  }
3577
- ctx.hasLegacyPromptsDir = fs24.existsSync(paths.promptsDir);
3971
+ ctx.hasLegacyPromptsDir = fs25.existsSync(paths.promptsDir);
3578
3972
  return ctx;
3579
3973
  }
3580
3974
  function gitignoreHasManagedBlock(repoRoot) {
3581
- const gitignore = path17.join(repoRoot, ".gitignore");
3582
- return fs24.existsSync(gitignore) && fs24.readFileSync(gitignore, "utf8").includes(".squad/secrets.yaml");
3975
+ const gitignore = path18.join(repoRoot, ".gitignore");
3976
+ return fs25.existsSync(gitignore) && fs25.readFileSync(gitignore, "utf8").includes(".squad/secrets.yaml");
3583
3977
  }
3584
3978
  function gitignoreHasTrashPattern(repoRoot) {
3585
- const gitignore = path17.join(repoRoot, ".gitignore");
3586
- return fs24.existsSync(gitignore) && fs24.readFileSync(gitignore, "utf8").includes(SQUAD_TRASH_PATTERN);
3979
+ const gitignore = path18.join(repoRoot, ".gitignore");
3980
+ return fs25.existsSync(gitignore) && fs25.readFileSync(gitignore, "utf8").includes(SQUAD_TRASH_PATTERN);
3587
3981
  }
3588
3982
  async function checkDirStructure(paths, _ctx, fix) {
3589
- const need = [paths.squadDir, paths.storiesDir, paths.plansDir].filter((p) => !fs24.existsSync(p));
3983
+ const need = [paths.squadDir, paths.storiesDir, paths.plansDir].filter((p) => !fs25.existsSync(p));
3590
3984
  if (need.length === 0) {
3591
3985
  return { id: "dirs", name: ".squad/ directory structure", status: "ok" };
3592
3986
  }
3593
3987
  if (fix) {
3594
3988
  for (const p of need) {
3595
- fs24.mkdirSync(p, { recursive: true });
3989
+ fs25.mkdirSync(p, { recursive: true });
3596
3990
  }
3597
3991
  return { id: "dirs", name: ".squad/ directory structure", status: "ok", detail: "repaired" };
3598
3992
  }
@@ -3600,7 +3994,7 @@ async function checkDirStructure(paths, _ctx, fix) {
3600
3994
  id: "dirs",
3601
3995
  name: ".squad/ directory structure",
3602
3996
  status: "warn",
3603
- detail: `missing: ${need.map((p) => path17.relative(paths.root, p)).join(", ")}`,
3997
+ detail: `missing: ${need.map((p) => path18.relative(paths.root, p)).join(", ")}`,
3604
3998
  fixable: true,
3605
3999
  fixHint: "squad doctor --fix"
3606
4000
  };
@@ -3655,15 +4049,15 @@ async function checkSecretsPermissions(paths, _ctx, fix) {
3655
4049
  if (process.platform === "win32") {
3656
4050
  return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "skip", detail: "Windows" };
3657
4051
  }
3658
- if (!fs24.existsSync(paths.secretsFile)) {
4052
+ if (!fs25.existsSync(paths.secretsFile)) {
3659
4053
  return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok", detail: "not present" };
3660
4054
  }
3661
- const mode = fs24.statSync(paths.secretsFile).mode & 511;
4055
+ const mode = fs25.statSync(paths.secretsFile).mode & 511;
3662
4056
  if (mode === 384) {
3663
4057
  return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok" };
3664
4058
  }
3665
4059
  if (fix) {
3666
- fs24.chmodSync(paths.secretsFile, 384);
4060
+ fs25.chmodSync(paths.secretsFile, 384);
3667
4061
  return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok", detail: "repaired" };
3668
4062
  }
3669
4063
  return {
@@ -3684,7 +4078,7 @@ async function checkSecretsParseable(_paths, ctx) {
3684
4078
  detail: ctx.secretsError.message
3685
4079
  };
3686
4080
  }
3687
- if (!fs24.existsSync(_paths.secretsFile)) {
4081
+ if (!fs25.existsSync(_paths.secretsFile)) {
3688
4082
  return { id: "secrets-yaml", name: ".squad/secrets.yaml parseable", status: "ok", detail: "not present" };
3689
4083
  }
3690
4084
  return { id: "secrets-yaml", name: ".squad/secrets.yaml parseable", status: "ok" };
@@ -3849,6 +4243,19 @@ async function checkPlannerTierAwareness(_paths, ctx) {
3849
4243
  if (!/opus/i.test(planModel)) {
3850
4244
  return { id, name, status: "ok", detail: `${planModel} is comfortably under Tier 1` };
3851
4245
  }
4246
+ const cacheOn = ctx.config.planner?.cache?.enabled !== false;
4247
+ if (cacheOn) {
4248
+ return {
4249
+ id,
4250
+ name,
4251
+ status: "warn",
4252
+ detail: "Anthropic Tier 1 with Opus \u2014 tight but viable with prompt caching on",
4253
+ fixHint: [
4254
+ "Caching saves ~70% on billed input tokens. Keep `planner.cache.enabled: true` in config.",
4255
+ "If you still hit 429s on long runs, reduce `planner.budget.maxContextBytes` or use Haiku for plan phase."
4256
+ ].join("\n")
4257
+ };
4258
+ }
3852
4259
  return {
3853
4260
  id,
3854
4261
  name,
@@ -3857,6 +4264,66 @@ async function checkPlannerTierAwareness(_paths, ctx) {
3857
4264
  fixHint: "Run `squad config set planner` and pick a Sonnet or Haiku id for planner.modelOverride.anthropic, or upgrade tier at https://console.anthropic.com/settings/limits. squad-kit will auto-retry a 429 once (waiting up to 90s), but repeated throttling means the plan model is simply too big for your quota."
3858
4265
  };
3859
4266
  }
4267
+ async function checkPlannerCache(paths, ctx) {
4268
+ const id = "planner-cache";
4269
+ const name = "planner cache effectiveness";
4270
+ const config2 = ctx.config;
4271
+ const cacheCfg = config2?.planner?.cache;
4272
+ if (config2?.planner?.enabled !== true) {
4273
+ return { id, name, status: "skip", detail: "planner disabled \u2014 cache check not applicable" };
4274
+ }
4275
+ if (cacheCfg?.enabled === false) {
4276
+ return {
4277
+ id,
4278
+ name,
4279
+ status: "warn",
4280
+ detail: "prompt caching is disabled in .squad/config.yaml",
4281
+ fixHint: "Cache saves ~70% on billed tokens. Re-enable with `squad config set planner`."
4282
+ };
4283
+ }
4284
+ const lastRun = await readLastRun(paths);
4285
+ if (!lastRun) {
4286
+ return {
4287
+ id,
4288
+ name,
4289
+ status: "skip",
4290
+ detail: "no planner runs logged yet",
4291
+ fixHint: "Run `squad new-plan --api` once, then re-run `squad doctor` to see cache telemetry."
4292
+ };
4293
+ }
4294
+ const { stats } = lastRun;
4295
+ const hitPct = Math.round(stats.cacheHitRatio * 100);
4296
+ if (stats.cacheReadTokens === 0 && stats.turns > 1) {
4297
+ return {
4298
+ id,
4299
+ name,
4300
+ status: "fail",
4301
+ detail: `caching configured but last run saw 0% hits across ${stats.turns} turns`,
4302
+ fixHint: [
4303
+ "Possible causes:",
4304
+ " \u2022 Planner provider is not Anthropic and the prefix is below 1024 tokens (OpenAI / Google need larger prefixes).",
4305
+ " \u2022 System prompt is mutating between turns \u2014 check recent changes.",
4306
+ " \u2022 Using an older model that doesn't support caching.",
4307
+ `Provider: ${lastRun.provider}/${lastRun.model}. Run \`NODE_ENV=development squad new-plan --api\` to surface prefix-mismatch warnings.`
4308
+ ].join("\n")
4309
+ };
4310
+ }
4311
+ if (hitPct < 30 && stats.turns > 3) {
4312
+ return {
4313
+ id,
4314
+ name,
4315
+ status: "warn",
4316
+ detail: `low cache hit rate: ${hitPct}% (last run, ${stats.turns} turns)`,
4317
+ fixHint: "Expected \u226560% for Anthropic, \u226540% for OpenAI, \u226550% for Google after 3+ turns. Prefix may be unstable."
4318
+ };
4319
+ }
4320
+ return {
4321
+ id,
4322
+ name,
4323
+ status: "ok",
4324
+ detail: `caching active \u2014 last run ${hitPct}% hit (${formatTokenK(stats.cacheReadTokens)} read, ${lastRun.provider}/${lastRun.model})`
4325
+ };
4326
+ }
3860
4327
  async function checkTrackerConfig(_paths, ctx) {
3861
4328
  if (!ctx.config) {
3862
4329
  return { id: "tracker-config", name: "tracker configuration", status: "skip", detail: "config unavailable" };
@@ -3979,6 +4446,7 @@ async function runAllChecks(paths, ctx, fix) {
3979
4446
  await add("planner credential resolves", () => checkPlannerCredential(paths, ctx));
3980
4447
  await add("planner model resolves at provider", () => checkPlannerModel(paths, ctx));
3981
4448
  await add("planner tier vs. model", () => checkPlannerTierAwareness(paths, ctx));
4449
+ await add("planner cache effectiveness", () => checkPlannerCache(paths, ctx));
3982
4450
  await add("tracker configuration", () => checkTrackerConfig(paths, ctx));
3983
4451
  await add("tracker credential resolves", () => checkTrackerCredential(paths, ctx));
3984
4452
  await add("tracker connectivity", () => checkTrackerConnectivity(paths, ctx));
@@ -4084,23 +4552,23 @@ async function runDoctor(opts) {
4084
4552
  }
4085
4553
 
4086
4554
  // src/commands/migrate.ts
4087
- import fs25 from "fs";
4088
- import path18 from "path";
4555
+ import fs26 from "fs";
4556
+ import path19 from "path";
4089
4557
  import { confirm as confirm7 } from "@inquirer/prompts";
4090
4558
  var LEGACY_PROMPTS_MIGRATION = {
4091
4559
  id: "legacy-prompts",
4092
4560
  name: "Remove legacy .squad/prompts/",
4093
- isApplied: (paths) => !fs25.existsSync(paths.promptsDir),
4094
- describe: (paths) => `delete ${path18.relative(paths.root, paths.promptsDir)} (prompts are now bundled in squad-kit)`,
4561
+ isApplied: (paths) => !fs26.existsSync(paths.promptsDir),
4562
+ describe: (paths) => `delete ${path19.relative(paths.root, paths.promptsDir)} (prompts are now bundled in squad-kit)`,
4095
4563
  apply: (paths) => {
4096
4564
  const target = paths.promptsDir;
4097
- if (!target.includes(path18.sep + ".squad" + path18.sep + "prompts")) {
4565
+ if (!target.includes(path19.sep + ".squad" + path19.sep + "prompts")) {
4098
4566
  throw new Error(
4099
4567
  `Refusing to delete suspicious path: ${target}. This is a safety check \u2014 run \`squad doctor\` if .squad/ paths look wrong.`
4100
4568
  );
4101
4569
  }
4102
- if (fs25.existsSync(target)) {
4103
- fs25.rmSync(target, { recursive: true, force: true });
4570
+ if (fs26.existsSync(target)) {
4571
+ fs26.rmSync(target, { recursive: true, force: true });
4104
4572
  }
4105
4573
  }
4106
4574
  };
@@ -4108,8 +4576,8 @@ var GITIGNORE_MIGRATION = {
4108
4576
  id: "gitignore",
4109
4577
  name: "Ensure .gitignore managed block",
4110
4578
  isApplied: (paths) => {
4111
- const f = path18.join(paths.root, ".gitignore");
4112
- return fs25.existsSync(f) && fs25.readFileSync(f, "utf8").includes(".squad/secrets.yaml");
4579
+ const f = path19.join(paths.root, ".gitignore");
4580
+ return fs26.existsSync(f) && fs26.readFileSync(f, "utf8").includes(".squad/secrets.yaml");
4113
4581
  },
4114
4582
  describe: () => "add squad-kit managed block to .gitignore",
4115
4583
  apply: (paths) => {
@@ -4121,23 +4589,23 @@ var SECRETS_PERMS_MIGRATION = {
4121
4589
  name: "Tighten .squad/secrets.yaml permissions",
4122
4590
  isApplied: (paths) => {
4123
4591
  if (process.platform === "win32") return true;
4124
- if (!fs25.existsSync(paths.secretsFile)) return true;
4125
- const st = fs25.statSync(paths.secretsFile);
4592
+ if (!fs26.existsSync(paths.secretsFile)) return true;
4593
+ const st = fs26.statSync(paths.secretsFile);
4126
4594
  return (st.mode & 511) === 384;
4127
4595
  },
4128
- describe: (paths) => `chmod 0600 ${path18.relative(paths.root, paths.secretsFile)}`,
4596
+ describe: (paths) => `chmod 0600 ${path19.relative(paths.root, paths.secretsFile)}`,
4129
4597
  apply: (paths) => {
4130
4598
  if (process.platform === "win32") return;
4131
- if (fs25.existsSync(paths.secretsFile)) fs25.chmodSync(paths.secretsFile, 384);
4599
+ if (fs26.existsSync(paths.secretsFile)) fs26.chmodSync(paths.secretsFile, 384);
4132
4600
  }
4133
4601
  };
4134
4602
  var PLANNER_BUDGET_DEFAULTS_MIGRATION = {
4135
4603
  id: "planner-budget-defaults",
4136
4604
  name: "Backfill planner.budget defaults in config.yaml",
4137
4605
  isApplied: (paths) => {
4138
- if (!fs25.existsSync(paths.configFile)) return true;
4606
+ if (!fs26.existsSync(paths.configFile)) return true;
4139
4607
  try {
4140
- const raw = fs25.readFileSync(paths.configFile, "utf8");
4608
+ const raw = fs26.readFileSync(paths.configFile, "utf8");
4141
4609
  if (!/^\s*planner:/m.test(raw)) return true;
4142
4610
  if (/^\s*budget:/m.test(raw)) return true;
4143
4611
  const cfg = loadConfig(paths.configFile);
@@ -4156,9 +4624,9 @@ var CONFIG_NORMALIZE_MIGRATION = {
4156
4624
  id: "config-normalize",
4157
4625
  name: "Normalise .squad/config.yaml formatting",
4158
4626
  isApplied: (paths) => {
4159
- if (!fs25.existsSync(paths.configFile)) return true;
4627
+ if (!fs26.existsSync(paths.configFile)) return true;
4160
4628
  try {
4161
- const raw = fs25.readFileSync(paths.configFile, "utf8");
4629
+ const raw = fs26.readFileSync(paths.configFile, "utf8");
4162
4630
  const cfg = loadConfig(paths.configFile);
4163
4631
  return raw.trim() === serializeConfig(cfg).trim();
4164
4632
  } catch {
@@ -4167,7 +4635,7 @@ var CONFIG_NORMALIZE_MIGRATION = {
4167
4635
  },
4168
4636
  describe: () => "rewrite config.yaml with canonical YAML formatting (drops comments)",
4169
4637
  apply: (paths) => {
4170
- if (!fs25.existsSync(paths.configFile)) return;
4638
+ if (!fs26.existsSync(paths.configFile)) return;
4171
4639
  const cfg = loadConfig(paths.configFile);
4172
4640
  saveConfig(paths.configFile, cfg);
4173
4641
  }
@@ -4235,7 +4703,11 @@ async function runMigrate(opts) {
4235
4703
  }
4236
4704
  info("");
4237
4705
  success(`Applied ${applied}/${pending.length} migration(s).`);
4238
- info("Run `squad doctor` to verify.");
4706
+ blank();
4707
+ step("Next:");
4708
+ info("1) Run `squad doctor` to verify every check is green.");
4709
+ info("2) If you were upgrading from 0.1.x, review .squad/config.yaml \u2014 new defaults (planner, cache) may have been added.");
4710
+ info("3) Continue your usual workflow: `squad new-story` \u2192 fill intake \u2192 `squad new-plan`.");
4239
4711
  }
4240
4712
 
4241
4713
  // src/commands/upgrade.ts
@@ -4244,8 +4716,8 @@ import process3 from "process";
4244
4716
  import { confirm as confirm8 } from "@inquirer/prompts";
4245
4717
 
4246
4718
  // src/core/package-info.ts
4247
- import fs26 from "fs";
4248
- import path19 from "path";
4719
+ import fs27 from "fs";
4720
+ import path20 from "path";
4249
4721
  function changelogBlobUrl(repositoryUrl, version) {
4250
4722
  let base = repositoryUrl;
4251
4723
  if (!base) base = "https://github.com/AzmSquad/squad-kit";
@@ -4256,9 +4728,9 @@ function changelogBlobUrl(repositoryUrl, version) {
4256
4728
  }
4257
4729
  function readInstalledPackage() {
4258
4730
  const root = packageRoot();
4259
- const pkgFile = path19.join(root, "package.json");
4260
- const pkg = JSON.parse(fs26.readFileSync(pkgFile, "utf8"));
4261
- const isDevInstall = fs26.existsSync(path19.join(root, "src"));
4731
+ const pkgFile = path20.join(root, "package.json");
4732
+ const pkg = JSON.parse(fs27.readFileSync(pkgFile, "utf8"));
4733
+ const isDevInstall = fs27.existsSync(path20.join(root, "src"));
4262
4734
  return {
4263
4735
  name: pkg.name,
4264
4736
  version: pkg.version,
@@ -4298,7 +4770,7 @@ function isNewer(a, b) {
4298
4770
  }
4299
4771
 
4300
4772
  // src/core/package-manager.ts
4301
- import path20 from "path";
4773
+ import path21 from "path";
4302
4774
  import process2 from "process";
4303
4775
  function detectPackageManager(installRoot) {
4304
4776
  const ua = process2.env.npm_config_user_agent;
@@ -4309,11 +4781,11 @@ function detectPackageManager(installRoot) {
4309
4781
  if (ua.startsWith("npm/")) return { pm: "npm", reason: "npm_config_user_agent" };
4310
4782
  }
4311
4783
  const root = installRoot.toLowerCase();
4312
- if (root.includes(`${path20.sep}pnpm${path20.sep}`) || root.includes(`${path20.sep}.pnpm${path20.sep}`))
4784
+ if (root.includes(`${path21.sep}pnpm${path21.sep}`) || root.includes(`${path21.sep}.pnpm${path21.sep}`))
4313
4785
  return { pm: "pnpm", reason: `install path contains pnpm` };
4314
- if (root.includes(`${path20.sep}.yarn${path20.sep}`) || root.includes(`${path20.sep}yarn${path20.sep}`))
4786
+ if (root.includes(`${path21.sep}.yarn${path21.sep}`) || root.includes(`${path21.sep}yarn${path21.sep}`))
4315
4787
  return { pm: "yarn", reason: `install path contains yarn` };
4316
- if (root.includes(`${path20.sep}.bun${path20.sep}`) || root.includes(`${path20.sep}bun${path20.sep}`))
4788
+ if (root.includes(`${path21.sep}.bun${path21.sep}`) || root.includes(`${path21.sep}bun${path21.sep}`))
4317
4789
  return { pm: "bun", reason: `install path contains bun` };
4318
4790
  return { pm: "npm", reason: "default fallback" };
4319
4791
  }
@@ -4414,8 +4886,8 @@ function spawnInherit(cmd, args) {
4414
4886
  }
4415
4887
 
4416
4888
  // src/commands/rm/shared.ts
4417
- import fs27 from "fs";
4418
- import path21 from "path";
4889
+ import fs28 from "fs";
4890
+ import path22 from "path";
4419
4891
  import { select as select7 } from "@inquirer/prompts";
4420
4892
  var STORY_PLAN = /^(\d{2,})-story-.+\.md$/;
4421
4893
  function canPromptForRm(opts) {
@@ -4423,9 +4895,9 @@ function canPromptForRm(opts) {
4423
4895
  return isInteractive() && Boolean(process.stdin.isTTY);
4424
4896
  }
4425
4897
  function removeOverviewRow(featurePlanDir, storyId) {
4426
- const overview = path21.join(featurePlanDir, "00-overview.md");
4427
- if (!fs27.existsSync(overview)) return;
4428
- const src = fs27.readFileSync(overview, "utf8");
4898
+ const overview = path22.join(featurePlanDir, "00-overview.md");
4899
+ if (!fs28.existsSync(overview)) return;
4900
+ const src = fs28.readFileSync(overview, "utf8");
4429
4901
  const lines = src.split("\n");
4430
4902
  const filtered = lines.filter((line2) => {
4431
4903
  if (!line2.startsWith("|")) return true;
@@ -4433,12 +4905,12 @@ function removeOverviewRow(featurePlanDir, storyId) {
4433
4905
  if (/^\|\s*-+/.test(line2)) return true;
4434
4906
  return !line2.includes(storyId);
4435
4907
  });
4436
- fs27.writeFileSync(overview, filtered.join("\n"), "utf8");
4908
+ fs28.writeFileSync(overview, filtered.join("\n"), "utf8");
4437
4909
  }
4438
4910
  function removeOverviewRowForPlanFile(featurePlanDir, planBasename) {
4439
- const overview = path21.join(featurePlanDir, "00-overview.md");
4440
- if (!fs27.existsSync(overview)) return;
4441
- const src = fs27.readFileSync(overview, "utf8");
4911
+ const overview = path22.join(featurePlanDir, "00-overview.md");
4912
+ if (!fs28.existsSync(overview)) return;
4913
+ const src = fs28.readFileSync(overview, "utf8");
4442
4914
  const token = `\`${planBasename}\``;
4443
4915
  const lines = src.split("\n");
4444
4916
  const filtered = lines.filter((line2) => {
@@ -4447,25 +4919,25 @@ function removeOverviewRowForPlanFile(featurePlanDir, planBasename) {
4447
4919
  if (/^\|\s*-+/.test(line2)) return true;
4448
4920
  return !line2.includes(token);
4449
4921
  });
4450
- fs27.writeFileSync(overview, filtered.join("\n"), "utf8");
4922
+ fs28.writeFileSync(overview, filtered.join("\n"), "utf8");
4451
4923
  }
4452
4924
  function trashOrDelete(absPaths, trashRoot, useTrash) {
4453
4925
  if (!useTrash) {
4454
- for (const p of absPaths) fs27.rmSync(p, { recursive: true, force: true });
4926
+ for (const p of absPaths) fs28.rmSync(p, { recursive: true, force: true });
4455
4927
  return { removed: absPaths };
4456
4928
  }
4457
4929
  const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
4458
- const bucket = path21.join(trashRoot, ts);
4459
- fs27.mkdirSync(bucket, { recursive: true });
4930
+ const bucket = path22.join(trashRoot, ts);
4931
+ fs28.mkdirSync(bucket, { recursive: true });
4460
4932
  for (const src of absPaths) {
4461
- const rel = path21.basename(src);
4462
- let dst = path21.join(bucket, rel);
4933
+ const rel = path22.basename(src);
4934
+ let dst = path22.join(bucket, rel);
4463
4935
  let n = 0;
4464
- while (fs27.existsSync(dst)) {
4936
+ while (fs28.existsSync(dst)) {
4465
4937
  n += 1;
4466
- dst = path21.join(bucket, `${n}_${rel}`);
4938
+ dst = path22.join(bucket, `${n}_${rel}`);
4467
4939
  }
4468
- fs27.renameSync(src, dst);
4940
+ fs28.renameSync(src, dst);
4469
4941
  }
4470
4942
  return { removed: absPaths, trashed: bucket };
4471
4943
  }
@@ -4476,21 +4948,21 @@ function renderPreview(kind, targets, opts) {
4476
4948
  kv(t.kind, t.label, 6);
4477
4949
  }
4478
4950
  if (opts.trash) {
4479
- info(`Items will move to ${path21.relative(process.cwd(), opts.trashRoot)}/<timestamp>/`);
4951
+ info(`Items will move to ${path22.relative(process.cwd(), opts.trashRoot)}/<timestamp>/`);
4480
4952
  } else if (!opts.dryRun) {
4481
4953
  warning("This is a permanent delete. Pass --trash to move to .squad/.trash/ instead.");
4482
4954
  }
4483
4955
  }
4484
4956
  function listAllPlanListEntries(paths, featureFilter) {
4485
- if (!fs27.existsSync(paths.plansDir)) return [];
4957
+ if (!fs28.existsSync(paths.plansDir)) return [];
4486
4958
  const out = [];
4487
- for (const ent of fs27.readdirSync(paths.plansDir, { withFileTypes: true })) {
4959
+ for (const ent of fs28.readdirSync(paths.plansDir, { withFileTypes: true })) {
4488
4960
  if (!ent.isDirectory()) continue;
4489
4961
  if (featureFilter && ent.name !== featureFilter) continue;
4490
- const featureDir = path21.join(paths.plansDir, ent.name);
4491
- for (const name of fs27.readdirSync(featureDir)) {
4962
+ const featureDir = path22.join(paths.plansDir, ent.name);
4963
+ for (const name of fs28.readdirSync(featureDir)) {
4492
4964
  if (!STORY_PLAN.test(name)) continue;
4493
- out.push({ feature: ent.name, file: name, abs: path21.join(featureDir, name) });
4965
+ out.push({ feature: ent.name, file: name, abs: path22.join(featureDir, name) });
4494
4966
  }
4495
4967
  }
4496
4968
  return out.sort((a, b) => (a.feature + a.file).localeCompare(b.feature + b.file));
@@ -4541,34 +5013,34 @@ function resolveStoryFromArg(arg, stories, root) {
4541
5013
  if (s.id === t) return s;
4542
5014
  if (`${s.feature}/${s.id}` === t) return s;
4543
5015
  }
4544
- const abs = path21.isAbsolute(t) ? t : path21.resolve(root, t);
4545
- const withIntake = t.endsWith("intake.md") ? abs : path21.join(abs, "intake.md");
5016
+ const abs = path22.isAbsolute(t) ? t : path22.resolve(root, t);
5017
+ const withIntake = t.endsWith("intake.md") ? abs : path22.join(abs, "intake.md");
4546
5018
  return findStoryByIntake(stories, withIntake) ?? findStoryByIntake(stories, abs);
4547
5019
  }
4548
5020
  function isSequenceToken(s) {
4549
5021
  return /^\d{1,4}$/.test(s) && s.length > 0;
4550
5022
  }
4551
5023
  function tryParsePlanFilePath(absPath, paths, featureFilter) {
4552
- if (!fs27.existsSync(absPath) || !STORY_PLAN.test(path21.basename(absPath))) return void 0;
4553
- const planRoot = fs27.realpathSync(path21.resolve(paths.plansDir));
4554
- const fileReal = fs27.realpathSync(path21.resolve(absPath));
4555
- const rel = path21.relative(planRoot, fileReal);
4556
- if (rel.startsWith("..") || path21.isAbsolute(rel)) return void 0;
4557
- const segs = rel.split(path21.sep);
5024
+ if (!fs28.existsSync(absPath) || !STORY_PLAN.test(path22.basename(absPath))) return void 0;
5025
+ const planRoot = fs28.realpathSync(path22.resolve(paths.plansDir));
5026
+ const fileReal = fs28.realpathSync(path22.resolve(absPath));
5027
+ const rel = path22.relative(planRoot, fileReal);
5028
+ if (rel.startsWith("..") || path22.isAbsolute(rel)) return void 0;
5029
+ const segs = rel.split(path22.sep);
4558
5030
  if (segs.length < 2) return void 0;
4559
5031
  const feature = segs[0];
4560
- const file = segs.slice(1).join(path21.sep);
5032
+ const file = segs.slice(1).join(path22.sep);
4561
5033
  if (featureFilter && feature !== featureFilter) return void 0;
4562
- return { feature, file, abs: path21.resolve(absPath) };
5034
+ return { feature, file, abs: path22.resolve(absPath) };
4563
5035
  }
4564
5036
  function resolvePlanFromArg(arg, paths, featureFilter) {
4565
5037
  const t = arg.trim();
4566
5038
  if (!t) return void 0;
4567
- const asPath = path21.isAbsolute(t) ? t : path21.resolve(process.cwd(), t);
5039
+ const asPath = path22.isAbsolute(t) ? t : path22.resolve(process.cwd(), t);
4568
5040
  const a = tryParsePlanFilePath(asPath, paths, featureFilter);
4569
5041
  if (a) return a;
4570
- if (!path21.isAbsolute(t)) {
4571
- const under = tryParsePlanFilePath(path21.join(paths.plansDir, t), paths, featureFilter);
5042
+ if (!path22.isAbsolute(t)) {
5043
+ const under = tryParsePlanFilePath(path22.join(paths.plansDir, t), paths, featureFilter);
4572
5044
  if (under) return under;
4573
5045
  }
4574
5046
  if (isSequenceToken(t)) {
@@ -4598,24 +5070,24 @@ function countOverviewDataRows(overviewContent) {
4598
5070
  return n;
4599
5071
  }
4600
5072
  function featureStoriesDirEmpty(paths, feature) {
4601
- const d = path21.join(paths.storiesDir, feature);
4602
- if (!fs27.existsSync(d)) return true;
4603
- return fs27.readdirSync(d).length === 0;
5073
+ const d = path22.join(paths.storiesDir, feature);
5074
+ if (!fs28.existsSync(d)) return true;
5075
+ return fs28.readdirSync(d).length === 0;
4604
5076
  }
4605
5077
 
4606
5078
  // src/commands/rm/story.ts
4607
- import fs29 from "fs";
4608
- import path23 from "path";
5079
+ import fs30 from "fs";
5080
+ import path24 from "path";
4609
5081
  import { confirm as confirm10 } from "@inquirer/prompts";
4610
5082
 
4611
5083
  // src/commands/rm/feature.ts
4612
- import fs28 from "fs";
4613
- import path22 from "path";
5084
+ import fs29 from "fs";
5085
+ import path23 from "path";
4614
5086
  import { confirm as confirm9 } from "@inquirer/prompts";
4615
5087
  function countStoryDirs(storiesDir, feature) {
4616
- const d = path22.join(storiesDir, feature);
4617
- if (!fs28.existsSync(d)) return 0;
4618
- return fs28.readdirSync(d, { withFileTypes: true }).filter((x) => x.isDirectory()).length;
5088
+ const d = path23.join(storiesDir, feature);
5089
+ if (!fs29.existsSync(d)) return 0;
5090
+ return fs29.readdirSync(d, { withFileTypes: true }).filter((x) => x.isDirectory()).length;
4619
5091
  }
4620
5092
  async function runRmFeature(featureSlug, opts = {}) {
4621
5093
  const root = requireSquadRoot();
@@ -4649,17 +5121,17 @@ async function runRmFeature(featureSlug, opts = {}) {
4649
5121
  feature = await pickFeatureInteractive(paths);
4650
5122
  }
4651
5123
  }
4652
- const storyDir = path22.join(paths.storiesDir, feature);
4653
- const planDir = path22.join(paths.plansDir, feature);
5124
+ const storyDir = path23.join(paths.storiesDir, feature);
5125
+ const planDir = path23.join(paths.plansDir, feature);
4654
5126
  const nStories = countStoryDirs(paths.storiesDir, feature);
4655
5127
  const nPlans = listAllPlanListEntries(paths, feature).length;
4656
- const hasOverview = fs28.existsSync(path22.join(planDir, "00-overview.md")) ? 1 : 0;
5128
+ const hasOverview = fs29.existsSync(path23.join(planDir, "00-overview.md")) ? 1 : 0;
4657
5129
  const targets = [];
4658
- if (fs28.existsSync(storyDir)) {
4659
- targets.push({ label: path22.relative(root, storyDir), absPath: storyDir, kind: "dir" });
5130
+ if (fs29.existsSync(storyDir)) {
5131
+ targets.push({ label: path23.relative(root, storyDir), absPath: storyDir, kind: "dir" });
4660
5132
  }
4661
- if (fs28.existsSync(planDir)) {
4662
- targets.push({ label: path22.relative(root, planDir), absPath: planDir, kind: "dir" });
5133
+ if (fs29.existsSync(planDir)) {
5134
+ targets.push({ label: path23.relative(root, planDir), absPath: planDir, kind: "dir" });
4663
5135
  }
4664
5136
  divider("squad rm feature");
4665
5137
  info(`Feature: ${feature}`);
@@ -4674,7 +5146,7 @@ async function runRmFeature(featureSlug, opts = {}) {
4674
5146
  kv(t.kind, t.label, 6);
4675
5147
  }
4676
5148
  if (useTrash) {
4677
- info(`Items will move to ${path22.relative(process.cwd(), paths.trashDir)}/<timestamp>/`);
5149
+ info(`Items will move to ${path23.relative(process.cwd(), paths.trashDir)}/<timestamp>/`);
4678
5150
  } else if (!dryRun) {
4679
5151
  warning("This is a permanent delete. Pass --trash to move to .squad/.trash/ instead.");
4680
5152
  }
@@ -4707,7 +5179,7 @@ async function runRmFeature(featureSlug, opts = {}) {
4707
5179
  useTrash
4708
5180
  );
4709
5181
  if (result.trashed) {
4710
- success(`Feature removed. Items moved to ${path22.relative(root, result.trashed)}.`);
5182
+ success(`Feature removed. Items moved to ${path23.relative(root, result.trashed)}.`);
4711
5183
  } else {
4712
5184
  success("Feature removed (stories and plans directories).");
4713
5185
  }
@@ -4748,11 +5220,11 @@ async function runRmStory(storyPathOrId, opts = {}) {
4748
5220
  }
4749
5221
  }
4750
5222
  const targets = [
4751
- { label: path23.relative(root, story.storyDir), absPath: story.storyDir, kind: "dir" }
5223
+ { label: path24.relative(root, story.storyDir), absPath: story.storyDir, kind: "dir" }
4752
5224
  ];
4753
5225
  if (story.planFile) {
4754
- const planAbs = path23.join(paths.plansDir, story.feature, story.planFile);
4755
- targets.push({ label: path23.relative(root, planAbs), absPath: planAbs, kind: "file" });
5226
+ const planAbs = path24.join(paths.plansDir, story.feature, story.planFile);
5227
+ targets.push({ label: path24.relative(root, planAbs), absPath: planAbs, kind: "file" });
4756
5228
  }
4757
5229
  renderPreview("story", targets, {
4758
5230
  dryRun,
@@ -4777,16 +5249,16 @@ async function runRmStory(storyPathOrId, opts = {}) {
4777
5249
  }
4778
5250
  const absPaths = targets.map((t) => t.absPath);
4779
5251
  const result = trashOrDelete(absPaths, paths.trashDir, useTrash);
4780
- removeOverviewRow(path23.join(paths.plansDir, story.feature), story.id);
5252
+ removeOverviewRow(path24.join(paths.plansDir, story.feature), story.id);
4781
5253
  if (result.trashed) {
4782
- success(`Removed (moved to ${path23.relative(root, result.trashed)}).`);
5254
+ success(`Removed (moved to ${path24.relative(root, result.trashed)}).`);
4783
5255
  } else {
4784
5256
  success("Removed story folder, plan file (if any), and overview row.");
4785
5257
  }
4786
- const featurePlanDir = path23.join(paths.plansDir, story.feature);
4787
- const overviewPath = path23.join(featurePlanDir, "00-overview.md");
4788
- if (fs29.existsSync(overviewPath)) {
4789
- const body = fs29.readFileSync(overviewPath, "utf8");
5258
+ const featurePlanDir = path24.join(paths.plansDir, story.feature);
5259
+ const overviewPath = path24.join(featurePlanDir, "00-overview.md");
5260
+ if (fs30.existsSync(overviewPath)) {
5261
+ const body = fs30.readFileSync(overviewPath, "utf8");
4790
5262
  const onlyShell = countOverviewDataRows(body) === 0;
4791
5263
  if (onlyShell && featureStoriesDirEmpty(paths, story.feature)) {
4792
5264
  if (canPrompt) {
@@ -4806,7 +5278,7 @@ async function runRmStory(storyPathOrId, opts = {}) {
4806
5278
  }
4807
5279
 
4808
5280
  // src/commands/rm/plan.ts
4809
- import path24 from "path";
5281
+ import path25 from "path";
4810
5282
  import { confirm as confirm11 } from "@inquirer/prompts";
4811
5283
  async function runRmPlan(planPathOrSequence, opts = {}) {
4812
5284
  const root = requireSquadRoot();
@@ -4842,7 +5314,7 @@ async function runRmPlan(planPathOrSequence, opts = {}) {
4842
5314
  }
4843
5315
  const storyRows = listStories(paths, { feature: plan.feature });
4844
5316
  const linked = storyRows.find((s) => s.planFile === plan.file);
4845
- const targets = [{ label: path24.relative(root, plan.abs), absPath: plan.abs, kind: "file" }];
5317
+ const targets = [{ label: path25.relative(root, plan.abs), absPath: plan.abs, kind: "file" }];
4846
5318
  renderPreview("plan", targets, {
4847
5319
  dryRun,
4848
5320
  trash: useTrash,
@@ -4865,14 +5337,14 @@ async function runRmPlan(planPathOrSequence, opts = {}) {
4865
5337
  }
4866
5338
  }
4867
5339
  trashOrDelete([plan.abs], paths.trashDir, useTrash);
4868
- removeOverviewRowForPlanFile(path24.join(paths.plansDir, plan.feature), plan.file);
4869
- const intakeHint = linked ? path24.relative(root, linked.intakePath) : `under .squad/stories/${plan.feature}/`;
5340
+ removeOverviewRowForPlanFile(path25.join(paths.plansDir, plan.feature), plan.file);
5341
+ const intakeHint = linked ? path25.relative(root, linked.intakePath) : `under .squad/stories/${plan.feature}/`;
4870
5342
  success(`Plan removed. Run \`squad new-plan ${intakeHint}\` to regenerate.`);
4871
5343
  }
4872
5344
 
4873
5345
  // src/cli.ts
4874
5346
  var program = new Command();
4875
- program.name("squad").description("Plan once, execute cheap. A 3-step SDD workflow CLI.").version("0.2.2");
5347
+ program.name("squad").description("Plan once, execute cheap. A 3-step SDD workflow CLI.").version("0.3.0");
4876
5348
  program.command("init").description("Bootstrap .squad/ in the current directory").option("--agents <list>", "Comma-separated agent list: claude-code,cursor,copilot,gemini").option("--tracker <type>", "Tracker type: none|github|linear|jira|azure").option(
4877
5349
  "--tracker-workspace <hostOrOrg>",
4878
5350
  "Jira host or Azure org (non-interactive default; also written to config for jira/azure)"