squad-kit 0.2.2 → 0.4.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
@@ -169,10 +169,23 @@ function renderError(err) {
169
169
  `);
170
170
  }
171
171
 
172
+ // src/core/cli-exit.ts
173
+ var SquadExit = class extends Error {
174
+ constructor(exitCode, message) {
175
+ super(message ?? "");
176
+ this.exitCode = exitCode;
177
+ }
178
+ exitCode;
179
+ name = "SquadExit";
180
+ };
181
+ function isSquadExit(err) {
182
+ return err instanceof SquadExit;
183
+ }
184
+
172
185
  // src/commands/init.ts
173
186
  import fs14 from "fs";
174
187
  import path6 from "path";
175
- import { input as input3, select as select3, checkbox, confirm as confirm5, password as password3 } from "@inquirer/prompts";
188
+ import { input as input2, select as select3, checkbox, confirm as confirm5, password as password3 } from "@inquirer/prompts";
176
189
 
177
190
  // src/core/paths.ts
178
191
  import path from "path";
@@ -212,8 +225,8 @@ function requireSquadRoot(startDir = process.cwd()) {
212
225
  }
213
226
  return root;
214
227
  }
215
- function slugify(input6) {
216
- return input6.toLowerCase().trim().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 80);
228
+ function slugify(input5) {
229
+ return input5.toLowerCase().trim().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 80);
217
230
  }
218
231
 
219
232
  // src/core/gitignore.ts
@@ -222,42 +235,38 @@ import path2 from "path";
222
235
  var SQUAD_SECRETS_PATTERN = ".squad/secrets.yaml";
223
236
  var SQUAD_ATTACHMENTS_PATTERN = ".squad/stories/**/attachments/";
224
237
  var SQUAD_TRASH_PATTERN = ".squad/.trash/";
238
+ var SQUAD_LAST_RUN_PATTERN = ".squad/.last-run.json";
239
+ var SQUAD_LAST_COPY_PROMPT_PATTERN = ".squad/.last-copy-prompt.md";
225
240
  var MANAGED_BLOCK_HEADER = "# Managed by squad-kit \u2014 do not edit this block";
226
241
  var MANAGED_BLOCK_FOOTER = "# End squad-kit block";
227
242
  function ensureGitignore(repoRoot) {
228
243
  const file = path2.join(repoRoot, ".gitignore");
229
244
  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);
245
+ const requiredPatterns = [
246
+ SQUAD_SECRETS_PATTERN,
247
+ SQUAD_ATTACHMENTS_PATTERN,
248
+ SQUAD_TRASH_PATTERN,
249
+ SQUAD_LAST_RUN_PATTERN,
250
+ SQUAD_LAST_COPY_PROMPT_PATTERN
251
+ ];
252
+ const allPresent = requiredPatterns.every((p) => text.includes(p));
253
+ if (allPresent) return false;
254
+ if (!text.includes(SQUAD_SECRETS_PATTERN)) {
255
+ const block = ["", MANAGED_BLOCK_HEADER, ...requiredPatterns, MANAGED_BLOCK_FOOTER, ""].join("\n");
256
+ const next2 = text.length === 0 ? block.trimStart() : text.endsWith("\n") ? text + block : text + "\n" + block;
257
+ fs2.writeFileSync(file, next2, "utf8");
258
+ return true;
259
+ }
260
+ let next = text;
261
+ for (const pattern of requiredPatterns) {
262
+ if (next.includes(pattern)) continue;
263
+ if (next.includes(MANAGED_BLOCK_FOOTER)) {
264
+ next = next.replace(MANAGED_BLOCK_FOOTER, pattern + "\n" + MANAGED_BLOCK_FOOTER);
242
265
  } 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;
266
+ next = next.replace(/\n?$/, (m) => m + pattern + "\n");
248
267
  }
249
268
  }
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;
269
+ if (next !== text) {
261
270
  fs2.writeFileSync(file, next, "utf8");
262
271
  return true;
263
272
  }
@@ -280,15 +289,27 @@ var DEFAULT_CONFIG = {
280
289
  planner: void 0
281
290
  };
282
291
  var FORBIDDEN_KEYS = ["apikey", "api_key", "token", "secret", "credential", "credentials"];
283
- function rejectSecretsInYaml(node, configFile, path25 = []) {
292
+ var DEFAULT_PLANNER_MAX_OUTPUT_TOKENS = 16384;
293
+ var MIN_PLANNER_MAX_OUTPUT_TOKENS = 1024;
294
+ var MAX_PLANNER_MAX_OUTPUT_TOKENS = 128e3;
295
+ function clampPlannerMaxOut(raw) {
296
+ if (raw === void 0 || raw === null) return DEFAULT_PLANNER_MAX_OUTPUT_TOKENS;
297
+ const n = typeof raw === "number" ? raw : typeof raw === "string" ? Number.parseInt(raw, 10) : NaN;
298
+ if (!Number.isFinite(n) || n < 1) return DEFAULT_PLANNER_MAX_OUTPUT_TOKENS;
299
+ return Math.min(
300
+ MAX_PLANNER_MAX_OUTPUT_TOKENS,
301
+ Math.max(MIN_PLANNER_MAX_OUTPUT_TOKENS, Math.round(n))
302
+ );
303
+ }
304
+ function rejectSecretsInYaml(node, configFile, path26 = []) {
284
305
  if (node === null || typeof node !== "object") return;
285
306
  for (const [key, value] of Object.entries(node)) {
286
307
  if (FORBIDDEN_KEYS.includes(key.toLowerCase())) {
287
308
  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.`
309
+ `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
310
  );
290
311
  }
291
- if (typeof value === "object") rejectSecretsInYaml(value, configFile, [...path25, key]);
312
+ if (typeof value === "object") rejectSecretsInYaml(value, configFile, [...path26, key]);
292
313
  }
293
314
  }
294
315
  function mergePlanner(base, override) {
@@ -306,7 +327,11 @@ function mergePlanner(base, override) {
306
327
  modelOverride: {
307
328
  ...base?.modelOverride ?? {},
308
329
  ...override.modelOverride ?? {}
309
- }
330
+ },
331
+ cache: {
332
+ enabled: override.cache?.enabled ?? base?.cache?.enabled ?? true
333
+ },
334
+ maxOutputTokens: clampPlannerMaxOut(override.maxOutputTokens ?? base?.maxOutputTokens)
310
335
  };
311
336
  if (merged.modelOverride) {
312
337
  const pruned = Object.fromEntries(
@@ -350,6 +375,20 @@ function loadConfig(configFile) {
350
375
  rejectSecretsInYaml(parsed, configFile);
351
376
  const merged = mergeConfig(DEFAULT_CONFIG, parsed);
352
377
  validateModelOverride(merged.planner?.modelOverride, configFile);
378
+ if (merged.planner?.cache !== void 0) {
379
+ const c = merged.planner.cache;
380
+ if (typeof c !== "object" || c === null || Array.isArray(c)) {
381
+ throw new Error(
382
+ `planner.cache in ${configFile} must be an object with \`enabled: true\` or \`false\`. Run \`squad config set planner\` to set prompt caching.`
383
+ );
384
+ }
385
+ const ce = c.enabled;
386
+ if (typeof ce !== "boolean") {
387
+ throw new Error(
388
+ `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.`
389
+ );
390
+ }
391
+ }
353
392
  if (merged.planner?.enabled) {
354
393
  if (!["anthropic", "openai", "google"].includes(merged.planner.provider)) {
355
394
  throw new Error(
@@ -571,6 +610,9 @@ function render(template, vars) {
571
610
  return value === void 0 || value === null ? "" : String(value);
572
611
  });
573
612
  }
613
+ function escapeTemplateValue(s) {
614
+ return s.replace(/\{\{/g, "{ {");
615
+ }
574
616
 
575
617
  // src/commands/config/show.ts
576
618
  import fs7 from "fs";
@@ -696,7 +738,7 @@ async function runConfigShow(opts) {
696
738
 
697
739
  // src/commands/config/set-planner.ts
698
740
  import fs9 from "fs";
699
- import { confirm as confirm2, input as input2, password as password2, select } from "@inquirer/prompts";
741
+ import { confirm as confirm2, password as password2, select } from "@inquirer/prompts";
700
742
 
701
743
  // src/tracker/env-overlay.ts
702
744
  function overlayTrackerEnv(base) {
@@ -842,7 +884,9 @@ function newPlannerBlock(provider) {
842
884
  maxFileReads: 25,
843
885
  maxContextBytes: 5e4,
844
886
  maxDurationSeconds: 180
845
- }
887
+ },
888
+ cache: { enabled: true },
889
+ maxOutputTokens: DEFAULT_PLANNER_MAX_OUTPUT_TOKENS
846
890
  };
847
891
  }
848
892
  function mergePlannerKeyIntoSecrets(base, provider, key) {
@@ -993,22 +1037,11 @@ async function runConfigSetPlanner(opts = {}) {
993
1037
  success("Planner key saved to .squad/secrets.yaml");
994
1038
  info(".squad/secrets.yaml updated (chmod 0600 on POSIX)");
995
1039
  }
996
- const wantOverride = await confirm2({
997
- message: "Set a custom plan-phase model id (planner.modelOverride)?",
998
- default: false
1040
+ const cacheEnabled = await confirm2({
1041
+ message: "Enable prompt caching? (Recommended \u2014 reduces billed tokens by ~70% on most providers.)",
1042
+ default: nextPlanner.cache?.enabled ?? true
999
1043
  });
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
- }
1044
+ nextPlanner = { ...nextPlanner, cache: { enabled: cacheEnabled } };
1012
1045
  }
1013
1046
  const next = { ...config2, planner: nextPlanner };
1014
1047
  saveConfig(paths.configFile, next);
@@ -1029,6 +1062,7 @@ async function runConfigSetPlanner(opts = {}) {
1029
1062
  kv("credential", `${cred.source} (${sourceText})`, 10);
1030
1063
  }
1031
1064
  if (process.env.CI === "true" || !cred) {
1065
+ printPlannerNextSteps(Boolean(cred));
1032
1066
  return;
1033
1067
  }
1034
1068
  const listed = await fetchProviderModelIds(provider, cred.value);
@@ -1042,6 +1076,23 @@ async function runConfigSetPlanner(opts = {}) {
1042
1076
  } else {
1043
1077
  info("Credential check: models API responded OK for this key.");
1044
1078
  }
1079
+ printPlannerNextSteps(true);
1080
+ }
1081
+ function printPlannerNextSteps(credentialReady) {
1082
+ blank();
1083
+ step("Next:");
1084
+ if (!credentialReady) {
1085
+ info("1) Save a planner key: re-run `squad config set planner` and paste the key when prompted.");
1086
+ info("2) Verify with `squad doctor` \u2014 all planner checks should turn green.");
1087
+ info("3) Then run `squad new-story <slug>` and `squad new-plan --api` to generate your first plan.");
1088
+ return;
1089
+ }
1090
+ info("1) Verify with `squad doctor` \u2014 every planner check should be green.");
1091
+ info("2) Create a story: squad new-story <feature-slug> (or --no-tracker for a manual story).");
1092
+ info("3) Fill the generated intake.md, then run `squad new-plan --api` to generate the plan.");
1093
+ info(
1094
+ "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)."
1095
+ );
1045
1096
  }
1046
1097
 
1047
1098
  // src/commands/config/set-tracker.ts
@@ -1196,6 +1247,7 @@ var AzureDevOpsClient = class {
1196
1247
  id: String(body.id ?? id),
1197
1248
  title: fields["System.Title"] ?? "(no title)",
1198
1249
  description: stripHtml(fields["System.Description"] ?? ""),
1250
+ acceptanceCriteria: stripHtml(fields["Microsoft.VSTS.Common.AcceptanceCriteria"] ?? ""),
1199
1251
  url: `${this.webBase}/${body.id ?? id}`,
1200
1252
  labels: parseTags(fields["System.Tags"]),
1201
1253
  type: fields["System.WorkItemType"],
@@ -1319,6 +1371,7 @@ var JiraClient = class {
1319
1371
  id: body.key ?? id,
1320
1372
  title: fields.summary ?? "(no title)",
1321
1373
  description,
1374
+ acceptanceCriteria: "",
1322
1375
  url: `${this.baseUrl}/browse/${body.key ?? id}`,
1323
1376
  labels: Array.isArray(fields.labels) ? fields.labels : [],
1324
1377
  type: fields.issuetype?.name,
@@ -1526,6 +1579,7 @@ async function runConfigSetTracker(opts = {}) {
1526
1579
  kv("project", next.tracker.project, 10);
1527
1580
  }
1528
1581
  if (process.env.CI === "true") {
1582
+ printTrackerNextSteps(next.tracker.type);
1529
1583
  return;
1530
1584
  }
1531
1585
  const reloaded = loadConfig(paths.configFile);
@@ -1549,6 +1603,20 @@ async function runConfigSetTracker(opts = {}) {
1549
1603
  );
1550
1604
  }
1551
1605
  }
1606
+ printTrackerNextSteps(next.tracker.type);
1607
+ }
1608
+ function printTrackerNextSteps(type) {
1609
+ blank();
1610
+ step("Next:");
1611
+ if (type === "none") {
1612
+ info("1) Tracker is off. Create stories manually with `squad new-story <slug> --no-tracker`.");
1613
+ info("2) Re-enable any time with `squad config set tracker`.");
1614
+ return;
1615
+ }
1616
+ info("1) Verify with `squad doctor` \u2014 tracker checks should be green.");
1617
+ info(`2) Create a story: squad new-story <feature-slug> --id <${type === "jira" ? "JIRA-123" : "azure-work-item-id"}>`);
1618
+ info(" squad-kit auto-fetches the title, description, and attachments into the intake.");
1619
+ info("3) Review the generated intake.md, then run `squad new-plan --api` to generate the plan.");
1552
1620
  }
1553
1621
 
1554
1622
  // src/commands/config/unset-tracker.ts
@@ -1671,8 +1739,8 @@ async function runInit(opts) {
1671
1739
  agents: parseAgentsFlag(opts.agents)
1672
1740
  };
1673
1741
  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 }),
1742
+ name: await input2({ message: "Project name", default: defaults.name }),
1743
+ language: await input2({ message: "Primary language", default: defaults.language }),
1676
1744
  tracker: await select3({
1677
1745
  message: "Issue tracker",
1678
1746
  choices: [
@@ -1736,7 +1804,9 @@ async function runInit(opts) {
1736
1804
  maxFileReads: 25,
1737
1805
  maxContextBytes: 5e4,
1738
1806
  maxDurationSeconds: 180
1739
- }
1807
+ },
1808
+ cache: { enabled: true },
1809
+ maxOutputTokens: DEFAULT_PLANNER_MAX_OUTPUT_TOKENS
1740
1810
  };
1741
1811
  }
1742
1812
  } else {
@@ -1762,7 +1832,9 @@ async function runInit(opts) {
1762
1832
  maxFileReads: 25,
1763
1833
  maxContextBytes: 5e4,
1764
1834
  maxDurationSeconds: 180
1765
- }
1835
+ },
1836
+ cache: { enabled: true },
1837
+ maxOutputTokens: DEFAULT_PLANNER_MAX_OUTPUT_TOKENS
1766
1838
  };
1767
1839
  }
1768
1840
  }
@@ -1893,7 +1965,7 @@ Plan meta-prompts (\`generate-plan.md\`, \`story-skeleton.md\`) ship inside the
1893
1965
  // src/commands/new-story.ts
1894
1966
  import fs16 from "fs";
1895
1967
  import path8 from "path";
1896
- import { input as input4, select as select4 } from "@inquirer/prompts";
1968
+ import { input as input3, select as select4 } from "@inquirer/prompts";
1897
1969
 
1898
1970
  // src/core/stories.ts
1899
1971
  import fs15 from "fs";
@@ -1933,11 +2005,14 @@ function listStories(paths, opts = {}) {
1933
2005
  function findPlanFor(plansDir, feature, storyId) {
1934
2006
  const featureDir = path7.join(plansDir, feature);
1935
2007
  if (!fs15.existsSync(featureDir)) return void 0;
2008
+ const candidates = [];
1936
2009
  for (const entry of fs15.readdirSync(featureDir)) {
1937
2010
  if (!STORY_FILE.test(entry)) continue;
1938
- if (entry.includes(storyId)) return entry;
2011
+ if (entry.includes(storyId)) candidates.push(entry);
1939
2012
  }
1940
- return void 0;
2013
+ if (candidates.length === 0) return void 0;
2014
+ const complete = candidates.find((e) => !e.endsWith(".partial.md"));
2015
+ return complete ?? candidates[0];
1941
2016
  }
1942
2017
  function findStoryByIntake(records, intakePath) {
1943
2018
  const target = normalizeIntakePath(intakePath.trim());
@@ -1953,21 +2028,53 @@ function normalizeIntakePath(p) {
1953
2028
  }
1954
2029
  function readTitleHint(intakePath) {
1955
2030
  try {
1956
- const head = fs15.readFileSync(intakePath, "utf8").split("\n").slice(0, 3).join("\n");
1957
- const m = head.match(/>\s*\*\*Title hint \(from CLI\):\*\*\s*(.+?)\s*$/m);
1958
- return m?.[1]?.trim() || void 0;
2031
+ const raw = fs15.readFileSync(intakePath, "utf8");
2032
+ const head = raw.split("\n").slice(0, 30).join("\n");
2033
+ const hintLine = head.match(/>\s*\*\*Title hint \(from CLI\):\*\*\s*(.+?)\s*$/m);
2034
+ const fromHint = hintLine?.[1]?.trim();
2035
+ if (fromHint) return fromHint;
2036
+ const titleFence = raw.match(/(?:^|\n)## Title\b[\s\S]*?\n```(?:[\w-]*)?\s*\n([\s\S]*?)\n```/);
2037
+ const fromFence = titleFence?.[1]?.trim();
2038
+ if (fromFence) return fromFence;
2039
+ return void 0;
1959
2040
  } catch {
1960
2041
  return void 0;
1961
2042
  }
1962
2043
  }
1963
- function readPlanMetadata(planFileAbsPath) {
1964
- try {
1965
- const first = fs15.readFileSync(planFileAbsPath, "utf8").split("\n", 1)[0] ?? "";
1966
- const m = first.match(/^<!--\s*squad-kit:\s*generated by\s+([^\s,]+)/);
1967
- if (!m) return {};
1968
- const generatedBy = m[1];
2044
+ var PLAN_GENERATED_BY = /^<!--\s*squad-kit:\s*generated by\s+([^\s,]+)/;
2045
+ var PLAN_ANY_SQUAD = /^<!--\s*squad-kit:\s*(.+?)\s*-->$/;
2046
+ var MAX_FREEFORM_META_LEN = 96;
2047
+ function parsePlanMetadataLine(line2) {
2048
+ const strict = line2.match(PLAN_GENERATED_BY);
2049
+ if (strict) {
2050
+ const generatedBy = strict[1];
2051
+ const [provider, model] = generatedBy.split("/");
2052
+ return { provider, model, generatedBy };
2053
+ }
2054
+ const any = line2.match(PLAN_ANY_SQUAD);
2055
+ if (!any) return null;
2056
+ const inner = any[1].trim();
2057
+ const flex = inner.match(/^generated\s+by\s+([^\s,]+)/i);
2058
+ if (flex) {
2059
+ const generatedBy = flex[1];
1969
2060
  const [provider, model] = generatedBy.split("/");
1970
2061
  return { provider, model, generatedBy };
2062
+ }
2063
+ const short = inner.length > MAX_FREEFORM_META_LEN ? `${inner.slice(0, MAX_FREEFORM_META_LEN - 1)}\u2026` : inner;
2064
+ return { generatedBy: short };
2065
+ }
2066
+ function readPlanMetadata(planFileAbsPath) {
2067
+ try {
2068
+ const raw = fs15.readFileSync(planFileAbsPath, "utf8").replace(/^\uFEFF/, "");
2069
+ const lines = raw.split(/\n/);
2070
+ const scan = Math.min(lines.length, 40);
2071
+ for (let i = 0; i < scan; i++) {
2072
+ const line2 = (lines[i] ?? "").replace(/\r$/, "").trim();
2073
+ if (!line2) continue;
2074
+ const parsed = parsePlanMetadataLine(line2);
2075
+ if (parsed) return parsed;
2076
+ }
2077
+ return {};
1971
2078
  } catch {
1972
2079
  return {};
1973
2080
  }
@@ -2024,7 +2131,7 @@ async function runNewStory(featureSlug, opts) {
2024
2131
  }
2025
2132
  }
2026
2133
  if (!featureSlugInput) {
2027
- featureSlugInput = (await input4({
2134
+ featureSlugInput = (await input3({
2028
2135
  message: "New feature slug (kebab-case):",
2029
2136
  validate: validateFeatureSlugForPrompt
2030
2137
  })).trim();
@@ -2063,7 +2170,7 @@ async function runNewStory(featureSlug, opts) {
2063
2170
  noTracker = true;
2064
2171
  trackerNeedsId = false;
2065
2172
  } else {
2066
- id = (await input4({
2173
+ id = (await input3({
2067
2174
  message: `${config2.tracker.type} work-item id:`,
2068
2175
  validate: (v) => validateTrackerId(config2.tracker.type, v.trim()) ? true : `Invalid ${config2.tracker.type} id format.`
2069
2176
  })).trim();
@@ -2082,12 +2189,12 @@ async function runNewStory(featureSlug, opts) {
2082
2189
  );
2083
2190
  }
2084
2191
  } else {
2085
- title = (await input4({ message: "Short story title:" })).trim();
2192
+ title = (await input3({ message: "Short story title:" })).trim();
2086
2193
  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
2194
  }
2088
2195
  }
2089
2196
  if (interactive && !title) {
2090
- const maybe = (await input4({ message: "Short title (optional, press Enter to skip):", default: "" })).trim();
2197
+ const maybe = (await input3({ message: "Short title (optional, press Enter to skip):", default: "" })).trim();
2091
2198
  if (maybe) title = maybe;
2092
2199
  }
2093
2200
  const storyFolderName = id ? trackerIdForFilename(config2.tracker.type, id) : slugify(title ?? "");
@@ -2107,10 +2214,12 @@ async function runNewStory(featureSlug, opts) {
2107
2214
  fs16.mkdirSync(attachmentsDir, { recursive: true });
2108
2215
  let sourceBlock = "";
2109
2216
  let fetchedSummary;
2217
+ let fetchedIssue;
2110
2218
  const shouldFetch = !noTracker && opts.fetch !== false && Boolean(id) && config2.tracker.type !== "none";
2111
2219
  if (shouldFetch) {
2112
2220
  const outcome = await tryFetchIssue(config2, id, opts, paths, attachmentsDir);
2113
2221
  if (outcome.outcome === "ok") {
2222
+ fetchedIssue = outcome.issue;
2114
2223
  sourceBlock = buildSourcePreamble(outcome.issue, outcome.downloads, config2.tracker.type);
2115
2224
  if (!title) title = outcome.issue.title;
2116
2225
  const written = outcome.downloads.filter((d) => d.outcome === "written").length;
@@ -2122,18 +2231,19 @@ async function runNewStory(featureSlug, opts) {
2122
2231
  }
2123
2232
  }
2124
2233
  const template = readBundledPrompt("intake.md");
2125
- const rendered = render(template, {
2126
- featureSlug: slug,
2127
- storyId: storyFolderName,
2128
- trackerType: config2.tracker.type,
2129
- projectRoots: (config2.project.projectRoots ?? ["."]).join(", "),
2130
- primaryLanguage: config2.project.primaryLanguage ?? ""
2131
- });
2234
+ const rendered = render(
2235
+ template,
2236
+ buildIntakeTemplateVars({
2237
+ config: config2,
2238
+ featureSlug: slug,
2239
+ storyFolderName,
2240
+ trackerId: id,
2241
+ title,
2242
+ fetchedIssue
2243
+ })
2244
+ );
2132
2245
  const skippedPreamble = noTracker && config2.tracker.type !== "none" && !sourceBlock ? buildSkippedTrackerPreamble(config2.tracker.type) : "";
2133
- const titleHint = title ? `> **Title hint (from CLI):** ${title}
2134
-
2135
- ` : "";
2136
- writeFileSafe(intakeFile, skippedPreamble + sourceBlock + titleHint + rendered, false);
2246
+ writeFileSafe(intakeFile, skippedPreamble + sourceBlock + rendered, false);
2137
2247
  ensureFeatureOverview(path8.join(paths.plansDir, slug), slug);
2138
2248
  const relativePath = path8.relative(root, intakeFile);
2139
2249
  const relAttachDir = path8.relative(root, attachmentsDir);
@@ -2206,6 +2316,25 @@ function explainTrackerErrorHint(err) {
2206
2316
  function truncate(s, n) {
2207
2317
  return s.length <= n ? s : s.slice(0, n - 1) + "\u2026";
2208
2318
  }
2319
+ function buildIntakeTemplateVars(args) {
2320
+ const e = escapeTemplateValue;
2321
+ const { config: config2, featureSlug, storyFolderName, trackerId, title, fetchedIssue } = args;
2322
+ return {
2323
+ featureSlug,
2324
+ storyId: storyFolderName,
2325
+ trackerType: config2.tracker.type,
2326
+ trackerWorkItemId: e((fetchedIssue?.id ?? trackerId ?? "").trim()),
2327
+ trackerWorkItemType: e((fetchedIssue?.type ?? "").trim()),
2328
+ trackerStatus: e((fetchedIssue?.status ?? "").trim()),
2329
+ trackerAssignee: e((fetchedIssue?.assignee ?? "").trim()),
2330
+ trackerLabels: e((fetchedIssue?.labels ?? []).join(", ").trim()),
2331
+ trackerTitle: e((title ?? "").trim()),
2332
+ trackerDescription: e((fetchedIssue?.description ?? "").trim()),
2333
+ trackerAcceptanceCriteria: e((fetchedIssue?.acceptanceCriteria ?? "").trim()),
2334
+ projectRoots: (config2.project.projectRoots ?? ["."]).join(", "),
2335
+ primaryLanguage: config2.project.primaryLanguage ?? ""
2336
+ };
2337
+ }
2209
2338
  function buildSourcePreamble(issue, downloads, trackerType) {
2210
2339
  const header = `> **Fetched from ${trackerType}:** [${issue.id}](${issue.url})
2211
2340
  > *Fetched ${issue.fetchedAt}. Edit the sections below as needed; the planner reads this file verbatim.*
@@ -2279,29 +2408,96 @@ function ensureFeatureOverview(featurePlanDir, slug) {
2279
2408
  }
2280
2409
 
2281
2410
  // src/commands/new-plan.ts
2282
- import fs21 from "fs";
2283
- import path14 from "path";
2411
+ import fs22 from "fs";
2412
+ import path15 from "path";
2284
2413
  import { confirm as confirm6, select as select5 } from "@inquirer/prompts";
2285
2414
 
2286
2415
  // src/utils/clipboard.ts
2287
- import { spawn } from "child_process";
2416
+ import { spawn, spawnSync } from "child_process";
2288
2417
  async function copyToClipboard(text) {
2289
- const cmd = clipboardCommand();
2290
- if (!cmd) return false;
2418
+ const candidates = clipboardCandidates();
2419
+ if (candidates.length === 0) {
2420
+ return { ok: false, reason: noCandidateReason() };
2421
+ }
2422
+ let lastReason;
2423
+ for (const cmd of candidates) {
2424
+ const reason = cmd.unavailableReason?.();
2425
+ if (reason) {
2426
+ lastReason = reason;
2427
+ continue;
2428
+ }
2429
+ const res = await tryCopy(cmd, text);
2430
+ if (res.ok) return res;
2431
+ lastReason = res.reason;
2432
+ }
2433
+ return { ok: false, reason: lastReason ?? "no working clipboard tool found" };
2434
+ }
2435
+ function tryCopy(cmd, text) {
2291
2436
  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();
2437
+ let child;
2438
+ try {
2439
+ child = spawn(cmd.command, cmd.args, { stdio: ["pipe", "ignore", "pipe"] });
2440
+ } catch (err) {
2441
+ resolve({ ok: false, reason: `${cmd.command} failed to spawn: ${err.message}` });
2442
+ return;
2443
+ }
2444
+ let stderr = "";
2445
+ child.stderr?.on("data", (chunk) => {
2446
+ stderr += chunk.toString();
2447
+ });
2448
+ child.on("error", (err) => {
2449
+ resolve({ ok: false, reason: `${cmd.command} spawn error: ${err.message}` });
2450
+ });
2451
+ child.on("close", (code) => {
2452
+ if (code === 0) {
2453
+ resolve({ ok: true, tool: cmd.command });
2454
+ } else {
2455
+ resolve({
2456
+ ok: false,
2457
+ reason: `${cmd.command} exited with code ${code}${stderr ? `: ${stderr.trim().slice(0, 200)}` : ""}`
2458
+ });
2459
+ }
2460
+ });
2461
+ child.stdin?.write(text);
2462
+ child.stdin?.end();
2297
2463
  });
2298
2464
  }
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;
2465
+ function clipboardCandidates() {
2466
+ if (process.platform === "darwin") {
2467
+ return [{ command: "pbcopy", args: [] }];
2468
+ }
2469
+ if (process.platform === "win32") {
2470
+ return [{ command: "clip", args: [] }];
2471
+ }
2472
+ const out = [];
2473
+ if (process.env.WAYLAND_DISPLAY) {
2474
+ out.push({ command: "wl-copy", args: [], unavailableReason: hasBinary("wl-copy") });
2475
+ }
2476
+ if (process.env.DISPLAY) {
2477
+ out.push({ command: "xclip", args: ["-selection", "clipboard"], unavailableReason: hasBinary("xclip") });
2478
+ out.push({ command: "xsel", args: ["--clipboard", "--input"], unavailableReason: hasBinary("xsel") });
2479
+ }
2480
+ if (process.env.TERMUX_VERSION) {
2481
+ out.push({ command: "termux-clipboard-set", args: [], unavailableReason: hasBinary("termux-clipboard-set") });
2482
+ }
2483
+ return out;
2484
+ }
2485
+ function hasBinary(bin) {
2486
+ return () => {
2487
+ const which = process.platform === "win32" ? "where" : "which";
2488
+ const res = spawnSync(which, [bin], { stdio: "ignore" });
2489
+ if (res.status === 0) return void 0;
2490
+ return `${bin} not found on PATH`;
2491
+ };
2492
+ }
2493
+ function noCandidateReason() {
2494
+ if (process.platform === "linux") {
2495
+ if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY && !process.env.TERMUX_VERSION) {
2496
+ return "no display server detected (headless / SSH) \u2014 install xclip or xsel, or use the saved file";
2497
+ }
2498
+ return "no clipboard tool detected \u2014 install xclip, xsel, or wl-copy";
2499
+ }
2500
+ return `no clipboard tool detected on ${process.platform}`;
2305
2501
  }
2306
2502
 
2307
2503
  // src/planner/loop.ts
@@ -2323,8 +2519,8 @@ var READ_FILE_TOOL = {
2323
2519
  }
2324
2520
  };
2325
2521
  var MAX_BYTES_PER_READ = 32e3;
2326
- function readFileTool(root, budget, input6) {
2327
- const raw = input6?.path;
2522
+ function readFileTool(root, budget, input5) {
2523
+ const raw = input5?.path;
2328
2524
  if (typeof raw !== "string" || raw.length === 0) {
2329
2525
  return error('read_file: missing or invalid "path" argument.');
2330
2526
  }
@@ -2455,117 +2651,16 @@ function rateLimitMessage(err) {
2455
2651
  ].join("\n");
2456
2652
  }
2457
2653
 
2458
- // src/planner/loop.ts
2459
- var MAX_RATE_LIMIT_RETRY_SEC = 90;
2460
- var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2461
- async function runPlanner(input6) {
2462
- const maxIter = input6.maxIterations ?? 40;
2463
- const turns = [{ role: "user", text: input6.userPrompt }];
2464
- let accumulatedText = "";
2465
- let iterations = 0;
2466
- let budgetExhausted = false;
2467
- let finishedNormally = false;
2468
- while (iterations < maxIter) {
2469
- iterations += 1;
2470
- if (input6.budget.timedOut()) {
2471
- return { planText: accumulatedText, budgetExhausted, timedOut: true, finishedNormally: false, iterations };
2472
- }
2473
- if (input6.budget.overCost()) {
2474
- return { planText: accumulatedText, budgetExhausted: true, timedOut: false, finishedNormally: false, iterations };
2475
- }
2476
- const request = {
2477
- systemPrompt: input6.systemPrompt,
2478
- model: input6.model,
2479
- tools: [READ_FILE_TOOL],
2480
- turns,
2481
- apiKey: input6.apiKey,
2482
- maxOutputTokens: 8192
2483
- };
2484
- let response = await input6.provider.send(request);
2485
- if (response.usage) {
2486
- input6.budget.recordUsage(response.usage);
2487
- input6.onUsage?.(response.usage);
2488
- }
2489
- let retriedRateLimit = false;
2490
- let retrySkippedReason;
2491
- if (response.stopReason === "error" && response.errorKind === "rate_limit") {
2492
- const asked = response.retryAfterSec;
2493
- if (asked !== void 0 && asked > MAX_RATE_LIMIT_RETRY_SEC) {
2494
- retrySkippedReason = "retry_after_too_long";
2495
- } else {
2496
- const waitSec = Math.min(asked ?? 10, MAX_RATE_LIMIT_RETRY_SEC);
2497
- input6.onRateLimit?.(waitSec);
2498
- await (input6.sleep ?? defaultSleep)(waitSec * 1e3);
2499
- response = await input6.provider.send(request);
2500
- if (response.usage) {
2501
- input6.budget.recordUsage(response.usage);
2502
- input6.onUsage?.(response.usage);
2503
- }
2504
- retriedRateLimit = true;
2505
- }
2506
- }
2507
- if (response.stopReason === "error") {
2508
- throw composePlannerError(input6.provider.name, response, retriedRateLimit, retrySkippedReason);
2509
- }
2510
- if (response.text) {
2511
- accumulatedText += response.text;
2512
- input6.onAssistantText?.(response.text);
2513
- }
2514
- if (response.stopReason === "max_tokens" && !response.toolCalls?.length) {
2515
- return { planText: accumulatedText, budgetExhausted, timedOut: false, finishedNormally: false, iterations };
2516
- }
2517
- if (response.stopReason === "end_turn" || !response.toolCalls?.length) {
2518
- finishedNormally = true;
2519
- return { planText: accumulatedText, budgetExhausted, timedOut: false, finishedNormally, iterations };
2520
- }
2521
- turns.push({
2522
- role: "assistant",
2523
- text: response.text,
2524
- toolCalls: response.toolCalls
2525
- });
2526
- const toolResults = [];
2527
- for (const tc of response.toolCalls) {
2528
- if (tc.name !== READ_FILE_TOOL.name) {
2529
- toolResults.push({ toolCallId: tc.id, content: `unknown tool "${tc.name}"`, isError: true });
2530
- continue;
2531
- }
2532
- const before = input6.budget.snapshot().bytes;
2533
- const result = readFileTool(input6.root, input6.budget, tc.input);
2534
- const after = input6.budget.snapshot().bytes;
2535
- input6.onToolCall?.(tc, after - before, after);
2536
- if (result.isError && (/budget/i.test(result.content) || /max file reads/i.test(result.content))) {
2537
- budgetExhausted = true;
2538
- }
2539
- toolResults.push({ toolCallId: tc.id, content: result.content, isError: result.isError });
2540
- }
2541
- turns.push({ role: "user", toolResults });
2542
- if (budgetExhausted) {
2543
- turns.push({
2544
- role: "user",
2545
- text: "Budget is exhausted. Finalise the plan with the information you already have. Do not call any more tools. Output the complete plan markdown now."
2546
- });
2547
- }
2548
- }
2549
- return { planText: accumulatedText, budgetExhausted, timedOut: false, finishedNormally: false, iterations };
2550
- }
2551
- function composePlannerError(providerName, response, retriedRateLimit, retrySkippedReason) {
2552
- if (response.errorKind === "rate_limit") {
2553
- return new Error(
2554
- rateLimitMessage({
2555
- provider: providerName,
2556
- retryAfterSec: response.retryAfterSec,
2557
- rawBody: response.rawError ?? "",
2558
- retryAlreadyAttempted: retriedRateLimit,
2559
- retrySkippedReason,
2560
- maxRetrySec: MAX_RATE_LIMIT_RETRY_SEC
2561
- })
2562
- );
2563
- }
2564
- if (response.errorKind === "model_not_found") {
2565
- return new Error(response.rawError ?? "planner: model not found");
2654
+ // src/planner/stable-json.ts
2655
+ function sortRecordKeys(v) {
2656
+ if (v === null || typeof v !== "object") return v;
2657
+ if (Array.isArray(v)) return v.map((x) => sortRecordKeys(x));
2658
+ const o = v;
2659
+ const out = {};
2660
+ for (const k of Object.keys(o).sort()) {
2661
+ out[k] = sortRecordKeys(o[k]);
2566
2662
  }
2567
- const base = response.rawError ?? "planner: provider error";
2568
- return new Error(`${base} Run \`squad doctor\` to diagnose, or retry \u2014 most 5xx errors are transient.`);
2663
+ return out;
2569
2664
  }
2570
2665
 
2571
2666
  // src/planner/providers/anthropic.ts
@@ -2577,14 +2672,39 @@ var anthropicProvider = {
2577
2672
  return callAnthropic(req);
2578
2673
  }
2579
2674
  };
2580
- async function callAnthropic(req) {
2581
- const body = {
2675
+ function buildAnthropicBody(req) {
2676
+ if (req.cacheEnabled === false) {
2677
+ const base2 = {
2678
+ model: req.model,
2679
+ max_tokens: req.maxOutputTokens ?? 4096,
2680
+ system: req.systemPrompt,
2681
+ tools: req.tools.map(toAnthropicTool)
2682
+ };
2683
+ if (req.turns.length === 0) {
2684
+ return base2;
2685
+ }
2686
+ return { ...base2, messages: req.turns.map((t) => toAnthropicMessage(t)) };
2687
+ }
2688
+ const base = {
2582
2689
  model: req.model,
2583
2690
  max_tokens: req.maxOutputTokens ?? 4096,
2584
- system: req.systemPrompt,
2585
- tools: req.tools.map(toAnthropicTool),
2586
- messages: req.turns.map(toAnthropicMessage)
2691
+ system: [
2692
+ {
2693
+ type: "text",
2694
+ text: req.systemPrompt,
2695
+ cache_control: { type: "ephemeral" }
2696
+ }
2697
+ ],
2698
+ tools: req.tools.map(toAnthropicTool)
2587
2699
  };
2700
+ if (req.turns.length === 0) {
2701
+ return base;
2702
+ }
2703
+ return { ...base, messages: toMessagesWithCacheMarkers(req.turns) };
2704
+ }
2705
+ async function callAnthropic(req) {
2706
+ const built = buildAnthropicBody(req);
2707
+ const body = { ...built, messages: built.messages ?? [] };
2588
2708
  const res = await fetch(API_URL, {
2589
2709
  method: "POST",
2590
2710
  headers: {
@@ -2624,26 +2744,73 @@ async function callAnthropic(req) {
2624
2744
  stopReason: mapStop(json.stop_reason),
2625
2745
  usage: {
2626
2746
  inputTokens: json.usage?.input_tokens ?? 0,
2627
- outputTokens: json.usage?.output_tokens ?? 0
2747
+ outputTokens: json.usage?.output_tokens ?? 0,
2748
+ cacheCreationTokens: json.usage?.cache_creation_input_tokens ?? 0,
2749
+ cacheReadTokens: json.usage?.cache_read_input_tokens ?? 0
2628
2750
  }
2629
2751
  };
2630
2752
  }
2753
+ function toMessagesWithCacheMarkers(turns) {
2754
+ let lastUserWithToolResultIdx = -1;
2755
+ for (let i = turns.length - 1; i >= 0; i -= 1) {
2756
+ const t = turns[i];
2757
+ if (t.role === "user" && hasToolResultBlock(t)) {
2758
+ lastUserWithToolResultIdx = i;
2759
+ break;
2760
+ }
2761
+ }
2762
+ return turns.map((turn, idx) => {
2763
+ const message = toAnthropicMessage(turn);
2764
+ if (idx === lastUserWithToolResultIdx) {
2765
+ return attachCacheControlToLastToolResult(message);
2766
+ }
2767
+ return message;
2768
+ });
2769
+ }
2770
+ function hasToolResultBlock(turn) {
2771
+ return (turn.toolResults?.length ?? 0) > 0;
2772
+ }
2773
+ function attachCacheControlToLastToolResult(msg) {
2774
+ const content = [...msg.content];
2775
+ for (let i = content.length - 1; i >= 0; i -= 1) {
2776
+ const block = content[i];
2777
+ if (block.type === "tool_result") {
2778
+ content[i] = { ...block, cache_control: { type: "ephemeral" } };
2779
+ break;
2780
+ }
2781
+ }
2782
+ return { ...msg, content };
2783
+ }
2631
2784
  function toAnthropicTool(t) {
2632
- return { name: t.name, description: t.description, input_schema: t.inputSchema };
2785
+ return {
2786
+ name: t.name,
2787
+ description: t.description,
2788
+ input_schema: sortRecordKeys(t.inputSchema)
2789
+ };
2633
2790
  }
2634
2791
  function toAnthropicMessage(turn) {
2635
2792
  const blocks = [];
2636
2793
  if (turn.text) blocks.push({ type: "text", text: turn.text });
2637
- if (turn.toolCalls)
2638
- for (const tc of turn.toolCalls) blocks.push({ type: "tool_use", id: tc.id, name: tc.name, input: tc.input });
2639
- if (turn.toolResults)
2640
- for (const tr of turn.toolResults)
2794
+ if (turn.toolCalls) {
2795
+ for (const tc of turn.toolCalls) {
2796
+ blocks.push({
2797
+ type: "tool_use",
2798
+ id: tc.id,
2799
+ name: tc.name,
2800
+ input: sortRecordKeys(tc.input)
2801
+ });
2802
+ }
2803
+ }
2804
+ if (turn.toolResults) {
2805
+ for (const tr of turn.toolResults) {
2641
2806
  blocks.push({
2642
2807
  type: "tool_result",
2643
2808
  tool_use_id: tr.toolCallId,
2644
2809
  content: tr.content,
2645
2810
  is_error: tr.isError ?? false
2646
2811
  });
2812
+ }
2813
+ }
2647
2814
  return { role: turn.role === "assistant" ? "assistant" : "user", content: blocks };
2648
2815
  }
2649
2816
  function mapStop(r) {
@@ -2660,15 +2827,23 @@ var openaiProvider = {
2660
2827
  return callOpenAI(req);
2661
2828
  }
2662
2829
  };
2663
- async function callOpenAI(req) {
2664
- const messages = [{ role: "system", content: req.systemPrompt }];
2665
- for (const turn of req.turns) messages.push(...toOpenAIMessages(turn));
2666
- const body = {
2830
+ function buildOpenAIBody(req) {
2831
+ const base = {
2667
2832
  model: req.model,
2668
- messages,
2669
- tools: req.tools.map(toOpenAITool),
2670
- max_completion_tokens: req.maxOutputTokens ?? 4096
2833
+ max_completion_tokens: req.maxOutputTokens ?? 4096,
2834
+ tools: req.tools.map(toOpenAITool)
2671
2835
  };
2836
+ if (req.turns.length === 0) {
2837
+ return base;
2838
+ }
2839
+ const messages = [{ role: "system", content: req.systemPrompt }];
2840
+ for (const turn of req.turns) messages.push(...toOpenAIMessages(turn));
2841
+ return { ...base, messages };
2842
+ }
2843
+ async function callOpenAI(req) {
2844
+ const built = buildOpenAIBody(req);
2845
+ const messages = built.messages && built.messages.length > 0 ? built.messages : [{ role: "system", content: req.systemPrompt }];
2846
+ const body = { ...built, messages };
2672
2847
  const res = await fetch(API_URL2, {
2673
2848
  method: "POST",
2674
2849
  headers: {
@@ -2718,7 +2893,14 @@ async function callOpenAI(req) {
2718
2893
  };
2719
2894
  }
2720
2895
  function toOpenAITool(t) {
2721
- return { type: "function", function: { name: t.name, description: t.description, parameters: t.inputSchema } };
2896
+ return {
2897
+ type: "function",
2898
+ function: {
2899
+ name: t.name,
2900
+ description: t.description,
2901
+ parameters: sortRecordKeys(t.inputSchema)
2902
+ }
2903
+ };
2722
2904
  }
2723
2905
  function toOpenAIMessages(turn) {
2724
2906
  const out = [];
@@ -2729,7 +2911,10 @@ function toOpenAIMessages(turn) {
2729
2911
  tool_calls: turn.toolCalls?.map((tc) => ({
2730
2912
  id: tc.id,
2731
2913
  type: "function",
2732
- function: { name: tc.name, arguments: JSON.stringify(tc.input) }
2914
+ function: {
2915
+ name: tc.name,
2916
+ arguments: JSON.stringify(sortRecordKeys(tc.input))
2917
+ }
2733
2918
  }))
2734
2919
  });
2735
2920
  } else {
@@ -2749,7 +2934,7 @@ function mapStop2(r) {
2749
2934
  }
2750
2935
  function safeJson(raw) {
2751
2936
  try {
2752
- return JSON.parse(raw);
2937
+ return sortRecordKeys(JSON.parse(raw));
2753
2938
  } catch {
2754
2939
  return {};
2755
2940
  }
@@ -2763,14 +2948,23 @@ var googleProvider = {
2763
2948
  return callGoogle(req);
2764
2949
  }
2765
2950
  };
2766
- async function callGoogle(req) {
2767
- const url = `${API_BASE}/${encodeURIComponent(req.model)}:generateContent?key=${encodeURIComponent(req.apiKey)}`;
2768
- const body = {
2951
+ function buildGoogleBody(req) {
2952
+ const base = {
2769
2953
  system_instruction: { parts: [{ text: req.systemPrompt }] },
2770
- contents: req.turns.map(toGoogleContent),
2771
- tools: req.tools.length ? [{ functionDeclarations: req.tools.map(toGoogleTool) }] : void 0,
2772
2954
  generationConfig: { maxOutputTokens: req.maxOutputTokens ?? 4096 }
2773
2955
  };
2956
+ if (req.tools.length) {
2957
+ base.tools = [{ functionDeclarations: req.tools.map(toGoogleTool) }];
2958
+ }
2959
+ if (req.turns.length === 0) {
2960
+ return base;
2961
+ }
2962
+ return { ...base, contents: req.turns.map(toGoogleContent) };
2963
+ }
2964
+ async function callGoogle(req) {
2965
+ const url = `${API_BASE}/${encodeURIComponent(req.model)}:generateContent?key=${encodeURIComponent(req.apiKey)}`;
2966
+ const built = buildGoogleBody(req);
2967
+ const body = { ...built, contents: built.contents ?? [] };
2774
2968
  const res = await fetch(url, {
2775
2969
  method: "POST",
2776
2970
  headers: { "content-type": "application/json" },
@@ -2805,7 +2999,7 @@ async function callGoogle(req) {
2805
2999
  const toolCalls = parts.filter((p) => "functionCall" in p && !!p.functionCall).map((p, i) => ({
2806
3000
  id: `gcall_${i}_${p.functionCall.name}`,
2807
3001
  name: p.functionCall.name,
2808
- input: p.functionCall.args ?? {}
3002
+ input: p.functionCall.args ? sortRecordKeys(p.functionCall.args) : {}
2809
3003
  }));
2810
3004
  return {
2811
3005
  text: textOut || void 0,
@@ -2818,14 +3012,17 @@ async function callGoogle(req) {
2818
3012
  };
2819
3013
  }
2820
3014
  function toGoogleTool(t) {
2821
- return { name: t.name, description: t.description, parameters: t.inputSchema };
3015
+ return { name: t.name, description: t.description, parameters: sortRecordKeys(t.inputSchema) };
2822
3016
  }
2823
3017
  function toGoogleContent(turn) {
2824
3018
  const role = turn.role === "assistant" ? "model" : "user";
2825
3019
  const parts = [];
2826
3020
  if (turn.text) parts.push({ text: turn.text });
2827
- if (turn.toolCalls)
2828
- for (const tc of turn.toolCalls) parts.push({ functionCall: { name: tc.name, args: tc.input } });
3021
+ if (turn.toolCalls) {
3022
+ for (const tc of turn.toolCalls) {
3023
+ parts.push({ functionCall: { name: tc.name, args: sortRecordKeys(tc.input) } });
3024
+ }
3025
+ }
2829
3026
  if (turn.toolResults) {
2830
3027
  for (const tr of turn.toolResults) {
2831
3028
  parts.push({
@@ -2845,6 +3042,416 @@ function mapStop3(r) {
2845
3042
  return "tool_use";
2846
3043
  }
2847
3044
 
3045
+ // src/planner/providers/prefix.ts
3046
+ function prefixOf(provider, req) {
3047
+ const trimmed = { ...req, turns: req.turns.slice(0, -1) };
3048
+ const s = (() => {
3049
+ switch (provider) {
3050
+ case "anthropic":
3051
+ return JSON.stringify(buildAnthropicBody(trimmed));
3052
+ case "openai":
3053
+ return JSON.stringify(buildOpenAIBody(trimmed));
3054
+ case "google":
3055
+ return JSON.stringify(buildGoogleBody(trimmed));
3056
+ }
3057
+ })();
3058
+ return s.length > 0 ? s.slice(0, -1) : s;
3059
+ }
3060
+
3061
+ // src/planner/session-limits.ts
3062
+ var DEFAULT_PLANNER_MAX_ITERATIONS = 40;
3063
+ var PLANNER_MARKDOWN_CONTINUATION_USER = "Your previous reply was cut off because it hit the per-response output token limit for this request. Continue the Markdown plan from the exact cut-off only: write the remainder, do not repeat headings or paragraphs already sent. When the plan is complete, use end_turn without unnecessary tool calls.";
3064
+
3065
+ // src/planner/loop.ts
3066
+ var MAX_RATE_LIMIT_RETRY_SEC = 90;
3067
+ var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3068
+ function buildPlannerRunStats(budget, turns, runStartedAt) {
3069
+ const u = budget.snapshot().usage;
3070
+ const cacheRead = u.cacheReadTokens ?? 0;
3071
+ const cacheCreate = u.cacheCreationTokens ?? 0;
3072
+ const inTok = u.inputTokens;
3073
+ const outTok = u.outputTokens;
3074
+ const totalInput = inTok + cacheRead;
3075
+ const cacheHitRatio = totalInput === 0 ? 0 : Math.round(cacheRead / totalInput * 100) / 100;
3076
+ return {
3077
+ turns,
3078
+ inputTokens: inTok,
3079
+ outputTokens: outTok,
3080
+ cacheCreationTokens: cacheCreate,
3081
+ cacheReadTokens: cacheRead,
3082
+ cacheHitRatio,
3083
+ durationMs: Date.now() - runStartedAt
3084
+ };
3085
+ }
3086
+ function readBudgetishError(result) {
3087
+ return result.isError && (/budget/i.test(result.content) || /max file reads/i.test(result.content));
3088
+ }
3089
+ function extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut) {
3090
+ input5.budget.extendSession();
3091
+ maxIter.current += baseMaxIter;
3092
+ maxOut.current += baseMaxOut;
3093
+ }
3094
+ async function runPlanner(input5) {
3095
+ const runStartedAt = Date.now();
3096
+ const baseMaxIter = input5.maxIterations ?? DEFAULT_PLANNER_MAX_ITERATIONS;
3097
+ const maxIter = { current: baseMaxIter };
3098
+ const baseMaxOut = input5.maxOutputTokens ?? DEFAULT_PLANNER_MAX_OUTPUT_TOKENS;
3099
+ const maxOut = { current: baseMaxOut };
3100
+ const turns = [{ role: "user", text: input5.userPrompt }];
3101
+ let accumulatedText = "";
3102
+ let iterations = 0;
3103
+ let budgetExhausted = false;
3104
+ let finishedNormally = false;
3105
+ let prevRequestPrefix;
3106
+ const decide = input5.decideOnLimit;
3107
+ const limitCtx = (kind) => ({
3108
+ kind,
3109
+ budgetSnapshot: input5.budget.snapshot(),
3110
+ iterations,
3111
+ maxIterations: maxIter.current,
3112
+ maxOutputTokens: maxOut.current
3113
+ });
3114
+ for (; ; ) {
3115
+ if (iterations >= maxIter.current) {
3116
+ if (decide) {
3117
+ const d = await decide(limitCtx("max_iterations"));
3118
+ if (d === "cancel") {
3119
+ return {
3120
+ planText: accumulatedText,
3121
+ budgetExhausted,
3122
+ timedOut: false,
3123
+ finishedNormally: false,
3124
+ iterations,
3125
+ stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
3126
+ incompleteKind: "max_iterations",
3127
+ userCancelled: true
3128
+ };
3129
+ }
3130
+ extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut);
3131
+ continue;
3132
+ }
3133
+ return {
3134
+ planText: accumulatedText,
3135
+ budgetExhausted,
3136
+ timedOut: false,
3137
+ finishedNormally: false,
3138
+ iterations,
3139
+ stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
3140
+ incompleteKind: "max_iterations"
3141
+ };
3142
+ }
3143
+ iterations += 1;
3144
+ if (input5.budget.timedOut()) {
3145
+ if (decide) {
3146
+ const d = await decide(limitCtx("wall_clock"));
3147
+ if (d === "cancel") {
3148
+ return {
3149
+ planText: accumulatedText,
3150
+ budgetExhausted,
3151
+ timedOut: true,
3152
+ finishedNormally: false,
3153
+ iterations,
3154
+ stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
3155
+ incompleteKind: "wall_clock",
3156
+ userCancelled: true
3157
+ };
3158
+ }
3159
+ extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut);
3160
+ iterations -= 1;
3161
+ continue;
3162
+ }
3163
+ return {
3164
+ planText: accumulatedText,
3165
+ budgetExhausted,
3166
+ timedOut: true,
3167
+ finishedNormally: false,
3168
+ iterations,
3169
+ stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt)
3170
+ };
3171
+ }
3172
+ if (input5.budget.overCost()) {
3173
+ if (decide) {
3174
+ const d = await decide(limitCtx("cost_cap"));
3175
+ if (d === "cancel") {
3176
+ return {
3177
+ planText: accumulatedText,
3178
+ budgetExhausted,
3179
+ timedOut: false,
3180
+ finishedNormally: false,
3181
+ iterations,
3182
+ stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
3183
+ incompleteKind: "cost_cap",
3184
+ userCancelled: true
3185
+ };
3186
+ }
3187
+ extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut);
3188
+ iterations -= 1;
3189
+ continue;
3190
+ }
3191
+ return {
3192
+ planText: accumulatedText,
3193
+ budgetExhausted: true,
3194
+ timedOut: false,
3195
+ finishedNormally: false,
3196
+ iterations,
3197
+ stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt)
3198
+ };
3199
+ }
3200
+ const request = {
3201
+ systemPrompt: input5.systemPrompt,
3202
+ model: input5.model,
3203
+ tools: [READ_FILE_TOOL],
3204
+ turns,
3205
+ apiKey: input5.apiKey,
3206
+ maxOutputTokens: maxOut.current,
3207
+ cacheEnabled: input5.cacheEnabled ?? true
3208
+ };
3209
+ if (process.env.NODE_ENV !== "production" && process.env.NODE_ENV !== "test" && prevRequestPrefix !== void 0) {
3210
+ const currentPrefix = prefixOf(input5.provider.name, request);
3211
+ if (!currentPrefix.startsWith(prevRequestPrefix)) {
3212
+ const byte = firstByteDiff(prevRequestPrefix, currentPrefix);
3213
+ warning(
3214
+ `planner: prefix mutation at turn ${iterations}, byte ${byte} \u2014 caching will not hit.`
3215
+ );
3216
+ }
3217
+ }
3218
+ let response = await input5.provider.send(request);
3219
+ if (response.usage) {
3220
+ input5.budget.recordUsage(response.usage);
3221
+ input5.onUsage?.(response.usage);
3222
+ }
3223
+ let retriedRateLimit = false;
3224
+ let retrySkippedReason;
3225
+ if (response.stopReason === "error" && response.errorKind === "rate_limit") {
3226
+ const asked = response.retryAfterSec;
3227
+ if (asked !== void 0 && asked > MAX_RATE_LIMIT_RETRY_SEC) {
3228
+ retrySkippedReason = "retry_after_too_long";
3229
+ } else {
3230
+ const waitSec = Math.min(asked ?? 10, MAX_RATE_LIMIT_RETRY_SEC);
3231
+ input5.onRateLimit?.(waitSec);
3232
+ await (input5.sleep ?? defaultSleep)(waitSec * 1e3);
3233
+ response = await input5.provider.send(request);
3234
+ if (response.usage) {
3235
+ input5.budget.recordUsage(response.usage);
3236
+ input5.onUsage?.(response.usage);
3237
+ }
3238
+ retriedRateLimit = true;
3239
+ }
3240
+ }
3241
+ if (response.stopReason === "error") {
3242
+ throw composePlannerError(input5.provider.name, response, retriedRateLimit, retrySkippedReason);
3243
+ }
3244
+ prevRequestPrefix = prefixOf(input5.provider.name, request);
3245
+ if (response.text) {
3246
+ accumulatedText += response.text;
3247
+ input5.onAssistantText?.(response.text);
3248
+ }
3249
+ if (response.stopReason === "max_tokens" && !response.toolCalls?.length) {
3250
+ if (decide) {
3251
+ const d = await decide(limitCtx("max_output_tokens"));
3252
+ if (d === "cancel") {
3253
+ return {
3254
+ planText: accumulatedText,
3255
+ budgetExhausted,
3256
+ timedOut: false,
3257
+ finishedNormally: false,
3258
+ iterations,
3259
+ stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
3260
+ incompleteKind: "max_output_tokens",
3261
+ userCancelled: true
3262
+ };
3263
+ }
3264
+ extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut);
3265
+ turns.push({ role: "assistant", text: response.text ?? "" });
3266
+ turns.push({ role: "user", text: PLANNER_MARKDOWN_CONTINUATION_USER });
3267
+ iterations -= 1;
3268
+ continue;
3269
+ }
3270
+ return {
3271
+ planText: accumulatedText,
3272
+ budgetExhausted,
3273
+ timedOut: false,
3274
+ finishedNormally: false,
3275
+ iterations,
3276
+ stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
3277
+ incompleteKind: "max_output_tokens"
3278
+ };
3279
+ }
3280
+ if (response.stopReason === "end_turn" || !response.toolCalls?.length) {
3281
+ finishedNormally = true;
3282
+ return {
3283
+ planText: accumulatedText,
3284
+ budgetExhausted,
3285
+ timedOut: false,
3286
+ finishedNormally,
3287
+ iterations,
3288
+ stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt)
3289
+ };
3290
+ }
3291
+ turns.push({
3292
+ role: "assistant",
3293
+ text: response.text,
3294
+ toolCalls: response.toolCalls
3295
+ });
3296
+ const toolResults = [];
3297
+ const toolCalls = response.toolCalls;
3298
+ for (let i = 0; i < toolCalls.length; i += 1) {
3299
+ const tc = toolCalls[i];
3300
+ if (tc.name !== READ_FILE_TOOL.name) {
3301
+ toolResults.push({ toolCallId: tc.id, content: `unknown tool "${tc.name}"`, isError: true });
3302
+ continue;
3303
+ }
3304
+ const before = input5.budget.snapshot().bytes;
3305
+ let result = readFileTool(input5.root, input5.budget, tc.input);
3306
+ const after = input5.budget.snapshot().bytes;
3307
+ input5.onToolCall?.(tc, after - before, after);
3308
+ if (readBudgetishError(result)) {
3309
+ if (decide) {
3310
+ const d = await decide(limitCtx("file_or_context_reads"));
3311
+ if (d === "cancel") {
3312
+ toolResults.push({ toolCallId: tc.id, content: result.content, isError: true });
3313
+ for (let j = i + 1; j < toolCalls.length; j += 1) {
3314
+ const t2 = toolCalls[j];
3315
+ toolResults.push({
3316
+ toolCallId: t2.id,
3317
+ content: t2.name === READ_FILE_TOOL.name ? "read_file: not executed (planning session stopped after file/context budget)." : `unknown tool "${t2.name}"`,
3318
+ isError: true
3319
+ });
3320
+ }
3321
+ turns.push({ role: "user", toolResults });
3322
+ return {
3323
+ planText: accumulatedText,
3324
+ budgetExhausted: false,
3325
+ timedOut: false,
3326
+ finishedNormally: false,
3327
+ iterations,
3328
+ stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
3329
+ incompleteKind: "budget_reads",
3330
+ userCancelled: true
3331
+ };
3332
+ }
3333
+ extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut);
3334
+ result = readFileTool(input5.root, input5.budget, tc.input);
3335
+ } else {
3336
+ budgetExhausted = true;
3337
+ }
3338
+ }
3339
+ toolResults.push({ toolCallId: tc.id, content: result.content, isError: result.isError });
3340
+ }
3341
+ turns.push({ role: "user", toolResults });
3342
+ if (budgetExhausted) {
3343
+ turns.push({
3344
+ role: "user",
3345
+ 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."
3346
+ });
3347
+ }
3348
+ }
3349
+ }
3350
+ function firstByteDiff(a, b) {
3351
+ const n = Math.min(a.length, b.length);
3352
+ for (let i = 0; i < n; i += 1) {
3353
+ if (a.charCodeAt(i) !== b.charCodeAt(i)) return i;
3354
+ }
3355
+ return n;
3356
+ }
3357
+ function composePlannerError(providerName, response, retriedRateLimit, retrySkippedReason) {
3358
+ if (response.errorKind === "rate_limit") {
3359
+ return new Error(
3360
+ rateLimitMessage({
3361
+ provider: providerName,
3362
+ retryAfterSec: response.retryAfterSec,
3363
+ rawBody: response.rawError ?? "",
3364
+ retryAlreadyAttempted: retriedRateLimit,
3365
+ retrySkippedReason,
3366
+ maxRetrySec: MAX_RATE_LIMIT_RETRY_SEC
3367
+ })
3368
+ );
3369
+ }
3370
+ if (response.errorKind === "model_not_found") {
3371
+ return new Error(response.rawError ?? "planner: model not found");
3372
+ }
3373
+ const base = response.rawError ?? "planner: provider error";
3374
+ return new Error(`${base} Run \`squad doctor\` to diagnose, or retry \u2014 most 5xx errors are transient.`);
3375
+ }
3376
+
3377
+ // src/planner/planner-limit-messages.ts
3378
+ function printPlannerApiCostNotice() {
3379
+ blank();
3380
+ step("Billing");
3381
+ info(
3382
+ "`squad new-plan --api` sends your intake and repo context to the configured provider. Each model round is billed like any other API usage (input tokens, output tokens, and cache-related tokens when applicable). Choosing \u201CContinue\u201D after a limit runs more rounds and usually increases cost."
3383
+ );
3384
+ blank();
3385
+ }
3386
+ function kindTitle(kind) {
3387
+ switch (kind) {
3388
+ case "max_output_tokens":
3389
+ return "Output length limit (single response)";
3390
+ case "max_iterations":
3391
+ return "Model round limit";
3392
+ case "wall_clock":
3393
+ return "Wall-clock time limit";
3394
+ case "cost_cap":
3395
+ return "Estimated cost cap";
3396
+ case "file_or_context_reads":
3397
+ return "File read or context size limit";
3398
+ }
3399
+ }
3400
+ function kindDetail(ctx) {
3401
+ const snap = ctx.budgetSnapshot;
3402
+ const cost = snap.usage.costUsd !== void 0 ? `Estimated spend so far: about $${snap.usage.costUsd.toFixed(2)} (provider-reported when available).` : "Estimated spend so far: not reported by the provider for this run.";
3403
+ switch (ctx.kind) {
3404
+ case "max_output_tokens":
3405
+ return [
3406
+ `The model hit the per-request output cap (${ctx.maxOutputTokens} completion tokens). Long plans can stop mid-markdown even though the session is otherwise healthy.`,
3407
+ "You can continue: squad-kit will ask the model to append the rest of the plan in a follow-up request (extra tokens apply).",
3408
+ cost
3409
+ ];
3410
+ case "max_iterations":
3411
+ return [
3412
+ `The planner reached its round cap (${ctx.maxIterations} model turns) without a clean stop.`,
3413
+ "Continuing raises the round cap and file/time budgets for this run by another full slice from your config.",
3414
+ cost
3415
+ ];
3416
+ case "wall_clock":
3417
+ return [
3418
+ "The configured wall-clock budget for this planning session elapsed before the model finished.",
3419
+ "Continuing adds another slice of time (and read/output/round limits) from your planner budget.",
3420
+ cost
3421
+ ];
3422
+ case "cost_cap":
3423
+ return [
3424
+ "Spending crossed the optional `planner.budget.maxCostUsd` ceiling for this session.",
3425
+ "Continuing raises that ceiling for this run by another slice from config (if a cap is set).",
3426
+ cost
3427
+ ];
3428
+ case "file_or_context_reads":
3429
+ return [
3430
+ "A `read_file` call could not run because the file-read count or total read-bytes budget was exhausted.",
3431
+ "Continuing extends read, context, time, round, and output limits for this session so the model can read again or finish without reads.",
3432
+ cost
3433
+ ];
3434
+ }
3435
+ }
3436
+ function printPlannerLimitExplanation(ctx) {
3437
+ warning(kindTitle(ctx.kind));
3438
+ for (const line2 of kindDetail(ctx)) {
3439
+ info(line2);
3440
+ }
3441
+ kv("session so far", `${snapSummary(ctx)}`, 18);
3442
+ }
3443
+ function snapSummary(ctx) {
3444
+ const s = ctx.budgetSnapshot;
3445
+ return `${s.reads} reads \xB7 ${(s.bytes / 1024).toFixed(1)} KB read context \xB7 ${s.usage.inputTokens} in / ${s.usage.outputTokens} out tokens`;
3446
+ }
3447
+ function printPlannerLimitNextSteps() {
3448
+ blank();
3449
+ step("If you stop here");
3450
+ info("An incomplete plan is saved as `*.partial.md` with front matter `squad-kit-plan-status: partial`.");
3451
+ info("Fix limits in `.squad/config.yaml` (`planner.budget`, `planner.maxOutputTokens`) or run again when ready.");
3452
+ blank();
3453
+ }
3454
+
2848
3455
  // src/planner/providers/index.ts
2849
3456
  function providerFor(name) {
2850
3457
  switch (name) {
@@ -2861,18 +3468,35 @@ function providerFor(name) {
2861
3468
  var Budget = class {
2862
3469
  constructor(cfg) {
2863
3470
  this.cfg = cfg;
3471
+ this.maxFileReadsAllowed = cfg.maxFileReads;
3472
+ this.maxContextBytesAllowed = cfg.maxContextBytes;
3473
+ this.maxWallSeconds = cfg.maxDurationSeconds;
3474
+ this.maxCostUsdCeiling = cfg.maxCostUsd;
2864
3475
  }
2865
3476
  cfg;
2866
3477
  reads = 0;
2867
3478
  bytes = 0;
2868
3479
  startedAt = Date.now();
2869
- totalUsage = { inputTokens: 0, outputTokens: 0 };
3480
+ totalUsage = { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 };
3481
+ maxFileReadsAllowed;
3482
+ maxContextBytesAllowed;
3483
+ maxWallSeconds;
3484
+ maxCostUsdCeiling;
3485
+ /** Add another slice of the original config limits (used when the user chooses to continue a planner session). */
3486
+ extendSession() {
3487
+ this.maxFileReadsAllowed += this.cfg.maxFileReads;
3488
+ this.maxContextBytesAllowed += this.cfg.maxContextBytes;
3489
+ this.maxWallSeconds += this.cfg.maxDurationSeconds;
3490
+ if (this.cfg.maxCostUsd !== void 0) {
3491
+ this.maxCostUsdCeiling = (this.maxCostUsdCeiling ?? 0) + this.cfg.maxCostUsd;
3492
+ }
3493
+ }
2870
3494
  canRead(nextFileBytes) {
2871
- if (this.reads >= this.cfg.maxFileReads) {
2872
- return { ok: false, reason: `max file reads (${this.cfg.maxFileReads}) reached` };
3495
+ if (this.reads >= this.maxFileReadsAllowed) {
3496
+ return { ok: false, reason: `max file reads (${this.maxFileReadsAllowed}) reached` };
2873
3497
  }
2874
- if (this.bytes + nextFileBytes > this.cfg.maxContextBytes) {
2875
- return { ok: false, reason: `context budget (${this.cfg.maxContextBytes} bytes) would be exceeded` };
3498
+ if (this.bytes + nextFileBytes > this.maxContextBytesAllowed) {
3499
+ return { ok: false, reason: `context budget (${this.maxContextBytesAllowed} bytes) would be exceeded` };
2876
3500
  }
2877
3501
  return { ok: true };
2878
3502
  }
@@ -2884,13 +3508,15 @@ var Budget = class {
2884
3508
  this.totalUsage.inputTokens += u.inputTokens;
2885
3509
  this.totalUsage.outputTokens += u.outputTokens;
2886
3510
  if (u.costUsd !== void 0) this.totalUsage.costUsd = (this.totalUsage.costUsd ?? 0) + u.costUsd;
3511
+ this.totalUsage.cacheCreationTokens = (this.totalUsage.cacheCreationTokens ?? 0) + (u.cacheCreationTokens ?? 0);
3512
+ this.totalUsage.cacheReadTokens = (this.totalUsage.cacheReadTokens ?? 0) + (u.cacheReadTokens ?? 0);
2887
3513
  }
2888
3514
  timedOut() {
2889
- return (Date.now() - this.startedAt) / 1e3 > this.cfg.maxDurationSeconds;
3515
+ return (Date.now() - this.startedAt) / 1e3 > this.maxWallSeconds;
2890
3516
  }
2891
3517
  overCost() {
2892
- if (this.cfg.maxCostUsd === void 0) return false;
2893
- return (this.totalUsage.costUsd ?? 0) > this.cfg.maxCostUsd;
3518
+ if (this.maxCostUsdCeiling === void 0) return false;
3519
+ return (this.totalUsage.costUsd ?? 0) > this.maxCostUsdCeiling;
2894
3520
  }
2895
3521
  snapshot() {
2896
3522
  return {
@@ -3029,10 +3655,19 @@ function formatSequence(n) {
3029
3655
  }
3030
3656
 
3031
3657
  // src/planner/writer.ts
3032
- function writePlanFile(input6) {
3033
- const { paths, config: config2, story } = input6;
3658
+ function planFileSlugParts(config2, story) {
3659
+ const baseSlug = slugify(story.id);
3660
+ const trackerId = config2.naming.includeTrackerId && config2.tracker.type !== "none" ? trackerIdForFilename(config2.tracker.type, story.id) : "";
3661
+ const trackerPart = trackerId && trackerId.toLowerCase() !== baseSlug ? `-${trackerId}` : "";
3662
+ return { baseSlug, trackerPart };
3663
+ }
3664
+ function writePlanFile(input5) {
3665
+ const { paths, config: config2, story } = input5;
3034
3666
  const featurePlanDir = path13.join(paths.plansDir, story.feature);
3035
3667
  fs20.mkdirSync(featurePlanDir, { recursive: true });
3668
+ if (input5.partial) {
3669
+ return writePartialPlanFile(input5, featurePlanDir, paths, config2, story);
3670
+ }
3036
3671
  const existing = findExistingPlan(featurePlanDir, story.id);
3037
3672
  let targetPath;
3038
3673
  let nn;
@@ -3043,23 +3678,53 @@ function writePlanFile(input6) {
3043
3678
  overwrote = true;
3044
3679
  } else {
3045
3680
  nn = scanPlans(paths.plansDir).nextGlobal;
3046
- const titleHintSlug = story.titleHint ? slugify(story.titleHint) : "";
3047
- const baseSlug = titleHintSlug || slugify(story.id);
3048
- const trackerId = config2.naming.includeTrackerId && config2.tracker.type !== "none" ? trackerIdForFilename(config2.tracker.type, story.id) : "";
3049
- const trackerPart = trackerId && trackerId !== baseSlug ? `-${trackerId}` : "";
3681
+ const { baseSlug, trackerPart } = planFileSlugParts(config2, story);
3050
3682
  targetPath = path13.join(featurePlanDir, `${formatSequence(nn)}-story-${baseSlug}${trackerPart}.md`);
3051
3683
  }
3052
- const body = `${input6.metadataHeader.trimEnd()}
3684
+ const body = `${input5.metadataHeader.trimEnd()}
3685
+
3686
+ ${input5.planBodyMarkdown.trimStart()}
3687
+ `;
3688
+ fs20.writeFileSync(targetPath, body, "utf8");
3689
+ upsertOverviewRow(featurePlanDir, story.feature, nn, path13.basename(targetPath), story, false);
3690
+ return { planFile: targetPath, sequenceNumber: nn, overwrote };
3691
+ }
3692
+ function writePartialPlanFile(input5, featurePlanDir, paths, config2, story) {
3693
+ const existingPartial = findExistingPartialPlan(featurePlanDir, story.id);
3694
+ let targetPath;
3695
+ let nn;
3696
+ let overwrote = false;
3697
+ if (existingPartial) {
3698
+ targetPath = existingPartial.absPath;
3699
+ nn = existingPartial.nn;
3700
+ overwrote = true;
3701
+ } else {
3702
+ nn = scanPlans(paths.plansDir).nextGlobal;
3703
+ const { baseSlug, trackerPart } = planFileSlugParts(config2, story);
3704
+ targetPath = path13.join(featurePlanDir, `${formatSequence(nn)}-story-${baseSlug}${trackerPart}.partial.md`);
3705
+ }
3706
+ const body = `${input5.metadataHeader.trimEnd()}
3053
3707
 
3054
- ${input6.planBodyMarkdown.trimStart()}
3708
+ ${input5.planBodyMarkdown.trimStart()}
3055
3709
  `;
3056
3710
  fs20.writeFileSync(targetPath, body, "utf8");
3057
- upsertOverviewRow(featurePlanDir, story.feature, nn, path13.basename(targetPath), story);
3711
+ upsertOverviewRow(featurePlanDir, story.feature, nn, path13.basename(targetPath), story, true);
3058
3712
  return { planFile: targetPath, sequenceNumber: nn, overwrote };
3059
3713
  }
3060
3714
  function findExistingPlan(featureDir, storyId) {
3061
3715
  if (!fs20.existsSync(featureDir)) return void 0;
3062
3716
  const pattern = /^(\d{2,})-story-.+\.md$/;
3717
+ for (const entry of fs20.readdirSync(featureDir)) {
3718
+ if (entry.endsWith(".partial.md")) continue;
3719
+ const m = entry.match(pattern);
3720
+ if (!m) continue;
3721
+ if (entry.includes(storyId)) return { absPath: path13.join(featureDir, entry), nn: parseInt(m[1], 10) };
3722
+ }
3723
+ return void 0;
3724
+ }
3725
+ function findExistingPartialPlan(featureDir, storyId) {
3726
+ if (!fs20.existsSync(featureDir)) return void 0;
3727
+ const pattern = /^(\d{2,})-story-.+\.partial\.md$/;
3063
3728
  for (const entry of fs20.readdirSync(featureDir)) {
3064
3729
  const m = entry.match(pattern);
3065
3730
  if (!m) continue;
@@ -3067,9 +3732,10 @@ function findExistingPlan(featureDir, storyId) {
3067
3732
  }
3068
3733
  return void 0;
3069
3734
  }
3070
- function upsertOverviewRow(featureDir, feature, nn, filename, story) {
3735
+ function upsertOverviewRow(featureDir, feature, nn, filename, story, isPartial) {
3071
3736
  const overviewPath = path13.join(featureDir, "00-overview.md");
3072
- const row = `| ${formatSequence(nn)} | \`${filename}\` | ${story.titleHint ?? story.id} | ${story.id} | \u2014 |`;
3737
+ const titleCell = isPartial ? `${story.titleHint ?? story.id} (partial)` : story.titleHint ?? story.id;
3738
+ const row = `| ${formatSequence(nn)} | \`${filename}\` | ${titleCell} | ${story.id} | \u2014 |`;
3073
3739
  if (!fs20.existsSync(overviewPath)) {
3074
3740
  const content = `# ${feature} \u2014 plan overview
3075
3741
 
@@ -3115,7 +3781,54 @@ function buildMetadataHeader(args) {
3115
3781
  `${args.inputTokens} in / ${args.outputTokens} out tokens`,
3116
3782
  args.costUsd !== void 0 ? `~$${args.costUsd.toFixed(2)}` : null
3117
3783
  ].filter(Boolean);
3118
- return `<!-- squad-kit: ${parts.join(", ")} -->`;
3784
+ const line1 = `<!-- squad-kit: ${parts.join(", ")} -->`;
3785
+ if (args.planStatus === "partial") {
3786
+ return `${line1}
3787
+
3788
+ ---
3789
+ squad-kit-plan-status: partial
3790
+ ---`;
3791
+ }
3792
+ return line1;
3793
+ }
3794
+
3795
+ // src/core/last-run.ts
3796
+ import fs21 from "fs/promises";
3797
+ import path14 from "path";
3798
+ async function writeLastRun(paths, rec) {
3799
+ const file = path14.join(paths.squadDir, ".last-run.json");
3800
+ await fs21.writeFile(file, JSON.stringify({ ...rec, version: 1 }, null, 2) + "\n", "utf8");
3801
+ }
3802
+ async function readLastRun(paths) {
3803
+ const file = path14.join(paths.squadDir, ".last-run.json");
3804
+ try {
3805
+ const raw = await fs21.readFile(file, "utf8");
3806
+ const parsed = JSON.parse(raw);
3807
+ if (parsed.version !== 1) return void 0;
3808
+ return parsed;
3809
+ } catch {
3810
+ return void 0;
3811
+ }
3812
+ }
3813
+
3814
+ // src/ui/planner-cache-summary.ts
3815
+ function formatTokenK(n) {
3816
+ return `${(n / 1e3).toFixed(1)}k`;
3817
+ }
3818
+ function formatPlannerCacheLine(opts) {
3819
+ if (!opts.cacheEnabled) {
3820
+ return "cache disabled";
3821
+ }
3822
+ const { stats } = opts;
3823
+ if (stats.cacheReadTokens > 0) {
3824
+ const pct = Math.round(stats.cacheHitRatio * 100);
3825
+ const total = stats.inputTokens + stats.cacheReadTokens;
3826
+ const read = formatTokenK(stats.cacheReadTokens);
3827
+ const tot = formatTokenK(total);
3828
+ const written = stats.cacheCreationTokens > 0 ? ` \xB7 ${formatTokenK(stats.cacheCreationTokens)} written` : "";
3829
+ return `cache hit ${pct}% (${read} read / ${tot} total${written})`;
3830
+ }
3831
+ return "cache miss 0% \u2014 run `squad doctor` to diagnose";
3119
3832
  }
3120
3833
 
3121
3834
  // src/commands/new-plan.ts
@@ -3149,8 +3862,8 @@ async function runNewPlan(intakePath, opts) {
3149
3862
  }
3150
3863
  }
3151
3864
  function resolveFromPath(intakePath, stories, root) {
3152
- const resolved = path14.resolve(intakePath.trim());
3153
- if (!fs21.existsSync(resolved)) {
3865
+ const resolved = path15.resolve(intakePath.trim());
3866
+ if (!fs22.existsSync(resolved)) {
3154
3867
  throw new Error(
3155
3868
  `Intake not found: ${resolved}. Run \`squad list\` to see intakes, or \`squad new-story\` to create one first.`
3156
3869
  );
@@ -3162,7 +3875,7 @@ function resolveFromPath(intakePath, stories, root) {
3162
3875
  );
3163
3876
  }
3164
3877
  function relativeStoriesDir(root) {
3165
- return path14.relative(root, path14.join(root, ".squad", "stories"));
3878
+ return path15.relative(root, path15.join(root, ".squad", "stories"));
3166
3879
  }
3167
3880
  async function pickStory(stories, opts) {
3168
3881
  const candidates = opts.all ? stories : stories.filter((s) => !s.planFile);
@@ -3193,7 +3906,7 @@ async function pickStory(stories, opts) {
3193
3906
  value: s.intakePath
3194
3907
  }))
3195
3908
  });
3196
- return candidates.find((s) => path14.resolve(s.intakePath) === path14.resolve(pick));
3909
+ return candidates.find((s) => path15.resolve(s.intakePath) === path15.resolve(pick));
3197
3910
  }
3198
3911
  function formatLabel(s) {
3199
3912
  const head = `${s.feature} / ${s.id}`;
@@ -3214,7 +3927,19 @@ async function confirmOverwrite(story, interactive, yes) {
3214
3927
  const go = await confirm6({ message: "Proceed and regenerate?", default: false });
3215
3928
  return go;
3216
3929
  }
3217
- async function emitViaApi(story, paths, config2, _opts) {
3930
+ function printPlannerRunIncompleteSummary(result, relPlanFile) {
3931
+ blank();
3932
+ warning("Planning stopped before the model reported a clean completion.");
3933
+ kv("saved", relPlanFile, 8);
3934
+ if (result.userCancelled) kv("stopped", "you chose not to continue after a limit", 8);
3935
+ if (result.timedOut) kv("timed out", "yes", 8);
3936
+ if (result.budgetExhausted) kv("read budget", "exhausted (model was asked to finalise)", 8);
3937
+ if (result.incompleteKind) kv("detail", result.incompleteKind, 8);
3938
+ blank();
3939
+ info("The file uses the `.partial.md` suffix and YAML `squad-kit-plan-status: partial`.");
3940
+ info("Raise limits in `.squad/config.yaml` (`planner.budget`, `planner.maxOutputTokens`) or re-run when ready.");
3941
+ }
3942
+ async function emitViaApi(story, paths, config2, opts) {
3218
3943
  const planner = config2.planner;
3219
3944
  if (!planner?.enabled) {
3220
3945
  throw new Error(
@@ -3239,6 +3964,10 @@ async function emitViaApi(story, paths, config2, _opts) {
3239
3964
  `${planner.budget.maxFileReads} reads \xB7 ${(planner.budget.maxContextBytes / 1024).toFixed(0)} KB \xB7 ${planner.budget.maxDurationSeconds}s`
3240
3965
  );
3241
3966
  blank();
3967
+ const interactive = !opts.yes && Boolean(process.stdin.isTTY);
3968
+ if (interactive) {
3969
+ printPlannerApiCostNotice();
3970
+ }
3242
3971
  const mapSpinner = spinner("building repo map\u2026");
3243
3972
  const repoMap = buildRepoMap(paths.root);
3244
3973
  mapSpinner.succeed(
@@ -3254,6 +3983,19 @@ async function emitViaApi(story, paths, config2, _opts) {
3254
3983
  const userPrompt = composeUserPrompt({ intakeContent: readFile(story.intakePath) });
3255
3984
  const sessionSpinner = { current: null };
3256
3985
  const startedAt = Date.now();
3986
+ const cacheEnabled = planner.cache?.enabled ?? true;
3987
+ const decideOnLimit = interactive ? async (ctx) => {
3988
+ printPlannerLimitExplanation(ctx);
3989
+ const ans = await select5({
3990
+ message: "Continue this planning session? (Continuing sends more API requests and is billed.)",
3991
+ choices: [
3992
+ { name: "Continue \u2014 extend limits for this run", value: "continue" },
3993
+ { name: "Stop \u2014 save partial plan and exit", value: "cancel" }
3994
+ ]
3995
+ });
3996
+ if (ans === "cancel") printPlannerLimitNextSteps();
3997
+ return ans;
3998
+ } : void 0;
3257
3999
  const result = await runPlanner({
3258
4000
  root: paths.root,
3259
4001
  provider,
@@ -3262,6 +4004,9 @@ async function emitViaApi(story, paths, config2, _opts) {
3262
4004
  systemPrompt,
3263
4005
  userPrompt,
3264
4006
  budget,
4007
+ maxOutputTokens: planner.maxOutputTokens,
4008
+ cacheEnabled,
4009
+ decideOnLimit,
3265
4010
  onToolCall: (tc, bytes, total) => {
3266
4011
  const p = tc.input.path ?? "<unknown>";
3267
4012
  sessionSpinner.current?.succeed(
@@ -3287,6 +4032,7 @@ async function emitViaApi(story, paths, config2, _opts) {
3287
4032
  }
3288
4033
  const snap = budget.snapshot();
3289
4034
  const elapsedMs = Date.now() - startedAt;
4035
+ const success2 = result.finishedNormally && !result.timedOut && !result.userCancelled;
3290
4036
  const header = buildMetadataHeader({
3291
4037
  provider: planner.provider,
3292
4038
  model,
@@ -3295,18 +4041,30 @@ async function emitViaApi(story, paths, config2, _opts) {
3295
4041
  inputTokens: snap.usage.inputTokens,
3296
4042
  outputTokens: snap.usage.outputTokens,
3297
4043
  durationMs: elapsedMs,
3298
- costUsd: snap.usage.costUsd
4044
+ costUsd: snap.usage.costUsd,
4045
+ planStatus: success2 ? void 0 : "partial"
3299
4046
  });
3300
4047
  const { planFile, sequenceNumber, overwrote } = writePlanFile({
3301
4048
  paths,
3302
4049
  config: config2,
3303
4050
  story,
3304
4051
  planBodyMarkdown: result.planText,
3305
- metadataHeader: header
4052
+ metadataHeader: header,
4053
+ partial: !success2
3306
4054
  });
4055
+ try {
4056
+ await writeLastRun(paths, {
4057
+ stats: result.stats,
4058
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
4059
+ provider: planner.provider,
4060
+ model
4061
+ });
4062
+ } catch {
4063
+ }
4064
+ const relPlan = path15.relative(paths.root, planFile);
3307
4065
  blank();
3308
- summaryBox(" plan generated ", [
3309
- { key: "file", value: path14.relative(paths.root, planFile) },
4066
+ summaryBox(success2 ? " plan generated " : " plan saved (incomplete) ", [
4067
+ { key: "file", value: relPlan },
3310
4068
  { key: "nn", value: String(sequenceNumber).padStart(2, "0") },
3311
4069
  { key: "model", value: `${planner.provider}/${model}` },
3312
4070
  { key: "reads", value: `${snap.reads} files \xB7 ${(snap.bytes / 1024).toFixed(1)} KB` },
@@ -3314,18 +4072,28 @@ async function emitViaApi(story, paths, config2, _opts) {
3314
4072
  key: "tokens",
3315
4073
  value: `${snap.usage.inputTokens} in \xB7 ${snap.usage.outputTokens} out${snap.usage.costUsd !== void 0 ? ` \u2248 $${snap.usage.costUsd.toFixed(2)}` : ""}`
3316
4074
  },
4075
+ { key: "cache", value: formatPlannerCacheLine({ cacheEnabled, stats: result.stats }) },
4076
+ {
4077
+ key: "max out",
4078
+ value: `${planner.maxOutputTokens ?? DEFAULT_PLANNER_MAX_OUTPUT_TOKENS} tok/req`
4079
+ },
3317
4080
  { key: "time", value: `${Math.round(elapsedMs / 1e3)}s` },
3318
- { key: "action", value: overwrote ? "overwrote existing plan" : "new plan" }
4081
+ {
4082
+ key: "action",
4083
+ value: success2 ? overwrote ? "overwrote existing plan" : "new plan" : "partial plan (review limits)"
4084
+ }
3319
4085
  ]);
3320
- if (result.budgetExhausted) warning("Budget exhausted mid-plan. Review and, if needed, re-run with a larger budget.");
3321
- if (result.timedOut) warning("Planner timed out; plan may be incomplete.");
3322
- if (!result.finishedNormally && !result.budgetExhausted && !result.timedOut) {
3323
- warning("Planner did not reach end_turn. Inspect the plan carefully.");
4086
+ if (!success2) {
4087
+ printPlannerRunIncompleteSummary(result, relPlan);
4088
+ throw new SquadExit(2);
4089
+ }
4090
+ if (result.budgetExhausted) {
4091
+ info("Note: file-read budget was reached; the model finalised without further reads.");
3324
4092
  }
3325
4093
  blank();
3326
- info(`next \u2192 open a new agent chat and attach only ${path14.basename(planFile)}`);
4094
+ info(`next \u2192 open a new agent chat and attach only ${path15.basename(planFile)}`);
3327
4095
  }
3328
- async function emitCopyPrompt(story, _paths, config2, clipboard) {
4096
+ async function emitCopyPrompt(story, paths, config2, clipboard) {
3329
4097
  const intakeContent = readFile(story.intakePath);
3330
4098
  const metaPromptTemplate = readBundledPrompt("generate-plan.md");
3331
4099
  const composed = render(metaPromptTemplate, {
@@ -3334,13 +4102,44 @@ async function emitCopyPrompt(story, _paths, config2, clipboard) {
3334
4102
  trackerType: config2.tracker.type,
3335
4103
  intakeContent
3336
4104
  });
4105
+ const promptFile = path15.join(paths.squadDir, ".last-copy-prompt.md");
4106
+ fs22.writeFileSync(promptFile, composed, "utf8");
4107
+ const relPromptFile = path15.relative(paths.root, promptFile);
4108
+ const bytes = Buffer.byteLength(composed, "utf8");
4109
+ const estTokens = Math.round(bytes / 4);
4110
+ const clipResult = clipboard ? await copyToClipboard(composed) : { ok: false, reason: "clipboard disabled (--no-clipboard)" };
4111
+ blank();
4112
+ divider("planner prompt ready");
4113
+ kv("mode", "copy-paste", 10);
4114
+ kv("story", `${story.feature} / ${story.id}`, 10);
4115
+ kv("prompt", relPromptFile, 10);
4116
+ kv("size", `${formatKB(bytes)} \xB7 ~${formatCount(estTokens)} tokens (est)`, 10);
4117
+ if (clipResult.ok) {
4118
+ kv("clipboard", `\u2713 copied via ${clipResult.tool}`, 10);
4119
+ } else {
4120
+ kv("clipboard", `! ${clipResult.reason}`, 10);
4121
+ }
3337
4122
  printModelBanner(config2);
3338
- process.stdout.write(composed);
3339
- if (!composed.endsWith("\n")) process.stdout.write("\n");
3340
- if (clipboard) {
3341
- const ok = await copyToClipboard(composed);
3342
- if (ok) info("copied to clipboard");
4123
+ blank();
4124
+ step("Next:");
4125
+ info("1) Open your agent chat (Cursor / Claude Code / Copilot / Gemini).");
4126
+ info("2) Switch to a strong plan model (Opus 4.x / GPT-5.3 Codex thinking / Gemini deep-think).");
4127
+ if (clipResult.ok) {
4128
+ info("3) Paste from clipboard and let the agent work. It will read files and write the plan itself.");
4129
+ } else {
4130
+ info(`3) Open ${relPromptFile}, paste its contents into the agent chat, and let it work.`);
3343
4131
  }
4132
+ info(
4133
+ `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).`
4134
+ );
4135
+ }
4136
+ function formatKB(bytes) {
4137
+ if (bytes < 1024) return `${bytes} B`;
4138
+ return `${(bytes / 1024).toFixed(1)} KB`;
4139
+ }
4140
+ function formatCount(n) {
4141
+ if (n < 1e3) return String(n);
4142
+ return `${(n / 1e3).toFixed(1)}k`;
3344
4143
  }
3345
4144
  function printModelBanner(config2) {
3346
4145
  if (!isInteractive()) return;
@@ -3363,7 +4162,7 @@ function printModelBanner(config2) {
3363
4162
  }
3364
4163
 
3365
4164
  // src/commands/status.ts
3366
- import fs22 from "fs";
4165
+ import fs23 from "fs";
3367
4166
  var kw = 28;
3368
4167
  function probeTrackerCredential(config2, overlay) {
3369
4168
  const { client, error: error2 } = clientFor(config2, overlay);
@@ -3389,7 +4188,7 @@ async function runStatus() {
3389
4188
  const scan = scanPlans(paths.plansDir);
3390
4189
  const storyCount = listStories(paths).length;
3391
4190
  const planCount = scan.usedNumbers.length;
3392
- const secrets = fs22.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
4191
+ const secrets = fs23.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
3393
4192
  const overlay = overlayTrackerEnv(secrets);
3394
4193
  banner();
3395
4194
  step("squad-kit status");
@@ -3443,13 +4242,13 @@ function buildTrackerLine(config2, overlay) {
3443
4242
  }
3444
4243
 
3445
4244
  // src/commands/list.ts
3446
- import path15 from "path";
4245
+ import path16 from "path";
3447
4246
  async function runList(opts) {
3448
4247
  const root = requireSquadRoot();
3449
4248
  const paths = buildPaths(root);
3450
4249
  const records = listStories(paths, { feature: opts.feature });
3451
4250
  const rows = records.map((r) => {
3452
- const planAbs = r.planFile ? path15.join(paths.plansDir, r.feature, r.planFile) : void 0;
4251
+ const planAbs = r.planFile ? path16.join(paths.plansDir, r.feature, r.planFile) : void 0;
3453
4252
  const meta = planAbs ? readPlanMetadata(planAbs) : void 0;
3454
4253
  return {
3455
4254
  feature: r.feature,
@@ -3469,7 +4268,7 @@ async function runList(opts) {
3469
4268
  r.id,
3470
4269
  r.hasIntake ? "yes" : "\u2014",
3471
4270
  r.planFile ?? theme_exports.dim("\u2014"),
3472
- r.meta?.generatedBy ?? (r.planFile ? "copy-paste" : theme_exports.dim("\u2014"))
4271
+ r.meta?.generatedBy ?? (r.planFile ? theme_exports.dim("not recorded") : theme_exports.dim("\u2014"))
3473
4272
  ]);
3474
4273
  printTable([header, ...table]);
3475
4274
  }
@@ -3488,9 +4287,9 @@ function stripAnsi(s) {
3488
4287
  }
3489
4288
 
3490
4289
  // src/commands/tracker-link.ts
3491
- import fs23 from "fs";
3492
- import path16 from "path";
3493
- import { input as input5, select as select6 } from "@inquirer/prompts";
4290
+ import fs24 from "fs";
4291
+ import path17 from "path";
4292
+ import { input as input4, select as select6 } from "@inquirer/prompts";
3494
4293
  async function runTrackerLink(storyPath, trackerId, opts = {}) {
3495
4294
  const root = requireSquadRoot();
3496
4295
  const paths = buildPaths(root);
@@ -3522,7 +4321,7 @@ async function runTrackerLink(storyPath, trackerId, opts = {}) {
3522
4321
  "Usage: squad tracker link [story-path] [tracker-id]. Run `squad tracker link` without arguments in a TTY to pick from a list, or pass `--yes` to opt out of prompts."
3523
4322
  );
3524
4323
  }
3525
- resolvedTrackerId = (await input5({
4324
+ resolvedTrackerId = (await input4({
3526
4325
  message: `${config2.tracker.type} work-item id:`,
3527
4326
  validate: (v) => {
3528
4327
  const trimmed = v.trim();
@@ -3536,17 +4335,21 @@ async function runTrackerLink(storyPath, trackerId, opts = {}) {
3536
4335
  `Invalid tracker id "${resolvedTrackerId}" for tracker type "${config2.tracker.type}". Run \`squad config show\` to review the expected format, or \`squad config set tracker\` if the type is wrong.`
3537
4336
  );
3538
4337
  }
3539
- const resolved = path16.resolve(resolvedStoryPath);
3540
- const intakeFile = resolved.endsWith("intake.md") ? resolved : path16.join(resolved, "intake.md");
3541
- if (!fs23.existsSync(intakeFile)) {
4338
+ const resolved = path17.resolve(resolvedStoryPath);
4339
+ const intakeFile = resolved.endsWith("intake.md") ? resolved : path17.join(resolved, "intake.md");
4340
+ if (!fs24.existsSync(intakeFile)) {
3542
4341
  throw new Error(
3543
4342
  `intake.md not found at ${intakeFile}. Run \`squad list\` to see valid paths, or \`squad new-story\` to create a story first.`
3544
4343
  );
3545
4344
  }
3546
- const content = fs23.readFileSync(intakeFile, "utf8");
4345
+ const content = fs24.readFileSync(intakeFile, "utf8");
3547
4346
  const updated = upsertTrackerId(content, resolvedTrackerId);
3548
- fs23.writeFileSync(intakeFile, updated, "utf8");
3549
- success(`Linked tracker id ${resolvedTrackerId} \u2192 ${path16.relative(root, intakeFile)}`);
4347
+ fs24.writeFileSync(intakeFile, updated, "utf8");
4348
+ success(`Linked tracker id ${resolvedTrackerId} \u2192 ${path17.relative(root, intakeFile)}`);
4349
+ blank();
4350
+ step("Next:");
4351
+ info("1) Review the intake \u2014 the Source block at the top now carries the tracker id.");
4352
+ info("2) Run `squad new-plan --api` (or `--copy`) to generate the plan for this story.");
3550
4353
  }
3551
4354
  function upsertTrackerId(content, id) {
3552
4355
  const pattern = /(-\s*\*\*Work item id:\*\*)[^\n]*/;
@@ -3560,8 +4363,8 @@ function upsertTrackerId(content, id) {
3560
4363
  }
3561
4364
 
3562
4365
  // src/commands/doctor.ts
3563
- import fs24 from "fs";
3564
- import path17 from "path";
4366
+ import fs25 from "fs";
4367
+ import path18 from "path";
3565
4368
  async function gatherContext(paths) {
3566
4369
  const ctx = { hasLegacyPromptsDir: false };
3567
4370
  try {
@@ -3570,29 +4373,29 @@ async function gatherContext(paths) {
3570
4373
  ctx.configError = err;
3571
4374
  }
3572
4375
  try {
3573
- ctx.secrets = fs24.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
4376
+ ctx.secrets = fs25.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
3574
4377
  } catch (err) {
3575
4378
  ctx.secretsError = err;
3576
4379
  }
3577
- ctx.hasLegacyPromptsDir = fs24.existsSync(paths.promptsDir);
4380
+ ctx.hasLegacyPromptsDir = fs25.existsSync(paths.promptsDir);
3578
4381
  return ctx;
3579
4382
  }
3580
4383
  function gitignoreHasManagedBlock(repoRoot) {
3581
- const gitignore = path17.join(repoRoot, ".gitignore");
3582
- return fs24.existsSync(gitignore) && fs24.readFileSync(gitignore, "utf8").includes(".squad/secrets.yaml");
4384
+ const gitignore = path18.join(repoRoot, ".gitignore");
4385
+ return fs25.existsSync(gitignore) && fs25.readFileSync(gitignore, "utf8").includes(".squad/secrets.yaml");
3583
4386
  }
3584
4387
  function gitignoreHasTrashPattern(repoRoot) {
3585
- const gitignore = path17.join(repoRoot, ".gitignore");
3586
- return fs24.existsSync(gitignore) && fs24.readFileSync(gitignore, "utf8").includes(SQUAD_TRASH_PATTERN);
4388
+ const gitignore = path18.join(repoRoot, ".gitignore");
4389
+ return fs25.existsSync(gitignore) && fs25.readFileSync(gitignore, "utf8").includes(SQUAD_TRASH_PATTERN);
3587
4390
  }
3588
4391
  async function checkDirStructure(paths, _ctx, fix) {
3589
- const need = [paths.squadDir, paths.storiesDir, paths.plansDir].filter((p) => !fs24.existsSync(p));
4392
+ const need = [paths.squadDir, paths.storiesDir, paths.plansDir].filter((p) => !fs25.existsSync(p));
3590
4393
  if (need.length === 0) {
3591
4394
  return { id: "dirs", name: ".squad/ directory structure", status: "ok" };
3592
4395
  }
3593
4396
  if (fix) {
3594
4397
  for (const p of need) {
3595
- fs24.mkdirSync(p, { recursive: true });
4398
+ fs25.mkdirSync(p, { recursive: true });
3596
4399
  }
3597
4400
  return { id: "dirs", name: ".squad/ directory structure", status: "ok", detail: "repaired" };
3598
4401
  }
@@ -3600,7 +4403,7 @@ async function checkDirStructure(paths, _ctx, fix) {
3600
4403
  id: "dirs",
3601
4404
  name: ".squad/ directory structure",
3602
4405
  status: "warn",
3603
- detail: `missing: ${need.map((p) => path17.relative(paths.root, p)).join(", ")}`,
4406
+ detail: `missing: ${need.map((p) => path18.relative(paths.root, p)).join(", ")}`,
3604
4407
  fixable: true,
3605
4408
  fixHint: "squad doctor --fix"
3606
4409
  };
@@ -3655,15 +4458,15 @@ async function checkSecretsPermissions(paths, _ctx, fix) {
3655
4458
  if (process.platform === "win32") {
3656
4459
  return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "skip", detail: "Windows" };
3657
4460
  }
3658
- if (!fs24.existsSync(paths.secretsFile)) {
4461
+ if (!fs25.existsSync(paths.secretsFile)) {
3659
4462
  return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok", detail: "not present" };
3660
4463
  }
3661
- const mode = fs24.statSync(paths.secretsFile).mode & 511;
4464
+ const mode = fs25.statSync(paths.secretsFile).mode & 511;
3662
4465
  if (mode === 384) {
3663
4466
  return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok" };
3664
4467
  }
3665
4468
  if (fix) {
3666
- fs24.chmodSync(paths.secretsFile, 384);
4469
+ fs25.chmodSync(paths.secretsFile, 384);
3667
4470
  return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok", detail: "repaired" };
3668
4471
  }
3669
4472
  return {
@@ -3684,7 +4487,7 @@ async function checkSecretsParseable(_paths, ctx) {
3684
4487
  detail: ctx.secretsError.message
3685
4488
  };
3686
4489
  }
3687
- if (!fs24.existsSync(_paths.secretsFile)) {
4490
+ if (!fs25.existsSync(_paths.secretsFile)) {
3688
4491
  return { id: "secrets-yaml", name: ".squad/secrets.yaml parseable", status: "ok", detail: "not present" };
3689
4492
  }
3690
4493
  return { id: "secrets-yaml", name: ".squad/secrets.yaml parseable", status: "ok" };
@@ -3849,6 +4652,19 @@ async function checkPlannerTierAwareness(_paths, ctx) {
3849
4652
  if (!/opus/i.test(planModel)) {
3850
4653
  return { id, name, status: "ok", detail: `${planModel} is comfortably under Tier 1` };
3851
4654
  }
4655
+ const cacheOn = ctx.config.planner?.cache?.enabled !== false;
4656
+ if (cacheOn) {
4657
+ return {
4658
+ id,
4659
+ name,
4660
+ status: "warn",
4661
+ detail: "Anthropic Tier 1 with Opus \u2014 tight but viable with prompt caching on",
4662
+ fixHint: [
4663
+ "Caching saves ~70% on billed input tokens. Keep `planner.cache.enabled: true` in config.",
4664
+ "If you still hit 429s on long runs, reduce `planner.budget.maxContextBytes` or use Haiku for plan phase."
4665
+ ].join("\n")
4666
+ };
4667
+ }
3852
4668
  return {
3853
4669
  id,
3854
4670
  name,
@@ -3857,6 +4673,66 @@ async function checkPlannerTierAwareness(_paths, ctx) {
3857
4673
  fixHint: "Run `squad config set planner` and pick a Sonnet or Haiku id for planner.modelOverride.anthropic, or upgrade tier at https://console.anthropic.com/settings/limits. squad-kit will auto-retry a 429 once (waiting up to 90s), but repeated throttling means the plan model is simply too big for your quota."
3858
4674
  };
3859
4675
  }
4676
+ async function checkPlannerCache(paths, ctx) {
4677
+ const id = "planner-cache";
4678
+ const name = "planner cache effectiveness";
4679
+ const config2 = ctx.config;
4680
+ const cacheCfg = config2?.planner?.cache;
4681
+ if (config2?.planner?.enabled !== true) {
4682
+ return { id, name, status: "skip", detail: "planner disabled \u2014 cache check not applicable" };
4683
+ }
4684
+ if (cacheCfg?.enabled === false) {
4685
+ return {
4686
+ id,
4687
+ name,
4688
+ status: "warn",
4689
+ detail: "prompt caching is disabled in .squad/config.yaml",
4690
+ fixHint: "Cache saves ~70% on billed tokens. Re-enable with `squad config set planner`."
4691
+ };
4692
+ }
4693
+ const lastRun = await readLastRun(paths);
4694
+ if (!lastRun) {
4695
+ return {
4696
+ id,
4697
+ name,
4698
+ status: "skip",
4699
+ detail: "no planner runs logged yet",
4700
+ fixHint: "Run `squad new-plan --api` once, then re-run `squad doctor` to see cache telemetry."
4701
+ };
4702
+ }
4703
+ const { stats } = lastRun;
4704
+ const hitPct = Math.round(stats.cacheHitRatio * 100);
4705
+ if (stats.cacheReadTokens === 0 && stats.turns > 1) {
4706
+ return {
4707
+ id,
4708
+ name,
4709
+ status: "fail",
4710
+ detail: `caching configured but last run saw 0% hits across ${stats.turns} turns`,
4711
+ fixHint: [
4712
+ "Possible causes:",
4713
+ " \u2022 Planner provider is not Anthropic and the prefix is below 1024 tokens (OpenAI / Google need larger prefixes).",
4714
+ " \u2022 System prompt is mutating between turns \u2014 check recent changes.",
4715
+ " \u2022 Using an older model that doesn't support caching.",
4716
+ `Provider: ${lastRun.provider}/${lastRun.model}. Run \`NODE_ENV=development squad new-plan --api\` to surface prefix-mismatch warnings.`
4717
+ ].join("\n")
4718
+ };
4719
+ }
4720
+ if (hitPct < 30 && stats.turns > 3) {
4721
+ return {
4722
+ id,
4723
+ name,
4724
+ status: "warn",
4725
+ detail: `low cache hit rate: ${hitPct}% (last run, ${stats.turns} turns)`,
4726
+ fixHint: "Expected \u226560% for Anthropic, \u226540% for OpenAI, \u226550% for Google after 3+ turns. Prefix may be unstable."
4727
+ };
4728
+ }
4729
+ return {
4730
+ id,
4731
+ name,
4732
+ status: "ok",
4733
+ detail: `caching active \u2014 last run ${hitPct}% hit (${formatTokenK(stats.cacheReadTokens)} read, ${lastRun.provider}/${lastRun.model})`
4734
+ };
4735
+ }
3860
4736
  async function checkTrackerConfig(_paths, ctx) {
3861
4737
  if (!ctx.config) {
3862
4738
  return { id: "tracker-config", name: "tracker configuration", status: "skip", detail: "config unavailable" };
@@ -3979,6 +4855,7 @@ async function runAllChecks(paths, ctx, fix) {
3979
4855
  await add("planner credential resolves", () => checkPlannerCredential(paths, ctx));
3980
4856
  await add("planner model resolves at provider", () => checkPlannerModel(paths, ctx));
3981
4857
  await add("planner tier vs. model", () => checkPlannerTierAwareness(paths, ctx));
4858
+ await add("planner cache effectiveness", () => checkPlannerCache(paths, ctx));
3982
4859
  await add("tracker configuration", () => checkTrackerConfig(paths, ctx));
3983
4860
  await add("tracker credential resolves", () => checkTrackerCredential(paths, ctx));
3984
4861
  await add("tracker connectivity", () => checkTrackerConnectivity(paths, ctx));
@@ -4084,23 +4961,23 @@ async function runDoctor(opts) {
4084
4961
  }
4085
4962
 
4086
4963
  // src/commands/migrate.ts
4087
- import fs25 from "fs";
4088
- import path18 from "path";
4964
+ import fs26 from "fs";
4965
+ import path19 from "path";
4089
4966
  import { confirm as confirm7 } from "@inquirer/prompts";
4090
4967
  var LEGACY_PROMPTS_MIGRATION = {
4091
4968
  id: "legacy-prompts",
4092
4969
  name: "Remove legacy .squad/prompts/",
4093
- isApplied: (paths) => !fs25.existsSync(paths.promptsDir),
4094
- describe: (paths) => `delete ${path18.relative(paths.root, paths.promptsDir)} (prompts are now bundled in squad-kit)`,
4970
+ isApplied: (paths) => !fs26.existsSync(paths.promptsDir),
4971
+ describe: (paths) => `delete ${path19.relative(paths.root, paths.promptsDir)} (prompts are now bundled in squad-kit)`,
4095
4972
  apply: (paths) => {
4096
4973
  const target = paths.promptsDir;
4097
- if (!target.includes(path18.sep + ".squad" + path18.sep + "prompts")) {
4974
+ if (!target.includes(path19.sep + ".squad" + path19.sep + "prompts")) {
4098
4975
  throw new Error(
4099
4976
  `Refusing to delete suspicious path: ${target}. This is a safety check \u2014 run \`squad doctor\` if .squad/ paths look wrong.`
4100
4977
  );
4101
4978
  }
4102
- if (fs25.existsSync(target)) {
4103
- fs25.rmSync(target, { recursive: true, force: true });
4979
+ if (fs26.existsSync(target)) {
4980
+ fs26.rmSync(target, { recursive: true, force: true });
4104
4981
  }
4105
4982
  }
4106
4983
  };
@@ -4108,8 +4985,8 @@ var GITIGNORE_MIGRATION = {
4108
4985
  id: "gitignore",
4109
4986
  name: "Ensure .gitignore managed block",
4110
4987
  isApplied: (paths) => {
4111
- const f = path18.join(paths.root, ".gitignore");
4112
- return fs25.existsSync(f) && fs25.readFileSync(f, "utf8").includes(".squad/secrets.yaml");
4988
+ const f = path19.join(paths.root, ".gitignore");
4989
+ return fs26.existsSync(f) && fs26.readFileSync(f, "utf8").includes(".squad/secrets.yaml");
4113
4990
  },
4114
4991
  describe: () => "add squad-kit managed block to .gitignore",
4115
4992
  apply: (paths) => {
@@ -4121,23 +4998,23 @@ var SECRETS_PERMS_MIGRATION = {
4121
4998
  name: "Tighten .squad/secrets.yaml permissions",
4122
4999
  isApplied: (paths) => {
4123
5000
  if (process.platform === "win32") return true;
4124
- if (!fs25.existsSync(paths.secretsFile)) return true;
4125
- const st = fs25.statSync(paths.secretsFile);
5001
+ if (!fs26.existsSync(paths.secretsFile)) return true;
5002
+ const st = fs26.statSync(paths.secretsFile);
4126
5003
  return (st.mode & 511) === 384;
4127
5004
  },
4128
- describe: (paths) => `chmod 0600 ${path18.relative(paths.root, paths.secretsFile)}`,
5005
+ describe: (paths) => `chmod 0600 ${path19.relative(paths.root, paths.secretsFile)}`,
4129
5006
  apply: (paths) => {
4130
5007
  if (process.platform === "win32") return;
4131
- if (fs25.existsSync(paths.secretsFile)) fs25.chmodSync(paths.secretsFile, 384);
5008
+ if (fs26.existsSync(paths.secretsFile)) fs26.chmodSync(paths.secretsFile, 384);
4132
5009
  }
4133
5010
  };
4134
5011
  var PLANNER_BUDGET_DEFAULTS_MIGRATION = {
4135
5012
  id: "planner-budget-defaults",
4136
5013
  name: "Backfill planner.budget defaults in config.yaml",
4137
5014
  isApplied: (paths) => {
4138
- if (!fs25.existsSync(paths.configFile)) return true;
5015
+ if (!fs26.existsSync(paths.configFile)) return true;
4139
5016
  try {
4140
- const raw = fs25.readFileSync(paths.configFile, "utf8");
5017
+ const raw = fs26.readFileSync(paths.configFile, "utf8");
4141
5018
  if (!/^\s*planner:/m.test(raw)) return true;
4142
5019
  if (/^\s*budget:/m.test(raw)) return true;
4143
5020
  const cfg = loadConfig(paths.configFile);
@@ -4156,9 +5033,9 @@ var CONFIG_NORMALIZE_MIGRATION = {
4156
5033
  id: "config-normalize",
4157
5034
  name: "Normalise .squad/config.yaml formatting",
4158
5035
  isApplied: (paths) => {
4159
- if (!fs25.existsSync(paths.configFile)) return true;
5036
+ if (!fs26.existsSync(paths.configFile)) return true;
4160
5037
  try {
4161
- const raw = fs25.readFileSync(paths.configFile, "utf8");
5038
+ const raw = fs26.readFileSync(paths.configFile, "utf8");
4162
5039
  const cfg = loadConfig(paths.configFile);
4163
5040
  return raw.trim() === serializeConfig(cfg).trim();
4164
5041
  } catch {
@@ -4167,7 +5044,7 @@ var CONFIG_NORMALIZE_MIGRATION = {
4167
5044
  },
4168
5045
  describe: () => "rewrite config.yaml with canonical YAML formatting (drops comments)",
4169
5046
  apply: (paths) => {
4170
- if (!fs25.existsSync(paths.configFile)) return;
5047
+ if (!fs26.existsSync(paths.configFile)) return;
4171
5048
  const cfg = loadConfig(paths.configFile);
4172
5049
  saveConfig(paths.configFile, cfg);
4173
5050
  }
@@ -4235,7 +5112,11 @@ async function runMigrate(opts) {
4235
5112
  }
4236
5113
  info("");
4237
5114
  success(`Applied ${applied}/${pending.length} migration(s).`);
4238
- info("Run `squad doctor` to verify.");
5115
+ blank();
5116
+ step("Next:");
5117
+ info("1) Run `squad doctor` to verify every check is green.");
5118
+ info("2) If you were upgrading from 0.1.x, review .squad/config.yaml \u2014 new defaults (planner, cache) may have been added.");
5119
+ info("3) Continue your usual workflow: `squad new-story` \u2192 fill intake \u2192 `squad new-plan`.");
4239
5120
  }
4240
5121
 
4241
5122
  // src/commands/upgrade.ts
@@ -4244,8 +5125,8 @@ import process3 from "process";
4244
5125
  import { confirm as confirm8 } from "@inquirer/prompts";
4245
5126
 
4246
5127
  // src/core/package-info.ts
4247
- import fs26 from "fs";
4248
- import path19 from "path";
5128
+ import fs27 from "fs";
5129
+ import path20 from "path";
4249
5130
  function changelogBlobUrl(repositoryUrl, version) {
4250
5131
  let base = repositoryUrl;
4251
5132
  if (!base) base = "https://github.com/AzmSquad/squad-kit";
@@ -4256,9 +5137,9 @@ function changelogBlobUrl(repositoryUrl, version) {
4256
5137
  }
4257
5138
  function readInstalledPackage() {
4258
5139
  const root = packageRoot();
4259
- const pkgFile = path19.join(root, "package.json");
4260
- const pkg = JSON.parse(fs26.readFileSync(pkgFile, "utf8"));
4261
- const isDevInstall = fs26.existsSync(path19.join(root, "src"));
5140
+ const pkgFile = path20.join(root, "package.json");
5141
+ const pkg = JSON.parse(fs27.readFileSync(pkgFile, "utf8"));
5142
+ const isDevInstall = fs27.existsSync(path20.join(root, "src"));
4262
5143
  return {
4263
5144
  name: pkg.name,
4264
5145
  version: pkg.version,
@@ -4298,7 +5179,7 @@ function isNewer(a, b) {
4298
5179
  }
4299
5180
 
4300
5181
  // src/core/package-manager.ts
4301
- import path20 from "path";
5182
+ import path21 from "path";
4302
5183
  import process2 from "process";
4303
5184
  function detectPackageManager(installRoot) {
4304
5185
  const ua = process2.env.npm_config_user_agent;
@@ -4309,11 +5190,11 @@ function detectPackageManager(installRoot) {
4309
5190
  if (ua.startsWith("npm/")) return { pm: "npm", reason: "npm_config_user_agent" };
4310
5191
  }
4311
5192
  const root = installRoot.toLowerCase();
4312
- if (root.includes(`${path20.sep}pnpm${path20.sep}`) || root.includes(`${path20.sep}.pnpm${path20.sep}`))
5193
+ if (root.includes(`${path21.sep}pnpm${path21.sep}`) || root.includes(`${path21.sep}.pnpm${path21.sep}`))
4313
5194
  return { pm: "pnpm", reason: `install path contains pnpm` };
4314
- if (root.includes(`${path20.sep}.yarn${path20.sep}`) || root.includes(`${path20.sep}yarn${path20.sep}`))
5195
+ if (root.includes(`${path21.sep}.yarn${path21.sep}`) || root.includes(`${path21.sep}yarn${path21.sep}`))
4315
5196
  return { pm: "yarn", reason: `install path contains yarn` };
4316
- if (root.includes(`${path20.sep}.bun${path20.sep}`) || root.includes(`${path20.sep}bun${path20.sep}`))
5197
+ if (root.includes(`${path21.sep}.bun${path21.sep}`) || root.includes(`${path21.sep}bun${path21.sep}`))
4317
5198
  return { pm: "bun", reason: `install path contains bun` };
4318
5199
  return { pm: "npm", reason: "default fallback" };
4319
5200
  }
@@ -4414,8 +5295,8 @@ function spawnInherit(cmd, args) {
4414
5295
  }
4415
5296
 
4416
5297
  // src/commands/rm/shared.ts
4417
- import fs27 from "fs";
4418
- import path21 from "path";
5298
+ import fs28 from "fs";
5299
+ import path22 from "path";
4419
5300
  import { select as select7 } from "@inquirer/prompts";
4420
5301
  var STORY_PLAN = /^(\d{2,})-story-.+\.md$/;
4421
5302
  function canPromptForRm(opts) {
@@ -4423,9 +5304,9 @@ function canPromptForRm(opts) {
4423
5304
  return isInteractive() && Boolean(process.stdin.isTTY);
4424
5305
  }
4425
5306
  function removeOverviewRow(featurePlanDir, storyId) {
4426
- const overview = path21.join(featurePlanDir, "00-overview.md");
4427
- if (!fs27.existsSync(overview)) return;
4428
- const src = fs27.readFileSync(overview, "utf8");
5307
+ const overview = path22.join(featurePlanDir, "00-overview.md");
5308
+ if (!fs28.existsSync(overview)) return;
5309
+ const src = fs28.readFileSync(overview, "utf8");
4429
5310
  const lines = src.split("\n");
4430
5311
  const filtered = lines.filter((line2) => {
4431
5312
  if (!line2.startsWith("|")) return true;
@@ -4433,12 +5314,12 @@ function removeOverviewRow(featurePlanDir, storyId) {
4433
5314
  if (/^\|\s*-+/.test(line2)) return true;
4434
5315
  return !line2.includes(storyId);
4435
5316
  });
4436
- fs27.writeFileSync(overview, filtered.join("\n"), "utf8");
5317
+ fs28.writeFileSync(overview, filtered.join("\n"), "utf8");
4437
5318
  }
4438
5319
  function removeOverviewRowForPlanFile(featurePlanDir, planBasename) {
4439
- const overview = path21.join(featurePlanDir, "00-overview.md");
4440
- if (!fs27.existsSync(overview)) return;
4441
- const src = fs27.readFileSync(overview, "utf8");
5320
+ const overview = path22.join(featurePlanDir, "00-overview.md");
5321
+ if (!fs28.existsSync(overview)) return;
5322
+ const src = fs28.readFileSync(overview, "utf8");
4442
5323
  const token = `\`${planBasename}\``;
4443
5324
  const lines = src.split("\n");
4444
5325
  const filtered = lines.filter((line2) => {
@@ -4447,25 +5328,25 @@ function removeOverviewRowForPlanFile(featurePlanDir, planBasename) {
4447
5328
  if (/^\|\s*-+/.test(line2)) return true;
4448
5329
  return !line2.includes(token);
4449
5330
  });
4450
- fs27.writeFileSync(overview, filtered.join("\n"), "utf8");
5331
+ fs28.writeFileSync(overview, filtered.join("\n"), "utf8");
4451
5332
  }
4452
5333
  function trashOrDelete(absPaths, trashRoot, useTrash) {
4453
5334
  if (!useTrash) {
4454
- for (const p of absPaths) fs27.rmSync(p, { recursive: true, force: true });
5335
+ for (const p of absPaths) fs28.rmSync(p, { recursive: true, force: true });
4455
5336
  return { removed: absPaths };
4456
5337
  }
4457
5338
  const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
4458
- const bucket = path21.join(trashRoot, ts);
4459
- fs27.mkdirSync(bucket, { recursive: true });
5339
+ const bucket = path22.join(trashRoot, ts);
5340
+ fs28.mkdirSync(bucket, { recursive: true });
4460
5341
  for (const src of absPaths) {
4461
- const rel = path21.basename(src);
4462
- let dst = path21.join(bucket, rel);
5342
+ const rel = path22.basename(src);
5343
+ let dst = path22.join(bucket, rel);
4463
5344
  let n = 0;
4464
- while (fs27.existsSync(dst)) {
5345
+ while (fs28.existsSync(dst)) {
4465
5346
  n += 1;
4466
- dst = path21.join(bucket, `${n}_${rel}`);
5347
+ dst = path22.join(bucket, `${n}_${rel}`);
4467
5348
  }
4468
- fs27.renameSync(src, dst);
5349
+ fs28.renameSync(src, dst);
4469
5350
  }
4470
5351
  return { removed: absPaths, trashed: bucket };
4471
5352
  }
@@ -4476,21 +5357,21 @@ function renderPreview(kind, targets, opts) {
4476
5357
  kv(t.kind, t.label, 6);
4477
5358
  }
4478
5359
  if (opts.trash) {
4479
- info(`Items will move to ${path21.relative(process.cwd(), opts.trashRoot)}/<timestamp>/`);
5360
+ info(`Items will move to ${path22.relative(process.cwd(), opts.trashRoot)}/<timestamp>/`);
4480
5361
  } else if (!opts.dryRun) {
4481
5362
  warning("This is a permanent delete. Pass --trash to move to .squad/.trash/ instead.");
4482
5363
  }
4483
5364
  }
4484
5365
  function listAllPlanListEntries(paths, featureFilter) {
4485
- if (!fs27.existsSync(paths.plansDir)) return [];
5366
+ if (!fs28.existsSync(paths.plansDir)) return [];
4486
5367
  const out = [];
4487
- for (const ent of fs27.readdirSync(paths.plansDir, { withFileTypes: true })) {
5368
+ for (const ent of fs28.readdirSync(paths.plansDir, { withFileTypes: true })) {
4488
5369
  if (!ent.isDirectory()) continue;
4489
5370
  if (featureFilter && ent.name !== featureFilter) continue;
4490
- const featureDir = path21.join(paths.plansDir, ent.name);
4491
- for (const name of fs27.readdirSync(featureDir)) {
5371
+ const featureDir = path22.join(paths.plansDir, ent.name);
5372
+ for (const name of fs28.readdirSync(featureDir)) {
4492
5373
  if (!STORY_PLAN.test(name)) continue;
4493
- out.push({ feature: ent.name, file: name, abs: path21.join(featureDir, name) });
5374
+ out.push({ feature: ent.name, file: name, abs: path22.join(featureDir, name) });
4494
5375
  }
4495
5376
  }
4496
5377
  return out.sort((a, b) => (a.feature + a.file).localeCompare(b.feature + b.file));
@@ -4541,34 +5422,34 @@ function resolveStoryFromArg(arg, stories, root) {
4541
5422
  if (s.id === t) return s;
4542
5423
  if (`${s.feature}/${s.id}` === t) return s;
4543
5424
  }
4544
- const abs = path21.isAbsolute(t) ? t : path21.resolve(root, t);
4545
- const withIntake = t.endsWith("intake.md") ? abs : path21.join(abs, "intake.md");
5425
+ const abs = path22.isAbsolute(t) ? t : path22.resolve(root, t);
5426
+ const withIntake = t.endsWith("intake.md") ? abs : path22.join(abs, "intake.md");
4546
5427
  return findStoryByIntake(stories, withIntake) ?? findStoryByIntake(stories, abs);
4547
5428
  }
4548
5429
  function isSequenceToken(s) {
4549
5430
  return /^\d{1,4}$/.test(s) && s.length > 0;
4550
5431
  }
4551
5432
  function tryParsePlanFilePath(absPath, paths, featureFilter) {
4552
- if (!fs27.existsSync(absPath) || !STORY_PLAN.test(path21.basename(absPath))) return void 0;
4553
- const planRoot = fs27.realpathSync(path21.resolve(paths.plansDir));
4554
- const fileReal = fs27.realpathSync(path21.resolve(absPath));
4555
- const rel = path21.relative(planRoot, fileReal);
4556
- if (rel.startsWith("..") || path21.isAbsolute(rel)) return void 0;
4557
- const segs = rel.split(path21.sep);
5433
+ if (!fs28.existsSync(absPath) || !STORY_PLAN.test(path22.basename(absPath))) return void 0;
5434
+ const planRoot = fs28.realpathSync(path22.resolve(paths.plansDir));
5435
+ const fileReal = fs28.realpathSync(path22.resolve(absPath));
5436
+ const rel = path22.relative(planRoot, fileReal);
5437
+ if (rel.startsWith("..") || path22.isAbsolute(rel)) return void 0;
5438
+ const segs = rel.split(path22.sep);
4558
5439
  if (segs.length < 2) return void 0;
4559
5440
  const feature = segs[0];
4560
- const file = segs.slice(1).join(path21.sep);
5441
+ const file = segs.slice(1).join(path22.sep);
4561
5442
  if (featureFilter && feature !== featureFilter) return void 0;
4562
- return { feature, file, abs: path21.resolve(absPath) };
5443
+ return { feature, file, abs: path22.resolve(absPath) };
4563
5444
  }
4564
5445
  function resolvePlanFromArg(arg, paths, featureFilter) {
4565
5446
  const t = arg.trim();
4566
5447
  if (!t) return void 0;
4567
- const asPath = path21.isAbsolute(t) ? t : path21.resolve(process.cwd(), t);
5448
+ const asPath = path22.isAbsolute(t) ? t : path22.resolve(process.cwd(), t);
4568
5449
  const a = tryParsePlanFilePath(asPath, paths, featureFilter);
4569
5450
  if (a) return a;
4570
- if (!path21.isAbsolute(t)) {
4571
- const under = tryParsePlanFilePath(path21.join(paths.plansDir, t), paths, featureFilter);
5451
+ if (!path22.isAbsolute(t)) {
5452
+ const under = tryParsePlanFilePath(path22.join(paths.plansDir, t), paths, featureFilter);
4572
5453
  if (under) return under;
4573
5454
  }
4574
5455
  if (isSequenceToken(t)) {
@@ -4598,24 +5479,24 @@ function countOverviewDataRows(overviewContent) {
4598
5479
  return n;
4599
5480
  }
4600
5481
  function featureStoriesDirEmpty(paths, feature) {
4601
- const d = path21.join(paths.storiesDir, feature);
4602
- if (!fs27.existsSync(d)) return true;
4603
- return fs27.readdirSync(d).length === 0;
5482
+ const d = path22.join(paths.storiesDir, feature);
5483
+ if (!fs28.existsSync(d)) return true;
5484
+ return fs28.readdirSync(d).length === 0;
4604
5485
  }
4605
5486
 
4606
5487
  // src/commands/rm/story.ts
4607
- import fs29 from "fs";
4608
- import path23 from "path";
5488
+ import fs30 from "fs";
5489
+ import path24 from "path";
4609
5490
  import { confirm as confirm10 } from "@inquirer/prompts";
4610
5491
 
4611
5492
  // src/commands/rm/feature.ts
4612
- import fs28 from "fs";
4613
- import path22 from "path";
5493
+ import fs29 from "fs";
5494
+ import path23 from "path";
4614
5495
  import { confirm as confirm9 } from "@inquirer/prompts";
4615
5496
  function countStoryDirs(storiesDir, feature) {
4616
- const d = path22.join(storiesDir, feature);
4617
- if (!fs28.existsSync(d)) return 0;
4618
- return fs28.readdirSync(d, { withFileTypes: true }).filter((x) => x.isDirectory()).length;
5497
+ const d = path23.join(storiesDir, feature);
5498
+ if (!fs29.existsSync(d)) return 0;
5499
+ return fs29.readdirSync(d, { withFileTypes: true }).filter((x) => x.isDirectory()).length;
4619
5500
  }
4620
5501
  async function runRmFeature(featureSlug, opts = {}) {
4621
5502
  const root = requireSquadRoot();
@@ -4649,17 +5530,17 @@ async function runRmFeature(featureSlug, opts = {}) {
4649
5530
  feature = await pickFeatureInteractive(paths);
4650
5531
  }
4651
5532
  }
4652
- const storyDir = path22.join(paths.storiesDir, feature);
4653
- const planDir = path22.join(paths.plansDir, feature);
5533
+ const storyDir = path23.join(paths.storiesDir, feature);
5534
+ const planDir = path23.join(paths.plansDir, feature);
4654
5535
  const nStories = countStoryDirs(paths.storiesDir, feature);
4655
5536
  const nPlans = listAllPlanListEntries(paths, feature).length;
4656
- const hasOverview = fs28.existsSync(path22.join(planDir, "00-overview.md")) ? 1 : 0;
5537
+ const hasOverview = fs29.existsSync(path23.join(planDir, "00-overview.md")) ? 1 : 0;
4657
5538
  const targets = [];
4658
- if (fs28.existsSync(storyDir)) {
4659
- targets.push({ label: path22.relative(root, storyDir), absPath: storyDir, kind: "dir" });
5539
+ if (fs29.existsSync(storyDir)) {
5540
+ targets.push({ label: path23.relative(root, storyDir), absPath: storyDir, kind: "dir" });
4660
5541
  }
4661
- if (fs28.existsSync(planDir)) {
4662
- targets.push({ label: path22.relative(root, planDir), absPath: planDir, kind: "dir" });
5542
+ if (fs29.existsSync(planDir)) {
5543
+ targets.push({ label: path23.relative(root, planDir), absPath: planDir, kind: "dir" });
4663
5544
  }
4664
5545
  divider("squad rm feature");
4665
5546
  info(`Feature: ${feature}`);
@@ -4674,7 +5555,7 @@ async function runRmFeature(featureSlug, opts = {}) {
4674
5555
  kv(t.kind, t.label, 6);
4675
5556
  }
4676
5557
  if (useTrash) {
4677
- info(`Items will move to ${path22.relative(process.cwd(), paths.trashDir)}/<timestamp>/`);
5558
+ info(`Items will move to ${path23.relative(process.cwd(), paths.trashDir)}/<timestamp>/`);
4678
5559
  } else if (!dryRun) {
4679
5560
  warning("This is a permanent delete. Pass --trash to move to .squad/.trash/ instead.");
4680
5561
  }
@@ -4707,7 +5588,7 @@ async function runRmFeature(featureSlug, opts = {}) {
4707
5588
  useTrash
4708
5589
  );
4709
5590
  if (result.trashed) {
4710
- success(`Feature removed. Items moved to ${path22.relative(root, result.trashed)}.`);
5591
+ success(`Feature removed. Items moved to ${path23.relative(root, result.trashed)}.`);
4711
5592
  } else {
4712
5593
  success("Feature removed (stories and plans directories).");
4713
5594
  }
@@ -4748,11 +5629,11 @@ async function runRmStory(storyPathOrId, opts = {}) {
4748
5629
  }
4749
5630
  }
4750
5631
  const targets = [
4751
- { label: path23.relative(root, story.storyDir), absPath: story.storyDir, kind: "dir" }
5632
+ { label: path24.relative(root, story.storyDir), absPath: story.storyDir, kind: "dir" }
4752
5633
  ];
4753
5634
  if (story.planFile) {
4754
- const planAbs = path23.join(paths.plansDir, story.feature, story.planFile);
4755
- targets.push({ label: path23.relative(root, planAbs), absPath: planAbs, kind: "file" });
5635
+ const planAbs = path24.join(paths.plansDir, story.feature, story.planFile);
5636
+ targets.push({ label: path24.relative(root, planAbs), absPath: planAbs, kind: "file" });
4756
5637
  }
4757
5638
  renderPreview("story", targets, {
4758
5639
  dryRun,
@@ -4777,16 +5658,16 @@ async function runRmStory(storyPathOrId, opts = {}) {
4777
5658
  }
4778
5659
  const absPaths = targets.map((t) => t.absPath);
4779
5660
  const result = trashOrDelete(absPaths, paths.trashDir, useTrash);
4780
- removeOverviewRow(path23.join(paths.plansDir, story.feature), story.id);
5661
+ removeOverviewRow(path24.join(paths.plansDir, story.feature), story.id);
4781
5662
  if (result.trashed) {
4782
- success(`Removed (moved to ${path23.relative(root, result.trashed)}).`);
5663
+ success(`Removed (moved to ${path24.relative(root, result.trashed)}).`);
4783
5664
  } else {
4784
5665
  success("Removed story folder, plan file (if any), and overview row.");
4785
5666
  }
4786
- const featurePlanDir = path23.join(paths.plansDir, story.feature);
4787
- const overviewPath = path23.join(featurePlanDir, "00-overview.md");
4788
- if (fs29.existsSync(overviewPath)) {
4789
- const body = fs29.readFileSync(overviewPath, "utf8");
5667
+ const featurePlanDir = path24.join(paths.plansDir, story.feature);
5668
+ const overviewPath = path24.join(featurePlanDir, "00-overview.md");
5669
+ if (fs30.existsSync(overviewPath)) {
5670
+ const body = fs30.readFileSync(overviewPath, "utf8");
4790
5671
  const onlyShell = countOverviewDataRows(body) === 0;
4791
5672
  if (onlyShell && featureStoriesDirEmpty(paths, story.feature)) {
4792
5673
  if (canPrompt) {
@@ -4806,7 +5687,7 @@ async function runRmStory(storyPathOrId, opts = {}) {
4806
5687
  }
4807
5688
 
4808
5689
  // src/commands/rm/plan.ts
4809
- import path24 from "path";
5690
+ import path25 from "path";
4810
5691
  import { confirm as confirm11 } from "@inquirer/prompts";
4811
5692
  async function runRmPlan(planPathOrSequence, opts = {}) {
4812
5693
  const root = requireSquadRoot();
@@ -4842,7 +5723,7 @@ async function runRmPlan(planPathOrSequence, opts = {}) {
4842
5723
  }
4843
5724
  const storyRows = listStories(paths, { feature: plan.feature });
4844
5725
  const linked = storyRows.find((s) => s.planFile === plan.file);
4845
- const targets = [{ label: path24.relative(root, plan.abs), absPath: plan.abs, kind: "file" }];
5726
+ const targets = [{ label: path25.relative(root, plan.abs), absPath: plan.abs, kind: "file" }];
4846
5727
  renderPreview("plan", targets, {
4847
5728
  dryRun,
4848
5729
  trash: useTrash,
@@ -4865,14 +5746,14 @@ async function runRmPlan(planPathOrSequence, opts = {}) {
4865
5746
  }
4866
5747
  }
4867
5748
  trashOrDelete([plan.abs], paths.trashDir, useTrash);
4868
- removeOverviewRowForPlanFile(path24.join(paths.plansDir, plan.feature), plan.file);
4869
- const intakeHint = linked ? path24.relative(root, linked.intakePath) : `under .squad/stories/${plan.feature}/`;
5749
+ removeOverviewRowForPlanFile(path25.join(paths.plansDir, plan.feature), plan.file);
5750
+ const intakeHint = linked ? path25.relative(root, linked.intakePath) : `under .squad/stories/${plan.feature}/`;
4870
5751
  success(`Plan removed. Run \`squad new-plan ${intakeHint}\` to regenerate.`);
4871
5752
  }
4872
5753
 
4873
5754
  // src/cli.ts
4874
5755
  var program = new Command();
4875
- program.name("squad").description("Plan once, execute cheap. A 3-step SDD workflow CLI.").version("0.2.2");
5756
+ program.name("squad").description("Plan once, execute cheap. A 3-step SDD workflow CLI.").version("0.4.0");
4876
5757
  program.command("init").description("Bootstrap .squad/ in the current directory").option("--agents <list>", "Comma-separated agent list: claude-code,cursor,copilot,gemini").option("--tracker <type>", "Tracker type: none|github|linear|jira|azure").option(
4877
5758
  "--tracker-workspace <hostOrOrg>",
4878
5759
  "Jira host or Azure org (non-interactive default; also written to config for jira/azure)"
@@ -4926,6 +5807,10 @@ config.command("remove-credential <section>").description('Delete credentials fo
4926
5807
  )
4927
5808
  );
4928
5809
  program.parseAsync(process.argv).catch((err) => {
5810
+ if (isSquadExit(err)) {
5811
+ if (err.message.trim()) info(err.message);
5812
+ process.exit(err.exitCode);
5813
+ }
4929
5814
  renderError(err);
4930
5815
  process.exit(1);
4931
5816
  });
@@ -4934,6 +5819,10 @@ function wrap(fn) {
4934
5819
  try {
4935
5820
  await fn(opts);
4936
5821
  } catch (err) {
5822
+ if (isSquadExit(err)) {
5823
+ if (err.message.trim()) info(err.message);
5824
+ process.exit(err.exitCode);
5825
+ }
4937
5826
  renderError(err);
4938
5827
  process.exit(1);
4939
5828
  }
@@ -4944,6 +5833,10 @@ function wrapArgs(fn) {
4944
5833
  try {
4945
5834
  await fn(...args);
4946
5835
  } catch (err) {
5836
+ if (isSquadExit(err)) {
5837
+ if (err.message.trim()) info(err.message);
5838
+ process.exit(err.exitCode);
5839
+ }
4947
5840
  renderError(err);
4948
5841
  process.exit(1);
4949
5842
  }