squad-kit 0.2.1 → 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
  }
@@ -2437,7 +2540,8 @@ function rateLimitMessage(err) {
2437
2540
  google: "https://aistudio.google.com/app/plan_information"
2438
2541
  };
2439
2542
  const headline = err.retryAfterSec ? `${err.provider} rate limit hit \u2014 provider asked us to wait ${err.retryAfterSec}s before retrying.` : `${err.provider} rate limit hit.`;
2440
- const retried = err.retryAlreadyAttempted ? "squad-kit already retried once automatically and was throttled again \u2014 your org is firmly over its per-minute quota." : "squad-kit aborted before retrying.";
2543
+ const cap = err.maxRetrySec ?? 90;
2544
+ const retried = err.retrySkippedReason === "retry_after_too_long" ? `squad-kit did not auto-retry: the provider's ${err.retryAfterSec}s wait is longer than our ${cap}s cap, so retrying would just burn another request inside the same throttle window.` : err.retryAlreadyAttempted ? "squad-kit already retried once automatically and was throttled again \u2014 your org is firmly over its per-minute quota." : "squad-kit aborted before retrying.";
2441
2545
  return [
2442
2546
  headline,
2443
2547
  retried,
@@ -2454,109 +2558,16 @@ function rateLimitMessage(err) {
2454
2558
  ].join("\n");
2455
2559
  }
2456
2560
 
2457
- // src/planner/loop.ts
2458
- var MAX_RATE_LIMIT_RETRY_SEC = 30;
2459
- var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2460
- async function runPlanner(input6) {
2461
- const maxIter = input6.maxIterations ?? 40;
2462
- const turns = [{ role: "user", text: input6.userPrompt }];
2463
- let accumulatedText = "";
2464
- let iterations = 0;
2465
- let budgetExhausted = false;
2466
- let finishedNormally = false;
2467
- while (iterations < maxIter) {
2468
- iterations += 1;
2469
- if (input6.budget.timedOut()) {
2470
- return { planText: accumulatedText, budgetExhausted, timedOut: true, finishedNormally: false, iterations };
2471
- }
2472
- if (input6.budget.overCost()) {
2473
- return { planText: accumulatedText, budgetExhausted: true, timedOut: false, finishedNormally: false, iterations };
2474
- }
2475
- const request = {
2476
- systemPrompt: input6.systemPrompt,
2477
- model: input6.model,
2478
- tools: [READ_FILE_TOOL],
2479
- turns,
2480
- apiKey: input6.apiKey,
2481
- maxOutputTokens: 8192
2482
- };
2483
- let response = await input6.provider.send(request);
2484
- if (response.usage) {
2485
- input6.budget.recordUsage(response.usage);
2486
- input6.onUsage?.(response.usage);
2487
- }
2488
- let retriedRateLimit = false;
2489
- if (response.stopReason === "error" && response.errorKind === "rate_limit") {
2490
- const waitSec = Math.min(response.retryAfterSec ?? 10, MAX_RATE_LIMIT_RETRY_SEC);
2491
- input6.onRateLimit?.(waitSec);
2492
- await (input6.sleep ?? defaultSleep)(waitSec * 1e3);
2493
- response = await input6.provider.send(request);
2494
- if (response.usage) {
2495
- input6.budget.recordUsage(response.usage);
2496
- input6.onUsage?.(response.usage);
2497
- }
2498
- retriedRateLimit = true;
2499
- }
2500
- if (response.stopReason === "error") {
2501
- throw composePlannerError(input6.provider.name, response, retriedRateLimit);
2502
- }
2503
- if (response.text) {
2504
- accumulatedText += response.text;
2505
- input6.onAssistantText?.(response.text);
2506
- }
2507
- if (response.stopReason === "max_tokens" && !response.toolCalls?.length) {
2508
- return { planText: accumulatedText, budgetExhausted, timedOut: false, finishedNormally: false, iterations };
2509
- }
2510
- if (response.stopReason === "end_turn" || !response.toolCalls?.length) {
2511
- finishedNormally = true;
2512
- return { planText: accumulatedText, budgetExhausted, timedOut: false, finishedNormally, iterations };
2513
- }
2514
- turns.push({
2515
- role: "assistant",
2516
- text: response.text,
2517
- toolCalls: response.toolCalls
2518
- });
2519
- const toolResults = [];
2520
- for (const tc of response.toolCalls) {
2521
- if (tc.name !== READ_FILE_TOOL.name) {
2522
- toolResults.push({ toolCallId: tc.id, content: `unknown tool "${tc.name}"`, isError: true });
2523
- continue;
2524
- }
2525
- const before = input6.budget.snapshot().bytes;
2526
- const result = readFileTool(input6.root, input6.budget, tc.input);
2527
- const after = input6.budget.snapshot().bytes;
2528
- input6.onToolCall?.(tc, after - before, after);
2529
- if (result.isError && (/budget/i.test(result.content) || /max file reads/i.test(result.content))) {
2530
- budgetExhausted = true;
2531
- }
2532
- toolResults.push({ toolCallId: tc.id, content: result.content, isError: result.isError });
2533
- }
2534
- turns.push({ role: "user", toolResults });
2535
- if (budgetExhausted) {
2536
- turns.push({
2537
- role: "user",
2538
- 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."
2539
- });
2540
- }
2541
- }
2542
- return { planText: accumulatedText, budgetExhausted, timedOut: false, finishedNormally: false, iterations };
2543
- }
2544
- function composePlannerError(providerName, response, retriedRateLimit) {
2545
- if (response.errorKind === "rate_limit") {
2546
- return new Error(
2547
- rateLimitMessage({
2548
- provider: providerName,
2549
- retryAfterSec: response.retryAfterSec,
2550
- rawBody: response.rawError ?? "",
2551
- retryAlreadyAttempted: retriedRateLimit
2552
- })
2553
- );
2554
- }
2555
- if (response.errorKind === "model_not_found") {
2556
- 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]);
2557
2569
  }
2558
- const base = response.rawError ?? "planner: provider error";
2559
- return new Error(`${base} Run \`squad doctor\` to diagnose, or retry \u2014 most 5xx errors are transient.`);
2570
+ return out;
2560
2571
  }
2561
2572
 
2562
2573
  // src/planner/providers/anthropic.ts
@@ -2568,14 +2579,39 @@ var anthropicProvider = {
2568
2579
  return callAnthropic(req);
2569
2580
  }
2570
2581
  };
2571
- async function callAnthropic(req) {
2572
- 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 = {
2573
2596
  model: req.model,
2574
2597
  max_tokens: req.maxOutputTokens ?? 4096,
2575
- system: req.systemPrompt,
2576
- tools: req.tools.map(toAnthropicTool),
2577
- 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)
2578
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 ?? [] };
2579
2615
  const res = await fetch(API_URL, {
2580
2616
  method: "POST",
2581
2617
  headers: {
@@ -2615,26 +2651,73 @@ async function callAnthropic(req) {
2615
2651
  stopReason: mapStop(json.stop_reason),
2616
2652
  usage: {
2617
2653
  inputTokens: json.usage?.input_tokens ?? 0,
2618
- 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
2619
2657
  }
2620
2658
  };
2621
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
+ }
2622
2691
  function toAnthropicTool(t) {
2623
- 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
+ };
2624
2697
  }
2625
2698
  function toAnthropicMessage(turn) {
2626
2699
  const blocks = [];
2627
2700
  if (turn.text) blocks.push({ type: "text", text: turn.text });
2628
- if (turn.toolCalls)
2629
- for (const tc of turn.toolCalls) blocks.push({ type: "tool_use", id: tc.id, name: tc.name, input: tc.input });
2630
- if (turn.toolResults)
2631
- 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) {
2632
2713
  blocks.push({
2633
2714
  type: "tool_result",
2634
2715
  tool_use_id: tr.toolCallId,
2635
2716
  content: tr.content,
2636
2717
  is_error: tr.isError ?? false
2637
2718
  });
2719
+ }
2720
+ }
2638
2721
  return { role: turn.role === "assistant" ? "assistant" : "user", content: blocks };
2639
2722
  }
2640
2723
  function mapStop(r) {
@@ -2651,15 +2734,23 @@ var openaiProvider = {
2651
2734
  return callOpenAI(req);
2652
2735
  }
2653
2736
  };
2654
- async function callOpenAI(req) {
2655
- const messages = [{ role: "system", content: req.systemPrompt }];
2656
- for (const turn of req.turns) messages.push(...toOpenAIMessages(turn));
2657
- const body = {
2737
+ function buildOpenAIBody(req) {
2738
+ const base = {
2658
2739
  model: req.model,
2659
- messages,
2660
- tools: req.tools.map(toOpenAITool),
2661
- max_completion_tokens: req.maxOutputTokens ?? 4096
2740
+ max_completion_tokens: req.maxOutputTokens ?? 4096,
2741
+ tools: req.tools.map(toOpenAITool)
2662
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 };
2663
2754
  const res = await fetch(API_URL2, {
2664
2755
  method: "POST",
2665
2756
  headers: {
@@ -2709,7 +2800,14 @@ async function callOpenAI(req) {
2709
2800
  };
2710
2801
  }
2711
2802
  function toOpenAITool(t) {
2712
- 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
+ };
2713
2811
  }
2714
2812
  function toOpenAIMessages(turn) {
2715
2813
  const out = [];
@@ -2720,7 +2818,10 @@ function toOpenAIMessages(turn) {
2720
2818
  tool_calls: turn.toolCalls?.map((tc) => ({
2721
2819
  id: tc.id,
2722
2820
  type: "function",
2723
- function: { name: tc.name, arguments: JSON.stringify(tc.input) }
2821
+ function: {
2822
+ name: tc.name,
2823
+ arguments: JSON.stringify(sortRecordKeys(tc.input))
2824
+ }
2724
2825
  }))
2725
2826
  });
2726
2827
  } else {
@@ -2740,7 +2841,7 @@ function mapStop2(r) {
2740
2841
  }
2741
2842
  function safeJson(raw) {
2742
2843
  try {
2743
- return JSON.parse(raw);
2844
+ return sortRecordKeys(JSON.parse(raw));
2744
2845
  } catch {
2745
2846
  return {};
2746
2847
  }
@@ -2754,14 +2855,23 @@ var googleProvider = {
2754
2855
  return callGoogle(req);
2755
2856
  }
2756
2857
  };
2757
- async function callGoogle(req) {
2758
- const url = `${API_BASE}/${encodeURIComponent(req.model)}:generateContent?key=${encodeURIComponent(req.apiKey)}`;
2759
- const body = {
2858
+ function buildGoogleBody(req) {
2859
+ const base = {
2760
2860
  system_instruction: { parts: [{ text: req.systemPrompt }] },
2761
- contents: req.turns.map(toGoogleContent),
2762
- tools: req.tools.length ? [{ functionDeclarations: req.tools.map(toGoogleTool) }] : void 0,
2763
2861
  generationConfig: { maxOutputTokens: req.maxOutputTokens ?? 4096 }
2764
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 ?? [] };
2765
2875
  const res = await fetch(url, {
2766
2876
  method: "POST",
2767
2877
  headers: { "content-type": "application/json" },
@@ -2796,7 +2906,7 @@ async function callGoogle(req) {
2796
2906
  const toolCalls = parts.filter((p) => "functionCall" in p && !!p.functionCall).map((p, i) => ({
2797
2907
  id: `gcall_${i}_${p.functionCall.name}`,
2798
2908
  name: p.functionCall.name,
2799
- input: p.functionCall.args ?? {}
2909
+ input: p.functionCall.args ? sortRecordKeys(p.functionCall.args) : {}
2800
2910
  }));
2801
2911
  return {
2802
2912
  text: textOut || void 0,
@@ -2809,14 +2919,17 @@ async function callGoogle(req) {
2809
2919
  };
2810
2920
  }
2811
2921
  function toGoogleTool(t) {
2812
- return { name: t.name, description: t.description, parameters: t.inputSchema };
2922
+ return { name: t.name, description: t.description, parameters: sortRecordKeys(t.inputSchema) };
2813
2923
  }
2814
2924
  function toGoogleContent(turn) {
2815
2925
  const role = turn.role === "assistant" ? "model" : "user";
2816
2926
  const parts = [];
2817
2927
  if (turn.text) parts.push({ text: turn.text });
2818
- if (turn.toolCalls)
2819
- 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
+ }
2820
2933
  if (turn.toolResults) {
2821
2934
  for (const tr of turn.toolResults) {
2822
2935
  parts.push({
@@ -2836,6 +2949,208 @@ function mapStop3(r) {
2836
2949
  return "tool_use";
2837
2950
  }
2838
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
+
2839
3154
  // src/planner/providers/index.ts
2840
3155
  function providerFor(name) {
2841
3156
  switch (name) {
@@ -2857,7 +3172,7 @@ var Budget = class {
2857
3172
  reads = 0;
2858
3173
  bytes = 0;
2859
3174
  startedAt = Date.now();
2860
- totalUsage = { inputTokens: 0, outputTokens: 0 };
3175
+ totalUsage = { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 };
2861
3176
  canRead(nextFileBytes) {
2862
3177
  if (this.reads >= this.cfg.maxFileReads) {
2863
3178
  return { ok: false, reason: `max file reads (${this.cfg.maxFileReads}) reached` };
@@ -2875,6 +3190,8 @@ var Budget = class {
2875
3190
  this.totalUsage.inputTokens += u.inputTokens;
2876
3191
  this.totalUsage.outputTokens += u.outputTokens;
2877
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);
2878
3195
  }
2879
3196
  timedOut() {
2880
3197
  return (Date.now() - this.startedAt) / 1e3 > this.cfg.maxDurationSeconds;
@@ -3020,8 +3337,8 @@ function formatSequence(n) {
3020
3337
  }
3021
3338
 
3022
3339
  // src/planner/writer.ts
3023
- function writePlanFile(input6) {
3024
- const { paths, config: config2, story } = input6;
3340
+ function writePlanFile(input5) {
3341
+ const { paths, config: config2, story } = input5;
3025
3342
  const featurePlanDir = path13.join(paths.plansDir, story.feature);
3026
3343
  fs20.mkdirSync(featurePlanDir, { recursive: true });
3027
3344
  const existing = findExistingPlan(featurePlanDir, story.id);
@@ -3040,9 +3357,9 @@ function writePlanFile(input6) {
3040
3357
  const trackerPart = trackerId && trackerId !== baseSlug ? `-${trackerId}` : "";
3041
3358
  targetPath = path13.join(featurePlanDir, `${formatSequence(nn)}-story-${baseSlug}${trackerPart}.md`);
3042
3359
  }
3043
- const body = `${input6.metadataHeader.trimEnd()}
3360
+ const body = `${input5.metadataHeader.trimEnd()}
3044
3361
 
3045
- ${input6.planBodyMarkdown.trimStart()}
3362
+ ${input5.planBodyMarkdown.trimStart()}
3046
3363
  `;
3047
3364
  fs20.writeFileSync(targetPath, body, "utf8");
3048
3365
  upsertOverviewRow(featurePlanDir, story.feature, nn, path13.basename(targetPath), story);
@@ -3109,6 +3426,45 @@ function buildMetadataHeader(args) {
3109
3426
  return `<!-- squad-kit: ${parts.join(", ")} -->`;
3110
3427
  }
3111
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
+
3112
3468
  // src/commands/new-plan.ts
3113
3469
  function decideMode(opts, config2) {
3114
3470
  if (opts.api && opts.copy) {
@@ -3140,8 +3496,8 @@ async function runNewPlan(intakePath, opts) {
3140
3496
  }
3141
3497
  }
3142
3498
  function resolveFromPath(intakePath, stories, root) {
3143
- const resolved = path14.resolve(intakePath.trim());
3144
- if (!fs21.existsSync(resolved)) {
3499
+ const resolved = path15.resolve(intakePath.trim());
3500
+ if (!fs22.existsSync(resolved)) {
3145
3501
  throw new Error(
3146
3502
  `Intake not found: ${resolved}. Run \`squad list\` to see intakes, or \`squad new-story\` to create one first.`
3147
3503
  );
@@ -3153,7 +3509,7 @@ function resolveFromPath(intakePath, stories, root) {
3153
3509
  );
3154
3510
  }
3155
3511
  function relativeStoriesDir(root) {
3156
- return path14.relative(root, path14.join(root, ".squad", "stories"));
3512
+ return path15.relative(root, path15.join(root, ".squad", "stories"));
3157
3513
  }
3158
3514
  async function pickStory(stories, opts) {
3159
3515
  const candidates = opts.all ? stories : stories.filter((s) => !s.planFile);
@@ -3184,7 +3540,7 @@ async function pickStory(stories, opts) {
3184
3540
  value: s.intakePath
3185
3541
  }))
3186
3542
  });
3187
- return candidates.find((s) => path14.resolve(s.intakePath) === path14.resolve(pick));
3543
+ return candidates.find((s) => path15.resolve(s.intakePath) === path15.resolve(pick));
3188
3544
  }
3189
3545
  function formatLabel(s) {
3190
3546
  const head = `${s.feature} / ${s.id}`;
@@ -3245,6 +3601,7 @@ async function emitViaApi(story, paths, config2, _opts) {
3245
3601
  const userPrompt = composeUserPrompt({ intakeContent: readFile(story.intakePath) });
3246
3602
  const sessionSpinner = { current: null };
3247
3603
  const startedAt = Date.now();
3604
+ const cacheEnabled = planner.cache?.enabled ?? true;
3248
3605
  const result = await runPlanner({
3249
3606
  root: paths.root,
3250
3607
  provider,
@@ -3253,6 +3610,7 @@ async function emitViaApi(story, paths, config2, _opts) {
3253
3610
  systemPrompt,
3254
3611
  userPrompt,
3255
3612
  budget,
3613
+ cacheEnabled,
3256
3614
  onToolCall: (tc, bytes, total) => {
3257
3615
  const p = tc.input.path ?? "<unknown>";
3258
3616
  sessionSpinner.current?.succeed(
@@ -3295,9 +3653,18 @@ async function emitViaApi(story, paths, config2, _opts) {
3295
3653
  planBodyMarkdown: result.planText,
3296
3654
  metadataHeader: header
3297
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
+ }
3298
3665
  blank();
3299
3666
  summaryBox(" plan generated ", [
3300
- { key: "file", value: path14.relative(paths.root, planFile) },
3667
+ { key: "file", value: path15.relative(paths.root, planFile) },
3301
3668
  { key: "nn", value: String(sequenceNumber).padStart(2, "0") },
3302
3669
  { key: "model", value: `${planner.provider}/${model}` },
3303
3670
  { key: "reads", value: `${snap.reads} files \xB7 ${(snap.bytes / 1024).toFixed(1)} KB` },
@@ -3305,6 +3672,7 @@ async function emitViaApi(story, paths, config2, _opts) {
3305
3672
  key: "tokens",
3306
3673
  value: `${snap.usage.inputTokens} in \xB7 ${snap.usage.outputTokens} out${snap.usage.costUsd !== void 0 ? ` \u2248 $${snap.usage.costUsd.toFixed(2)}` : ""}`
3307
3674
  },
3675
+ { key: "cache", value: formatPlannerCacheLine({ cacheEnabled, stats: result.stats }) },
3308
3676
  { key: "time", value: `${Math.round(elapsedMs / 1e3)}s` },
3309
3677
  { key: "action", value: overwrote ? "overwrote existing plan" : "new plan" }
3310
3678
  ]);
@@ -3314,9 +3682,9 @@ async function emitViaApi(story, paths, config2, _opts) {
3314
3682
  warning("Planner did not reach end_turn. Inspect the plan carefully.");
3315
3683
  }
3316
3684
  blank();
3317
- 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)}`);
3318
3686
  }
3319
- async function emitCopyPrompt(story, _paths, config2, clipboard) {
3687
+ async function emitCopyPrompt(story, paths, config2, clipboard) {
3320
3688
  const intakeContent = readFile(story.intakePath);
3321
3689
  const metaPromptTemplate = readBundledPrompt("generate-plan.md");
3322
3690
  const composed = render(metaPromptTemplate, {
@@ -3325,13 +3693,44 @@ async function emitCopyPrompt(story, _paths, config2, clipboard) {
3325
3693
  trackerType: config2.tracker.type,
3326
3694
  intakeContent
3327
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
+ }
3328
3713
  printModelBanner(config2);
3329
- process.stdout.write(composed);
3330
- if (!composed.endsWith("\n")) process.stdout.write("\n");
3331
- if (clipboard) {
3332
- const ok = await copyToClipboard(composed);
3333
- 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.`);
3334
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`;
3335
3734
  }
3336
3735
  function printModelBanner(config2) {
3337
3736
  if (!isInteractive()) return;
@@ -3354,7 +3753,7 @@ function printModelBanner(config2) {
3354
3753
  }
3355
3754
 
3356
3755
  // src/commands/status.ts
3357
- import fs22 from "fs";
3756
+ import fs23 from "fs";
3358
3757
  var kw = 28;
3359
3758
  function probeTrackerCredential(config2, overlay) {
3360
3759
  const { client, error: error2 } = clientFor(config2, overlay);
@@ -3380,7 +3779,7 @@ async function runStatus() {
3380
3779
  const scan = scanPlans(paths.plansDir);
3381
3780
  const storyCount = listStories(paths).length;
3382
3781
  const planCount = scan.usedNumbers.length;
3383
- const secrets = fs22.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
3782
+ const secrets = fs23.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
3384
3783
  const overlay = overlayTrackerEnv(secrets);
3385
3784
  banner();
3386
3785
  step("squad-kit status");
@@ -3434,13 +3833,13 @@ function buildTrackerLine(config2, overlay) {
3434
3833
  }
3435
3834
 
3436
3835
  // src/commands/list.ts
3437
- import path15 from "path";
3836
+ import path16 from "path";
3438
3837
  async function runList(opts) {
3439
3838
  const root = requireSquadRoot();
3440
3839
  const paths = buildPaths(root);
3441
3840
  const records = listStories(paths, { feature: opts.feature });
3442
3841
  const rows = records.map((r) => {
3443
- 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;
3444
3843
  const meta = planAbs ? readPlanMetadata(planAbs) : void 0;
3445
3844
  return {
3446
3845
  feature: r.feature,
@@ -3479,9 +3878,9 @@ function stripAnsi(s) {
3479
3878
  }
3480
3879
 
3481
3880
  // src/commands/tracker-link.ts
3482
- import fs23 from "fs";
3483
- import path16 from "path";
3484
- 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";
3485
3884
  async function runTrackerLink(storyPath, trackerId, opts = {}) {
3486
3885
  const root = requireSquadRoot();
3487
3886
  const paths = buildPaths(root);
@@ -3513,7 +3912,7 @@ async function runTrackerLink(storyPath, trackerId, opts = {}) {
3513
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."
3514
3913
  );
3515
3914
  }
3516
- resolvedTrackerId = (await input5({
3915
+ resolvedTrackerId = (await input4({
3517
3916
  message: `${config2.tracker.type} work-item id:`,
3518
3917
  validate: (v) => {
3519
3918
  const trimmed = v.trim();
@@ -3527,17 +3926,21 @@ async function runTrackerLink(storyPath, trackerId, opts = {}) {
3527
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.`
3528
3927
  );
3529
3928
  }
3530
- const resolved = path16.resolve(resolvedStoryPath);
3531
- const intakeFile = resolved.endsWith("intake.md") ? resolved : path16.join(resolved, "intake.md");
3532
- 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)) {
3533
3932
  throw new Error(
3534
3933
  `intake.md not found at ${intakeFile}. Run \`squad list\` to see valid paths, or \`squad new-story\` to create a story first.`
3535
3934
  );
3536
3935
  }
3537
- const content = fs23.readFileSync(intakeFile, "utf8");
3936
+ const content = fs24.readFileSync(intakeFile, "utf8");
3538
3937
  const updated = upsertTrackerId(content, resolvedTrackerId);
3539
- fs23.writeFileSync(intakeFile, updated, "utf8");
3540
- 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.");
3541
3944
  }
3542
3945
  function upsertTrackerId(content, id) {
3543
3946
  const pattern = /(-\s*\*\*Work item id:\*\*)[^\n]*/;
@@ -3551,8 +3954,8 @@ function upsertTrackerId(content, id) {
3551
3954
  }
3552
3955
 
3553
3956
  // src/commands/doctor.ts
3554
- import fs24 from "fs";
3555
- import path17 from "path";
3957
+ import fs25 from "fs";
3958
+ import path18 from "path";
3556
3959
  async function gatherContext(paths) {
3557
3960
  const ctx = { hasLegacyPromptsDir: false };
3558
3961
  try {
@@ -3561,29 +3964,29 @@ async function gatherContext(paths) {
3561
3964
  ctx.configError = err;
3562
3965
  }
3563
3966
  try {
3564
- ctx.secrets = fs24.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
3967
+ ctx.secrets = fs25.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
3565
3968
  } catch (err) {
3566
3969
  ctx.secretsError = err;
3567
3970
  }
3568
- ctx.hasLegacyPromptsDir = fs24.existsSync(paths.promptsDir);
3971
+ ctx.hasLegacyPromptsDir = fs25.existsSync(paths.promptsDir);
3569
3972
  return ctx;
3570
3973
  }
3571
3974
  function gitignoreHasManagedBlock(repoRoot) {
3572
- const gitignore = path17.join(repoRoot, ".gitignore");
3573
- 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");
3574
3977
  }
3575
3978
  function gitignoreHasTrashPattern(repoRoot) {
3576
- const gitignore = path17.join(repoRoot, ".gitignore");
3577
- 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);
3578
3981
  }
3579
3982
  async function checkDirStructure(paths, _ctx, fix) {
3580
- 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));
3581
3984
  if (need.length === 0) {
3582
3985
  return { id: "dirs", name: ".squad/ directory structure", status: "ok" };
3583
3986
  }
3584
3987
  if (fix) {
3585
3988
  for (const p of need) {
3586
- fs24.mkdirSync(p, { recursive: true });
3989
+ fs25.mkdirSync(p, { recursive: true });
3587
3990
  }
3588
3991
  return { id: "dirs", name: ".squad/ directory structure", status: "ok", detail: "repaired" };
3589
3992
  }
@@ -3591,7 +3994,7 @@ async function checkDirStructure(paths, _ctx, fix) {
3591
3994
  id: "dirs",
3592
3995
  name: ".squad/ directory structure",
3593
3996
  status: "warn",
3594
- detail: `missing: ${need.map((p) => path17.relative(paths.root, p)).join(", ")}`,
3997
+ detail: `missing: ${need.map((p) => path18.relative(paths.root, p)).join(", ")}`,
3595
3998
  fixable: true,
3596
3999
  fixHint: "squad doctor --fix"
3597
4000
  };
@@ -3646,15 +4049,15 @@ async function checkSecretsPermissions(paths, _ctx, fix) {
3646
4049
  if (process.platform === "win32") {
3647
4050
  return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "skip", detail: "Windows" };
3648
4051
  }
3649
- if (!fs24.existsSync(paths.secretsFile)) {
4052
+ if (!fs25.existsSync(paths.secretsFile)) {
3650
4053
  return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok", detail: "not present" };
3651
4054
  }
3652
- const mode = fs24.statSync(paths.secretsFile).mode & 511;
4055
+ const mode = fs25.statSync(paths.secretsFile).mode & 511;
3653
4056
  if (mode === 384) {
3654
4057
  return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok" };
3655
4058
  }
3656
4059
  if (fix) {
3657
- fs24.chmodSync(paths.secretsFile, 384);
4060
+ fs25.chmodSync(paths.secretsFile, 384);
3658
4061
  return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok", detail: "repaired" };
3659
4062
  }
3660
4063
  return {
@@ -3675,7 +4078,7 @@ async function checkSecretsParseable(_paths, ctx) {
3675
4078
  detail: ctx.secretsError.message
3676
4079
  };
3677
4080
  }
3678
- if (!fs24.existsSync(_paths.secretsFile)) {
4081
+ if (!fs25.existsSync(_paths.secretsFile)) {
3679
4082
  return { id: "secrets-yaml", name: ".squad/secrets.yaml parseable", status: "ok", detail: "not present" };
3680
4083
  }
3681
4084
  return { id: "secrets-yaml", name: ".squad/secrets.yaml parseable", status: "ok" };
@@ -3826,6 +4229,101 @@ async function checkPlannerModel(_paths, ctx) {
3826
4229
  };
3827
4230
  }
3828
4231
  }
4232
+ async function checkPlannerTierAwareness(_paths, ctx) {
4233
+ const id = "planner-tier";
4234
+ const name = "planner tier vs. model";
4235
+ if (!ctx.config || ctx.config.planner?.enabled !== true) {
4236
+ return { id, name, status: "skip", detail: "planner disabled" };
4237
+ }
4238
+ const provider = ctx.config.planner.provider;
4239
+ if (provider !== "anthropic") {
4240
+ return { id, name, status: "skip", detail: `not applicable for ${provider}` };
4241
+ }
4242
+ const planModel = modelFor(provider, "plan", ctx.config.planner.modelOverride);
4243
+ if (!/opus/i.test(planModel)) {
4244
+ return { id, name, status: "ok", detail: `${planModel} is comfortably under Tier 1` };
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
+ }
4259
+ return {
4260
+ id,
4261
+ name,
4262
+ status: "warn",
4263
+ detail: `${planModel} on Anthropic Tier 1 (10k input tokens/min) is throttle-prone for plans over ~3 file reads`,
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."
4265
+ };
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
+ }
3829
4327
  async function checkTrackerConfig(_paths, ctx) {
3830
4328
  if (!ctx.config) {
3831
4329
  return { id: "tracker-config", name: "tracker configuration", status: "skip", detail: "config unavailable" };
@@ -3947,6 +4445,8 @@ async function runAllChecks(paths, ctx, fix) {
3947
4445
  await add("planner configuration", () => checkPlannerConfig(paths, ctx));
3948
4446
  await add("planner credential resolves", () => checkPlannerCredential(paths, ctx));
3949
4447
  await add("planner model resolves at provider", () => checkPlannerModel(paths, ctx));
4448
+ await add("planner tier vs. model", () => checkPlannerTierAwareness(paths, ctx));
4449
+ await add("planner cache effectiveness", () => checkPlannerCache(paths, ctx));
3950
4450
  await add("tracker configuration", () => checkTrackerConfig(paths, ctx));
3951
4451
  await add("tracker credential resolves", () => checkTrackerCredential(paths, ctx));
3952
4452
  await add("tracker connectivity", () => checkTrackerConnectivity(paths, ctx));
@@ -4052,23 +4552,23 @@ async function runDoctor(opts) {
4052
4552
  }
4053
4553
 
4054
4554
  // src/commands/migrate.ts
4055
- import fs25 from "fs";
4056
- import path18 from "path";
4555
+ import fs26 from "fs";
4556
+ import path19 from "path";
4057
4557
  import { confirm as confirm7 } from "@inquirer/prompts";
4058
4558
  var LEGACY_PROMPTS_MIGRATION = {
4059
4559
  id: "legacy-prompts",
4060
4560
  name: "Remove legacy .squad/prompts/",
4061
- isApplied: (paths) => !fs25.existsSync(paths.promptsDir),
4062
- 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)`,
4063
4563
  apply: (paths) => {
4064
4564
  const target = paths.promptsDir;
4065
- if (!target.includes(path18.sep + ".squad" + path18.sep + "prompts")) {
4565
+ if (!target.includes(path19.sep + ".squad" + path19.sep + "prompts")) {
4066
4566
  throw new Error(
4067
4567
  `Refusing to delete suspicious path: ${target}. This is a safety check \u2014 run \`squad doctor\` if .squad/ paths look wrong.`
4068
4568
  );
4069
4569
  }
4070
- if (fs25.existsSync(target)) {
4071
- fs25.rmSync(target, { recursive: true, force: true });
4570
+ if (fs26.existsSync(target)) {
4571
+ fs26.rmSync(target, { recursive: true, force: true });
4072
4572
  }
4073
4573
  }
4074
4574
  };
@@ -4076,8 +4576,8 @@ var GITIGNORE_MIGRATION = {
4076
4576
  id: "gitignore",
4077
4577
  name: "Ensure .gitignore managed block",
4078
4578
  isApplied: (paths) => {
4079
- const f = path18.join(paths.root, ".gitignore");
4080
- 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");
4081
4581
  },
4082
4582
  describe: () => "add squad-kit managed block to .gitignore",
4083
4583
  apply: (paths) => {
@@ -4089,23 +4589,23 @@ var SECRETS_PERMS_MIGRATION = {
4089
4589
  name: "Tighten .squad/secrets.yaml permissions",
4090
4590
  isApplied: (paths) => {
4091
4591
  if (process.platform === "win32") return true;
4092
- if (!fs25.existsSync(paths.secretsFile)) return true;
4093
- const st = fs25.statSync(paths.secretsFile);
4592
+ if (!fs26.existsSync(paths.secretsFile)) return true;
4593
+ const st = fs26.statSync(paths.secretsFile);
4094
4594
  return (st.mode & 511) === 384;
4095
4595
  },
4096
- describe: (paths) => `chmod 0600 ${path18.relative(paths.root, paths.secretsFile)}`,
4596
+ describe: (paths) => `chmod 0600 ${path19.relative(paths.root, paths.secretsFile)}`,
4097
4597
  apply: (paths) => {
4098
4598
  if (process.platform === "win32") return;
4099
- if (fs25.existsSync(paths.secretsFile)) fs25.chmodSync(paths.secretsFile, 384);
4599
+ if (fs26.existsSync(paths.secretsFile)) fs26.chmodSync(paths.secretsFile, 384);
4100
4600
  }
4101
4601
  };
4102
4602
  var PLANNER_BUDGET_DEFAULTS_MIGRATION = {
4103
4603
  id: "planner-budget-defaults",
4104
4604
  name: "Backfill planner.budget defaults in config.yaml",
4105
4605
  isApplied: (paths) => {
4106
- if (!fs25.existsSync(paths.configFile)) return true;
4606
+ if (!fs26.existsSync(paths.configFile)) return true;
4107
4607
  try {
4108
- const raw = fs25.readFileSync(paths.configFile, "utf8");
4608
+ const raw = fs26.readFileSync(paths.configFile, "utf8");
4109
4609
  if (!/^\s*planner:/m.test(raw)) return true;
4110
4610
  if (/^\s*budget:/m.test(raw)) return true;
4111
4611
  const cfg = loadConfig(paths.configFile);
@@ -4124,9 +4624,9 @@ var CONFIG_NORMALIZE_MIGRATION = {
4124
4624
  id: "config-normalize",
4125
4625
  name: "Normalise .squad/config.yaml formatting",
4126
4626
  isApplied: (paths) => {
4127
- if (!fs25.existsSync(paths.configFile)) return true;
4627
+ if (!fs26.existsSync(paths.configFile)) return true;
4128
4628
  try {
4129
- const raw = fs25.readFileSync(paths.configFile, "utf8");
4629
+ const raw = fs26.readFileSync(paths.configFile, "utf8");
4130
4630
  const cfg = loadConfig(paths.configFile);
4131
4631
  return raw.trim() === serializeConfig(cfg).trim();
4132
4632
  } catch {
@@ -4135,7 +4635,7 @@ var CONFIG_NORMALIZE_MIGRATION = {
4135
4635
  },
4136
4636
  describe: () => "rewrite config.yaml with canonical YAML formatting (drops comments)",
4137
4637
  apply: (paths) => {
4138
- if (!fs25.existsSync(paths.configFile)) return;
4638
+ if (!fs26.existsSync(paths.configFile)) return;
4139
4639
  const cfg = loadConfig(paths.configFile);
4140
4640
  saveConfig(paths.configFile, cfg);
4141
4641
  }
@@ -4203,7 +4703,11 @@ async function runMigrate(opts) {
4203
4703
  }
4204
4704
  info("");
4205
4705
  success(`Applied ${applied}/${pending.length} migration(s).`);
4206
- 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`.");
4207
4711
  }
4208
4712
 
4209
4713
  // src/commands/upgrade.ts
@@ -4212,8 +4716,8 @@ import process3 from "process";
4212
4716
  import { confirm as confirm8 } from "@inquirer/prompts";
4213
4717
 
4214
4718
  // src/core/package-info.ts
4215
- import fs26 from "fs";
4216
- import path19 from "path";
4719
+ import fs27 from "fs";
4720
+ import path20 from "path";
4217
4721
  function changelogBlobUrl(repositoryUrl, version) {
4218
4722
  let base = repositoryUrl;
4219
4723
  if (!base) base = "https://github.com/AzmSquad/squad-kit";
@@ -4224,9 +4728,9 @@ function changelogBlobUrl(repositoryUrl, version) {
4224
4728
  }
4225
4729
  function readInstalledPackage() {
4226
4730
  const root = packageRoot();
4227
- const pkgFile = path19.join(root, "package.json");
4228
- const pkg = JSON.parse(fs26.readFileSync(pkgFile, "utf8"));
4229
- 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"));
4230
4734
  return {
4231
4735
  name: pkg.name,
4232
4736
  version: pkg.version,
@@ -4266,7 +4770,7 @@ function isNewer(a, b) {
4266
4770
  }
4267
4771
 
4268
4772
  // src/core/package-manager.ts
4269
- import path20 from "path";
4773
+ import path21 from "path";
4270
4774
  import process2 from "process";
4271
4775
  function detectPackageManager(installRoot) {
4272
4776
  const ua = process2.env.npm_config_user_agent;
@@ -4277,11 +4781,11 @@ function detectPackageManager(installRoot) {
4277
4781
  if (ua.startsWith("npm/")) return { pm: "npm", reason: "npm_config_user_agent" };
4278
4782
  }
4279
4783
  const root = installRoot.toLowerCase();
4280
- 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}`))
4281
4785
  return { pm: "pnpm", reason: `install path contains pnpm` };
4282
- 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}`))
4283
4787
  return { pm: "yarn", reason: `install path contains yarn` };
4284
- 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}`))
4285
4789
  return { pm: "bun", reason: `install path contains bun` };
4286
4790
  return { pm: "npm", reason: "default fallback" };
4287
4791
  }
@@ -4382,8 +4886,8 @@ function spawnInherit(cmd, args) {
4382
4886
  }
4383
4887
 
4384
4888
  // src/commands/rm/shared.ts
4385
- import fs27 from "fs";
4386
- import path21 from "path";
4889
+ import fs28 from "fs";
4890
+ import path22 from "path";
4387
4891
  import { select as select7 } from "@inquirer/prompts";
4388
4892
  var STORY_PLAN = /^(\d{2,})-story-.+\.md$/;
4389
4893
  function canPromptForRm(opts) {
@@ -4391,9 +4895,9 @@ function canPromptForRm(opts) {
4391
4895
  return isInteractive() && Boolean(process.stdin.isTTY);
4392
4896
  }
4393
4897
  function removeOverviewRow(featurePlanDir, storyId) {
4394
- const overview = path21.join(featurePlanDir, "00-overview.md");
4395
- if (!fs27.existsSync(overview)) return;
4396
- 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");
4397
4901
  const lines = src.split("\n");
4398
4902
  const filtered = lines.filter((line2) => {
4399
4903
  if (!line2.startsWith("|")) return true;
@@ -4401,12 +4905,12 @@ function removeOverviewRow(featurePlanDir, storyId) {
4401
4905
  if (/^\|\s*-+/.test(line2)) return true;
4402
4906
  return !line2.includes(storyId);
4403
4907
  });
4404
- fs27.writeFileSync(overview, filtered.join("\n"), "utf8");
4908
+ fs28.writeFileSync(overview, filtered.join("\n"), "utf8");
4405
4909
  }
4406
4910
  function removeOverviewRowForPlanFile(featurePlanDir, planBasename) {
4407
- const overview = path21.join(featurePlanDir, "00-overview.md");
4408
- if (!fs27.existsSync(overview)) return;
4409
- 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");
4410
4914
  const token = `\`${planBasename}\``;
4411
4915
  const lines = src.split("\n");
4412
4916
  const filtered = lines.filter((line2) => {
@@ -4415,25 +4919,25 @@ function removeOverviewRowForPlanFile(featurePlanDir, planBasename) {
4415
4919
  if (/^\|\s*-+/.test(line2)) return true;
4416
4920
  return !line2.includes(token);
4417
4921
  });
4418
- fs27.writeFileSync(overview, filtered.join("\n"), "utf8");
4922
+ fs28.writeFileSync(overview, filtered.join("\n"), "utf8");
4419
4923
  }
4420
4924
  function trashOrDelete(absPaths, trashRoot, useTrash) {
4421
4925
  if (!useTrash) {
4422
- 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 });
4423
4927
  return { removed: absPaths };
4424
4928
  }
4425
4929
  const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
4426
- const bucket = path21.join(trashRoot, ts);
4427
- fs27.mkdirSync(bucket, { recursive: true });
4930
+ const bucket = path22.join(trashRoot, ts);
4931
+ fs28.mkdirSync(bucket, { recursive: true });
4428
4932
  for (const src of absPaths) {
4429
- const rel = path21.basename(src);
4430
- let dst = path21.join(bucket, rel);
4933
+ const rel = path22.basename(src);
4934
+ let dst = path22.join(bucket, rel);
4431
4935
  let n = 0;
4432
- while (fs27.existsSync(dst)) {
4936
+ while (fs28.existsSync(dst)) {
4433
4937
  n += 1;
4434
- dst = path21.join(bucket, `${n}_${rel}`);
4938
+ dst = path22.join(bucket, `${n}_${rel}`);
4435
4939
  }
4436
- fs27.renameSync(src, dst);
4940
+ fs28.renameSync(src, dst);
4437
4941
  }
4438
4942
  return { removed: absPaths, trashed: bucket };
4439
4943
  }
@@ -4444,21 +4948,21 @@ function renderPreview(kind, targets, opts) {
4444
4948
  kv(t.kind, t.label, 6);
4445
4949
  }
4446
4950
  if (opts.trash) {
4447
- 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>/`);
4448
4952
  } else if (!opts.dryRun) {
4449
4953
  warning("This is a permanent delete. Pass --trash to move to .squad/.trash/ instead.");
4450
4954
  }
4451
4955
  }
4452
4956
  function listAllPlanListEntries(paths, featureFilter) {
4453
- if (!fs27.existsSync(paths.plansDir)) return [];
4957
+ if (!fs28.existsSync(paths.plansDir)) return [];
4454
4958
  const out = [];
4455
- for (const ent of fs27.readdirSync(paths.plansDir, { withFileTypes: true })) {
4959
+ for (const ent of fs28.readdirSync(paths.plansDir, { withFileTypes: true })) {
4456
4960
  if (!ent.isDirectory()) continue;
4457
4961
  if (featureFilter && ent.name !== featureFilter) continue;
4458
- const featureDir = path21.join(paths.plansDir, ent.name);
4459
- for (const name of fs27.readdirSync(featureDir)) {
4962
+ const featureDir = path22.join(paths.plansDir, ent.name);
4963
+ for (const name of fs28.readdirSync(featureDir)) {
4460
4964
  if (!STORY_PLAN.test(name)) continue;
4461
- 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) });
4462
4966
  }
4463
4967
  }
4464
4968
  return out.sort((a, b) => (a.feature + a.file).localeCompare(b.feature + b.file));
@@ -4509,34 +5013,34 @@ function resolveStoryFromArg(arg, stories, root) {
4509
5013
  if (s.id === t) return s;
4510
5014
  if (`${s.feature}/${s.id}` === t) return s;
4511
5015
  }
4512
- const abs = path21.isAbsolute(t) ? t : path21.resolve(root, t);
4513
- 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");
4514
5018
  return findStoryByIntake(stories, withIntake) ?? findStoryByIntake(stories, abs);
4515
5019
  }
4516
5020
  function isSequenceToken(s) {
4517
5021
  return /^\d{1,4}$/.test(s) && s.length > 0;
4518
5022
  }
4519
5023
  function tryParsePlanFilePath(absPath, paths, featureFilter) {
4520
- if (!fs27.existsSync(absPath) || !STORY_PLAN.test(path21.basename(absPath))) return void 0;
4521
- const planRoot = fs27.realpathSync(path21.resolve(paths.plansDir));
4522
- const fileReal = fs27.realpathSync(path21.resolve(absPath));
4523
- const rel = path21.relative(planRoot, fileReal);
4524
- if (rel.startsWith("..") || path21.isAbsolute(rel)) return void 0;
4525
- 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);
4526
5030
  if (segs.length < 2) return void 0;
4527
5031
  const feature = segs[0];
4528
- const file = segs.slice(1).join(path21.sep);
5032
+ const file = segs.slice(1).join(path22.sep);
4529
5033
  if (featureFilter && feature !== featureFilter) return void 0;
4530
- return { feature, file, abs: path21.resolve(absPath) };
5034
+ return { feature, file, abs: path22.resolve(absPath) };
4531
5035
  }
4532
5036
  function resolvePlanFromArg(arg, paths, featureFilter) {
4533
5037
  const t = arg.trim();
4534
5038
  if (!t) return void 0;
4535
- const asPath = path21.isAbsolute(t) ? t : path21.resolve(process.cwd(), t);
5039
+ const asPath = path22.isAbsolute(t) ? t : path22.resolve(process.cwd(), t);
4536
5040
  const a = tryParsePlanFilePath(asPath, paths, featureFilter);
4537
5041
  if (a) return a;
4538
- if (!path21.isAbsolute(t)) {
4539
- 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);
4540
5044
  if (under) return under;
4541
5045
  }
4542
5046
  if (isSequenceToken(t)) {
@@ -4566,24 +5070,24 @@ function countOverviewDataRows(overviewContent) {
4566
5070
  return n;
4567
5071
  }
4568
5072
  function featureStoriesDirEmpty(paths, feature) {
4569
- const d = path21.join(paths.storiesDir, feature);
4570
- if (!fs27.existsSync(d)) return true;
4571
- 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;
4572
5076
  }
4573
5077
 
4574
5078
  // src/commands/rm/story.ts
4575
- import fs29 from "fs";
4576
- import path23 from "path";
5079
+ import fs30 from "fs";
5080
+ import path24 from "path";
4577
5081
  import { confirm as confirm10 } from "@inquirer/prompts";
4578
5082
 
4579
5083
  // src/commands/rm/feature.ts
4580
- import fs28 from "fs";
4581
- import path22 from "path";
5084
+ import fs29 from "fs";
5085
+ import path23 from "path";
4582
5086
  import { confirm as confirm9 } from "@inquirer/prompts";
4583
5087
  function countStoryDirs(storiesDir, feature) {
4584
- const d = path22.join(storiesDir, feature);
4585
- if (!fs28.existsSync(d)) return 0;
4586
- 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;
4587
5091
  }
4588
5092
  async function runRmFeature(featureSlug, opts = {}) {
4589
5093
  const root = requireSquadRoot();
@@ -4617,17 +5121,17 @@ async function runRmFeature(featureSlug, opts = {}) {
4617
5121
  feature = await pickFeatureInteractive(paths);
4618
5122
  }
4619
5123
  }
4620
- const storyDir = path22.join(paths.storiesDir, feature);
4621
- const planDir = path22.join(paths.plansDir, feature);
5124
+ const storyDir = path23.join(paths.storiesDir, feature);
5125
+ const planDir = path23.join(paths.plansDir, feature);
4622
5126
  const nStories = countStoryDirs(paths.storiesDir, feature);
4623
5127
  const nPlans = listAllPlanListEntries(paths, feature).length;
4624
- 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;
4625
5129
  const targets = [];
4626
- if (fs28.existsSync(storyDir)) {
4627
- 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" });
4628
5132
  }
4629
- if (fs28.existsSync(planDir)) {
4630
- 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" });
4631
5135
  }
4632
5136
  divider("squad rm feature");
4633
5137
  info(`Feature: ${feature}`);
@@ -4642,7 +5146,7 @@ async function runRmFeature(featureSlug, opts = {}) {
4642
5146
  kv(t.kind, t.label, 6);
4643
5147
  }
4644
5148
  if (useTrash) {
4645
- 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>/`);
4646
5150
  } else if (!dryRun) {
4647
5151
  warning("This is a permanent delete. Pass --trash to move to .squad/.trash/ instead.");
4648
5152
  }
@@ -4675,7 +5179,7 @@ async function runRmFeature(featureSlug, opts = {}) {
4675
5179
  useTrash
4676
5180
  );
4677
5181
  if (result.trashed) {
4678
- success(`Feature removed. Items moved to ${path22.relative(root, result.trashed)}.`);
5182
+ success(`Feature removed. Items moved to ${path23.relative(root, result.trashed)}.`);
4679
5183
  } else {
4680
5184
  success("Feature removed (stories and plans directories).");
4681
5185
  }
@@ -4716,11 +5220,11 @@ async function runRmStory(storyPathOrId, opts = {}) {
4716
5220
  }
4717
5221
  }
4718
5222
  const targets = [
4719
- { label: path23.relative(root, story.storyDir), absPath: story.storyDir, kind: "dir" }
5223
+ { label: path24.relative(root, story.storyDir), absPath: story.storyDir, kind: "dir" }
4720
5224
  ];
4721
5225
  if (story.planFile) {
4722
- const planAbs = path23.join(paths.plansDir, story.feature, story.planFile);
4723
- 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" });
4724
5228
  }
4725
5229
  renderPreview("story", targets, {
4726
5230
  dryRun,
@@ -4745,16 +5249,16 @@ async function runRmStory(storyPathOrId, opts = {}) {
4745
5249
  }
4746
5250
  const absPaths = targets.map((t) => t.absPath);
4747
5251
  const result = trashOrDelete(absPaths, paths.trashDir, useTrash);
4748
- removeOverviewRow(path23.join(paths.plansDir, story.feature), story.id);
5252
+ removeOverviewRow(path24.join(paths.plansDir, story.feature), story.id);
4749
5253
  if (result.trashed) {
4750
- success(`Removed (moved to ${path23.relative(root, result.trashed)}).`);
5254
+ success(`Removed (moved to ${path24.relative(root, result.trashed)}).`);
4751
5255
  } else {
4752
5256
  success("Removed story folder, plan file (if any), and overview row.");
4753
5257
  }
4754
- const featurePlanDir = path23.join(paths.plansDir, story.feature);
4755
- const overviewPath = path23.join(featurePlanDir, "00-overview.md");
4756
- if (fs29.existsSync(overviewPath)) {
4757
- 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");
4758
5262
  const onlyShell = countOverviewDataRows(body) === 0;
4759
5263
  if (onlyShell && featureStoriesDirEmpty(paths, story.feature)) {
4760
5264
  if (canPrompt) {
@@ -4774,7 +5278,7 @@ async function runRmStory(storyPathOrId, opts = {}) {
4774
5278
  }
4775
5279
 
4776
5280
  // src/commands/rm/plan.ts
4777
- import path24 from "path";
5281
+ import path25 from "path";
4778
5282
  import { confirm as confirm11 } from "@inquirer/prompts";
4779
5283
  async function runRmPlan(planPathOrSequence, opts = {}) {
4780
5284
  const root = requireSquadRoot();
@@ -4810,7 +5314,7 @@ async function runRmPlan(planPathOrSequence, opts = {}) {
4810
5314
  }
4811
5315
  const storyRows = listStories(paths, { feature: plan.feature });
4812
5316
  const linked = storyRows.find((s) => s.planFile === plan.file);
4813
- 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" }];
4814
5318
  renderPreview("plan", targets, {
4815
5319
  dryRun,
4816
5320
  trash: useTrash,
@@ -4833,14 +5337,14 @@ async function runRmPlan(planPathOrSequence, opts = {}) {
4833
5337
  }
4834
5338
  }
4835
5339
  trashOrDelete([plan.abs], paths.trashDir, useTrash);
4836
- removeOverviewRowForPlanFile(path24.join(paths.plansDir, plan.feature), plan.file);
4837
- 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}/`;
4838
5342
  success(`Plan removed. Run \`squad new-plan ${intakeHint}\` to regenerate.`);
4839
5343
  }
4840
5344
 
4841
5345
  // src/cli.ts
4842
5346
  var program = new Command();
4843
- program.name("squad").description("Plan once, execute cheap. A 3-step SDD workflow CLI.").version("0.2.1");
5347
+ program.name("squad").description("Plan once, execute cheap. A 3-step SDD workflow CLI.").version("0.3.0");
4844
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(
4845
5349
  "--tracker-workspace <hostOrOrg>",
4846
5350
  "Jira host or Azure org (non-interactive default; also written to config for jira/azure)"