squad-kit 0.3.0 → 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,6 +169,19 @@ 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";
@@ -276,6 +289,18 @@ var DEFAULT_CONFIG = {
276
289
  planner: void 0
277
290
  };
278
291
  var FORBIDDEN_KEYS = ["apikey", "api_key", "token", "secret", "credential", "credentials"];
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
+ }
279
304
  function rejectSecretsInYaml(node, configFile, path26 = []) {
280
305
  if (node === null || typeof node !== "object") return;
281
306
  for (const [key, value] of Object.entries(node)) {
@@ -305,7 +330,8 @@ function mergePlanner(base, override) {
305
330
  },
306
331
  cache: {
307
332
  enabled: override.cache?.enabled ?? base?.cache?.enabled ?? true
308
- }
333
+ },
334
+ maxOutputTokens: clampPlannerMaxOut(override.maxOutputTokens ?? base?.maxOutputTokens)
309
335
  };
310
336
  if (merged.modelOverride) {
311
337
  const pruned = Object.fromEntries(
@@ -584,6 +610,9 @@ function render(template, vars) {
584
610
  return value === void 0 || value === null ? "" : String(value);
585
611
  });
586
612
  }
613
+ function escapeTemplateValue(s) {
614
+ return s.replace(/\{\{/g, "{ {");
615
+ }
587
616
 
588
617
  // src/commands/config/show.ts
589
618
  import fs7 from "fs";
@@ -856,7 +885,8 @@ function newPlannerBlock(provider) {
856
885
  maxContextBytes: 5e4,
857
886
  maxDurationSeconds: 180
858
887
  },
859
- cache: { enabled: true }
888
+ cache: { enabled: true },
889
+ maxOutputTokens: DEFAULT_PLANNER_MAX_OUTPUT_TOKENS
860
890
  };
861
891
  }
862
892
  function mergePlannerKeyIntoSecrets(base, provider, key) {
@@ -1217,6 +1247,7 @@ var AzureDevOpsClient = class {
1217
1247
  id: String(body.id ?? id),
1218
1248
  title: fields["System.Title"] ?? "(no title)",
1219
1249
  description: stripHtml(fields["System.Description"] ?? ""),
1250
+ acceptanceCriteria: stripHtml(fields["Microsoft.VSTS.Common.AcceptanceCriteria"] ?? ""),
1220
1251
  url: `${this.webBase}/${body.id ?? id}`,
1221
1252
  labels: parseTags(fields["System.Tags"]),
1222
1253
  type: fields["System.WorkItemType"],
@@ -1340,6 +1371,7 @@ var JiraClient = class {
1340
1371
  id: body.key ?? id,
1341
1372
  title: fields.summary ?? "(no title)",
1342
1373
  description,
1374
+ acceptanceCriteria: "",
1343
1375
  url: `${this.baseUrl}/browse/${body.key ?? id}`,
1344
1376
  labels: Array.isArray(fields.labels) ? fields.labels : [],
1345
1377
  type: fields.issuetype?.name,
@@ -1772,7 +1804,9 @@ async function runInit(opts) {
1772
1804
  maxFileReads: 25,
1773
1805
  maxContextBytes: 5e4,
1774
1806
  maxDurationSeconds: 180
1775
- }
1807
+ },
1808
+ cache: { enabled: true },
1809
+ maxOutputTokens: DEFAULT_PLANNER_MAX_OUTPUT_TOKENS
1776
1810
  };
1777
1811
  }
1778
1812
  } else {
@@ -1798,7 +1832,9 @@ async function runInit(opts) {
1798
1832
  maxFileReads: 25,
1799
1833
  maxContextBytes: 5e4,
1800
1834
  maxDurationSeconds: 180
1801
- }
1835
+ },
1836
+ cache: { enabled: true },
1837
+ maxOutputTokens: DEFAULT_PLANNER_MAX_OUTPUT_TOKENS
1802
1838
  };
1803
1839
  }
1804
1840
  }
@@ -1969,11 +2005,14 @@ function listStories(paths, opts = {}) {
1969
2005
  function findPlanFor(plansDir, feature, storyId) {
1970
2006
  const featureDir = path7.join(plansDir, feature);
1971
2007
  if (!fs15.existsSync(featureDir)) return void 0;
2008
+ const candidates = [];
1972
2009
  for (const entry of fs15.readdirSync(featureDir)) {
1973
2010
  if (!STORY_FILE.test(entry)) continue;
1974
- if (entry.includes(storyId)) return entry;
2011
+ if (entry.includes(storyId)) candidates.push(entry);
1975
2012
  }
1976
- 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];
1977
2016
  }
1978
2017
  function findStoryByIntake(records, intakePath) {
1979
2018
  const target = normalizeIntakePath(intakePath.trim());
@@ -1989,21 +2028,53 @@ function normalizeIntakePath(p) {
1989
2028
  }
1990
2029
  function readTitleHint(intakePath) {
1991
2030
  try {
1992
- const head = fs15.readFileSync(intakePath, "utf8").split("\n").slice(0, 3).join("\n");
1993
- const m = head.match(/>\s*\*\*Title hint \(from CLI\):\*\*\s*(.+?)\s*$/m);
1994
- 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;
1995
2040
  } catch {
1996
2041
  return void 0;
1997
2042
  }
1998
2043
  }
1999
- function readPlanMetadata(planFileAbsPath) {
2000
- try {
2001
- const first = fs15.readFileSync(planFileAbsPath, "utf8").split("\n", 1)[0] ?? "";
2002
- const m = first.match(/^<!--\s*squad-kit:\s*generated by\s+([^\s,]+)/);
2003
- if (!m) return {};
2004
- 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];
2005
2051
  const [provider, model] = generatedBy.split("/");
2006
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];
2060
+ const [provider, model] = generatedBy.split("/");
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 {};
2007
2078
  } catch {
2008
2079
  return {};
2009
2080
  }
@@ -2143,10 +2214,12 @@ async function runNewStory(featureSlug, opts) {
2143
2214
  fs16.mkdirSync(attachmentsDir, { recursive: true });
2144
2215
  let sourceBlock = "";
2145
2216
  let fetchedSummary;
2217
+ let fetchedIssue;
2146
2218
  const shouldFetch = !noTracker && opts.fetch !== false && Boolean(id) && config2.tracker.type !== "none";
2147
2219
  if (shouldFetch) {
2148
2220
  const outcome = await tryFetchIssue(config2, id, opts, paths, attachmentsDir);
2149
2221
  if (outcome.outcome === "ok") {
2222
+ fetchedIssue = outcome.issue;
2150
2223
  sourceBlock = buildSourcePreamble(outcome.issue, outcome.downloads, config2.tracker.type);
2151
2224
  if (!title) title = outcome.issue.title;
2152
2225
  const written = outcome.downloads.filter((d) => d.outcome === "written").length;
@@ -2158,18 +2231,19 @@ async function runNewStory(featureSlug, opts) {
2158
2231
  }
2159
2232
  }
2160
2233
  const template = readBundledPrompt("intake.md");
2161
- const rendered = render(template, {
2162
- featureSlug: slug,
2163
- storyId: storyFolderName,
2164
- trackerType: config2.tracker.type,
2165
- projectRoots: (config2.project.projectRoots ?? ["."]).join(", "),
2166
- primaryLanguage: config2.project.primaryLanguage ?? ""
2167
- });
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
+ );
2168
2245
  const skippedPreamble = noTracker && config2.tracker.type !== "none" && !sourceBlock ? buildSkippedTrackerPreamble(config2.tracker.type) : "";
2169
- const titleHint = title ? `> **Title hint (from CLI):** ${title}
2170
-
2171
- ` : "";
2172
- writeFileSafe(intakeFile, skippedPreamble + sourceBlock + titleHint + rendered, false);
2246
+ writeFileSafe(intakeFile, skippedPreamble + sourceBlock + rendered, false);
2173
2247
  ensureFeatureOverview(path8.join(paths.plansDir, slug), slug);
2174
2248
  const relativePath = path8.relative(root, intakeFile);
2175
2249
  const relAttachDir = path8.relative(root, attachmentsDir);
@@ -2242,6 +2316,25 @@ function explainTrackerErrorHint(err) {
2242
2316
  function truncate(s, n) {
2243
2317
  return s.length <= n ? s : s.slice(0, n - 1) + "\u2026";
2244
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
+ }
2245
2338
  function buildSourcePreamble(issue, downloads, trackerType) {
2246
2339
  const header = `> **Fetched from ${trackerType}:** [${issue.id}](${issue.url})
2247
2340
  > *Fetched ${issue.fetchedAt}. Edit the sections below as needed; the planner reads this file verbatim.*
@@ -2965,6 +3058,10 @@ function prefixOf(provider, req) {
2965
3058
  return s.length > 0 ? s.slice(0, -1) : s;
2966
3059
  }
2967
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
+
2968
3065
  // src/planner/loop.ts
2969
3066
  var MAX_RATE_LIMIT_RETRY_SEC = 90;
2970
3067
  var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -2986,18 +3083,83 @@ function buildPlannerRunStats(budget, turns, runStartedAt) {
2986
3083
  durationMs: Date.now() - runStartedAt
2987
3084
  };
2988
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
+ }
2989
3094
  async function runPlanner(input5) {
2990
3095
  const runStartedAt = Date.now();
2991
- const maxIter = input5.maxIterations ?? 40;
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 };
2992
3100
  const turns = [{ role: "user", text: input5.userPrompt }];
2993
3101
  let accumulatedText = "";
2994
3102
  let iterations = 0;
2995
3103
  let budgetExhausted = false;
2996
3104
  let finishedNormally = false;
2997
3105
  let prevRequestPrefix;
2998
- while (iterations < maxIter) {
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
+ }
2999
3143
  iterations += 1;
3000
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
+ }
3001
3163
  return {
3002
3164
  planText: accumulatedText,
3003
3165
  budgetExhausted,
@@ -3008,6 +3170,24 @@ async function runPlanner(input5) {
3008
3170
  };
3009
3171
  }
3010
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
+ }
3011
3191
  return {
3012
3192
  planText: accumulatedText,
3013
3193
  budgetExhausted: true,
@@ -3023,7 +3203,7 @@ async function runPlanner(input5) {
3023
3203
  tools: [READ_FILE_TOOL],
3024
3204
  turns,
3025
3205
  apiKey: input5.apiKey,
3026
- maxOutputTokens: 8192,
3206
+ maxOutputTokens: maxOut.current,
3027
3207
  cacheEnabled: input5.cacheEnabled ?? true
3028
3208
  };
3029
3209
  if (process.env.NODE_ENV !== "production" && process.env.NODE_ENV !== "test" && prevRequestPrefix !== void 0) {
@@ -3067,13 +3247,34 @@ async function runPlanner(input5) {
3067
3247
  input5.onAssistantText?.(response.text);
3068
3248
  }
3069
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
+ }
3070
3270
  return {
3071
3271
  planText: accumulatedText,
3072
3272
  budgetExhausted,
3073
3273
  timedOut: false,
3074
3274
  finishedNormally: false,
3075
3275
  iterations,
3076
- stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt)
3276
+ stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
3277
+ incompleteKind: "max_output_tokens"
3077
3278
  };
3078
3279
  }
3079
3280
  if (response.stopReason === "end_turn" || !response.toolCalls?.length) {
@@ -3093,17 +3294,47 @@ async function runPlanner(input5) {
3093
3294
  toolCalls: response.toolCalls
3094
3295
  });
3095
3296
  const toolResults = [];
3096
- for (const tc of response.toolCalls) {
3297
+ const toolCalls = response.toolCalls;
3298
+ for (let i = 0; i < toolCalls.length; i += 1) {
3299
+ const tc = toolCalls[i];
3097
3300
  if (tc.name !== READ_FILE_TOOL.name) {
3098
3301
  toolResults.push({ toolCallId: tc.id, content: `unknown tool "${tc.name}"`, isError: true });
3099
3302
  continue;
3100
3303
  }
3101
3304
  const before = input5.budget.snapshot().bytes;
3102
- const result = readFileTool(input5.root, input5.budget, tc.input);
3305
+ let result = readFileTool(input5.root, input5.budget, tc.input);
3103
3306
  const after = input5.budget.snapshot().bytes;
3104
3307
  input5.onToolCall?.(tc, after - before, after);
3105
- if (result.isError && (/budget/i.test(result.content) || /max file reads/i.test(result.content))) {
3106
- budgetExhausted = true;
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
+ }
3107
3338
  }
3108
3339
  toolResults.push({ toolCallId: tc.id, content: result.content, isError: result.isError });
3109
3340
  }
@@ -3115,14 +3346,6 @@ async function runPlanner(input5) {
3115
3346
  });
3116
3347
  }
3117
3348
  }
3118
- return {
3119
- planText: accumulatedText,
3120
- budgetExhausted,
3121
- timedOut: false,
3122
- finishedNormally: false,
3123
- iterations,
3124
- stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt)
3125
- };
3126
3349
  }
3127
3350
  function firstByteDiff(a, b) {
3128
3351
  const n = Math.min(a.length, b.length);
@@ -3151,6 +3374,84 @@ function composePlannerError(providerName, response, retriedRateLimit, retrySkip
3151
3374
  return new Error(`${base} Run \`squad doctor\` to diagnose, or retry \u2014 most 5xx errors are transient.`);
3152
3375
  }
3153
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
+
3154
3455
  // src/planner/providers/index.ts
3155
3456
  function providerFor(name) {
3156
3457
  switch (name) {
@@ -3167,18 +3468,35 @@ function providerFor(name) {
3167
3468
  var Budget = class {
3168
3469
  constructor(cfg) {
3169
3470
  this.cfg = cfg;
3471
+ this.maxFileReadsAllowed = cfg.maxFileReads;
3472
+ this.maxContextBytesAllowed = cfg.maxContextBytes;
3473
+ this.maxWallSeconds = cfg.maxDurationSeconds;
3474
+ this.maxCostUsdCeiling = cfg.maxCostUsd;
3170
3475
  }
3171
3476
  cfg;
3172
3477
  reads = 0;
3173
3478
  bytes = 0;
3174
3479
  startedAt = Date.now();
3175
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
+ }
3176
3494
  canRead(nextFileBytes) {
3177
- if (this.reads >= this.cfg.maxFileReads) {
3178
- 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` };
3179
3497
  }
3180
- if (this.bytes + nextFileBytes > this.cfg.maxContextBytes) {
3181
- 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` };
3182
3500
  }
3183
3501
  return { ok: true };
3184
3502
  }
@@ -3194,11 +3512,11 @@ var Budget = class {
3194
3512
  this.totalUsage.cacheReadTokens = (this.totalUsage.cacheReadTokens ?? 0) + (u.cacheReadTokens ?? 0);
3195
3513
  }
3196
3514
  timedOut() {
3197
- return (Date.now() - this.startedAt) / 1e3 > this.cfg.maxDurationSeconds;
3515
+ return (Date.now() - this.startedAt) / 1e3 > this.maxWallSeconds;
3198
3516
  }
3199
3517
  overCost() {
3200
- if (this.cfg.maxCostUsd === void 0) return false;
3201
- return (this.totalUsage.costUsd ?? 0) > this.cfg.maxCostUsd;
3518
+ if (this.maxCostUsdCeiling === void 0) return false;
3519
+ return (this.totalUsage.costUsd ?? 0) > this.maxCostUsdCeiling;
3202
3520
  }
3203
3521
  snapshot() {
3204
3522
  return {
@@ -3337,10 +3655,19 @@ function formatSequence(n) {
3337
3655
  }
3338
3656
 
3339
3657
  // src/planner/writer.ts
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
+ }
3340
3664
  function writePlanFile(input5) {
3341
3665
  const { paths, config: config2, story } = input5;
3342
3666
  const featurePlanDir = path13.join(paths.plansDir, story.feature);
3343
3667
  fs20.mkdirSync(featurePlanDir, { recursive: true });
3668
+ if (input5.partial) {
3669
+ return writePartialPlanFile(input5, featurePlanDir, paths, config2, story);
3670
+ }
3344
3671
  const existing = findExistingPlan(featurePlanDir, story.id);
3345
3672
  let targetPath;
3346
3673
  let nn;
@@ -3351,10 +3678,7 @@ function writePlanFile(input5) {
3351
3678
  overwrote = true;
3352
3679
  } else {
3353
3680
  nn = scanPlans(paths.plansDir).nextGlobal;
3354
- const titleHintSlug = story.titleHint ? slugify(story.titleHint) : "";
3355
- const baseSlug = titleHintSlug || slugify(story.id);
3356
- const trackerId = config2.naming.includeTrackerId && config2.tracker.type !== "none" ? trackerIdForFilename(config2.tracker.type, story.id) : "";
3357
- const trackerPart = trackerId && trackerId !== baseSlug ? `-${trackerId}` : "";
3681
+ const { baseSlug, trackerPart } = planFileSlugParts(config2, story);
3358
3682
  targetPath = path13.join(featurePlanDir, `${formatSequence(nn)}-story-${baseSlug}${trackerPart}.md`);
3359
3683
  }
3360
3684
  const body = `${input5.metadataHeader.trimEnd()}
@@ -3362,12 +3686,45 @@ function writePlanFile(input5) {
3362
3686
  ${input5.planBodyMarkdown.trimStart()}
3363
3687
  `;
3364
3688
  fs20.writeFileSync(targetPath, body, "utf8");
3365
- upsertOverviewRow(featurePlanDir, story.feature, nn, path13.basename(targetPath), story);
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()}
3707
+
3708
+ ${input5.planBodyMarkdown.trimStart()}
3709
+ `;
3710
+ fs20.writeFileSync(targetPath, body, "utf8");
3711
+ upsertOverviewRow(featurePlanDir, story.feature, nn, path13.basename(targetPath), story, true);
3366
3712
  return { planFile: targetPath, sequenceNumber: nn, overwrote };
3367
3713
  }
3368
3714
  function findExistingPlan(featureDir, storyId) {
3369
3715
  if (!fs20.existsSync(featureDir)) return void 0;
3370
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$/;
3371
3728
  for (const entry of fs20.readdirSync(featureDir)) {
3372
3729
  const m = entry.match(pattern);
3373
3730
  if (!m) continue;
@@ -3375,9 +3732,10 @@ function findExistingPlan(featureDir, storyId) {
3375
3732
  }
3376
3733
  return void 0;
3377
3734
  }
3378
- function upsertOverviewRow(featureDir, feature, nn, filename, story) {
3735
+ function upsertOverviewRow(featureDir, feature, nn, filename, story, isPartial) {
3379
3736
  const overviewPath = path13.join(featureDir, "00-overview.md");
3380
- 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 |`;
3381
3739
  if (!fs20.existsSync(overviewPath)) {
3382
3740
  const content = `# ${feature} \u2014 plan overview
3383
3741
 
@@ -3423,7 +3781,15 @@ function buildMetadataHeader(args) {
3423
3781
  `${args.inputTokens} in / ${args.outputTokens} out tokens`,
3424
3782
  args.costUsd !== void 0 ? `~$${args.costUsd.toFixed(2)}` : null
3425
3783
  ].filter(Boolean);
3426
- 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;
3427
3793
  }
3428
3794
 
3429
3795
  // src/core/last-run.ts
@@ -3561,7 +3927,19 @@ async function confirmOverwrite(story, interactive, yes) {
3561
3927
  const go = await confirm6({ message: "Proceed and regenerate?", default: false });
3562
3928
  return go;
3563
3929
  }
3564
- 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) {
3565
3943
  const planner = config2.planner;
3566
3944
  if (!planner?.enabled) {
3567
3945
  throw new Error(
@@ -3586,6 +3964,10 @@ async function emitViaApi(story, paths, config2, _opts) {
3586
3964
  `${planner.budget.maxFileReads} reads \xB7 ${(planner.budget.maxContextBytes / 1024).toFixed(0)} KB \xB7 ${planner.budget.maxDurationSeconds}s`
3587
3965
  );
3588
3966
  blank();
3967
+ const interactive = !opts.yes && Boolean(process.stdin.isTTY);
3968
+ if (interactive) {
3969
+ printPlannerApiCostNotice();
3970
+ }
3589
3971
  const mapSpinner = spinner("building repo map\u2026");
3590
3972
  const repoMap = buildRepoMap(paths.root);
3591
3973
  mapSpinner.succeed(
@@ -3602,6 +3984,18 @@ async function emitViaApi(story, paths, config2, _opts) {
3602
3984
  const sessionSpinner = { current: null };
3603
3985
  const startedAt = Date.now();
3604
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;
3605
3999
  const result = await runPlanner({
3606
4000
  root: paths.root,
3607
4001
  provider,
@@ -3610,7 +4004,9 @@ async function emitViaApi(story, paths, config2, _opts) {
3610
4004
  systemPrompt,
3611
4005
  userPrompt,
3612
4006
  budget,
4007
+ maxOutputTokens: planner.maxOutputTokens,
3613
4008
  cacheEnabled,
4009
+ decideOnLimit,
3614
4010
  onToolCall: (tc, bytes, total) => {
3615
4011
  const p = tc.input.path ?? "<unknown>";
3616
4012
  sessionSpinner.current?.succeed(
@@ -3636,6 +4032,7 @@ async function emitViaApi(story, paths, config2, _opts) {
3636
4032
  }
3637
4033
  const snap = budget.snapshot();
3638
4034
  const elapsedMs = Date.now() - startedAt;
4035
+ const success2 = result.finishedNormally && !result.timedOut && !result.userCancelled;
3639
4036
  const header = buildMetadataHeader({
3640
4037
  provider: planner.provider,
3641
4038
  model,
@@ -3644,14 +4041,16 @@ async function emitViaApi(story, paths, config2, _opts) {
3644
4041
  inputTokens: snap.usage.inputTokens,
3645
4042
  outputTokens: snap.usage.outputTokens,
3646
4043
  durationMs: elapsedMs,
3647
- costUsd: snap.usage.costUsd
4044
+ costUsd: snap.usage.costUsd,
4045
+ planStatus: success2 ? void 0 : "partial"
3648
4046
  });
3649
4047
  const { planFile, sequenceNumber, overwrote } = writePlanFile({
3650
4048
  paths,
3651
4049
  config: config2,
3652
4050
  story,
3653
4051
  planBodyMarkdown: result.planText,
3654
- metadataHeader: header
4052
+ metadataHeader: header,
4053
+ partial: !success2
3655
4054
  });
3656
4055
  try {
3657
4056
  await writeLastRun(paths, {
@@ -3662,9 +4061,10 @@ async function emitViaApi(story, paths, config2, _opts) {
3662
4061
  });
3663
4062
  } catch {
3664
4063
  }
4064
+ const relPlan = path15.relative(paths.root, planFile);
3665
4065
  blank();
3666
- summaryBox(" plan generated ", [
3667
- { key: "file", value: path15.relative(paths.root, planFile) },
4066
+ summaryBox(success2 ? " plan generated " : " plan saved (incomplete) ", [
4067
+ { key: "file", value: relPlan },
3668
4068
  { key: "nn", value: String(sequenceNumber).padStart(2, "0") },
3669
4069
  { key: "model", value: `${planner.provider}/${model}` },
3670
4070
  { key: "reads", value: `${snap.reads} files \xB7 ${(snap.bytes / 1024).toFixed(1)} KB` },
@@ -3673,13 +4073,22 @@ async function emitViaApi(story, paths, config2, _opts) {
3673
4073
  value: `${snap.usage.inputTokens} in \xB7 ${snap.usage.outputTokens} out${snap.usage.costUsd !== void 0 ? ` \u2248 $${snap.usage.costUsd.toFixed(2)}` : ""}`
3674
4074
  },
3675
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
+ },
3676
4080
  { key: "time", value: `${Math.round(elapsedMs / 1e3)}s` },
3677
- { 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
+ }
3678
4085
  ]);
3679
- if (result.budgetExhausted) warning("Budget exhausted mid-plan. Review and, if needed, re-run with a larger budget.");
3680
- if (result.timedOut) warning("Planner timed out; plan may be incomplete.");
3681
- if (!result.finishedNormally && !result.budgetExhausted && !result.timedOut) {
3682
- 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.");
3683
4092
  }
3684
4093
  blank();
3685
4094
  info(`next \u2192 open a new agent chat and attach only ${path15.basename(planFile)}`);
@@ -3859,7 +4268,7 @@ async function runList(opts) {
3859
4268
  r.id,
3860
4269
  r.hasIntake ? "yes" : "\u2014",
3861
4270
  r.planFile ?? theme_exports.dim("\u2014"),
3862
- 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"))
3863
4272
  ]);
3864
4273
  printTable([header, ...table]);
3865
4274
  }
@@ -5344,7 +5753,7 @@ async function runRmPlan(planPathOrSequence, opts = {}) {
5344
5753
 
5345
5754
  // src/cli.ts
5346
5755
  var program = new Command();
5347
- program.name("squad").description("Plan once, execute cheap. A 3-step SDD workflow CLI.").version("0.3.0");
5756
+ program.name("squad").description("Plan once, execute cheap. A 3-step SDD workflow CLI.").version("0.4.0");
5348
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(
5349
5758
  "--tracker-workspace <hostOrOrg>",
5350
5759
  "Jira host or Azure org (non-interactive default; also written to config for jira/azure)"
@@ -5398,6 +5807,10 @@ config.command("remove-credential <section>").description('Delete credentials fo
5398
5807
  )
5399
5808
  );
5400
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
+ }
5401
5814
  renderError(err);
5402
5815
  process.exit(1);
5403
5816
  });
@@ -5406,6 +5819,10 @@ function wrap(fn) {
5406
5819
  try {
5407
5820
  await fn(opts);
5408
5821
  } catch (err) {
5822
+ if (isSquadExit(err)) {
5823
+ if (err.message.trim()) info(err.message);
5824
+ process.exit(err.exitCode);
5825
+ }
5409
5826
  renderError(err);
5410
5827
  process.exit(1);
5411
5828
  }
@@ -5416,6 +5833,10 @@ function wrapArgs(fn) {
5416
5833
  try {
5417
5834
  await fn(...args);
5418
5835
  } catch (err) {
5836
+ if (isSquadExit(err)) {
5837
+ if (err.message.trim()) info(err.message);
5838
+ process.exit(err.exitCode);
5839
+ }
5419
5840
  renderError(err);
5420
5841
  process.exit(1);
5421
5842
  }