squad-kit 0.8.0 → 0.9.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
@@ -163,8 +163,8 @@ ${body}
163
163
  }
164
164
 
165
165
  // src/ui/error.ts
166
- function renderError(err) {
167
- const msg = err instanceof Error ? err.message : String(err);
166
+ function renderError(err2) {
167
+ const msg = err2 instanceof Error ? err2.message : String(err2);
168
168
  process.stderr.write(` ${danger("\u2717")} ${msg}
169
169
  `);
170
170
  }
@@ -178,8 +178,8 @@ var SquadExit = class extends Error {
178
178
  exitCode;
179
179
  name = "SquadExit";
180
180
  };
181
- function isSquadExit(err) {
182
- return err instanceof SquadExit;
181
+ function isSquadExit(err2) {
182
+ return err2 instanceof SquadExit;
183
183
  }
184
184
 
185
185
  // src/commands/init.ts
@@ -303,15 +303,15 @@ function clampPlannerMaxOut(raw) {
303
303
  Math.max(MIN_PLANNER_MAX_OUTPUT_TOKENS, Math.round(n))
304
304
  );
305
305
  }
306
- function rejectSecretsInYaml(node, configFile, path34 = []) {
306
+ function rejectSecretsInYaml(node, configFile, path38 = []) {
307
307
  if (node === null || typeof node !== "object") return;
308
308
  for (const [key, value] of Object.entries(node)) {
309
309
  if (FORBIDDEN_KEYS.includes(key.toLowerCase())) {
310
310
  throw new Error(
311
- `Refusing to load ${configFile}: key "${[...path34, 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.`
311
+ `Refusing to load ${configFile}: key "${[...path38, 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.`
312
312
  );
313
313
  }
314
- if (typeof value === "object") rejectSecretsInYaml(value, configFile, [...path34, key]);
314
+ if (typeof value === "object") rejectSecretsInYaml(value, configFile, [...path38, key]);
315
315
  }
316
316
  }
317
317
  function mergePlanner(base, override) {
@@ -323,8 +323,7 @@ function mergePlanner(base, override) {
323
323
  budget: {
324
324
  maxFileReads: override.budget?.maxFileReads ?? base?.budget?.maxFileReads ?? 25,
325
325
  maxContextBytes: override.budget?.maxContextBytes ?? base?.budget?.maxContextBytes ?? 5e4,
326
- maxDurationSeconds: override.budget?.maxDurationSeconds ?? base?.budget?.maxDurationSeconds ?? 180,
327
- maxCostUsd: override.budget?.maxCostUsd ?? base?.budget?.maxCostUsd
326
+ maxDurationSeconds: override.budget?.maxDurationSeconds ?? base?.budget?.maxDurationSeconds ?? 180
328
327
  },
329
328
  modelOverride: {
330
329
  ...base?.modelOverride ?? {},
@@ -333,7 +332,24 @@ function mergePlanner(base, override) {
333
332
  cache: {
334
333
  enabled: override.cache?.enabled ?? base?.cache?.enabled ?? true
335
334
  },
336
- maxOutputTokens: clampPlannerMaxOut(override.maxOutputTokens ?? base?.maxOutputTokens)
335
+ maxOutputTokens: clampPlannerMaxOut(override.maxOutputTokens ?? base?.maxOutputTokens),
336
+ stages: {
337
+ scout: {
338
+ enabled: override.stages?.scout?.enabled ?? base?.stages?.scout?.enabled ?? true,
339
+ modelOverride: override.stages?.scout?.modelOverride ?? base?.stages?.scout?.modelOverride,
340
+ maxFiles: override.stages?.scout?.maxFiles ?? base?.stages?.scout?.maxFiles ?? 12,
341
+ maxOutputTokens: override.stages?.scout?.maxOutputTokens ?? base?.stages?.scout?.maxOutputTokens ?? 2048
342
+ }
343
+ },
344
+ tools: {
345
+ grep: override.tools?.grep ?? base?.tools?.grep ?? true,
346
+ listDir: override.tools?.listDir ?? base?.tools?.listDir ?? true,
347
+ rangedRead: override.tools?.rangedRead ?? base?.tools?.rangedRead ?? true
348
+ },
349
+ validation: {
350
+ enabled: override.validation?.enabled ?? base?.validation?.enabled ?? true,
351
+ strict: override.validation?.strict ?? base?.validation?.strict ?? false
352
+ }
337
353
  };
338
354
  if (merged.modelOverride) {
339
355
  const pruned = Object.fromEntries(
@@ -363,9 +379,9 @@ function parseConfig(raw, configFile) {
363
379
  let parsed;
364
380
  try {
365
381
  parsed = yaml.load(raw);
366
- } catch (err) {
382
+ } catch (err2) {
367
383
  throw new Error(
368
- `Invalid YAML in ${configFile}: ${err.message}. Run \`squad doctor\` to review, or fix the file, or \`squad init --force\` to replace it.`
384
+ `Invalid YAML in ${configFile}: ${err2.message}. Run \`squad doctor\` to review, or fix the file, or \`squad init --force\` to replace it.`
369
385
  );
370
386
  }
371
387
  if (!parsed || typeof parsed !== "object") {
@@ -450,17 +466,17 @@ function loadSecrets(secretsFile) {
450
466
  let raw;
451
467
  try {
452
468
  raw = fs4.readFileSync(secretsFile, "utf8");
453
- } catch (err) {
469
+ } catch (err2) {
454
470
  throw new Error(
455
- `Failed to read ${secretsFile}: ${err.message}. Run \`squad doctor\` to diagnose, or delete the file and run \`squad config set planner\` or \`squad config set tracker\` to re-enter credentials.`
471
+ `Failed to read ${secretsFile}: ${err2.message}. Run \`squad doctor\` to diagnose, or delete the file and run \`squad config set planner\` or \`squad config set tracker\` to re-enter credentials.`
456
472
  );
457
473
  }
458
474
  let parsed;
459
475
  try {
460
476
  parsed = yaml2.load(raw);
461
- } catch (err) {
477
+ } catch (err2) {
462
478
  throw new Error(
463
- `Invalid YAML in ${secretsFile}: ${err.message}. Run \`squad doctor\` to review paths and permissions, or fix the file, or delete it and run \`squad config set planner\` / \`squad config set tracker\` to re-enter secrets.`
479
+ `Invalid YAML in ${secretsFile}: ${err2.message}. Run \`squad doctor\` to review paths and permissions, or fix the file, or delete it and run \`squad config set planner\` / \`squad config set tracker\` to re-enter secrets.`
464
480
  );
465
481
  }
466
482
  if (parsed === null || parsed === void 0) return EMPTY;
@@ -515,21 +531,25 @@ function filterEmpty(obj) {
515
531
  var PLANNER_MODEL_MAP = {
516
532
  anthropic: {
517
533
  plan: "claude-opus-4-7",
518
- execute: "claude-haiku-4-5-20251001"
534
+ execute: "claude-haiku-4-5-20251001",
535
+ scout: "claude-haiku-4-5-20251001"
519
536
  },
520
537
  openai: {
521
538
  // Unverified for 0.2.0. 0.2.1 will pin to dated snapshots once a maintainer can probe.
522
539
  plan: "gpt-5.3-thinking",
523
- execute: "gpt-5.3-mini"
540
+ execute: "gpt-5.3-mini",
541
+ scout: "gpt-5.3-mini"
524
542
  },
525
543
  google: {
526
544
  // Unverified for 0.2.0. 0.2.1 will pin to dated snapshots once a maintainer can probe.
527
545
  plan: "gemini-3-pro-latest",
528
- execute: "gemini-3-flash-latest"
546
+ execute: "gemini-3-flash-latest",
547
+ scout: "gemini-3-flash-latest"
529
548
  }
530
549
  };
531
- function modelFor(provider, phase, override) {
550
+ function modelFor(provider, phase, override, scoutModelId) {
532
551
  if (phase === "plan" && override?.[provider]) return override[provider];
552
+ if (phase === "scout" && scoutModelId?.trim()) return scoutModelId.trim();
533
553
  return PLANNER_MODEL_MAP[provider][phase];
534
554
  }
535
555
  function providerEnvVar(provider) {
@@ -850,8 +870,8 @@ async function probeJiraConnectivity(secrets, config2) {
850
870
  });
851
871
  if (res.ok) return { ok: true };
852
872
  return { ok: false, status: res.status, detail: (await res.text()).slice(0, 200) };
853
- } catch (err) {
854
- return { ok: false, detail: err.message };
873
+ } catch (err2) {
874
+ return { ok: false, detail: err2.message };
855
875
  }
856
876
  }
857
877
  async function probeAzureConnectivity(secrets, config2) {
@@ -864,8 +884,8 @@ async function probeAzureConnectivity(secrets, config2) {
864
884
  const res = await fetch(url, { headers: { Authorization: `Basic ${auth}` } });
865
885
  if (res.ok) return { ok: true };
866
886
  return { ok: false, status: res.status, detail: (await res.text()).slice(0, 200) };
867
- } catch (err) {
868
- return { ok: false, detail: err.message };
887
+ } catch (err2) {
888
+ return { ok: false, detail: err2.message };
869
889
  }
870
890
  }
871
891
  async function probeGitHubConnectivity(secrets, _config) {
@@ -885,8 +905,8 @@ async function probeGitHubConnectivity(secrets, _config) {
885
905
  });
886
906
  if (res.ok) return { ok: true };
887
907
  return { ok: false, status: res.status, detail: (await res.text()).slice(0, 200) };
888
- } catch (err) {
889
- return { ok: false, detail: err.message };
908
+ } catch (err2) {
909
+ return { ok: false, detail: err2.message };
890
910
  }
891
911
  }
892
912
 
@@ -1211,12 +1231,12 @@ async function one(ref, targetDir, headers, maxBytes, maxMb) {
1211
1231
  let res;
1212
1232
  try {
1213
1233
  res = await fetch(ref.url, { headers });
1214
- } catch (err) {
1234
+ } catch (err2) {
1215
1235
  return {
1216
1236
  source: ref,
1217
1237
  outcome: "skipped-error",
1218
1238
  bytesWritten: 0,
1219
- skipReason: `network error: ${err.message}`
1239
+ skipReason: `network error: ${err2.message}`
1220
1240
  };
1221
1241
  }
1222
1242
  if (!res.ok) {
@@ -1318,8 +1338,8 @@ var AzureDevOpsClient = class {
1318
1338
  headers: { ...this.headers(), "content-type": "application/json" },
1319
1339
  body: JSON.stringify({ query: `${wiql}` })
1320
1340
  });
1321
- } catch (err) {
1322
- throw new TrackerError(`Azure DevOps search failed: ${err.message}`, "network");
1341
+ } catch (err2) {
1342
+ throw new TrackerError(`Azure DevOps search failed: ${err2.message}`, "network");
1323
1343
  }
1324
1344
  if (!wiqlRes.ok) throw this.mapHttpError(wiqlRes.status, "search");
1325
1345
  const wiqlBody = await wiqlRes.json();
@@ -1329,8 +1349,8 @@ var AzureDevOpsClient = class {
1329
1349
  let batchRes;
1330
1350
  try {
1331
1351
  batchRes = await fetch(batchUrl, { headers: this.headers() });
1332
- } catch (err) {
1333
- throw new TrackerError(`Azure DevOps work items fetch failed: ${err.message}`, "network");
1352
+ } catch (err2) {
1353
+ throw new TrackerError(`Azure DevOps work items fetch failed: ${err2.message}`, "network");
1334
1354
  }
1335
1355
  if (!batchRes.ok) throw this.mapHttpError(batchRes.status, "search");
1336
1356
  const batchBody = await batchRes.json();
@@ -1352,8 +1372,8 @@ var AzureDevOpsClient = class {
1352
1372
  let res;
1353
1373
  try {
1354
1374
  res = await fetch(url, { headers: this.headers() });
1355
- } catch (err) {
1356
- throw new TrackerError(`Azure DevOps fetch failed: ${err.message}`, "network");
1375
+ } catch (err2) {
1376
+ throw new TrackerError(`Azure DevOps fetch failed: ${err2.message}`, "network");
1357
1377
  }
1358
1378
  if (!res.ok) throw this.mapHttpError(res.status, id);
1359
1379
  const body = await res.json();
@@ -1459,8 +1479,8 @@ var GitHubClient = class {
1459
1479
  let res;
1460
1480
  try {
1461
1481
  res = await fetch(url, { headers: this.headers() });
1462
- } catch (err) {
1463
- throw new TrackerError(`GitHub fetch failed: ${err.message}`, "network");
1482
+ } catch (err2) {
1483
+ throw new TrackerError(`GitHub fetch failed: ${err2.message}`, "network");
1464
1484
  }
1465
1485
  if (!res.ok) throw this.mapHttpError(res, number);
1466
1486
  const body = await res.json();
@@ -1475,8 +1495,8 @@ var GitHubClient = class {
1475
1495
  let res2;
1476
1496
  try {
1477
1497
  res2 = await fetch(url2, { headers: this.headers() });
1478
- } catch (err) {
1479
- throw new TrackerError(`GitHub search failed: ${err.message}`, "network");
1498
+ } catch (err2) {
1499
+ throw new TrackerError(`GitHub search failed: ${err2.message}`, "network");
1480
1500
  }
1481
1501
  if (!res2.ok) throw this.mapHttpError(res2, "search");
1482
1502
  const body2 = await res2.json();
@@ -1494,9 +1514,9 @@ var GitHubClient = class {
1494
1514
  url: issue.url
1495
1515
  }
1496
1516
  ];
1497
- } catch (err) {
1498
- if (err instanceof TrackerError && err.kind === "not-found") return [];
1499
- throw err;
1517
+ } catch (err2) {
1518
+ if (err2 instanceof TrackerError && err2.kind === "not-found") return [];
1519
+ throw err2;
1500
1520
  }
1501
1521
  }
1502
1522
  const searchQuery = `${q} repo:${this.cfg.owner}/${this.cfg.repo}`;
@@ -1504,8 +1524,8 @@ var GitHubClient = class {
1504
1524
  let res;
1505
1525
  try {
1506
1526
  res = await fetch(url, { headers: this.headers() });
1507
- } catch (err) {
1508
- throw new TrackerError(`GitHub search failed: ${err.message}`, "network");
1527
+ } catch (err2) {
1528
+ throw new TrackerError(`GitHub search failed: ${err2.message}`, "network");
1509
1529
  }
1510
1530
  if (!res.ok) throw this.mapHttpError(res, "search");
1511
1531
  const body = await res.json();
@@ -1645,10 +1665,13 @@ var JiraClient = class {
1645
1665
  let res;
1646
1666
  try {
1647
1667
  res = await fetch(url, { headers: this.headers() });
1648
- } catch (err) {
1649
- throw new TrackerError(`Jira fetch failed: ${err.message}`, "network");
1668
+ } catch (err2) {
1669
+ throw new TrackerError(`Jira fetch failed: ${err2.message}`, "network");
1670
+ }
1671
+ if (!res.ok) {
1672
+ const errBody = await res.text().catch(() => "");
1673
+ throw this.mapHttpError(res.status, id, errBody);
1650
1674
  }
1651
- if (!res.ok) throw this.mapHttpError(res.status, id);
1652
1675
  const body = await res.json();
1653
1676
  const fields = body.fields ?? {};
1654
1677
  const rendered = body.renderedFields ?? {};
@@ -1680,7 +1703,7 @@ var JiraClient = class {
1680
1703
  if (/^[A-Z][A-Z0-9_]+-\d+$/i.test(q)) {
1681
1704
  jql = `issuekey = "${q.toUpperCase()}"`;
1682
1705
  } else if (q.length === 0) {
1683
- jql = "order by updated DESC";
1706
+ jql = "updated >= -90d ORDER BY updated DESC";
1684
1707
  } else {
1685
1708
  const esc = q.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
1686
1709
  jql = `text ~ "${esc}" order by updated DESC`;
@@ -1689,10 +1712,13 @@ var JiraClient = class {
1689
1712
  let res;
1690
1713
  try {
1691
1714
  res = await fetch(url, { headers: this.headers() });
1692
- } catch (err) {
1693
- throw new TrackerError(`Jira search failed: ${err.message}`, "network");
1715
+ } catch (err2) {
1716
+ throw new TrackerError(`Jira search failed: ${err2.message}`, "network");
1717
+ }
1718
+ if (!res.ok) {
1719
+ const errBody = await res.text().catch(() => "");
1720
+ throw this.mapHttpError(res.status, "search", errBody);
1694
1721
  }
1695
- if (!res.ok) throw this.mapHttpError(res.status, "search");
1696
1722
  const body = await res.json();
1697
1723
  const issues = body.issues ?? [];
1698
1724
  return issues.map((it) => {
@@ -1716,7 +1742,7 @@ var JiraClient = class {
1716
1742
  accept: "application/json"
1717
1743
  };
1718
1744
  }
1719
- mapHttpError(status, id) {
1745
+ mapHttpError(status, id, body = "") {
1720
1746
  if (status === 401 || status === 403) {
1721
1747
  return new TrackerError(
1722
1748
  `Jira authentication failed (HTTP ${status}). Check your email and API token in .squad/secrets.yaml.`,
@@ -1741,16 +1767,37 @@ var JiraClient = class {
1741
1767
  status
1742
1768
  );
1743
1769
  }
1744
- return new TrackerError(
1745
- id === "search" ? `Jira search failed (HTTP ${status}).` : `Jira request failed (HTTP ${status}).`,
1746
- "other",
1747
- status
1748
- );
1770
+ const reason = extractJiraErrorMessages(body);
1771
+ const base = id === "search" ? `Jira search failed (HTTP ${status}).` : `Jira request failed (HTTP ${status}).`;
1772
+ return new TrackerError(reason ? `${base} ${reason}` : base, "other", status);
1749
1773
  }
1750
1774
  };
1751
1775
  function stripHtml2(html) {
1752
1776
  return html.replace(/<\s*br\s*\/?>/gi, "\n").replace(/<\/(p|div|li|h[1-6])>/gi, "\n\n").replace(/<[^>]+>/g, "").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/\n{3,}/g, "\n\n").trim();
1753
1777
  }
1778
+ function extractJiraErrorMessages(body) {
1779
+ if (!body) return "";
1780
+ let parsed;
1781
+ try {
1782
+ parsed = JSON.parse(body);
1783
+ } catch {
1784
+ return body.slice(0, 200);
1785
+ }
1786
+ const messages = [];
1787
+ if (Array.isArray(parsed.errorMessages)) {
1788
+ for (const m of parsed.errorMessages) {
1789
+ if (typeof m === "string" && m.trim().length > 0) messages.push(m.trim());
1790
+ }
1791
+ }
1792
+ if (parsed.errors && typeof parsed.errors === "object") {
1793
+ for (const [k, v] of Object.entries(parsed.errors)) {
1794
+ if (typeof v === "string" && v.trim().length > 0) messages.push(`${k}: ${v.trim()}`);
1795
+ }
1796
+ }
1797
+ if (messages.length === 0) return "";
1798
+ const joined = messages.join(" ");
1799
+ return joined.length > 200 ? `${joined.slice(0, 197)}...` : joined;
1800
+ }
1754
1801
 
1755
1802
  // src/tracker/index.ts
1756
1803
  function clientFor(config2, secrets) {
@@ -3108,12 +3155,12 @@ async function tryFetchIssue(config2, id, opts, paths, attachmentsDir) {
3108
3155
  let issue;
3109
3156
  try {
3110
3157
  issue = await client.fetchIssue(id);
3111
- } catch (err) {
3158
+ } catch (err2) {
3112
3159
  spin.fail("Tracker fetch failed");
3113
- if (err instanceof TrackerError) {
3114
- return { outcome: "failed", reason: err.message, hint: explainTrackerErrorHint(err) };
3160
+ if (err2 instanceof TrackerError) {
3161
+ return { outcome: "failed", reason: err2.message, hint: explainTrackerErrorHint(err2) };
3115
3162
  }
3116
- return { outcome: "failed", reason: err.message };
3163
+ return { outcome: "failed", reason: err2.message };
3117
3164
  }
3118
3165
  spin.succeed(`Fetched ${issue.id}: ${truncate(issue.title, 60)}`);
3119
3166
  let downloads = [];
@@ -3135,8 +3182,8 @@ async function tryFetchIssue(config2, id, opts, paths, attachmentsDir) {
3135
3182
  }
3136
3183
  return { outcome: "ok", issue, downloads };
3137
3184
  }
3138
- function explainTrackerErrorHint(err) {
3139
- switch (err.kind) {
3185
+ function explainTrackerErrorHint(err2) {
3186
+ switch (err2.kind) {
3140
3187
  case "auth":
3141
3188
  return "Run `squad config set tracker` to re-enter credentials, or `squad doctor` to verify.";
3142
3189
  case "not-found":
@@ -3154,8 +3201,8 @@ function truncate(s, n) {
3154
3201
  }
3155
3202
 
3156
3203
  // src/commands/new-plan.ts
3157
- import fs26 from "fs";
3158
- import path19 from "path";
3204
+ import fs31 from "fs";
3205
+ import path23 from "path";
3159
3206
  import { confirm as confirm6, select as select6 } from "@inquirer/prompts";
3160
3207
 
3161
3208
  // src/utils/clipboard.ts
@@ -3183,16 +3230,16 @@ function tryCopy(cmd, text) {
3183
3230
  let child;
3184
3231
  try {
3185
3232
  child = spawn(cmd.command, cmd.args, { stdio: ["pipe", "ignore", "pipe"] });
3186
- } catch (err) {
3187
- resolve2({ ok: false, reason: `${cmd.command} failed to spawn: ${err.message}` });
3233
+ } catch (err2) {
3234
+ resolve2({ ok: false, reason: `${cmd.command} failed to spawn: ${err2.message}` });
3188
3235
  return;
3189
3236
  }
3190
3237
  let stderr = "";
3191
3238
  child.stderr?.on("data", (chunk) => {
3192
3239
  stderr += chunk.toString();
3193
3240
  });
3194
- child.on("error", (err) => {
3195
- resolve2({ ok: false, reason: `${cmd.command} spawn error: ${err.message}` });
3241
+ child.on("error", (err2) => {
3242
+ resolve2({ ok: false, reason: `${cmd.command} spawn error: ${err2.message}` });
3196
3243
  });
3197
3244
  child.on("close", (code) => {
3198
3245
  if (code === 0) {
@@ -3247,63 +3294,189 @@ function noCandidateReason() {
3247
3294
  }
3248
3295
 
3249
3296
  // src/planner/loop.ts
3250
- import path15 from "path";
3297
+ import path19 from "path";
3298
+ import fs27 from "fs";
3299
+ import { APICallError as APICallError2 } from "ai";
3251
3300
 
3252
- // src/planner/tools.ts
3301
+ // src/planner/tools/read-file.ts
3253
3302
  import fs21 from "fs";
3254
3303
  import path13 from "path";
3255
- var READ_FILE_TOOL = {
3256
- name: "read_file",
3257
- description: "Read a UTF-8 text file from the project, returning its contents so you can plan against real code. Paths must be relative to the project root. Binary files and files outside the project are refused. You have a bounded context budget; prefer small, targeted reads.",
3258
- inputSchema: {
3259
- type: "object",
3260
- additionalProperties: false,
3261
- required: ["path"],
3262
- properties: {
3263
- path: { type: "string", description: "Repo-relative POSIX path." }
3264
- }
3265
- }
3266
- };
3267
- var MAX_BYTES_PER_READ = 32e3;
3304
+ import { tool } from "ai";
3305
+ import { z } from "zod";
3306
+ var READ_FILE_TOOL_NAME = "read_file";
3307
+ var MAX_BYTES_PER_FULL_READ = 32e3;
3308
+ var MAX_LINES_PER_RANGED_READ = 400;
3309
+ var MAX_BYTES_FOR_SYNC_LINE_SPLIT = 2e6;
3268
3310
  function readFileTool(root, budget, input5) {
3269
- const raw = input5?.path;
3270
- if (typeof raw !== "string" || raw.length === 0) {
3271
- return error('read_file: missing or invalid "path" argument.');
3311
+ const raw = typeof input5.path === "string" ? input5.path : "";
3312
+ if (!raw.length) {
3313
+ return err('read_file: missing or invalid "path" argument.');
3314
+ }
3315
+ const offset = typeof input5.offset === "number" && Number.isInteger(input5.offset) ? input5.offset : void 0;
3316
+ const limit = typeof input5.limit === "number" && Number.isInteger(input5.limit) ? input5.limit : void 0;
3317
+ if (offset !== void 0 && offset < 1) {
3318
+ return err("read_file: offset must be a 1-indexed line number \u2265 1.");
3319
+ }
3320
+ if (limit !== void 0 && (limit < 1 || limit > MAX_LINES_PER_RANGED_READ)) {
3321
+ return err(`read_file: limit must be between 1 and ${MAX_LINES_PER_RANGED_READ}.`);
3322
+ }
3323
+ if (offset === void 0 !== (limit === void 0)) {
3324
+ return err("read_file: pass both offset and limit for a ranged read, or neither for a full read.");
3272
3325
  }
3273
3326
  const resolved = path13.resolve(root, raw);
3274
3327
  const relCheck = path13.relative(root, resolved);
3275
3328
  if (relCheck.startsWith("..") || path13.isAbsolute(relCheck)) {
3276
- return error(`read_file: path "${raw}" escapes the project root and was refused.`);
3329
+ return err(`read_file: path "${raw}" escapes the project root and was refused.`);
3277
3330
  }
3278
3331
  if (!fs21.existsSync(resolved)) {
3279
- return error(`read_file: not found "${raw}".`);
3332
+ return err(`read_file: not found "${raw}".`);
3280
3333
  }
3281
3334
  const stat = fs21.statSync(resolved);
3282
3335
  if (stat.isDirectory()) {
3283
- return error(`read_file: "${raw}" is a directory. Ask for a specific file path.`);
3336
+ return err(`read_file: "${raw}" is a directory. Ask for a specific file path.`);
3337
+ }
3338
+ if (offset !== void 0 && limit !== void 0) {
3339
+ return readFileRanged(resolved, raw, budget, stat.size, offset, limit);
3284
3340
  }
3285
- if (stat.size > MAX_BYTES_PER_READ) {
3286
- return error(
3287
- `read_file: "${raw}" is ${stat.size} bytes (> ${MAX_BYTES_PER_READ}). Ask for a smaller file or a specific range.`
3341
+ if (stat.size > MAX_BYTES_PER_FULL_READ) {
3342
+ return err(
3343
+ `read_file: "${raw}" is ${stat.size} bytes (> ${MAX_BYTES_PER_FULL_READ}). Use offset/limit for a ranged read or ask for a smaller region.`
3288
3344
  );
3289
3345
  }
3290
3346
  const capacity = budget.canRead(stat.size);
3291
3347
  if (!capacity.ok) {
3292
- return error(`read_file: ${capacity.reason}. Finalise the plan with what you already have.`);
3348
+ return err(`read_file: ${capacity.reason}. Finalise the plan with what you already have.`);
3293
3349
  }
3294
3350
  let buf;
3295
3351
  try {
3296
3352
  buf = fs21.readFileSync(resolved);
3297
3353
  } catch (e) {
3298
- return error(`read_file: failed to read "${raw}" (${e.message}).`);
3354
+ return err(`read_file: failed to read "${raw}" (${e.message}).`);
3299
3355
  }
3300
3356
  if (looksBinary(buf)) {
3301
- return error(`read_file: "${raw}" looks binary and was refused.`);
3357
+ return err(`read_file: "${raw}" looks binary and was refused.`);
3302
3358
  }
3303
3359
  budget.recordRead(stat.size);
3304
3360
  return { content: buf.toString("utf8"), isError: false };
3305
3361
  }
3306
- function error(msg) {
3362
+ function readFileRanged(resolved, rawRel, budget, fileSize, offset, limit) {
3363
+ const est = Math.min(fileSize, limit * 200);
3364
+ const cap = budget.canRead(est);
3365
+ if (!cap.ok) {
3366
+ return err(`read_file: ${cap.reason}. Finalise the plan with what you already have.`);
3367
+ }
3368
+ if (fileSize > MAX_BYTES_FOR_SYNC_LINE_SPLIT) {
3369
+ return err(
3370
+ `read_file: "${rawRel}" is too large for a ranged read in-process (${fileSize} bytes). Grep for a symbol or narrow the path.`
3371
+ );
3372
+ }
3373
+ let data;
3374
+ try {
3375
+ data = fs21.readFileSync(resolved);
3376
+ } catch (e) {
3377
+ return err(`read_file: failed to read "${rawRel}" (${e.message}).`);
3378
+ }
3379
+ if (looksBinary(data)) {
3380
+ return err(`read_file: "${rawRel}" looks binary and was refused.`);
3381
+ }
3382
+ const text = data.toString("utf8");
3383
+ const lines = text.split(/\r?\n/);
3384
+ if (offset > lines.length) {
3385
+ return err(
3386
+ `read_file: offset ${offset} is past the end of the file (only ${lines.length} lines).`
3387
+ );
3388
+ }
3389
+ const slice = lines.slice(offset - 1, offset - 1 + limit);
3390
+ const out = slice.map((line2, i) => `${String(offset + i).padStart(6, " ")}\u2502 ${line2}`).join("\n");
3391
+ const bytes = Buffer.byteLength(out, "utf8");
3392
+ budget.recordRead(bytes);
3393
+ return { content: out, isError: false };
3394
+ }
3395
+ function readFileToolFactory(root, budget, hooks, opts) {
3396
+ const ranged = opts?.ranged ?? true;
3397
+ const params = ranged ? z.object({
3398
+ path: z.string().min(1).describe("Repo-relative POSIX path."),
3399
+ offset: z.number().int().min(1).optional().describe("1-indexed line number to start reading at (use with limit)."),
3400
+ limit: z.number().int().min(1).max(MAX_LINES_PER_RANGED_READ).optional().describe(`Line count from offset (max ${MAX_LINES_PER_RANGED_READ}).`)
3401
+ }).refine(
3402
+ (o) => o.offset === void 0 && o.limit === void 0 || o.offset !== void 0 && o.limit !== void 0,
3403
+ { message: "Provide both offset and limit for a ranged read, or neither for a full read." }
3404
+ ) : z.object({
3405
+ path: z.string().min(1).describe("Repo-relative POSIX path.")
3406
+ });
3407
+ return tool({
3408
+ description: "Read a UTF-8 text file from the project, returning its contents so you can plan against real code. " + (ranged ? "Pass `offset` and `limit` together to read a line range (preferred when you know the region). Without them the whole file is read, capped at 32 KB. " : "Whole-file reads only; capped at 32 KB. ") + "Paths must be relative to the project root. Binary files are refused. You have a bounded context budget; prefer small, targeted reads.",
3409
+ parameters: params,
3410
+ execute: async (args, { toolCallId }) => {
3411
+ const tc = {
3412
+ id: toolCallId,
3413
+ name: READ_FILE_TOOL_NAME,
3414
+ input: { path: args.path, offset: args.offset, limit: args.limit }
3415
+ };
3416
+ const before = budget.snapshot().bytes;
3417
+ let result = readFileTool(root, budget, args);
3418
+ let after = budget.snapshot().bytes;
3419
+ let bytesLoaded = after - before;
3420
+ hooks.onToolCall?.(tc, bytesLoaded, after);
3421
+ hooks.bus.emit({
3422
+ kind: "tool_call",
3423
+ runId: hooks.runId,
3424
+ turn: hooks.turn,
3425
+ toolCall: tc,
3426
+ bytesLoaded,
3427
+ totalBytes: after
3428
+ });
3429
+ if (readBudgetishError(result)) {
3430
+ const decide = hooks.decideOnLimit;
3431
+ if (decide) {
3432
+ const d = await decide(hooks.getLimitCtx());
3433
+ if (d === "cancel") {
3434
+ throw new PlannerUserCancelledError(
3435
+ hooks.getAccumulatedText(),
3436
+ hooks.runId,
3437
+ hooks.turn,
3438
+ "budget_reads",
3439
+ tc,
3440
+ result
3441
+ );
3442
+ }
3443
+ hooks.extendSessionLimits();
3444
+ result = readFileTool(root, budget, args);
3445
+ after = budget.snapshot().bytes;
3446
+ bytesLoaded = after - before;
3447
+ } else {
3448
+ hooks.setBudgetExhausted(true);
3449
+ }
3450
+ }
3451
+ return { content: result.content, isError: result.isError };
3452
+ }
3453
+ });
3454
+ }
3455
+ var PlannerUserCancelledError = class _PlannerUserCancelledError extends Error {
3456
+ constructor(planText, runId, turn, incompleteKind, toolCall, pendingResult) {
3457
+ super("PlannerUserCancelledError");
3458
+ this.planText = planText;
3459
+ this.runId = runId;
3460
+ this.turn = turn;
3461
+ this.incompleteKind = incompleteKind;
3462
+ this.toolCall = toolCall;
3463
+ this.pendingResult = pendingResult;
3464
+ this.name = "PlannerUserCancelledError";
3465
+ }
3466
+ planText;
3467
+ runId;
3468
+ turn;
3469
+ incompleteKind;
3470
+ toolCall;
3471
+ pendingResult;
3472
+ static is(e) {
3473
+ return e instanceof _PlannerUserCancelledError;
3474
+ }
3475
+ };
3476
+ function readBudgetishError(result) {
3477
+ return result.isError && (/budget/i.test(result.content) || /max file reads/i.test(result.content));
3478
+ }
3479
+ function err(msg) {
3307
3480
  return { content: msg, isError: true };
3308
3481
  }
3309
3482
  function looksBinary(buf) {
@@ -3314,6 +3487,436 @@ function looksBinary(buf) {
3314
3487
  return false;
3315
3488
  }
3316
3489
 
3490
+ // src/planner/tools/grep.ts
3491
+ import fs23 from "fs";
3492
+ import path15 from "path";
3493
+ import { tool as tool2 } from "ai";
3494
+ import { z as z2 } from "zod";
3495
+
3496
+ // src/core/repo-map.ts
3497
+ import fs22 from "fs";
3498
+ import path14 from "path";
3499
+ import ignore from "ignore";
3500
+ var DEFAULT_IGNORES = [
3501
+ "node_modules",
3502
+ ".git",
3503
+ "dist",
3504
+ "build",
3505
+ "coverage",
3506
+ ".next",
3507
+ ".turbo",
3508
+ ".cache",
3509
+ ".squad/plans"
3510
+ // don't include existing plans as context; planner will see them separately
3511
+ ];
3512
+ function buildRepoIgnore(root) {
3513
+ const ig = ignore().add(DEFAULT_IGNORES);
3514
+ const gitignore = path14.join(root, ".gitignore");
3515
+ if (fs22.existsSync(gitignore)) {
3516
+ try {
3517
+ ig.add(fs22.readFileSync(gitignore, "utf8"));
3518
+ } catch {
3519
+ }
3520
+ }
3521
+ return ig;
3522
+ }
3523
+ function buildRepoMap(root, opts = {}) {
3524
+ const maxEntries = opts.maxEntries ?? 5e3;
3525
+ const format = opts.format ?? "flat";
3526
+ const ig = buildRepoIgnore(root);
3527
+ if (format === "tree") {
3528
+ const entries2 = [];
3529
+ collectFileEntries(root, root, ig, entries2, maxEntries, !!opts.includeHidden);
3530
+ entries2.sort((a, b) => a.rel.localeCompare(b.rel));
3531
+ return renderRepoTree(entries2) + "\n";
3532
+ }
3533
+ const entries = [];
3534
+ walkFlat(root, root, ig, entries, maxEntries, !!opts.includeHidden);
3535
+ entries.sort();
3536
+ return entries.join("\n") + "\n";
3537
+ }
3538
+ function formatSizeKb(n) {
3539
+ if (n < 1024) return `${n} B`;
3540
+ return `${(n / 1024).toFixed(1)} KB`;
3541
+ }
3542
+ function insertTree(tree, relParts, size) {
3543
+ if (relParts.length === 1) {
3544
+ tree.files.push({ name: relParts[0], size });
3545
+ return;
3546
+ }
3547
+ const [head, ...rest] = relParts;
3548
+ if (!tree.dirs.has(head)) tree.dirs.set(head, { files: [], dirs: /* @__PURE__ */ new Map() });
3549
+ insertTree(tree.dirs.get(head), rest, size);
3550
+ }
3551
+ function renderDirTree(name, tree, depth) {
3552
+ const lines = [];
3553
+ const indent = " ".repeat(depth);
3554
+ if (name) {
3555
+ lines.push(`${indent}${name}/`);
3556
+ }
3557
+ const innerIndent = " ".repeat(name ? depth + 1 : depth);
3558
+ const dirNames = [...tree.dirs.keys()].sort((a, b) => a.localeCompare(b));
3559
+ for (const d of dirNames) {
3560
+ lines.push(...renderDirTree(d, tree.dirs.get(d), name ? depth + 1 : depth));
3561
+ }
3562
+ tree.files.sort((a, b) => a.name.localeCompare(b.name));
3563
+ for (const f of tree.files) {
3564
+ const left = `${innerIndent}${f.name}`;
3565
+ const pad2 = Math.max(1, 48 - left.length);
3566
+ lines.push(`${left}${" ".repeat(pad2)}${formatSizeKb(f.size)}`);
3567
+ }
3568
+ return lines;
3569
+ }
3570
+ function renderRepoTree(entries) {
3571
+ const root = { files: [], dirs: /* @__PURE__ */ new Map() };
3572
+ for (const e of entries) {
3573
+ const parts = e.rel.split(path14.sep).filter(Boolean);
3574
+ if (parts.length === 0) continue;
3575
+ insertTree(root, parts, e.size);
3576
+ }
3577
+ return renderDirTree("", root, 0).join("\n");
3578
+ }
3579
+ function collectFileEntries(root, dir, ig, out, max, includeHidden) {
3580
+ if (out.length >= max) return out;
3581
+ let dirents;
3582
+ try {
3583
+ dirents = fs22.readdirSync(dir, { withFileTypes: true });
3584
+ } catch {
3585
+ return out;
3586
+ }
3587
+ for (const entry of dirents) {
3588
+ if (out.length >= max) break;
3589
+ if (!includeHidden && entry.name.startsWith(".") && entry.name !== ".squad") continue;
3590
+ const abs = path14.join(dir, entry.name);
3591
+ const rel = path14.relative(root, abs);
3592
+ if (!rel || ig.ignores(rel) || entry.isDirectory() && ig.ignores(rel + "/")) continue;
3593
+ if (entry.isDirectory()) {
3594
+ collectFileEntries(root, abs, ig, out, max, includeHidden);
3595
+ } else if (entry.isFile()) {
3596
+ let size = 0;
3597
+ try {
3598
+ size = fs22.statSync(abs).size;
3599
+ } catch {
3600
+ size = 0;
3601
+ }
3602
+ out.push({ rel: rel.split(path14.sep).join("/"), size });
3603
+ }
3604
+ }
3605
+ return out;
3606
+ }
3607
+ function walkFlat(root, dir, ig, out, max, includeHidden) {
3608
+ if (out.length >= max) return;
3609
+ let entries;
3610
+ try {
3611
+ entries = fs22.readdirSync(dir, { withFileTypes: true });
3612
+ } catch {
3613
+ return;
3614
+ }
3615
+ for (const entry of entries) {
3616
+ if (out.length >= max) return;
3617
+ if (!includeHidden && entry.name.startsWith(".") && entry.name !== ".squad") continue;
3618
+ const abs = path14.join(dir, entry.name);
3619
+ const rel = path14.relative(root, abs);
3620
+ if (!rel || ig.ignores(rel) || entry.isDirectory() && ig.ignores(rel + "/")) continue;
3621
+ if (entry.isDirectory()) {
3622
+ walkFlat(root, abs, ig, out, max, includeHidden);
3623
+ } else if (entry.isFile()) {
3624
+ out.push(rel.split(path14.sep).join("/"));
3625
+ }
3626
+ }
3627
+ }
3628
+
3629
+ // src/planner/tools/grep.ts
3630
+ var MAX_GREP_MATCHES = 200;
3631
+ var MAX_GREP_FILE_SIZE_BYTES = 1e6;
3632
+ var GREP_TOOL_NAME = "grep";
3633
+ function grepToolFactory(root, budget, hooks) {
3634
+ return tool2({
3635
+ description: "Search the project for a literal string or regex pattern. Returns up to 200 matches across files, each shown as `path:line:match`. Use this BEFORE `read_file` to locate symbols or call sites; it is much cheaper than reading whole files.",
3636
+ parameters: z2.object({
3637
+ pattern: z2.string().min(1).describe("Literal string or regex (default literal)."),
3638
+ regex: z2.boolean().optional().default(false),
3639
+ path: z2.string().optional().describe("Optional sub-path to scope the search to (relative)."),
3640
+ caseInsensitive: z2.boolean().optional().default(false)
3641
+ }),
3642
+ execute: async (args, { toolCallId }) => {
3643
+ const tc = {
3644
+ id: toolCallId,
3645
+ name: GREP_TOOL_NAME,
3646
+ input: { ...args }
3647
+ };
3648
+ const before = budget.snapshot().bytes;
3649
+ const out = runGrep(root, budget, args);
3650
+ const after = budget.snapshot().bytes;
3651
+ const bytesLoaded = after - before;
3652
+ hooks.onToolCall?.(tc, bytesLoaded, after);
3653
+ hooks.bus.emit({
3654
+ kind: "tool_call",
3655
+ runId: hooks.runId,
3656
+ turn: hooks.turn,
3657
+ toolCall: tc,
3658
+ bytesLoaded,
3659
+ totalBytes: after
3660
+ });
3661
+ return out;
3662
+ }
3663
+ });
3664
+ }
3665
+ function runGrep(root, budget, args) {
3666
+ const exec = budget.canExecuteTool();
3667
+ if (!exec.ok) {
3668
+ return {
3669
+ content: `grep: ${exec.reason}. Finalise the plan with what you already have.`,
3670
+ isError: true
3671
+ };
3672
+ }
3673
+ let re;
3674
+ try {
3675
+ if (args.regex) {
3676
+ re = new RegExp(args.pattern, args.caseInsensitive ? "gi" : "g");
3677
+ } else {
3678
+ const esc = args.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3679
+ re = new RegExp(esc, args.caseInsensitive ? "gi" : "g");
3680
+ }
3681
+ } catch (e) {
3682
+ return { content: `grep: invalid pattern (${e.message}).`, isError: true };
3683
+ }
3684
+ const ig = buildRepoIgnore(root);
3685
+ const scopeRoot = args.path?.trim() ? path15.resolve(root, args.path) : root;
3686
+ const relScope = path15.relative(root, scopeRoot);
3687
+ if (relScope.startsWith("..") || path15.isAbsolute(relScope)) {
3688
+ return { content: `grep: scope path escapes the project root and was refused.`, isError: true };
3689
+ }
3690
+ if (!fs23.existsSync(scopeRoot)) {
3691
+ return { content: `grep: scope path not found "${args.path}".`, isError: true };
3692
+ }
3693
+ const matches = [];
3694
+ const walk2 = (dir) => {
3695
+ if (matches.length >= MAX_GREP_MATCHES) return;
3696
+ let entries;
3697
+ try {
3698
+ entries = fs23.readdirSync(dir, { withFileTypes: true });
3699
+ } catch {
3700
+ return;
3701
+ }
3702
+ for (const entry of entries) {
3703
+ if (matches.length >= MAX_GREP_MATCHES) return;
3704
+ const abs = path15.join(dir, entry.name);
3705
+ const rel = path15.relative(root, abs).split(path15.sep).join("/");
3706
+ if (!rel || ig.ignores(rel) || entry.isDirectory() && ig.ignores(rel + "/")) continue;
3707
+ if (entry.isDirectory()) {
3708
+ walk2(abs);
3709
+ } else if (entry.isFile()) {
3710
+ let st;
3711
+ try {
3712
+ st = fs23.statSync(abs);
3713
+ } catch {
3714
+ continue;
3715
+ }
3716
+ if (st.size > MAX_GREP_FILE_SIZE_BYTES) continue;
3717
+ let buf;
3718
+ try {
3719
+ buf = fs23.readFileSync(abs);
3720
+ } catch {
3721
+ continue;
3722
+ }
3723
+ if (looksBinary(buf)) continue;
3724
+ const text = buf.toString("utf8");
3725
+ const lines = text.split(/\r?\n/);
3726
+ for (let i = 0; i < lines.length; i++) {
3727
+ if (matches.length >= MAX_GREP_MATCHES) return;
3728
+ const line2 = lines[i] ?? "";
3729
+ re.lastIndex = 0;
3730
+ if (re.test(line2)) {
3731
+ const trimmed = line2.trim().slice(0, 200);
3732
+ matches.push(`${rel}:${i + 1}:${trimmed}`);
3733
+ }
3734
+ }
3735
+ }
3736
+ }
3737
+ };
3738
+ if (fs23.statSync(scopeRoot).isFile()) {
3739
+ const rel = path15.relative(root, scopeRoot).split(path15.sep).join("/");
3740
+ if (ig.ignores(rel)) {
3741
+ return { content: `grep: file is ignored by repository filters.`, isError: true };
3742
+ }
3743
+ let st;
3744
+ try {
3745
+ st = fs23.statSync(scopeRoot);
3746
+ } catch {
3747
+ return { content: `grep: not found.`, isError: true };
3748
+ }
3749
+ if (st.size > MAX_GREP_FILE_SIZE_BYTES) {
3750
+ return { content: `grep: file too large to search.`, isError: true };
3751
+ }
3752
+ let buf;
3753
+ try {
3754
+ buf = fs23.readFileSync(scopeRoot);
3755
+ } catch (e) {
3756
+ return { content: `grep: failed to read (${e.message}).`, isError: true };
3757
+ }
3758
+ if (looksBinary(buf)) return { content: `grep: file looks binary and was skipped.`, isError: true };
3759
+ const text = buf.toString("utf8");
3760
+ const lines = text.split(/\r?\n/);
3761
+ for (let i = 0; i < lines.length; i++) {
3762
+ if (matches.length >= MAX_GREP_MATCHES) break;
3763
+ const line2 = lines[i] ?? "";
3764
+ re.lastIndex = 0;
3765
+ if (re.test(line2)) {
3766
+ matches.push(`${rel}:${i + 1}:${line2.trim().slice(0, 200)}`);
3767
+ }
3768
+ }
3769
+ } else {
3770
+ walk2(scopeRoot);
3771
+ }
3772
+ const header = matches.length >= MAX_GREP_MATCHES ? `Found ${matches.length} matches (capped at ${MAX_GREP_MATCHES}):` : `Found ${matches.length} match${matches.length === 1 ? "" : "es"}:`;
3773
+ const body = matches.length ? `${header}
3774
+ ${matches.join("\n")}` : `No matches found for "${args.pattern}".`;
3775
+ const byteSize = Buffer.byteLength(body, "utf8");
3776
+ const cap = budget.canRead(byteSize);
3777
+ if (!cap.ok) {
3778
+ return {
3779
+ content: `grep: ${cap.reason}. Finalise the plan with what you already have.`,
3780
+ isError: true
3781
+ };
3782
+ }
3783
+ budget.recordRead(byteSize);
3784
+ return { content: body, isError: false };
3785
+ }
3786
+
3787
+ // src/planner/tools/list-dir.ts
3788
+ import fs24 from "fs";
3789
+ import path16 from "path";
3790
+ import { tool as tool3 } from "ai";
3791
+ import { z as z3 } from "zod";
3792
+ var MAX_ENTRIES_PER_LIST = 200;
3793
+ var LIST_DIR_TOOL_NAME = "list_dir";
3794
+ function listDirToolFactory(root, budget, hooks) {
3795
+ return tool3({
3796
+ description: "List the contents of a directory in the project (one level deep). Returns up to 200 entries with type (file/dir) and size. Cheap; use this to discover sibling files before reading them.",
3797
+ parameters: z3.object({
3798
+ path: z3.string().describe('Repo-relative directory path. Use "." for the project root.')
3799
+ }),
3800
+ execute: async (args, { toolCallId }) => {
3801
+ const tc = {
3802
+ id: toolCallId,
3803
+ name: LIST_DIR_TOOL_NAME,
3804
+ input: { path: args.path }
3805
+ };
3806
+ const before = budget.snapshot().bytes;
3807
+ const out = runListDir(root, budget, args.path);
3808
+ const after = budget.snapshot().bytes;
3809
+ hooks.onToolCall?.(tc, after - before, after);
3810
+ hooks.bus.emit({
3811
+ kind: "tool_call",
3812
+ runId: hooks.runId,
3813
+ turn: hooks.turn,
3814
+ toolCall: tc,
3815
+ bytesLoaded: after - before,
3816
+ totalBytes: after
3817
+ });
3818
+ return out;
3819
+ }
3820
+ });
3821
+ }
3822
+ function runListDir(root, budget, rawPath) {
3823
+ const exec = budget.canExecuteTool();
3824
+ if (!exec.ok) {
3825
+ return {
3826
+ content: `list_dir: ${exec.reason}. Finalise the plan with what you already have.`,
3827
+ isError: true
3828
+ };
3829
+ }
3830
+ const normalized = rawPath.trim() === "" ? "." : rawPath.trim();
3831
+ const resolved = path16.resolve(root, normalized);
3832
+ const relCheck = path16.relative(root, resolved);
3833
+ if (relCheck.startsWith("..") || path16.isAbsolute(relCheck)) {
3834
+ return {
3835
+ content: `list_dir: path "${rawPath}" escapes the project root and was refused.`,
3836
+ isError: true
3837
+ };
3838
+ }
3839
+ const relNorm = relCheck.split(path16.sep).join("/");
3840
+ const ig = buildRepoIgnore(root);
3841
+ if (relNorm && (ig.ignores(relNorm) || ig.ignores(`${relNorm}/`))) {
3842
+ return { content: "list_dir: (directory ignored by repo-map filter)", isError: true };
3843
+ }
3844
+ if (!fs24.existsSync(resolved)) {
3845
+ return { content: `list_dir: not found "${rawPath}".`, isError: true };
3846
+ }
3847
+ const st = fs24.statSync(resolved);
3848
+ if (!st.isDirectory()) {
3849
+ return { content: `list_dir: "${rawPath}" is not a directory.`, isError: true };
3850
+ }
3851
+ let dirents;
3852
+ try {
3853
+ dirents = fs24.readdirSync(resolved, { withFileTypes: true });
3854
+ } catch (e) {
3855
+ return { content: `list_dir: failed to read (${e.message}).`, isError: true };
3856
+ }
3857
+ const rows = [];
3858
+ for (const d of dirents) {
3859
+ if (rows.length >= MAX_ENTRIES_PER_LIST) break;
3860
+ const childAbs = path16.join(resolved, d.name);
3861
+ const childRel = path16.relative(root, childAbs).split(path16.sep).join("/");
3862
+ if (childRel && (ig.ignores(childRel) || d.isDirectory() && ig.ignores(`${childRel}/`))) continue;
3863
+ if (d.isDirectory()) {
3864
+ rows.push({ type: "d", name: d.name + "/", size: null });
3865
+ } else if (d.isFile()) {
3866
+ let size = 0;
3867
+ try {
3868
+ size = fs24.statSync(childAbs).size;
3869
+ } catch {
3870
+ size = 0;
3871
+ }
3872
+ rows.push({ type: "f", name: d.name, size });
3873
+ }
3874
+ }
3875
+ rows.sort((a, b) => {
3876
+ if (a.type !== b.type) return a.type === "d" ? -1 : 1;
3877
+ return a.name.localeCompare(b.name);
3878
+ });
3879
+ const lines = rows.map((r) => {
3880
+ const sz = r.type === "d" ? "\u2014" : String(r.size ?? 0);
3881
+ return `${r.type} ${sz.padStart(8, " ")} ${r.name}`;
3882
+ });
3883
+ const body = lines.length > 0 ? `Entries (max ${MAX_ENTRIES_PER_LIST}):
3884
+ ${lines.join("\n")}` : "(empty directory)";
3885
+ const byteSize = Buffer.byteLength(body, "utf8");
3886
+ const cap = budget.canRead(byteSize);
3887
+ if (!cap.ok) {
3888
+ return {
3889
+ content: `list_dir: ${cap.reason}. Finalise the plan with what you already have.`,
3890
+ isError: true
3891
+ };
3892
+ }
3893
+ budget.recordRead(byteSize);
3894
+ return { content: body, isError: false };
3895
+ }
3896
+
3897
+ // src/planner/tools/index.ts
3898
+ function buildPlannerTools(opts) {
3899
+ const enabled = opts.enabled ?? { rangedRead: true, grep: true, listDir: true };
3900
+ const gh = opts.grepHooks ?? {
3901
+ runId: opts.readHooks.runId,
3902
+ turn: opts.readHooks.turn,
3903
+ bus: opts.readHooks.bus,
3904
+ onToolCall: opts.readHooks.onToolCall
3905
+ };
3906
+ const lh = opts.listDirHooks ?? {
3907
+ runId: opts.readHooks.runId,
3908
+ turn: opts.readHooks.turn,
3909
+ bus: opts.readHooks.bus,
3910
+ onToolCall: opts.readHooks.onToolCall
3911
+ };
3912
+ const readOpts = { ranged: enabled.rangedRead ?? true };
3913
+ return {
3914
+ read_file: readFileToolFactory(opts.root, opts.budget, opts.readHooks, readOpts),
3915
+ ...enabled.grep ? { grep: grepToolFactory(opts.root, opts.budget, gh) } : {},
3916
+ ...enabled.listDir ? { list_dir: listDirToolFactory(opts.root, opts.budget, lh) } : {}
3917
+ };
3918
+ }
3919
+
3317
3920
  // src/planner/provider-errors.ts
3318
3921
  function detectModelNotFound(provider, model, status, rawBody) {
3319
3922
  if (status !== 404) return void 0;
@@ -3322,16 +3925,16 @@ function detectModelNotFound(provider, model, status, rawBody) {
3322
3925
  if (!hints.some((h) => body.includes(h))) return void 0;
3323
3926
  return { provider, model, status, rawBody };
3324
3927
  }
3325
- function modelNotFoundMessage(err) {
3928
+ function modelNotFoundMessage(err2) {
3326
3929
  return [
3327
- `The ${err.provider} planner model "${err.model}" is no longer available.`,
3930
+ `The ${err2.provider} planner model "${err2.model}" is no longer available.`,
3328
3931
  "",
3329
3932
  "Recovery options:",
3330
3933
  ` 1. Run \`squad upgrade\` to install a newer squad-kit (or install manually from npm).`,
3331
3934
  ` 2. Run \`squad config set planner --provider openai\` (or another \`--provider\`) to switch models via a different provider, then save a key when prompted.`,
3332
- ` 3. Run \`squad config set planner\` to set \`planner.modelOverride.${err.provider}\` to a still-valid id (no raw YAML), then \`squad doctor\` to confirm.`,
3935
+ ` 3. Run \`squad config set planner\` to set \`planner.modelOverride.${err2.provider}\` to a still-valid id (no raw YAML), then \`squad doctor\` to confirm.`,
3333
3936
  "",
3334
- `Raw provider response: ${err.rawBody.slice(0, 200)}`
3937
+ `Raw provider response: ${err2.rawBody.slice(0, 200)}`
3335
3938
  ].join("\n");
3336
3939
  }
3337
3940
  function detectRateLimit(provider, status, headers, rawBody) {
@@ -3343,16 +3946,20 @@ function detectRateLimit(provider, status, headers, rawBody) {
3343
3946
  rawBody
3344
3947
  };
3345
3948
  }
3346
- function parseRetryAfter(headers, body) {
3949
+ function parseRetryAfterSec(headers) {
3347
3950
  const raw = headerGet(headers, "retry-after");
3348
- if (raw) {
3349
- const sec = Number(raw);
3350
- if (Number.isFinite(sec) && sec >= 0) return Math.ceil(sec);
3351
- const asDate = Date.parse(raw);
3352
- if (Number.isFinite(asDate)) {
3353
- return Math.max(0, Math.ceil((asDate - Date.now()) / 1e3));
3354
- }
3951
+ if (!raw) return void 0;
3952
+ const sec = Number(raw);
3953
+ if (Number.isFinite(sec) && sec >= 0) return Math.ceil(sec);
3954
+ const asDate = Date.parse(raw);
3955
+ if (Number.isFinite(asDate)) {
3956
+ return Math.max(0, Math.ceil((asDate - Date.now()) / 1e3));
3355
3957
  }
3958
+ return void 0;
3959
+ }
3960
+ function parseRetryAfter(headers, body) {
3961
+ const fromHeader = parseRetryAfterSec(headers);
3962
+ if (fromHeader !== void 0) return fromHeader;
3356
3963
  const bodyMatch = /"retryDelay"\s*:\s*"(\d+)(?:\.\d+)?s"/i.exec(body);
3357
3964
  if (bodyMatch) {
3358
3965
  const sec = Number(bodyMatch[1]);
@@ -3371,16 +3978,16 @@ function headerGet(h, name) {
3371
3978
  }
3372
3979
  return null;
3373
3980
  }
3374
- function rateLimitMessage(err) {
3375
- const waitHint = err.retryAfterSec ?? 60;
3981
+ function rateLimitMessage(err2) {
3982
+ const waitHint = err2.retryAfterSec ?? 60;
3376
3983
  const limitsUrl = {
3377
3984
  anthropic: "https://console.anthropic.com/settings/limits",
3378
3985
  openai: "https://platform.openai.com/settings/organization/limits",
3379
3986
  google: "https://aistudio.google.com/app/plan_information"
3380
3987
  };
3381
- const headline = err.retryAfterSec ? `${err.provider} rate limit hit \u2014 provider asked us to wait ${err.retryAfterSec}s before retrying.` : `${err.provider} rate limit hit.`;
3382
- const cap = err.maxRetrySec ?? 90;
3383
- const retried = err.retrySkippedReason === "retry_after_too_long" ? `squad-kit did not auto-retry: the provider's ${err.retryAfterSec}s wait is longer than our ${cap}s cap, so retrying would just burn another request inside the same throttle window.` : err.retryAlreadyAttempted ? "squad-kit already retried once automatically and was throttled again \u2014 your org is firmly over its per-minute quota." : "squad-kit aborted before retrying.";
3988
+ const headline = err2.retryAfterSec ? `${err2.provider} rate limit hit \u2014 provider asked us to wait ${err2.retryAfterSec}s before retrying.` : `${err2.provider} rate limit hit.`;
3989
+ const cap = err2.maxRetrySec ?? 90;
3990
+ const retried = err2.retrySkippedReason === "retry_after_too_long" ? `squad-kit did not auto-retry: the provider's ${err2.retryAfterSec}s wait is longer than our ${cap}s cap, so retrying would just burn another request inside the same throttle window.` : err2.retryAlreadyAttempted ? "squad-kit already retried once automatically and was throttled again \u2014 your org is firmly over its per-minute quota." : "squad-kit aborted before retrying.";
3384
3991
  return [
3385
3992
  headline,
3386
3993
  retried,
@@ -3389,427 +3996,148 @@ function rateLimitMessage(err) {
3389
3996
  ` 1. Wait ${waitHint}s and rerun \`squad new-plan --api\`. Per-minute limits reset quickly.`,
3390
3997
  ` 2. Switch to a smaller planner model: \`squad config set planner\` (pick a cheaper model id).`,
3391
3998
  ` 3. Tighten \`planner.budget\` in \`.squad/config.yaml\` (smaller \`maxContextBytes\` / \`maxFileReads\`) so each request carries fewer tokens.`,
3392
- ` 4. Upgrade your ${err.provider} tier: ${limitsUrl[err.provider]}.`,
3999
+ ` 4. Upgrade your ${err2.provider} tier: ${limitsUrl[err2.provider]}.`,
3393
4000
  "",
3394
4001
  "Full runbook: docs/migrating-from-0.1.md (see \xA78. If something goes wrong).",
3395
4002
  "",
3396
- `Raw provider response: ${err.rawBody.slice(0, 200)}`
4003
+ `Raw provider response: ${err2.rawBody.slice(0, 200)}`
3397
4004
  ].join("\n");
3398
4005
  }
3399
4006
 
3400
- // src/planner/stable-json.ts
3401
- function sortRecordKeys(v) {
3402
- if (v === null || typeof v !== "object") return v;
3403
- if (Array.isArray(v)) return v.map((x) => sortRecordKeys(x));
3404
- const o = v;
3405
- const out = {};
3406
- for (const k of Object.keys(o).sort()) {
3407
- out[k] = sortRecordKeys(o[k]);
4007
+ // src/planner/usage-map.ts
4008
+ function usageFromLanguageModelStep(usage, providerMetadata) {
4009
+ const out = {
4010
+ inputTokens: usage.promptTokens,
4011
+ outputTokens: usage.completionTokens
4012
+ };
4013
+ const meta = providerMetadata;
4014
+ const a = meta?.anthropic;
4015
+ if (a) {
4016
+ out.cacheCreationTokens = a.cacheCreationInputTokens ?? 0;
4017
+ out.cacheReadTokens = a.cacheReadInputTokens ?? 0;
3408
4018
  }
3409
4019
  return out;
3410
4020
  }
3411
4021
 
3412
- // src/planner/providers/anthropic.ts
3413
- var API_URL = "https://api.anthropic.com/v1/messages";
3414
- var API_VERSION = "2023-06-01";
3415
- var anthropicProvider = {
3416
- name: "anthropic",
3417
- async send(req) {
3418
- return callAnthropic(req);
3419
- }
3420
- };
3421
- function buildAnthropicBody(req) {
3422
- if (req.cacheEnabled === false) {
3423
- const base2 = {
3424
- model: req.model,
3425
- max_tokens: req.maxOutputTokens ?? 4096,
3426
- system: req.systemPrompt,
3427
- tools: req.tools.map(toAnthropicTool)
3428
- };
3429
- if (req.turns.length === 0) {
3430
- return base2;
3431
- }
3432
- return { ...base2, messages: req.turns.map((t) => toAnthropicMessage(t)) };
3433
- }
3434
- const base = {
3435
- model: req.model,
3436
- max_tokens: req.maxOutputTokens ?? 4096,
3437
- system: [
3438
- {
3439
- type: "text",
3440
- text: req.systemPrompt,
3441
- cache_control: { type: "ephemeral" }
3442
- }
3443
- ],
3444
- tools: req.tools.map(toAnthropicTool)
3445
- };
3446
- if (req.turns.length === 0) {
3447
- return base;
3448
- }
3449
- return { ...base, messages: toMessagesWithCacheMarkers(req.turns) };
3450
- }
3451
- async function callAnthropic(req) {
3452
- const built = buildAnthropicBody(req);
3453
- const body = { ...built, messages: built.messages ?? [] };
3454
- const res = await fetch(API_URL, {
3455
- method: "POST",
3456
- headers: {
3457
- "x-api-key": req.apiKey,
3458
- "anthropic-version": API_VERSION,
3459
- "content-type": "application/json"
3460
- },
3461
- body: JSON.stringify(body),
3462
- signal: req.abort
3463
- });
3464
- if (!res.ok) {
3465
- const text = await res.text().catch(() => "");
3466
- const nf = detectModelNotFound("anthropic", req.model, res.status, text);
3467
- if (nf) {
3468
- return {
3469
- stopReason: "error",
3470
- rawError: modelNotFoundMessage(nf),
3471
- errorKind: "model_not_found"
3472
- };
3473
- }
3474
- const rl = detectRateLimit("anthropic", res.status, res.headers, text);
3475
- if (rl) {
3476
- return {
3477
- stopReason: "error",
3478
- rawError: `anthropic 429: ${text.slice(0, 500)}`,
3479
- errorKind: "rate_limit",
3480
- retryAfterSec: rl.retryAfterSec
3481
- };
3482
- }
3483
- return { stopReason: "error", rawError: `anthropic ${res.status}: ${text.slice(0, 500)}` };
3484
- }
3485
- const json = await res.json();
3486
- const textOut = json.content.filter((b) => b.type === "text").map((b) => b.text).join("");
3487
- const toolCalls = json.content.filter((b) => b.type === "tool_use").map((b) => ({ id: b.id, name: b.name, input: b.input }));
3488
- return {
3489
- text: textOut || void 0,
3490
- toolCalls: toolCalls.length ? toolCalls : void 0,
3491
- stopReason: mapStop(json.stop_reason),
3492
- usage: {
3493
- inputTokens: json.usage?.input_tokens ?? 0,
3494
- outputTokens: json.usage?.output_tokens ?? 0,
3495
- cacheCreationTokens: json.usage?.cache_creation_input_tokens ?? 0,
3496
- cacheReadTokens: json.usage?.cache_read_input_tokens ?? 0
3497
- }
3498
- };
3499
- }
3500
- function toMessagesWithCacheMarkers(turns) {
3501
- let lastUserWithToolResultIdx = -1;
3502
- for (let i = turns.length - 1; i >= 0; i -= 1) {
3503
- const t = turns[i];
3504
- if (t.role === "user" && hasToolResultBlock(t)) {
3505
- lastUserWithToolResultIdx = i;
3506
- break;
3507
- }
3508
- }
3509
- return turns.map((turn, idx) => {
3510
- const message = toAnthropicMessage(turn);
3511
- if (idx === lastUserWithToolResultIdx) {
3512
- return attachCacheControlToLastToolResult(message);
3513
- }
3514
- return message;
3515
- });
3516
- }
3517
- function hasToolResultBlock(turn) {
3518
- return (turn.toolResults?.length ?? 0) > 0;
3519
- }
3520
- function attachCacheControlToLastToolResult(msg) {
3521
- const content = [...msg.content];
3522
- for (let i = content.length - 1; i >= 0; i -= 1) {
3523
- const block = content[i];
3524
- if (block.type === "tool_result") {
3525
- content[i] = { ...block, cache_control: { type: "ephemeral" } };
3526
- break;
3527
- }
3528
- }
3529
- return { ...msg, content };
4022
+ // src/planner/system-prompt.ts
4023
+ function composeSystemPrompt(args) {
4024
+ const base = readBundledPrompt("generate-plan.md").replace(/\{\{projectRoots\}\}/g, args.projectRoots.join(", ")).replace(/\{\{primaryLanguage\}\}/g, args.primaryLanguage).replace(/\{\{trackerType\}\}/g, args.trackerType).replace(
4025
+ /\{\{intakeContent\}\}/g,
4026
+ "_(The intake story is provided in the user message below. Do not ask for it.)_"
4027
+ );
4028
+ const apiPreamble = [
4029
+ "",
4030
+ "---",
4031
+ "",
4032
+ "## Direct-API mode notes",
4033
+ "",
4034
+ "You are being invoked by the squad-kit CLI, not by a human editor. The intake is already inlined below.",
4035
+ "The repository tree is provided. Tools: **`list_dir`** (one-level directory listing), **`grep`** (search), **`read_file`** (whole file up to 32 KB, or **`offset` + `limit`** for a line range \u2014 preferred when you know the region).",
4036
+ "You have a bounded context budget (tool calls, total bytes, wall-clock time). Prefer `grep` and ranged `read_file` over whole-file reads.",
4037
+ "When you have enough information, output the **complete plan markdown** as your final assistant message. No prose around it, no code fences wrapping the whole plan. The CLI will write your final message verbatim to disk.",
4038
+ "",
4039
+ "## Repository tree",
4040
+ "",
4041
+ "```",
4042
+ args.repoMap.trimEnd(),
4043
+ "```",
4044
+ ""
4045
+ ].join("\n");
4046
+ return base + apiPreamble;
3530
4047
  }
3531
- function toAnthropicTool(t) {
3532
- return {
3533
- name: t.name,
3534
- description: t.description,
3535
- input_schema: sortRecordKeys(t.inputSchema)
3536
- };
4048
+ function composeScoutSystemPrompt(args) {
4049
+ const plans = args.plansIndex?.trim() || "(no prior plans yet)";
4050
+ const base = readBundledPrompt("scout.md").replace(/\{\{projectRoots\}\}/g, args.projectRoots.join(", ")).replace(/\{\{primaryLanguage\}\}/g, args.primaryLanguage).replace(/\{\{trackerType\}\}/g, args.trackerType).replace(/\{\{repoMap\}\}/g, args.repoMap.trimEnd()).replace(/\{\{plansIndex\}\}/g, plans);
4051
+ return base;
3537
4052
  }
3538
- function toAnthropicMessage(turn) {
3539
- const blocks = [];
3540
- if (turn.text) blocks.push({ type: "text", text: turn.text });
3541
- if (turn.toolCalls) {
3542
- for (const tc of turn.toolCalls) {
3543
- blocks.push({
3544
- type: "tool_use",
3545
- id: tc.id,
3546
- name: tc.name,
3547
- input: sortRecordKeys(tc.input)
3548
- });
3549
- }
3550
- }
3551
- if (turn.toolResults) {
3552
- for (const tr of turn.toolResults) {
3553
- blocks.push({
3554
- type: "tool_result",
3555
- tool_use_id: tr.toolCallId,
3556
- content: tr.content,
3557
- is_error: tr.isError ?? false
3558
- });
3559
- }
3560
- }
3561
- return { role: turn.role === "assistant" ? "assistant" : "user", content: blocks };
4053
+ var SCOUTED_USER_MESSAGE_CACHE_SENTINEL = "I have already scouted the project";
4054
+ function composeScoutedUserMessage(args) {
4055
+ return [
4056
+ SCOUTED_USER_MESSAGE_CACHE_SENTINEL + " for relevant files. Use this as your starting context; call `read_file`/`grep`/`list_dir` if you need to read more.",
4057
+ "",
4058
+ args.scoutedSection.trim(),
4059
+ ""
4060
+ ].join("\n");
3562
4061
  }
3563
- function mapStop(r) {
3564
- if (r === "tool_use") return "tool_use";
3565
- if (r === "max_tokens") return "max_tokens";
3566
- return "end_turn";
4062
+ function composeUserPrompt(args) {
4063
+ return [
4064
+ "Produce the implementation plan for the following intake.",
4065
+ "",
4066
+ "---",
4067
+ "",
4068
+ args.intakeContent.trimEnd(),
4069
+ ""
4070
+ ].join("\n");
3567
4071
  }
3568
4072
 
3569
- // src/planner/providers/openai.ts
3570
- var API_URL2 = "https://api.openai.com/v1/chat/completions";
3571
- var openaiProvider = {
3572
- name: "openai",
3573
- async send(req) {
3574
- return callOpenAI(req);
3575
- }
3576
- };
3577
- function buildOpenAIBody(req) {
3578
- const base = {
3579
- model: req.model,
3580
- max_completion_tokens: req.maxOutputTokens ?? 4096,
3581
- tools: req.tools.map(toOpenAITool)
3582
- };
3583
- if (req.turns.length === 0) {
3584
- return base;
3585
- }
3586
- const messages = [{ role: "system", content: req.systemPrompt }];
3587
- for (const turn of req.turns) messages.push(...toOpenAIMessages(turn));
3588
- return { ...base, messages };
3589
- }
3590
- async function callOpenAI(req) {
3591
- const built = buildOpenAIBody(req);
3592
- const messages = built.messages && built.messages.length > 0 ? built.messages : [{ role: "system", content: req.systemPrompt }];
3593
- const body = { ...built, messages };
3594
- const res = await fetch(API_URL2, {
3595
- method: "POST",
3596
- headers: {
3597
- Authorization: `Bearer ${req.apiKey}`,
3598
- "content-type": "application/json"
3599
- },
3600
- body: JSON.stringify(body),
3601
- signal: req.abort
3602
- });
3603
- if (!res.ok) {
3604
- const text = await res.text().catch(() => "");
3605
- const nf = detectModelNotFound("openai", req.model, res.status, text);
3606
- if (nf) {
3607
- return {
3608
- stopReason: "error",
3609
- rawError: modelNotFoundMessage(nf),
3610
- errorKind: "model_not_found"
3611
- };
3612
- }
3613
- const rl = detectRateLimit("openai", res.status, res.headers, text);
3614
- if (rl) {
3615
- return {
3616
- stopReason: "error",
3617
- rawError: `openai 429: ${text.slice(0, 500)}`,
3618
- errorKind: "rate_limit",
3619
- retryAfterSec: rl.retryAfterSec
3620
- };
3621
- }
3622
- return { stopReason: "error", rawError: `openai ${res.status}: ${text.slice(0, 500)}` };
3623
- }
3624
- const json = await res.json();
3625
- const choice = json.choices?.[0];
3626
- if (!choice) return { stopReason: "error", rawError: "openai: empty choices" };
3627
- const textOut = choice.message.content ?? "";
3628
- const toolCalls = (choice.message.tool_calls ?? []).map((tc) => ({
3629
- id: tc.id,
3630
- name: tc.function.name,
3631
- input: safeJson(tc.function.arguments)
3632
- }));
3633
- return {
3634
- text: textOut || void 0,
3635
- toolCalls: toolCalls.length ? toolCalls : void 0,
3636
- stopReason: mapStop2(choice.finish_reason),
3637
- usage: {
3638
- inputTokens: json.usage?.prompt_tokens ?? 0,
3639
- outputTokens: json.usage?.completion_tokens ?? 0
3640
- }
3641
- };
3642
- }
3643
- function toOpenAITool(t) {
3644
- return {
3645
- type: "function",
3646
- function: {
3647
- name: t.name,
3648
- description: t.description,
3649
- parameters: sortRecordKeys(t.inputSchema)
4073
+ // src/planner/core-messages.ts
4074
+ function turnsToCoreMessages(systemPrompt, turns, opts) {
4075
+ const { cacheEnabled, provider } = opts;
4076
+ const messages = [
4077
+ {
4078
+ role: "system",
4079
+ content: systemPrompt,
4080
+ ...cacheEnabled && provider === "anthropic" ? { providerOptions: { anthropic: { cacheControl: { type: "ephemeral" } } } } : {}
3650
4081
  }
3651
- };
3652
- }
3653
- function toOpenAIMessages(turn) {
3654
- const out = [];
3655
- if (turn.role === "assistant") {
3656
- out.push({
3657
- role: "assistant",
3658
- content: turn.text ?? null,
3659
- tool_calls: turn.toolCalls?.map((tc) => ({
3660
- id: tc.id,
3661
- type: "function",
3662
- function: {
3663
- name: tc.name,
3664
- arguments: JSON.stringify(sortRecordKeys(tc.input))
4082
+ ];
4083
+ let lastToolMessageIndex = -1;
4084
+ for (const t of turns) {
4085
+ if (t.role === "user") {
4086
+ if (t.text !== void 0 && t.text.length > 0) {
4087
+ const userMsg = { role: "user", content: t.text };
4088
+ if (cacheEnabled && provider === "anthropic" && t.text.startsWith(SCOUTED_USER_MESSAGE_CACHE_SENTINEL)) {
4089
+ userMsg.providerOptions = {
4090
+ anthropic: { cacheControl: { type: "ephemeral" } }
4091
+ };
3665
4092
  }
3666
- }))
3667
- });
3668
- } else {
3669
- if (turn.text) out.push({ role: "user", content: turn.text });
3670
- if (turn.toolResults) {
3671
- for (const tr of turn.toolResults) {
3672
- out.push({ role: "tool", tool_call_id: tr.toolCallId, content: tr.content });
4093
+ messages.push(userMsg);
4094
+ }
4095
+ if (t.toolResults?.length) {
4096
+ const msg = {
4097
+ role: "tool",
4098
+ content: t.toolResults.map((tr) => ({
4099
+ type: "tool-result",
4100
+ toolCallId: tr.toolCallId,
4101
+ toolName: tr.name,
4102
+ result: tr.content,
4103
+ ...tr.isError ? { isError: true } : {}
4104
+ }))
4105
+ };
4106
+ lastToolMessageIndex = messages.push(msg) - 1;
4107
+ }
4108
+ } else {
4109
+ const parts = [];
4110
+ if (t.text) parts.push({ type: "text", text: t.text });
4111
+ for (const tc of t.toolCalls ?? []) {
4112
+ parts.push({
4113
+ type: "tool-call",
4114
+ toolCallId: tc.id,
4115
+ toolName: tc.name,
4116
+ args: tc.input
4117
+ });
4118
+ }
4119
+ if (parts.length === 0) continue;
4120
+ if (parts.length === 1 && parts[0].type === "text") {
4121
+ messages.push({ role: "assistant", content: parts[0].text });
4122
+ } else {
4123
+ messages.push({ role: "assistant", content: parts });
3673
4124
  }
3674
4125
  }
3675
4126
  }
3676
- return out;
3677
- }
3678
- function mapStop2(r) {
3679
- if (r === "tool_calls") return "tool_use";
3680
- if (r === "length") return "max_tokens";
3681
- return "end_turn";
3682
- }
3683
- function safeJson(raw) {
3684
- try {
3685
- return sortRecordKeys(JSON.parse(raw));
3686
- } catch {
3687
- return {};
3688
- }
3689
- }
3690
-
3691
- // src/planner/providers/google.ts
3692
- var API_BASE = "https://generativelanguage.googleapis.com/v1beta/models";
3693
- var googleProvider = {
3694
- name: "google",
3695
- async send(req) {
3696
- return callGoogle(req);
3697
- }
3698
- };
3699
- function buildGoogleBody(req) {
3700
- const base = {
3701
- system_instruction: { parts: [{ text: req.systemPrompt }] },
3702
- generationConfig: { maxOutputTokens: req.maxOutputTokens ?? 4096 }
3703
- };
3704
- if (req.tools.length) {
3705
- base.tools = [{ functionDeclarations: req.tools.map(toGoogleTool) }];
3706
- }
3707
- if (req.turns.length === 0) {
3708
- return base;
3709
- }
3710
- return { ...base, contents: req.turns.map(toGoogleContent) };
3711
- }
3712
- async function callGoogle(req) {
3713
- const url = `${API_BASE}/${encodeURIComponent(req.model)}:generateContent?key=${encodeURIComponent(req.apiKey)}`;
3714
- const built = buildGoogleBody(req);
3715
- const body = { ...built, contents: built.contents ?? [] };
3716
- const res = await fetch(url, {
3717
- method: "POST",
3718
- headers: { "content-type": "application/json" },
3719
- body: JSON.stringify(body),
3720
- signal: req.abort
3721
- });
3722
- if (!res.ok) {
3723
- const text = await res.text().catch(() => "");
3724
- const nf = detectModelNotFound("google", req.model, res.status, text);
3725
- if (nf) {
3726
- return {
3727
- stopReason: "error",
3728
- rawError: modelNotFoundMessage(nf),
3729
- errorKind: "model_not_found"
4127
+ if (cacheEnabled && provider === "anthropic" && lastToolMessageIndex >= 0) {
4128
+ const target = messages[lastToolMessageIndex];
4129
+ if (target.role === "tool") {
4130
+ target.providerOptions = {
4131
+ anthropic: { cacheControl: { type: "ephemeral" } }
3730
4132
  };
3731
4133
  }
3732
- const rl = detectRateLimit("google", res.status, res.headers, text);
3733
- if (rl) {
3734
- return {
3735
- stopReason: "error",
3736
- rawError: `google 429: ${text.slice(0, 500)}`,
3737
- errorKind: "rate_limit",
3738
- retryAfterSec: rl.retryAfterSec
3739
- };
3740
- }
3741
- return { stopReason: "error", rawError: `google ${res.status}: ${text.slice(0, 500)}` };
3742
- }
3743
- const json = await res.json();
3744
- const cand = json.candidates?.[0];
3745
- if (!cand) return { stopReason: "error", rawError: "google: empty candidates" };
3746
- const parts = cand.content?.parts ?? [];
3747
- const textOut = parts.filter((p) => "text" in p && !!p.text).map((p) => p.text).join("");
3748
- const toolCalls = parts.filter((p) => "functionCall" in p && !!p.functionCall).map((p, i) => ({
3749
- id: `gcall_${i}_${p.functionCall.name}`,
3750
- name: p.functionCall.name,
3751
- input: p.functionCall.args ? sortRecordKeys(p.functionCall.args) : {}
3752
- }));
3753
- return {
3754
- text: textOut || void 0,
3755
- toolCalls: toolCalls.length ? toolCalls : void 0,
3756
- stopReason: mapStop3(cand.finishReason),
3757
- usage: {
3758
- inputTokens: json.usageMetadata?.promptTokenCount ?? 0,
3759
- outputTokens: json.usageMetadata?.candidatesTokenCount ?? 0
3760
- }
3761
- };
3762
- }
3763
- function toGoogleTool(t) {
3764
- return { name: t.name, description: t.description, parameters: sortRecordKeys(t.inputSchema) };
3765
- }
3766
- function toGoogleContent(turn) {
3767
- const role = turn.role === "assistant" ? "model" : "user";
3768
- const parts = [];
3769
- if (turn.text) parts.push({ text: turn.text });
3770
- if (turn.toolCalls) {
3771
- for (const tc of turn.toolCalls) {
3772
- parts.push({ functionCall: { name: tc.name, args: sortRecordKeys(tc.input) } });
3773
- }
3774
4134
  }
3775
- if (turn.toolResults) {
3776
- for (const tr of turn.toolResults) {
3777
- parts.push({
3778
- functionResponse: { name: nameFromToolCallId(tr.toolCallId), response: { content: tr.content } }
3779
- });
3780
- }
3781
- }
3782
- return { role, parts };
3783
- }
3784
- function nameFromToolCallId(id) {
3785
- const m = id.match(/^gcall_\d+_(.+)$/);
3786
- return m?.[1] ?? "read_file";
3787
- }
3788
- function mapStop3(r) {
3789
- if (r === "STOP" || r === "END_OF_TURN" || r === "FINISH_REASON_STOP") return "end_turn";
3790
- if (r === "MAX_TOKENS") return "max_tokens";
3791
- return "tool_use";
3792
- }
3793
-
3794
- // src/planner/providers/prefix.ts
3795
- function prefixOf(provider, req) {
3796
- const trimmed = { ...req, turns: req.turns.slice(0, -1) };
3797
- const s = (() => {
3798
- switch (provider) {
3799
- case "anthropic":
3800
- return JSON.stringify(buildAnthropicBody(trimmed));
3801
- case "openai":
3802
- return JSON.stringify(buildOpenAIBody(trimmed));
3803
- case "google":
3804
- return JSON.stringify(buildGoogleBody(trimmed));
3805
- }
3806
- })();
3807
- return s.length > 0 ? s.slice(0, -1) : s;
4135
+ return messages;
3808
4136
  }
3809
4137
 
3810
4138
  // src/core/runs.ts
3811
- import fs22 from "fs/promises";
3812
- import path14 from "path";
4139
+ import fs25 from "fs/promises";
4140
+ import path17 from "path";
3813
4141
  import { randomBytes } from "crypto";
3814
4142
  var RING_SIZE = 20;
3815
4143
  function newRunId() {
@@ -3818,24 +4146,24 @@ function newRunId() {
3818
4146
  return `${ts}-${rnd}`;
3819
4147
  }
3820
4148
  async function appendRun(paths, rec) {
3821
- const dir = path14.join(paths.squadDir, "runs");
3822
- await fs22.mkdir(dir, { recursive: true });
3823
- const file = path14.join(dir, `${rec.runId}.json`);
3824
- await fs22.writeFile(file, JSON.stringify({ ...rec, version: 1 }, null, 2) + "\n", "utf8");
4149
+ const dir = path17.join(paths.squadDir, "runs");
4150
+ await fs25.mkdir(dir, { recursive: true });
4151
+ const file = path17.join(dir, `${rec.runId}.json`);
4152
+ await fs25.writeFile(file, JSON.stringify({ ...rec, version: 1 }, null, 2) + "\n", "utf8");
3825
4153
  await pruneOldRuns(dir);
3826
4154
  }
3827
4155
  async function pruneOldRuns(dir) {
3828
- const entries = (await fs22.readdir(dir)).filter((n) => n.endsWith(".json"));
4156
+ const entries = (await fs25.readdir(dir)).filter((n) => n.endsWith(".json"));
3829
4157
  if (entries.length <= RING_SIZE) return;
3830
4158
  entries.sort();
3831
4159
  const drop = entries.slice(0, entries.length - RING_SIZE);
3832
- await Promise.all(drop.map((n) => fs22.rm(path14.join(dir, n), { force: true })));
4160
+ await Promise.all(drop.map((n) => fs25.rm(path17.join(dir, n), { force: true })));
3833
4161
  }
3834
4162
  async function listRuns(paths) {
3835
- const dir = path14.join(paths.squadDir, "runs");
4163
+ const dir = path17.join(paths.squadDir, "runs");
3836
4164
  let entries;
3837
4165
  try {
3838
- entries = await fs22.readdir(dir);
4166
+ entries = await fs25.readdir(dir);
3839
4167
  } catch {
3840
4168
  return [];
3841
4169
  }
@@ -3843,7 +4171,7 @@ async function listRuns(paths) {
3843
4171
  const out = [];
3844
4172
  for (const f of files) {
3845
4173
  try {
3846
- const raw = await fs22.readFile(path14.join(dir, f), "utf8");
4174
+ const raw = await fs25.readFile(path17.join(dir, f), "utf8");
3847
4175
  const parsed = JSON.parse(raw);
3848
4176
  if (parsed.version === 1) out.push(parsed);
3849
4177
  } catch {
@@ -3873,6 +4201,355 @@ var PlannerEventBus = class {
3873
4201
  }
3874
4202
  };
3875
4203
 
4204
+ // src/planner/stages/scout.ts
4205
+ import { APICallError, generateObject, NoObjectGeneratedError } from "ai";
4206
+ import { z as z4 } from "zod";
4207
+ var ScoutOutputSchema = z4.object({
4208
+ selectedFiles: z4.array(z4.string()).min(1).max(25),
4209
+ reasoning: z4.string().min(1),
4210
+ suggestedReadStrategy: z4.enum(["read_full", "read_ranges", "mixed"]),
4211
+ readRanges: z4.array(
4212
+ z4.object({
4213
+ path: z4.string(),
4214
+ offset: z4.number().int().min(1),
4215
+ limit: z4.number().int().min(1).max(400)
4216
+ })
4217
+ ).optional()
4218
+ });
4219
+ async function runScout(input5) {
4220
+ const maxTokens = input5.maxTokens ?? 2048;
4221
+ let attempt = 0;
4222
+ while (true) {
4223
+ try {
4224
+ const { object, usage, providerMetadata } = await generateObject({
4225
+ model: input5.model,
4226
+ system: input5.systemPrompt,
4227
+ prompt: input5.userPrompt,
4228
+ schema: ScoutOutputSchema,
4229
+ maxTokens,
4230
+ abortSignal: input5.abort
4231
+ });
4232
+ const u = usageFromLanguageModelStep(usage, providerMetadata);
4233
+ input5.budget.recordUsage(u);
4234
+ return {
4235
+ output: object,
4236
+ usage: { inputTokens: u.inputTokens, outputTokens: u.outputTokens }
4237
+ };
4238
+ } catch (e) {
4239
+ if (NoObjectGeneratedError.isInstance(e)) {
4240
+ return null;
4241
+ }
4242
+ if (APICallError.isInstance(e) && attempt === 0) {
4243
+ const status = e.statusCode ?? 0;
4244
+ if (status === 429) {
4245
+ const ra = parseRetryAfterSec(e.responseHeaders ?? {});
4246
+ if (ra !== void 0 && ra <= 90) {
4247
+ await new Promise((r) => setTimeout(r, ra * 1e3));
4248
+ attempt += 1;
4249
+ continue;
4250
+ }
4251
+ }
4252
+ }
4253
+ throw e;
4254
+ }
4255
+ }
4256
+ }
4257
+
4258
+ // src/planner/validation.ts
4259
+ import fs26 from "fs";
4260
+ import path18 from "path";
4261
+ var CODE_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|md|json|ya?ml|css|html|py|go|rs|cs|java|kt|kts|swift|rb|php|vue|svelte)$/i;
4262
+ function summariseIssuesByKind(issues) {
4263
+ const out = {};
4264
+ for (const i of issues) {
4265
+ out[i.kind] = (out[i.kind] ?? 0) + 1;
4266
+ }
4267
+ return out;
4268
+ }
4269
+ function extractBacktickPaths(planText) {
4270
+ const claims = [];
4271
+ const re = /`([^`\n]+)`/g;
4272
+ let m;
4273
+ while ((m = re.exec(planText)) !== null) {
4274
+ const inner = m[1].trim();
4275
+ if (!inner.includes("/") || inner.startsWith("http")) continue;
4276
+ if (inner.includes("story ") || inner.includes("Story ")) continue;
4277
+ const seg = inner.split(/[\s:]/)[0];
4278
+ if (!seg || !CODE_EXT.test(seg)) continue;
4279
+ claims.push({ path: seg.replace(/^\.\//, ""), isCreate: false, excerpt: m[0] });
4280
+ }
4281
+ return claims;
4282
+ }
4283
+ function extractFileHeadingPaths(planText) {
4284
+ const claims = [];
4285
+ const fileRe = /\*\*\s*(?:File|Create file)\s*:\s*`([^`\n]+)`\s*\*\*/gi;
4286
+ let m;
4287
+ while ((m = fileRe.exec(planText)) !== null) {
4288
+ const p = m[1].trim().replace(/^\.\//, "");
4289
+ const isCreate = /create file/i.test(m[0]);
4290
+ claims.push({ path: p, isCreate, excerpt: m[0].slice(0, 120) });
4291
+ }
4292
+ return claims;
4293
+ }
4294
+ function mergePathClaims(planText) {
4295
+ const map = /* @__PURE__ */ new Map();
4296
+ for (const c of [...extractFileHeadingPaths(planText), ...extractBacktickPaths(planText)]) {
4297
+ const key = `${c.isCreate}:${c.path}`;
4298
+ if (!map.has(key)) map.set(key, c);
4299
+ }
4300
+ return [...map.values()];
4301
+ }
4302
+ var LINE_RANGE_RE = /(?:~?\s*lines?\s*|lines\s+)(\d+)\s*[–-]\s*(\d+)/gi;
4303
+ var LINE_SINGLE_WORD_RE = /\bline\s+(\d+)\b|\(L(\d+)\)/gi;
4304
+ var PATH_COLON_LINE_RE = /`([^`\n]+\.[a-z0-9]{1,6})`\s*:\s*(\d+)\b/gi;
4305
+ function pathNear(planText, idx) {
4306
+ const before = planText.slice(Math.max(0, idx - 200), idx);
4307
+ const pathMatch = before.match(/`([^`\n]+\.[a-z0-9]{1,6})`/i);
4308
+ const filePath = pathMatch?.[1]?.trim().replace(/^\.\//, "");
4309
+ return filePath;
4310
+ }
4311
+ function trigrams(s) {
4312
+ const x = s.toLowerCase();
4313
+ const g = /* @__PURE__ */ new Set();
4314
+ for (let i = 0; i + 3 <= x.length; i++) {
4315
+ g.add(x.slice(i, i + 3));
4316
+ }
4317
+ return g;
4318
+ }
4319
+ function trigramSimilarity(a, b) {
4320
+ const A = trigrams(a);
4321
+ const B = trigrams(b);
4322
+ if (A.size === 0 || B.size === 0) return 0;
4323
+ let inter = 0;
4324
+ for (const t of A) {
4325
+ if (B.has(t)) inter += 1;
4326
+ }
4327
+ return inter / Math.sqrt(A.size * B.size);
4328
+ }
4329
+ function suggestPaths(root, missingRel, max = 3) {
4330
+ const targetBase = path18.basename(missingRel).toLowerCase();
4331
+ const ig = buildRepoIgnore(root);
4332
+ const candidates = [];
4333
+ const walk2 = (dir) => {
4334
+ if (candidates.length >= 2e3) return;
4335
+ let ents;
4336
+ try {
4337
+ ents = fs26.readdirSync(dir, { withFileTypes: true });
4338
+ } catch {
4339
+ return;
4340
+ }
4341
+ for (const ent of ents) {
4342
+ if (candidates.length >= 2e3) return;
4343
+ const abs = path18.join(dir, ent.name);
4344
+ const rel = path18.relative(root, abs).split(path18.sep).join("/");
4345
+ if (!rel || ig.ignores(rel) || ent.isDirectory() && ig.ignores(`${rel}/`)) continue;
4346
+ if (ent.isDirectory()) walk2(abs);
4347
+ else if (ent.isFile()) candidates.push(rel);
4348
+ }
4349
+ };
4350
+ walk2(root);
4351
+ const rows = [];
4352
+ for (const rel of candidates) {
4353
+ const base = path18.basename(rel).toLowerCase();
4354
+ let score = 0;
4355
+ if (base === targetBase) score = 1e3;
4356
+ else if (base.startsWith(targetBase) || targetBase.startsWith(base)) score = 500;
4357
+ else if (base.includes(targetBase) || targetBase.includes(base)) score = 200;
4358
+ score += trigramSimilarity(targetBase, base) * 50;
4359
+ if (score > 0) rows.push({ rel, score });
4360
+ }
4361
+ rows.sort((a, b) => b.score - a.score || a.rel.localeCompare(b.rel));
4362
+ const uniq = [];
4363
+ for (const r of rows) {
4364
+ if (r.rel === missingRel.replace(/^\.\//, "")) continue;
4365
+ if (!uniq.includes(r.rel)) uniq.push(r.rel);
4366
+ if (uniq.length >= max) break;
4367
+ }
4368
+ return uniq;
4369
+ }
4370
+ var SYMBOL_DEF_RE = /(?:^|\n)\s*(?:export\s+)?(?:async\s+)?(?:function|class|interface|type|const|let|var)\s+([A-Za-z_][A-Za-z0-9_]*)/g;
4371
+ function collectSymbols(src, maxLines = 200) {
4372
+ const head = src.split(/\r?\n/).slice(0, maxLines).join("\n");
4373
+ const syms = /* @__PURE__ */ new Set();
4374
+ SYMBOL_DEF_RE.lastIndex = 0;
4375
+ let m;
4376
+ while ((m = SYMBOL_DEF_RE.exec(head)) !== null) {
4377
+ syms.add(m[1]);
4378
+ }
4379
+ return [...syms];
4380
+ }
4381
+ function leadingCharScore(sym, missing) {
4382
+ let i = 0;
4383
+ const a = sym.toLowerCase();
4384
+ const b = missing.toLowerCase();
4385
+ while (i < a.length && i < b.length && a[i] === b[i]) i++;
4386
+ return i * 10 + trigramSimilarity(sym, missing) * 30;
4387
+ }
4388
+ function suggestSymbols(fileText, missingSym, max = 3) {
4389
+ const defs = collectSymbols(fileText);
4390
+ const ranked = defs.filter((s) => s !== missingSym).map((s) => ({ s, score: leadingCharScore(s, missingSym) })).filter((r) => r.score > 0).sort((a, b) => b.score - a.score || a.s.localeCompare(b.s));
4391
+ const out = [];
4392
+ for (const r of ranked) {
4393
+ if (!out.includes(r.s)) out.push(r.s);
4394
+ if (out.length >= max) break;
4395
+ }
4396
+ return out;
4397
+ }
4398
+ function parseMetadataLine(planText) {
4399
+ const lines = planText.split(/\r?\n/);
4400
+ for (let i = 0; i < Math.min(5, lines.length); i++) {
4401
+ const line2 = (lines[i] ?? "").trim();
4402
+ if (!line2.startsWith("<!--") || !line2.includes("squad-kit:")) continue;
4403
+ if (!line2.endsWith("-->")) {
4404
+ return {
4405
+ severity: "warning",
4406
+ kind: "malformed_metadata",
4407
+ detail: "Metadata comment is not a single-line <!-- squad-kit: \u2026 -->",
4408
+ excerpt: line2.slice(0, 120)
4409
+ };
4410
+ }
4411
+ const inner = line2.replace(/^<!--\s*squad-kit:\s*/i, "").replace(/\s*-->$/, "");
4412
+ if (!inner.trim()) {
4413
+ return {
4414
+ severity: "warning",
4415
+ kind: "malformed_metadata",
4416
+ detail: "Empty squad-kit metadata body",
4417
+ excerpt: line2
4418
+ };
4419
+ }
4420
+ return void 0;
4421
+ }
4422
+ return void 0;
4423
+ }
4424
+ function validatePlan(input5) {
4425
+ const issues = [];
4426
+ const metaIssue = parseMetadataLine(input5.planText);
4427
+ if (metaIssue) issues.push(metaIssue);
4428
+ const cache = /* @__PURE__ */ new Map();
4429
+ const readCached = (rel) => {
4430
+ if (cache.has(rel)) return cache.get(rel);
4431
+ const resolved = path18.resolve(input5.root, rel);
4432
+ const rc = path18.relative(input5.root, resolved);
4433
+ if (rc.startsWith("..") || path18.isAbsolute(rc)) return void 0;
4434
+ if (!fs26.existsSync(resolved) || !fs26.statSync(resolved).isFile()) return void 0;
4435
+ try {
4436
+ const buf = fs26.readFileSync(resolved);
4437
+ const isBinary = buf.includes(0);
4438
+ const text = isBinary ? "" : buf.toString("utf8");
4439
+ const lines = isBinary ? 0 : text.split(/\r?\n/).length;
4440
+ const entry = { text, lines, isBinary };
4441
+ cache.set(rel, entry);
4442
+ return entry;
4443
+ } catch {
4444
+ return void 0;
4445
+ }
4446
+ };
4447
+ const pathClaims = mergePathClaims(input5.planText);
4448
+ for (const c of pathClaims) {
4449
+ if (c.isCreate) continue;
4450
+ const resolved = path18.resolve(input5.root, c.path);
4451
+ const rel = path18.relative(input5.root, resolved);
4452
+ if (rel.startsWith("..") || path18.isAbsolute(rel)) continue;
4453
+ if (!fs26.existsSync(resolved)) {
4454
+ const suggestions = suggestPaths(input5.root, c.path);
4455
+ issues.push({
4456
+ severity: "warning",
4457
+ kind: "missing_path",
4458
+ path: c.path,
4459
+ detail: "Path cited in plan does not exist in the workspace" + (suggestions.length ? ` \u2014 did you mean: ${suggestions.join(", ")}?` : ""),
4460
+ excerpt: c.excerpt
4461
+ });
4462
+ }
4463
+ }
4464
+ const rangeText = input5.planText;
4465
+ LINE_RANGE_RE.lastIndex = 0;
4466
+ let rm2;
4467
+ while ((rm2 = LINE_RANGE_RE.exec(rangeText)) !== null) {
4468
+ const lo = parseInt(rm2[1], 10);
4469
+ const hi = parseInt(rm2[2], 10);
4470
+ if (!Number.isFinite(lo) || !Number.isFinite(hi)) continue;
4471
+ const filePath = pathNear(rangeText, rm2.index);
4472
+ if (!filePath) continue;
4473
+ const cached = readCached(filePath);
4474
+ const lines = cached?.isBinary ? void 0 : cached?.lines;
4475
+ if (lines !== void 0 && hi > lines) {
4476
+ issues.push({
4477
+ severity: "warning",
4478
+ kind: "line_range_too_large",
4479
+ path: filePath,
4480
+ detail: `Range ends at line ${hi} but file has ${lines} lines`,
4481
+ excerpt: rm2[0].slice(0, 80)
4482
+ });
4483
+ }
4484
+ }
4485
+ LINE_SINGLE_WORD_RE.lastIndex = 0;
4486
+ while ((rm2 = LINE_SINGLE_WORD_RE.exec(rangeText)) !== null) {
4487
+ const n = parseInt(rm2[1] || rm2[2] || "", 10);
4488
+ if (!Number.isFinite(n)) continue;
4489
+ const filePath = pathNear(rangeText, rm2.index);
4490
+ if (!filePath) continue;
4491
+ const cached = readCached(filePath);
4492
+ const lines = cached?.isBinary ? void 0 : cached?.lines;
4493
+ if (lines !== void 0 && n > lines) {
4494
+ issues.push({
4495
+ severity: "warning",
4496
+ kind: "line_range_too_large",
4497
+ path: filePath,
4498
+ detail: `Range ends at line ${n} but file has ${lines} lines`,
4499
+ excerpt: rm2[0].slice(0, 80)
4500
+ });
4501
+ }
4502
+ }
4503
+ PATH_COLON_LINE_RE.lastIndex = 0;
4504
+ while ((rm2 = PATH_COLON_LINE_RE.exec(rangeText)) !== null) {
4505
+ const fp = rm2[1].trim().replace(/^\.\//, "");
4506
+ const n = parseInt(rm2[2], 10);
4507
+ if (!Number.isFinite(n)) continue;
4508
+ const cached = readCached(fp);
4509
+ const lines = cached?.isBinary ? void 0 : cached?.lines;
4510
+ if (lines !== void 0 && n > lines) {
4511
+ issues.push({
4512
+ severity: "warning",
4513
+ kind: "line_range_too_large",
4514
+ path: fp,
4515
+ detail: `Range ends at line ${n} but file has ${lines} lines`,
4516
+ excerpt: rm2[0].slice(0, 80)
4517
+ });
4518
+ }
4519
+ }
4520
+ const textByLine = input5.planText.split(/\r?\n/);
4521
+ for (let li = 0; li < textByLine.length; li++) {
4522
+ const line2 = textByLine[li] ?? "";
4523
+ const near = [textByLine[li - 1] ?? "", line2, textByLine[li + 1] ?? ""].join("\n");
4524
+ const pathNearCell = near.match(/`([^`\n/]+\/[^`\n]+?\.[a-z0-9]{1,6})`/i);
4525
+ if (!pathNearCell) continue;
4526
+ const fp = pathNearCell[1].replace(/^\.\//, "");
4527
+ const cached = readCached(fp);
4528
+ if (!cached || cached.isBinary) continue;
4529
+ const symRe = /`([A-Za-z_][A-Za-z0-9_]{2,50})`/g;
4530
+ let sm;
4531
+ while ((sm = symRe.exec(line2)) !== null) {
4532
+ const sym = sm[1];
4533
+ if (["true", "false", "null", "undefined", "string", "number", "boolean", "void", "never"].includes(
4534
+ sym
4535
+ )) {
4536
+ continue;
4537
+ }
4538
+ if (!cached.text.includes(sym)) {
4539
+ const suggestions = suggestSymbols(cached.text, sym);
4540
+ issues.push({
4541
+ severity: "warning",
4542
+ kind: "symbol_not_found",
4543
+ path: fp,
4544
+ detail: `Symbol \`${sym}\` not found as substring in file` + (suggestions.length ? ` \u2014 did you mean: ${suggestions.map((s) => `\`${s}\``).join(", ")}?` : ""),
4545
+ excerpt: sm[0]
4546
+ });
4547
+ }
4548
+ }
4549
+ }
4550
+ return issues;
4551
+ }
4552
+
3876
4553
  // src/planner/loop.ts
3877
4554
  var MAX_RATE_LIMIT_RETRY_SEC = 90;
3878
4555
  var defaultSleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
@@ -3910,8 +4587,147 @@ function buildPlannerRunStats(budget, turns, runStartedAt) {
3910
4587
  durationMs: Date.now() - runStartedAt
3911
4588
  };
3912
4589
  }
3913
- function readBudgetishError(result) {
3914
- return result.isError && (/budget/i.test(result.content) || /max file reads/i.test(result.content));
4590
+ function buildScoutedContextSection(root, budget, selectedFiles, bus, runId, readRanges) {
4591
+ const normalizeRel = (p) => p.replace(/^\.\//, "");
4592
+ const rangeByPath = /* @__PURE__ */ new Map();
4593
+ for (const rr of readRanges ?? []) {
4594
+ rangeByPath.set(normalizeRel(rr.path), rr);
4595
+ }
4596
+ const ordered = [];
4597
+ const seenPath = /* @__PURE__ */ new Set();
4598
+ for (const f of selectedFiles) {
4599
+ const c = normalizeRel(f);
4600
+ if (!seenPath.has(c)) {
4601
+ seenPath.add(c);
4602
+ ordered.push(c);
4603
+ }
4604
+ }
4605
+ for (const p of rangeByPath.keys()) {
4606
+ if (!seenPath.has(p)) {
4607
+ seenPath.add(p);
4608
+ ordered.push(p);
4609
+ }
4610
+ }
4611
+ const blocks = ["\n## Scouted context (already loaded)\n"];
4612
+ for (const cleaned of ordered) {
4613
+ const rr = rangeByPath.get(cleaned);
4614
+ if (rr) {
4615
+ const result = readFileTool(root, budget, { path: cleaned, offset: rr.offset, limit: rr.limit });
4616
+ const hi = rr.offset + rr.limit - 1;
4617
+ if (result.isError) {
4618
+ blocks.push(
4619
+ `
4620
+ ### \`${cleaned}\` (lines ${rr.offset}\u2013${hi})
4621
+
4622
+ _${result.content}_
4623
+ `
4624
+ );
4625
+ } else {
4626
+ blocks.push(
4627
+ `
4628
+ ### \`${cleaned}\` (lines ${rr.offset}\u2013${hi})
4629
+
4630
+ \`\`\`
4631
+ ${result.content}
4632
+ \`\`\`
4633
+ `
4634
+ );
4635
+ }
4636
+ continue;
4637
+ }
4638
+ const resolved = path19.resolve(root, cleaned);
4639
+ const rc = path19.relative(root, resolved);
4640
+ if (rc.startsWith("..") || path19.isAbsolute(rc)) {
4641
+ bus.emit({
4642
+ kind: "validation_issue",
4643
+ runId,
4644
+ severity: "warning",
4645
+ issueKind: "missing_path",
4646
+ path: cleaned,
4647
+ detail: "Scout-selected path escapes the project root"
4648
+ });
4649
+ continue;
4650
+ }
4651
+ if (!fs27.existsSync(resolved)) {
4652
+ bus.emit({
4653
+ kind: "validation_issue",
4654
+ runId,
4655
+ severity: "warning",
4656
+ issueKind: "missing_path",
4657
+ path: cleaned,
4658
+ detail: "Scout selected this path but it does not exist on disk"
4659
+ });
4660
+ continue;
4661
+ }
4662
+ const st = fs27.statSync(resolved);
4663
+ if (!st.isFile()) continue;
4664
+ const n = Math.min(st.size, MAX_BYTES_PER_FULL_READ);
4665
+ const cap = budget.canRead(n);
4666
+ if (!cap.ok) {
4667
+ blocks.push(`
4668
+ ### \`${cleaned}\`
4669
+
4670
+ _(skipped: ${cap.reason})_
4671
+ `);
4672
+ continue;
4673
+ }
4674
+ let buf;
4675
+ try {
4676
+ buf = fs27.readFileSync(resolved);
4677
+ } catch {
4678
+ continue;
4679
+ }
4680
+ if (buf.length > MAX_BYTES_PER_FULL_READ) {
4681
+ const head = buf.subarray(0, MAX_BYTES_PER_FULL_READ);
4682
+ if (looksBinary(head)) {
4683
+ blocks.push(`
4684
+ ### \`${cleaned}\`
4685
+
4686
+ _(binary file skipped)_
4687
+ `);
4688
+ continue;
4689
+ }
4690
+ budget.recordRead(head.length);
4691
+ blocks.push(
4692
+ `
4693
+ ### \`${cleaned}\` (truncated: first ${MAX_BYTES_PER_FULL_READ}-byte head; full size ${buf.length} bytes)
4694
+
4695
+ \`\`\`
4696
+ ${head.toString("utf8")}
4697
+ \`\`\`
4698
+ `
4699
+ );
4700
+ continue;
4701
+ }
4702
+ if (looksBinary(buf)) {
4703
+ blocks.push(`
4704
+ ### \`${cleaned}\`
4705
+
4706
+ _(binary file skipped)_
4707
+ `);
4708
+ continue;
4709
+ }
4710
+ budget.recordRead(buf.length);
4711
+ blocks.push(`
4712
+ ### \`${cleaned}\`
4713
+
4714
+ \`\`\`
4715
+ ${buf.toString("utf8")}
4716
+ \`\`\`
4717
+ `);
4718
+ }
4719
+ return blocks.join("");
4720
+ }
4721
+ function scoutStageStats(usage, durationMs) {
4722
+ return {
4723
+ turns: 0,
4724
+ inputTokens: usage.inputTokens,
4725
+ outputTokens: usage.outputTokens,
4726
+ cacheCreationTokens: 0,
4727
+ cacheReadTokens: 0,
4728
+ cacheHitRatio: 0,
4729
+ durationMs
4730
+ };
3915
4731
  }
3916
4732
  function extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut) {
3917
4733
  input5.budget.extendSession();
@@ -3951,28 +4767,106 @@ function cancelledOutput(input5, bus, runId, accumulatedText, budgetExhausted, i
3951
4767
  userCancelled: true
3952
4768
  };
3953
4769
  }
3954
- async function runPlanner(input5) {
4770
+ function asToolInput(args) {
4771
+ if (args && typeof args === "object" && !Array.isArray(args)) return args;
4772
+ if (typeof args === "string") {
4773
+ try {
4774
+ return JSON.parse(args);
4775
+ } catch {
4776
+ return {};
4777
+ }
4778
+ }
4779
+ return {};
4780
+ }
4781
+ function normalizeToolExecutionResult(result) {
4782
+ if (typeof result === "string") {
4783
+ try {
4784
+ const j = JSON.parse(result);
4785
+ if (j && typeof j === "object" && !Array.isArray(j) && "content" in j) {
4786
+ const o = j;
4787
+ return {
4788
+ content: typeof o.content === "string" ? o.content : JSON.stringify(o.content ?? ""),
4789
+ isError: o.isError === true
4790
+ };
4791
+ }
4792
+ } catch {
4793
+ }
4794
+ return { content: result, isError: false };
4795
+ }
4796
+ if (result && typeof result === "object" && !Array.isArray(result) && "content" in result) {
4797
+ const o = result;
4798
+ return {
4799
+ content: typeof o.content === "string" ? o.content : JSON.stringify(o.content ?? ""),
4800
+ isError: o.isError === true
4801
+ };
4802
+ }
4803
+ return { content: typeof result === "undefined" ? "" : JSON.stringify(result), isError: false };
4804
+ }
4805
+ function applyStepToTurns(turns, step2) {
4806
+ const callsById = /* @__PURE__ */ new Map();
4807
+ const toolCalls = step2.toolCalls.map((tc) => {
4808
+ callsById.set(tc.toolCallId, tc.toolName);
4809
+ return { id: tc.toolCallId, name: tc.toolName, input: asToolInput(tc.args) };
4810
+ });
4811
+ turns.push({
4812
+ role: "assistant",
4813
+ ...step2.text ? { text: step2.text } : {},
4814
+ ...toolCalls.length ? { toolCalls } : {}
4815
+ });
4816
+ if (step2.toolResults.length) {
4817
+ const toolResults = step2.toolResults.map((tr) => {
4818
+ const norm = normalizeToolExecutionResult(tr.result);
4819
+ return {
4820
+ toolCallId: tr.toolCallId,
4821
+ name: callsById.get(tr.toolCallId) ?? "read_file",
4822
+ content: norm.content,
4823
+ ...norm.isError ? { isError: true } : {}
4824
+ };
4825
+ });
4826
+ turns.push({ role: "user", toolResults });
4827
+ }
4828
+ }
4829
+ function providerResponseFromApiError(provider, modelId, err2) {
4830
+ const body = err2.responseBody ?? "";
4831
+ const nf = detectModelNotFound(provider, modelId, err2.statusCode ?? 0, body);
4832
+ if (nf) {
4833
+ return {
4834
+ stopReason: "error",
4835
+ errorKind: "model_not_found",
4836
+ rawError: modelNotFoundMessage(nf)
4837
+ };
4838
+ }
4839
+ const rl = detectRateLimit(provider, err2.statusCode ?? 0, err2.responseHeaders ?? {}, body);
4840
+ if (rl) {
4841
+ return {
4842
+ stopReason: "error",
4843
+ errorKind: "rate_limit",
4844
+ retryAfterSec: rl.retryAfterSec,
4845
+ rawError: `${provider} ${err2.statusCode ?? "error"}: ${body.slice(0, 500)}`
4846
+ };
4847
+ }
4848
+ return {
4849
+ stopReason: "error",
4850
+ rawError: `${provider} ${err2.statusCode ?? "error"}: ${body.slice(0, 500)}`
4851
+ };
4852
+ }
4853
+ async function runDraftStage(input5) {
3955
4854
  const runStartedAt = Date.now();
3956
4855
  const bus = input5.events ?? new PlannerEventBus();
3957
4856
  const runId = input5.runId ?? newRunId();
3958
4857
  const cacheEnabled = input5.cacheEnabled ?? true;
3959
- bus.emit({
3960
- kind: "started",
3961
- runId,
3962
- provider: input5.provider.name,
3963
- model: input5.model,
3964
- cacheEnabled
3965
- });
3966
4858
  const baseMaxIter = input5.maxIterations ?? DEFAULT_PLANNER_MAX_ITERATIONS;
3967
4859
  const maxIter = { current: baseMaxIter };
3968
4860
  const baseMaxOut = input5.maxOutputTokens ?? DEFAULT_PLANNER_MAX_OUTPUT_TOKENS;
3969
4861
  const maxOut = { current: baseMaxOut };
3970
4862
  const turns = [{ role: "user", text: input5.userPrompt }];
4863
+ if (input5.scoutedSection?.trim()) {
4864
+ turns.push({ role: "user", text: composeScoutedUserMessage({ scoutedSection: input5.scoutedSection }) });
4865
+ }
3971
4866
  let accumulatedText = "";
3972
4867
  let iterations = 0;
3973
4868
  let budgetExhausted = false;
3974
4869
  let finishedNormally = false;
3975
- let prevRequestPrefix;
3976
4870
  const decide = input5.decideOnLimit;
3977
4871
  const sleepFn = input5.sleep ?? defaultSleep;
3978
4872
  const limitCtx = (kind) => ({
@@ -4043,126 +4937,160 @@ async function runPlanner(input5) {
4043
4937
  stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt)
4044
4938
  };
4045
4939
  }
4046
- if (input5.budget.overCost()) {
4047
- if (decide) {
4048
- const d = await decide(limitCtx("cost_cap"));
4049
- if (d === "cancel") {
4050
- return {
4051
- planText: accumulatedText,
4052
- budgetExhausted,
4053
- timedOut: false,
4054
- finishedNormally: false,
4055
- iterations,
4056
- stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
4057
- incompleteKind: "cost_cap",
4058
- userCancelled: true
4059
- };
4060
- }
4061
- extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut);
4062
- iterations -= 1;
4063
- continue;
4064
- }
4065
- return {
4066
- planText: accumulatedText,
4067
- budgetExhausted: true,
4068
- timedOut: false,
4069
- finishedNormally: false,
4070
- iterations,
4071
- stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt)
4072
- };
4073
- }
4074
4940
  if (iterations >= 2) {
4075
4941
  bus.emit({ kind: "turn_started", runId, turn: iterations });
4076
4942
  }
4077
- const request = {
4078
- systemPrompt: input5.systemPrompt,
4079
- model: input5.model,
4080
- tools: [READ_FILE_TOOL],
4081
- turns,
4082
- apiKey: input5.apiKey,
4083
- maxOutputTokens: maxOut.current,
4943
+ const tools = buildPlannerTools({
4944
+ root: input5.root,
4945
+ budget: input5.budget,
4946
+ enabled: {
4947
+ grep: input5.toolsEnabled?.grep ?? true,
4948
+ listDir: input5.toolsEnabled?.listDir ?? true,
4949
+ rangedRead: input5.toolsEnabled?.rangedRead ?? true
4950
+ },
4951
+ readHooks: {
4952
+ runId,
4953
+ turn: iterations,
4954
+ bus,
4955
+ onToolCall: input5.onToolCall,
4956
+ decideOnLimit: input5.decideOnLimit,
4957
+ getLimitCtx: () => limitCtx("file_or_context_reads"),
4958
+ extendSessionLimits: () => extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut),
4959
+ getAccumulatedText: () => accumulatedText,
4960
+ setBudgetExhausted: (v) => {
4961
+ budgetExhausted = v;
4962
+ }
4963
+ }
4964
+ });
4965
+ const messages = turnsToCoreMessages(input5.systemPrompt, turns, {
4084
4966
  cacheEnabled,
4085
- abort: input5.abort
4086
- };
4967
+ provider: input5.provider
4968
+ });
4087
4969
  bus.emit({ kind: "request_sent", runId, turn: iterations });
4088
- if (process.env.NODE_ENV !== "production" && process.env.NODE_ENV !== "test" && prevRequestPrefix !== void 0) {
4089
- const currentPrefix = prefixOf(input5.provider.name, request);
4090
- if (!currentPrefix.startsWith(prevRequestPrefix)) {
4091
- const byte = firstByteDiff(prevRequestPrefix, currentPrefix);
4092
- warning(
4093
- `planner: prefix mutation at turn ${iterations}, byte ${byte} \u2014 caching will not hit.`
4094
- );
4095
- }
4096
- }
4097
- let response;
4098
- try {
4099
- response = await input5.provider.send(request);
4100
- } catch (e) {
4101
- if (isAbortError(e)) {
4102
- return cancelledOutput(input5, bus, runId, accumulatedText, budgetExhausted, iterations, runStartedAt);
4103
- }
4104
- throw e;
4105
- }
4106
- if (response.usage) {
4107
- recordUsageAndEmit(bus, input5, runId, iterations, response.usage);
4108
- }
4109
- let retriedRateLimit = false;
4970
+ let attempt = 0;
4971
+ let stepResult;
4110
4972
  let retrySkippedReason;
4111
- if (response.stopReason === "error" && response.errorKind === "rate_limit") {
4112
- const asked = response.retryAfterSec;
4113
- const emitRateLimit = (phase, waitSec) => {
4114
- bus.emit({
4115
- kind: "rate_limit",
4116
- runId,
4117
- turn: iterations,
4118
- retryAfterSec: response.retryAfterSec,
4119
- waitSec,
4120
- capSec: MAX_RATE_LIMIT_RETRY_SEC,
4121
- phase,
4122
- provider: input5.provider.name,
4123
- rawBody: (response.rawError ?? "").slice(0, 200)
4124
- });
4125
- };
4126
- if (asked !== void 0 && asked > MAX_RATE_LIMIT_RETRY_SEC) {
4127
- emitRateLimit("aborted", asked);
4128
- retrySkippedReason = "retry_after_too_long";
4129
- } else {
4130
- const waitSec = Math.min(asked ?? 10, MAX_RATE_LIMIT_RETRY_SEC);
4131
- emitRateLimit("retrying", waitSec);
4132
- input5.onRateLimit?.(waitSec);
4133
- try {
4134
- await sleepWithAbort(waitSec * 1e3, sleepFn, input5.abort);
4135
- } catch (e) {
4136
- if (isAbortError(e)) {
4137
- return cancelledOutput(input5, bus, runId, accumulatedText, budgetExhausted, iterations, runStartedAt);
4973
+ let retriedRateLimit = false;
4974
+ const callStream = async () => {
4975
+ const { streamText } = await import("ai");
4976
+ const stream = streamText({
4977
+ model: input5.model,
4978
+ messages,
4979
+ tools,
4980
+ toolChoice: "auto",
4981
+ maxSteps: 1,
4982
+ maxRetries: 0,
4983
+ maxTokens: maxOut.current,
4984
+ abortSignal: input5.abort,
4985
+ onChunk: ({ chunk }) => {
4986
+ if (chunk.type === "text-delta") {
4987
+ accumulatedText += chunk.textDelta;
4988
+ input5.onAssistantText?.(chunk.textDelta);
4989
+ bus.emit({ kind: "assistant_text", runId, turn: iterations, delta: chunk.textDelta });
4138
4990
  }
4139
- throw e;
4991
+ },
4992
+ onStepFinish: (step2) => {
4993
+ const u = usageFromLanguageModelStep(step2.usage, step2.providerMetadata);
4994
+ recordUsageAndEmit(bus, input5, runId, iterations, u);
4995
+ }
4996
+ });
4997
+ for await (const part of stream.fullStream) {
4998
+ const p = part;
4999
+ if (p.type === "error") {
5000
+ throw p.error;
4140
5001
  }
5002
+ }
5003
+ const steps = await stream.steps;
5004
+ const last = steps[steps.length - 1];
5005
+ if (!last) throw new Error("planner: empty model step");
5006
+ stepResult = {
5007
+ text: last.text,
5008
+ finishReason: last.finishReason,
5009
+ toolCalls: last.toolCalls.flatMap(
5010
+ (tc) => tc ? [
5011
+ {
5012
+ toolCallId: tc.toolCallId,
5013
+ toolName: tc.toolName,
5014
+ args: tc.args
5015
+ }
5016
+ ] : []
5017
+ ),
5018
+ toolResults: last.toolResults.flatMap(
5019
+ (tr) => tr ? [{ toolCallId: tr.toolCallId, result: tr.result }] : []
5020
+ ),
5021
+ usage: last.usage,
5022
+ providerMetadata: last.providerMetadata
5023
+ };
5024
+ };
5025
+ try {
5026
+ while (true) {
4141
5027
  try {
4142
- response = await input5.provider.send(request);
5028
+ await callStream();
5029
+ break;
4143
5030
  } catch (e) {
5031
+ if (PlannerUserCancelledError.is(e)) {
5032
+ return {
5033
+ planText: e.planText,
5034
+ budgetExhausted: false,
5035
+ timedOut: false,
5036
+ finishedNormally: false,
5037
+ iterations,
5038
+ stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
5039
+ incompleteKind: "budget_reads",
5040
+ userCancelled: true
5041
+ };
5042
+ }
4144
5043
  if (isAbortError(e)) {
4145
5044
  return cancelledOutput(input5, bus, runId, accumulatedText, budgetExhausted, iterations, runStartedAt);
4146
5045
  }
5046
+ if (APICallError2.isInstance(e)) {
5047
+ const synthetic = providerResponseFromApiError(input5.provider, input5.modelId, e);
5048
+ if (synthetic.errorKind === "rate_limit") {
5049
+ const asked = synthetic.retryAfterSec;
5050
+ const emitRateLimit = (phase, waitSec) => {
5051
+ bus.emit({
5052
+ kind: "rate_limit",
5053
+ runId,
5054
+ turn: iterations,
5055
+ retryAfterSec: synthetic.retryAfterSec,
5056
+ waitSec,
5057
+ capSec: MAX_RATE_LIMIT_RETRY_SEC,
5058
+ phase,
5059
+ provider: input5.provider,
5060
+ rawBody: (synthetic.rawError ?? "").slice(0, 200)
5061
+ });
5062
+ };
5063
+ if (asked !== void 0 && asked > MAX_RATE_LIMIT_RETRY_SEC) {
5064
+ emitRateLimit("aborted", asked);
5065
+ retrySkippedReason = "retry_after_too_long";
5066
+ throw composePlannerError(input5.provider, synthetic, false, retrySkippedReason);
5067
+ }
5068
+ if (attempt === 0) {
5069
+ const waitSec = Math.min(asked ?? 10, MAX_RATE_LIMIT_RETRY_SEC);
5070
+ emitRateLimit("retrying", waitSec);
5071
+ input5.onRateLimit?.(waitSec);
5072
+ await sleepWithAbort(waitSec * 1e3, sleepFn, input5.abort);
5073
+ attempt += 1;
5074
+ retriedRateLimit = true;
5075
+ continue;
5076
+ }
5077
+ throw composePlannerError(input5.provider, synthetic, true, retrySkippedReason);
5078
+ }
5079
+ throw composePlannerError(input5.provider, synthetic, retriedRateLimit, retrySkippedReason);
5080
+ }
4147
5081
  throw e;
4148
5082
  }
4149
- if (response.usage) {
4150
- recordUsageAndEmit(bus, input5, runId, iterations, response.usage);
4151
- }
4152
- retriedRateLimit = true;
4153
5083
  }
5084
+ } catch (e) {
5085
+ if (isAbortError(e)) {
5086
+ return cancelledOutput(input5, bus, runId, accumulatedText, budgetExhausted, iterations, runStartedAt);
5087
+ }
5088
+ throw e;
4154
5089
  }
4155
- if (response.stopReason === "error") {
4156
- throw composePlannerError(input5.provider.name, response, retriedRateLimit, retrySkippedReason);
4157
- }
4158
- prevRequestPrefix = prefixOf(input5.provider.name, request);
4159
- if (response.text) {
4160
- accumulatedText += response.text;
4161
- input5.onAssistantText?.(response.text);
4162
- bus.emit({ kind: "assistant_text", runId, turn: iterations, delta: response.text });
4163
- }
4164
- if (response.stopReason === "max_tokens" && !response.toolCalls?.length) {
4165
- bus.emit({ kind: "turn_complete", runId, turn: iterations, stopReason: response.stopReason });
5090
+ const finishReason = stepResult.finishReason;
5091
+ const toolCallsLen = stepResult.toolCalls.length;
5092
+ if (finishReason === "length" && toolCallsLen === 0) {
5093
+ bus.emit({ kind: "turn_complete", runId, turn: iterations, stopReason: "max_tokens" });
4166
5094
  if (decide) {
4167
5095
  const d = await decide(limitCtx("max_output_tokens"));
4168
5096
  if (d === "cancel") {
@@ -4178,7 +5106,7 @@ async function runPlanner(input5) {
4178
5106
  };
4179
5107
  }
4180
5108
  extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut);
4181
- turns.push({ role: "assistant", text: response.text ?? "" });
5109
+ applyStepToTurns(turns, stepResult);
4182
5110
  turns.push({ role: "user", text: PLANNER_MARKDOWN_CONTINUATION_USER });
4183
5111
  iterations -= 1;
4184
5112
  continue;
@@ -4193,8 +5121,13 @@ async function runPlanner(input5) {
4193
5121
  incompleteKind: "max_output_tokens"
4194
5122
  };
4195
5123
  }
4196
- if (response.stopReason === "end_turn" || !response.toolCalls?.length) {
4197
- bus.emit({ kind: "turn_complete", runId, turn: iterations, stopReason: response.stopReason });
5124
+ if (finishReason === "error" || finishReason === "content-filter") {
5125
+ throw new Error(
5126
+ stepResult.text || `planner: model reported ${finishReason}. Run \`squad doctor\` to diagnose, or retry \u2014 most 5xx errors are transient.`
5127
+ );
5128
+ }
5129
+ if ((finishReason === "stop" || finishReason === "unknown") && toolCallsLen === 0) {
5130
+ bus.emit({ kind: "turn_complete", runId, turn: iterations, stopReason: finishReason });
4198
5131
  finishedNormally = true;
4199
5132
  return {
4200
5133
  planText: accumulatedText,
@@ -4205,81 +5138,173 @@ async function runPlanner(input5) {
4205
5138
  stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt)
4206
5139
  };
4207
5140
  }
4208
- turns.push({
4209
- role: "assistant",
4210
- text: response.text,
4211
- toolCalls: response.toolCalls
4212
- });
4213
- const toolResults = [];
4214
- const toolCalls = response.toolCalls;
4215
- for (let i = 0; i < toolCalls.length; i += 1) {
4216
- const tc = toolCalls[i];
4217
- if (tc.name !== READ_FILE_TOOL.name) {
4218
- toolResults.push({ toolCallId: tc.id, content: `unknown tool "${tc.name}"`, isError: true });
4219
- continue;
4220
- }
4221
- const before = input5.budget.snapshot().bytes;
4222
- let result = readFileTool(input5.root, input5.budget, tc.input);
4223
- const after = input5.budget.snapshot().bytes;
4224
- const bytesLoaded = after - before;
4225
- input5.onToolCall?.(tc, bytesLoaded, after);
4226
- bus.emit({
4227
- kind: "tool_call",
4228
- runId,
4229
- turn: iterations,
4230
- toolCall: tc,
4231
- bytesLoaded,
4232
- totalBytes: after
5141
+ if (toolCallsLen === 0) {
5142
+ bus.emit({ kind: "turn_complete", runId, turn: iterations, stopReason: finishReason });
5143
+ finishedNormally = true;
5144
+ return {
5145
+ planText: accumulatedText,
5146
+ budgetExhausted,
5147
+ timedOut: false,
5148
+ finishedNormally,
5149
+ iterations,
5150
+ stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt)
5151
+ };
5152
+ }
5153
+ applyStepToTurns(turns, stepResult);
5154
+ if (budgetExhausted) {
5155
+ turns.push({
5156
+ role: "user",
5157
+ 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."
4233
5158
  });
4234
- if (readBudgetishError(result)) {
4235
- if (decide) {
4236
- const d = await decide(limitCtx("file_or_context_reads"));
4237
- if (d === "cancel") {
4238
- toolResults.push({ toolCallId: tc.id, content: result.content, isError: true });
4239
- for (let j = i + 1; j < toolCalls.length; j += 1) {
4240
- const t2 = toolCalls[j];
4241
- toolResults.push({
4242
- toolCallId: t2.id,
4243
- content: t2.name === READ_FILE_TOOL.name ? "read_file: not executed (planning session stopped after file/context budget)." : `unknown tool "${t2.name}"`,
4244
- isError: true
4245
- });
4246
- }
4247
- turns.push({ role: "user", toolResults });
4248
- return {
4249
- planText: accumulatedText,
4250
- budgetExhausted: false,
4251
- timedOut: false,
4252
- finishedNormally: false,
4253
- iterations,
4254
- stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
4255
- incompleteKind: "budget_reads",
4256
- userCancelled: true
4257
- };
4258
- }
4259
- extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut);
4260
- result = readFileTool(input5.root, input5.budget, tc.input);
4261
- } else {
4262
- budgetExhausted = true;
5159
+ }
5160
+ bus.emit({ kind: "turn_complete", runId, turn: iterations, stopReason: finishReason });
5161
+ }
5162
+ }
5163
+ async function runPlanner(input5) {
5164
+ const bus = input5.events ?? new PlannerEventBus();
5165
+ const runId = input5.runId ?? newRunId();
5166
+ const cacheEnabled = input5.cacheEnabled ?? true;
5167
+ bus.emit({
5168
+ kind: "started",
5169
+ runId,
5170
+ provider: input5.provider,
5171
+ model: input5.modelId,
5172
+ cacheEnabled
5173
+ });
5174
+ const merged = { ...input5, events: bus, runId };
5175
+ const systemPrompt = input5.systemPrompt;
5176
+ let scoutSummary;
5177
+ let scoutStats;
5178
+ let scoutedSection;
5179
+ const scoutOn = input5.stages?.scout?.enabled !== false;
5180
+ const scoutModel = input5.stages?.scout?.model;
5181
+ const maxScoutFiles = input5.stages?.scout?.maxFiles ?? 12;
5182
+ const scoutMaxOut = input5.stages?.scout?.maxOutputTokens ?? 2048;
5183
+ const scoutModelIdStr = input5.stages?.scout?.modelId ?? "";
5184
+ if (scoutOn && scoutModel) {
5185
+ if (!input5.scoutSystemPrompt?.trim()) {
5186
+ throw new Error(
5187
+ "planner: scout enabled but `scoutSystemPrompt` was not provided. Compose it via composeScoutSystemPrompt() or set stages.scout.enabled = false."
5188
+ );
5189
+ }
5190
+ const t0 = Date.now();
5191
+ bus.emit({ kind: "stage_started", runId, stage: "scout" });
5192
+ try {
5193
+ const scoutRes = await runScout({
5194
+ model: scoutModel,
5195
+ systemPrompt: input5.scoutSystemPrompt,
5196
+ userPrompt: input5.userPrompt,
5197
+ budget: input5.budget,
5198
+ abort: input5.abort,
5199
+ maxTokens: scoutMaxOut
5200
+ });
5201
+ const durationMs = Date.now() - t0;
5202
+ if (scoutRes) {
5203
+ const files = scoutRes.output.selectedFiles.slice(0, maxScoutFiles);
5204
+ const tokensUsed = scoutRes.usage.inputTokens + scoutRes.usage.outputTokens;
5205
+ scoutSummary = {
5206
+ selected: files,
5207
+ reasoning: scoutRes.output.reasoning,
5208
+ durationMs,
5209
+ tokensUsed
5210
+ };
5211
+ scoutStats = scoutStageStats(scoutRes.usage, durationMs);
5212
+ bus.emit({
5213
+ kind: "scout_result",
5214
+ runId,
5215
+ selected: files,
5216
+ reasoning: scoutRes.output.reasoning
5217
+ });
5218
+ scoutedSection = buildScoutedContextSection(
5219
+ input5.root,
5220
+ input5.budget,
5221
+ files,
5222
+ bus,
5223
+ runId,
5224
+ scoutRes.output.readRanges
5225
+ );
5226
+ bus.emit({
5227
+ kind: "stage_complete",
5228
+ runId,
5229
+ stage: "scout",
5230
+ success: true,
5231
+ durationMs,
5232
+ tokensUsed
5233
+ });
5234
+ } else {
5235
+ bus.emit({
5236
+ kind: "stage_complete",
5237
+ runId,
5238
+ stage: "scout",
5239
+ success: false,
5240
+ durationMs
5241
+ });
5242
+ }
5243
+ } catch (err2) {
5244
+ const msg = err2 instanceof Error ? err2.message : String(err2);
5245
+ if (APICallError2.isInstance(err2)) {
5246
+ const status = err2.statusCode ?? 0;
5247
+ if (status === 401 || status === 403) {
5248
+ throw err2;
5249
+ }
5250
+ if (detectModelNotFound(input5.provider, scoutModelIdStr, status, err2.responseBody ?? "")) {
5251
+ throw err2;
4263
5252
  }
4264
5253
  }
4265
- toolResults.push({ toolCallId: tc.id, content: result.content, isError: result.isError });
4266
- }
4267
- turns.push({ role: "user", toolResults });
4268
- if (budgetExhausted) {
4269
- turns.push({
4270
- role: "user",
4271
- 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."
5254
+ bus.emit({
5255
+ kind: "stage_complete",
5256
+ runId,
5257
+ stage: "scout",
5258
+ success: false,
5259
+ durationMs: Date.now() - t0,
5260
+ errorMessage: msg.slice(0, 200)
4272
5261
  });
4273
5262
  }
4274
- bus.emit({ kind: "turn_complete", runId, turn: iterations, stopReason: response.stopReason });
4275
5263
  }
4276
- }
4277
- function firstByteDiff(a, b) {
4278
- const n = Math.min(a.length, b.length);
4279
- for (let i = 0; i < n; i += 1) {
4280
- if (a.charCodeAt(i) !== b.charCodeAt(i)) return i;
5264
+ const draftStarted = Date.now();
5265
+ bus.emit({ kind: "stage_started", runId, stage: "draft" });
5266
+ const draft = await runDraftStage({ ...merged, systemPrompt, scoutedSection });
5267
+ bus.emit({
5268
+ kind: "stage_complete",
5269
+ runId,
5270
+ stage: "draft",
5271
+ success: draft.finishedNormally && !draft.timedOut && !draft.userCancelled,
5272
+ durationMs: Date.now() - draftStarted
5273
+ });
5274
+ const validationOn = input5.validation?.enabled !== false;
5275
+ let issues = [];
5276
+ let validationDurationMs = 0;
5277
+ if (validationOn && draft.planText.trim()) {
5278
+ const v0 = Date.now();
5279
+ bus.emit({ kind: "stage_started", runId, stage: "validation" });
5280
+ issues = validatePlan({ root: input5.root, planText: draft.planText });
5281
+ for (const iss of issues.slice(0, 100)) {
5282
+ bus.emit({
5283
+ kind: "validation_issue",
5284
+ runId,
5285
+ severity: iss.severity,
5286
+ issueKind: iss.kind,
5287
+ path: iss.path,
5288
+ detail: iss.detail,
5289
+ excerpt: iss.excerpt
5290
+ });
5291
+ }
5292
+ validationDurationMs = Date.now() - v0;
5293
+ bus.emit({
5294
+ kind: "stage_complete",
5295
+ runId,
5296
+ stage: "validation",
5297
+ success: true,
5298
+ durationMs: validationDurationMs
5299
+ });
4281
5300
  }
4282
- return n;
5301
+ return {
5302
+ ...draft,
5303
+ stats: draft.stats,
5304
+ stagesStats: { scout: scoutStats, draft: draft.stats },
5305
+ scout: scoutSummary,
5306
+ validation: validationOn ? { issues, durationMs: validationDurationMs } : { issues: [], durationMs: 0 }
5307
+ };
4283
5308
  }
4284
5309
  function composePlannerError(providerName, response, retriedRateLimit, retrySkippedReason) {
4285
5310
  if (response.errorKind === "rate_limit") {
@@ -4318,45 +5343,39 @@ function kindTitle(kind) {
4318
5343
  return "Model round limit";
4319
5344
  case "wall_clock":
4320
5345
  return "Wall-clock time limit";
4321
- case "cost_cap":
4322
- return "Estimated cost cap";
4323
5346
  case "file_or_context_reads":
4324
5347
  return "File read or context size limit";
4325
5348
  }
4326
5349
  }
4327
- function kindDetail(ctx) {
5350
+ function tokenSummary(ctx) {
4328
5351
  const snap = ctx.budgetSnapshot;
4329
- 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.";
5352
+ return `Tokens so far: ${snap.usage.inputTokens} in / ${snap.usage.outputTokens} out for this session.`;
5353
+ }
5354
+ function kindDetail(ctx) {
4330
5355
  switch (ctx.kind) {
4331
5356
  case "max_output_tokens":
4332
5357
  return [
4333
5358
  `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.`,
4334
5359
  "You can continue: squad-kit will ask the model to append the rest of the plan in a follow-up request (extra tokens apply).",
4335
- cost
5360
+ tokenSummary(ctx)
4336
5361
  ];
4337
5362
  case "max_iterations":
4338
5363
  return [
4339
5364
  `The planner reached its round cap (${ctx.maxIterations} model turns) without a clean stop.`,
4340
5365
  "Continuing raises the round cap and file/time budgets for this run by another full slice from your config.",
4341
- cost
5366
+ tokenSummary(ctx)
4342
5367
  ];
4343
5368
  case "wall_clock":
4344
5369
  return [
4345
5370
  "The configured wall-clock budget for this planning session elapsed before the model finished.",
4346
5371
  "Continuing adds another slice of time (and read/output/round limits) from your planner budget.",
4347
- cost
4348
- ];
4349
- case "cost_cap":
4350
- return [
4351
- "Spending crossed the optional `planner.budget.maxCostUsd` ceiling for this session.",
4352
- "Continuing raises that ceiling for this run by another slice from config (if a cap is set).",
4353
- cost
5372
+ tokenSummary(ctx)
4354
5373
  ];
4355
5374
  case "file_or_context_reads":
4356
5375
  return [
4357
5376
  "A `read_file` call could not run because the file-read count or total read-bytes budget was exhausted.",
4358
5377
  "Continuing extends read, context, time, round, and output limits for this session so the model can read again or finish without reads.",
4359
- cost
5378
+ tokenSummary(ctx)
4360
5379
  ];
4361
5380
  }
4362
5381
  }
@@ -4380,14 +5399,17 @@ function printPlannerLimitNextSteps() {
4380
5399
  }
4381
5400
 
4382
5401
  // src/planner/providers/index.ts
4383
- function providerFor(name) {
4384
- switch (name) {
5402
+ import { createAnthropic } from "@ai-sdk/anthropic";
5403
+ import { createOpenAI } from "@ai-sdk/openai";
5404
+ import { createGoogleGenerativeAI } from "@ai-sdk/google";
5405
+ function resolveModel(provider, modelId, apiKey) {
5406
+ switch (provider) {
4385
5407
  case "anthropic":
4386
- return anthropicProvider;
5408
+ return { provider, model: createAnthropic({ apiKey })(modelId), modelId };
4387
5409
  case "openai":
4388
- return openaiProvider;
5410
+ return { provider, model: createOpenAI({ apiKey })(modelId), modelId };
4389
5411
  case "google":
4390
- return googleProvider;
5412
+ return { provider, model: createGoogleGenerativeAI({ apiKey })(modelId), modelId };
4391
5413
  }
4392
5414
  }
4393
5415
 
@@ -4398,7 +5420,6 @@ var Budget = class {
4398
5420
  this.maxFileReadsAllowed = cfg.maxFileReads;
4399
5421
  this.maxContextBytesAllowed = cfg.maxContextBytes;
4400
5422
  this.maxWallSeconds = cfg.maxDurationSeconds;
4401
- this.maxCostUsdCeiling = cfg.maxCostUsd;
4402
5423
  }
4403
5424
  cfg;
4404
5425
  reads = 0;
@@ -4408,15 +5429,11 @@ var Budget = class {
4408
5429
  maxFileReadsAllowed;
4409
5430
  maxContextBytesAllowed;
4410
5431
  maxWallSeconds;
4411
- maxCostUsdCeiling;
4412
5432
  /** Add another slice of the original config limits (used when the user chooses to continue a planner session). */
4413
5433
  extendSession() {
4414
5434
  this.maxFileReadsAllowed += this.cfg.maxFileReads;
4415
5435
  this.maxContextBytesAllowed += this.cfg.maxContextBytes;
4416
5436
  this.maxWallSeconds += this.cfg.maxDurationSeconds;
4417
- if (this.cfg.maxCostUsd !== void 0) {
4418
- this.maxCostUsdCeiling = (this.maxCostUsdCeiling ?? 0) + this.cfg.maxCostUsd;
4419
- }
4420
5437
  }
4421
5438
  canRead(nextFileBytes) {
4422
5439
  if (this.reads >= this.maxFileReadsAllowed) {
@@ -4427,6 +5444,13 @@ var Budget = class {
4427
5444
  }
4428
5445
  return { ok: true };
4429
5446
  }
5447
+ /** Whether another tool invocation is allowed under the read-count budget (bytes checked per tool). */
5448
+ canExecuteTool() {
5449
+ if (this.reads >= this.maxFileReadsAllowed) {
5450
+ return { ok: false, reason: `max file reads (${this.maxFileReadsAllowed}) reached` };
5451
+ }
5452
+ return { ok: true };
5453
+ }
4430
5454
  recordRead(bytes) {
4431
5455
  this.reads += 1;
4432
5456
  this.bytes += bytes;
@@ -4434,17 +5458,12 @@ var Budget = class {
4434
5458
  recordUsage(u) {
4435
5459
  this.totalUsage.inputTokens += u.inputTokens;
4436
5460
  this.totalUsage.outputTokens += u.outputTokens;
4437
- if (u.costUsd !== void 0) this.totalUsage.costUsd = (this.totalUsage.costUsd ?? 0) + u.costUsd;
4438
5461
  this.totalUsage.cacheCreationTokens = (this.totalUsage.cacheCreationTokens ?? 0) + (u.cacheCreationTokens ?? 0);
4439
5462
  this.totalUsage.cacheReadTokens = (this.totalUsage.cacheReadTokens ?? 0) + (u.cacheReadTokens ?? 0);
4440
5463
  }
4441
5464
  timedOut() {
4442
5465
  return (Date.now() - this.startedAt) / 1e3 > this.maxWallSeconds;
4443
5466
  }
4444
- overCost() {
4445
- if (this.maxCostUsdCeiling === void 0) return false;
4446
- return (this.totalUsage.costUsd ?? 0) > this.maxCostUsdCeiling;
4447
- }
4448
5467
  snapshot() {
4449
5468
  return {
4450
5469
  reads: this.reads,
@@ -4455,103 +5474,9 @@ var Budget = class {
4455
5474
  }
4456
5475
  };
4457
5476
 
4458
- // src/core/repo-map.ts
4459
- import fs23 from "fs";
4460
- import path16 from "path";
4461
- import ignore from "ignore";
4462
- var DEFAULT_IGNORES = [
4463
- "node_modules",
4464
- ".git",
4465
- "dist",
4466
- "build",
4467
- "coverage",
4468
- ".next",
4469
- ".turbo",
4470
- ".cache",
4471
- ".squad/plans"
4472
- // don't include existing plans as context; planner will see them separately
4473
- ];
4474
- function buildRepoMap(root, opts = {}) {
4475
- const maxEntries = opts.maxEntries ?? 5e3;
4476
- const ig = buildIgnore(root);
4477
- const entries = [];
4478
- walk2(root, root, ig, entries, maxEntries, !!opts.includeHidden);
4479
- entries.sort();
4480
- return entries.join("\n") + "\n";
4481
- }
4482
- function buildIgnore(root) {
4483
- const ig = ignore().add(DEFAULT_IGNORES);
4484
- const gitignore = path16.join(root, ".gitignore");
4485
- if (fs23.existsSync(gitignore)) {
4486
- try {
4487
- ig.add(fs23.readFileSync(gitignore, "utf8"));
4488
- } catch {
4489
- }
4490
- }
4491
- return ig;
4492
- }
4493
- function walk2(root, dir, ig, out, max, includeHidden) {
4494
- if (out.length >= max) return;
4495
- let entries;
4496
- try {
4497
- entries = fs23.readdirSync(dir, { withFileTypes: true });
4498
- } catch {
4499
- return;
4500
- }
4501
- for (const entry of entries) {
4502
- if (out.length >= max) return;
4503
- if (!includeHidden && entry.name.startsWith(".") && entry.name !== ".squad") continue;
4504
- const abs = path16.join(dir, entry.name);
4505
- const rel = path16.relative(root, abs);
4506
- if (!rel || ig.ignores(rel) || entry.isDirectory() && ig.ignores(rel + "/")) continue;
4507
- if (entry.isDirectory()) {
4508
- walk2(root, abs, ig, out, max, includeHidden);
4509
- } else if (entry.isFile()) {
4510
- out.push(rel);
4511
- }
4512
- }
4513
- }
4514
-
4515
- // src/planner/system-prompt.ts
4516
- function composeSystemPrompt(args) {
4517
- const base = readBundledPrompt("generate-plan.md").replace(/\{\{projectRoots\}\}/g, args.projectRoots.join(", ")).replace(/\{\{primaryLanguage\}\}/g, args.primaryLanguage).replace(/\{\{trackerType\}\}/g, args.trackerType).replace(
4518
- /\{\{intakeContent\}\}/g,
4519
- "_(The intake story is provided in the user message below. Do not ask for it.)_"
4520
- );
4521
- const apiPreamble = [
4522
- "",
4523
- "---",
4524
- "",
4525
- "## Direct-API mode notes",
4526
- "",
4527
- "You are being invoked by the squad-kit CLI, not by a human editor. The intake is already inlined below.",
4528
- "The repository tree is provided. To see the contents of any file, **call the `read_file` tool** with a repo-relative path.",
4529
- "You have a bounded context budget (file reads, total bytes, wall-clock time). Read files you genuinely need.",
4530
- "When you have enough information, output the **complete plan markdown** as your final assistant message. No prose around it, no code fences wrapping the whole plan. The CLI will write your final message verbatim to disk.",
4531
- "",
4532
- "## Repository tree",
4533
- "",
4534
- "```",
4535
- args.repoMap.trimEnd(),
4536
- "```",
4537
- ""
4538
- ].join("\n");
4539
- return base + apiPreamble;
4540
- }
4541
- function composeUserPrompt(args) {
4542
- return [
4543
- "Produce the implementation plan for the following intake.",
4544
- "",
4545
- "---",
4546
- "",
4547
- args.intakeContent.trimEnd(),
4548
- ""
4549
- ].join("\n");
4550
- }
4551
-
4552
5477
  // src/planner/writer.ts
4553
- import fs24 from "fs";
4554
- import path17 from "path";
5478
+ import fs28 from "fs";
5479
+ import path20 from "path";
4555
5480
  function planFileSlugParts(config2, story) {
4556
5481
  const baseSlug = slugify(story.id);
4557
5482
  const trackerId = config2.naming.includeTrackerId && config2.tracker.type !== "none" ? trackerIdForFilename(config2.tracker.type, story.id) : "";
@@ -4560,8 +5485,8 @@ function planFileSlugParts(config2, story) {
4560
5485
  }
4561
5486
  function writePlanFile(input5) {
4562
5487
  const { paths, config: config2, story } = input5;
4563
- const featurePlanDir = path17.join(paths.plansDir, story.feature);
4564
- fs24.mkdirSync(featurePlanDir, { recursive: true });
5488
+ const featurePlanDir = path20.join(paths.plansDir, story.feature);
5489
+ fs28.mkdirSync(featurePlanDir, { recursive: true });
4565
5490
  if (input5.partial) {
4566
5491
  return writePartialPlanFile(input5, featurePlanDir, paths, config2, story);
4567
5492
  }
@@ -4576,14 +5501,14 @@ function writePlanFile(input5) {
4576
5501
  } else {
4577
5502
  nn = scanPlans(paths.plansDir).nextGlobal;
4578
5503
  const { baseSlug, trackerPart } = planFileSlugParts(config2, story);
4579
- targetPath = path17.join(featurePlanDir, `${formatSequence(nn)}-story-${baseSlug}${trackerPart}.md`);
5504
+ targetPath = path20.join(featurePlanDir, `${formatSequence(nn)}-story-${baseSlug}${trackerPart}.md`);
4580
5505
  }
4581
5506
  const body = `${input5.metadataHeader.trimEnd()}
4582
5507
 
4583
5508
  ${input5.planBodyMarkdown.trimStart()}
4584
5509
  `;
4585
- fs24.writeFileSync(targetPath, body, "utf8");
4586
- upsertOverviewRow(featurePlanDir, story.feature, nn, path17.basename(targetPath), story, false);
5510
+ fs28.writeFileSync(targetPath, body, "utf8");
5511
+ upsertOverviewRow(featurePlanDir, story.feature, nn, path20.basename(targetPath), story, false);
4587
5512
  return { planFile: targetPath, sequenceNumber: nn, overwrote };
4588
5513
  }
4589
5514
  function writePartialPlanFile(input5, featurePlanDir, paths, config2, story) {
@@ -4598,42 +5523,42 @@ function writePartialPlanFile(input5, featurePlanDir, paths, config2, story) {
4598
5523
  } else {
4599
5524
  nn = scanPlans(paths.plansDir).nextGlobal;
4600
5525
  const { baseSlug, trackerPart } = planFileSlugParts(config2, story);
4601
- targetPath = path17.join(featurePlanDir, `${formatSequence(nn)}-story-${baseSlug}${trackerPart}.partial.md`);
5526
+ targetPath = path20.join(featurePlanDir, `${formatSequence(nn)}-story-${baseSlug}${trackerPart}.partial.md`);
4602
5527
  }
4603
5528
  const body = `${input5.metadataHeader.trimEnd()}
4604
5529
 
4605
5530
  ${input5.planBodyMarkdown.trimStart()}
4606
5531
  `;
4607
- fs24.writeFileSync(targetPath, body, "utf8");
4608
- upsertOverviewRow(featurePlanDir, story.feature, nn, path17.basename(targetPath), story, true);
5532
+ fs28.writeFileSync(targetPath, body, "utf8");
5533
+ upsertOverviewRow(featurePlanDir, story.feature, nn, path20.basename(targetPath), story, true);
4609
5534
  return { planFile: targetPath, sequenceNumber: nn, overwrote };
4610
5535
  }
4611
5536
  function findExistingPlan(featureDir, storyId) {
4612
- if (!fs24.existsSync(featureDir)) return void 0;
5537
+ if (!fs28.existsSync(featureDir)) return void 0;
4613
5538
  const pattern = /^(\d{2,})-story-.+\.md$/;
4614
- for (const entry of fs24.readdirSync(featureDir)) {
5539
+ for (const entry of fs28.readdirSync(featureDir)) {
4615
5540
  if (entry.endsWith(".partial.md")) continue;
4616
5541
  const m = entry.match(pattern);
4617
5542
  if (!m) continue;
4618
- if (entry.includes(storyId)) return { absPath: path17.join(featureDir, entry), nn: parseInt(m[1], 10) };
5543
+ if (entry.includes(storyId)) return { absPath: path20.join(featureDir, entry), nn: parseInt(m[1], 10) };
4619
5544
  }
4620
5545
  return void 0;
4621
5546
  }
4622
5547
  function findExistingPartialPlan(featureDir, storyId) {
4623
- if (!fs24.existsSync(featureDir)) return void 0;
5548
+ if (!fs28.existsSync(featureDir)) return void 0;
4624
5549
  const pattern = /^(\d{2,})-story-.+\.partial\.md$/;
4625
- for (const entry of fs24.readdirSync(featureDir)) {
5550
+ for (const entry of fs28.readdirSync(featureDir)) {
4626
5551
  const m = entry.match(pattern);
4627
5552
  if (!m) continue;
4628
- if (entry.includes(storyId)) return { absPath: path17.join(featureDir, entry), nn: parseInt(m[1], 10) };
5553
+ if (entry.includes(storyId)) return { absPath: path20.join(featureDir, entry), nn: parseInt(m[1], 10) };
4629
5554
  }
4630
5555
  return void 0;
4631
5556
  }
4632
5557
  function upsertOverviewRow(featureDir, feature, nn, filename, story, isPartial) {
4633
- const overviewPath = path17.join(featureDir, "00-overview.md");
5558
+ const overviewPath = path20.join(featureDir, "00-overview.md");
4634
5559
  const titleCell = isPartial ? `${story.titleHint ?? story.id} (partial)` : story.titleHint ?? story.id;
4635
5560
  const row = `| ${formatSequence(nn)} | \`${filename}\` | ${titleCell} | ${story.id} | \u2014 |`;
4636
- if (!fs24.existsSync(overviewPath)) {
5561
+ if (!fs28.existsSync(overviewPath)) {
4637
5562
  const content = `# ${feature} \u2014 plan overview
4638
5563
 
4639
5564
  Entry point for the **${feature}** feature. Stories execute in order by their \`NN\` prefix.
@@ -4644,10 +5569,10 @@ Entry point for the **${feature}** feature. Stories execute in order by their \`
4644
5569
  |----|------|-------|------------|------------|
4645
5570
  ${row}
4646
5571
  `;
4647
- fs24.writeFileSync(overviewPath, content, "utf8");
5572
+ fs28.writeFileSync(overviewPath, content, "utf8");
4648
5573
  return;
4649
5574
  }
4650
- const current = fs24.readFileSync(overviewPath, "utf8");
5575
+ const current = fs28.readFileSync(overviewPath, "utf8");
4651
5576
  const lines = current.split("\n");
4652
5577
  const existingIdx = lines.findIndex((l) => l.includes(`\`${filename}\``) || l.includes(`| ${formatSequence(nn)} |`));
4653
5578
  if (existingIdx >= 0) {
@@ -4656,7 +5581,7 @@ ${row}
4656
5581
  const tableEnd = findTableEnd(lines);
4657
5582
  lines.splice(tableEnd, 0, row);
4658
5583
  }
4659
- fs24.writeFileSync(overviewPath, lines.join("\n"), "utf8");
5584
+ fs28.writeFileSync(overviewPath, lines.join("\n"), "utf8");
4660
5585
  }
4661
5586
  function findTableEnd(lines) {
4662
5587
  let inTable = false;
@@ -4675,9 +5600,17 @@ function buildMetadataHeader(args) {
4675
5600
  `generated by ${args.provider}/${args.model}`,
4676
5601
  `at ${(/* @__PURE__ */ new Date()).toISOString()}`,
4677
5602
  `${args.reads} reads / ${(args.bytes / 1024).toFixed(1)} KB context`,
4678
- `${args.inputTokens} in / ${args.outputTokens} out tokens`,
4679
- args.costUsd !== void 0 ? `~$${args.costUsd.toFixed(2)}` : null
5603
+ `${args.inputTokens} in / ${args.outputTokens} out tokens`
4680
5604
  ].filter(Boolean);
5605
+ if (args.scoutEnabled !== void 0) {
5606
+ parts.push(`scout=${args.scoutEnabled ? "on" : "off"}`);
5607
+ }
5608
+ if (args.validationEnabled !== void 0) {
5609
+ parts.push(`validation=${args.validationEnabled ? "on" : "off"}`);
5610
+ }
5611
+ if (args.validationIssueCount !== void 0) {
5612
+ parts.push(`issues=${args.validationIssueCount}`);
5613
+ }
4681
5614
  const line1 = `<!-- squad-kit: ${parts.join(", ")} -->`;
4682
5615
  if (args.planStatus === "partial") {
4683
5616
  return `${line1}
@@ -4701,16 +5634,16 @@ function buildCopyPlanPromptMarkdown(config2, intakeContent) {
4701
5634
  }
4702
5635
 
4703
5636
  // src/core/last-run.ts
4704
- import fs25 from "fs/promises";
4705
- import path18 from "path";
5637
+ import fs29 from "fs/promises";
5638
+ import path21 from "path";
4706
5639
  async function writeLastRun(paths, rec) {
4707
- const file = path18.join(paths.squadDir, ".last-run.json");
4708
- await fs25.writeFile(file, JSON.stringify({ ...rec, version: 1 }, null, 2) + "\n", "utf8");
5640
+ const file = path21.join(paths.squadDir, ".last-run.json");
5641
+ await fs29.writeFile(file, JSON.stringify({ ...rec, version: 1 }, null, 2) + "\n", "utf8");
4709
5642
  }
4710
5643
  async function readLastRun(paths) {
4711
- const file = path18.join(paths.squadDir, ".last-run.json");
5644
+ const file = path21.join(paths.squadDir, ".last-run.json");
4712
5645
  try {
4713
- const raw = await fs25.readFile(file, "utf8");
5646
+ const raw = await fs29.readFile(file, "utf8");
4714
5647
  const parsed = JSON.parse(raw);
4715
5648
  if (parsed.version !== 1) return void 0;
4716
5649
  return parsed;
@@ -4739,6 +5672,59 @@ function formatPlannerCacheLine(opts) {
4739
5672
  return "cache miss 0% \u2014 run `squad doctor` to diagnose";
4740
5673
  }
4741
5674
 
5675
+ // src/core/plans-index.ts
5676
+ import fs30 from "fs";
5677
+ import path22 from "path";
5678
+ var MAX_ENTRIES = 30;
5679
+ function firstMarkdownH1(src) {
5680
+ const lines = src.split(/\r?\n/);
5681
+ for (const line2 of lines) {
5682
+ const m = /^\s*#\s+(.+?)\s*$/.exec(line2);
5683
+ if (m) return m[1].trim();
5684
+ }
5685
+ return void 0;
5686
+ }
5687
+ function collectPlanMarkdownFiles(plansDir) {
5688
+ const out = [];
5689
+ const walk2 = (dir) => {
5690
+ let ents;
5691
+ try {
5692
+ ents = fs30.readdirSync(dir, { withFileTypes: true });
5693
+ } catch {
5694
+ return;
5695
+ }
5696
+ for (const ent of ents) {
5697
+ const abs = path22.join(dir, ent.name);
5698
+ if (ent.isDirectory()) {
5699
+ walk2(abs);
5700
+ } else if (ent.isFile() && ent.name.endsWith(".md")) {
5701
+ if (ent.name === "00-index.md" || ent.name === "00-overview.md") continue;
5702
+ out.push(abs);
5703
+ }
5704
+ }
5705
+ };
5706
+ if (fs30.existsSync(plansDir)) walk2(plansDir);
5707
+ out.sort((a, b) => a.localeCompare(b));
5708
+ return out.slice(0, MAX_ENTRIES);
5709
+ }
5710
+ function buildPlansIndex(paths) {
5711
+ const files = collectPlanMarkdownFiles(paths.plansDir);
5712
+ if (files.length === 0) return "(no prior plans yet)";
5713
+ const bullets = [];
5714
+ for (const abs of files) {
5715
+ const rel = path22.relative(paths.root, abs).split(path22.sep).join("/");
5716
+ let title = "";
5717
+ try {
5718
+ const raw = fs30.readFileSync(abs, "utf8");
5719
+ title = firstMarkdownH1(raw) ?? path22.basename(abs);
5720
+ } catch {
5721
+ title = path22.basename(abs);
5722
+ }
5723
+ bullets.push(`- ${rel} \u2014 "${title}"`);
5724
+ }
5725
+ return bullets.join("\n");
5726
+ }
5727
+
4742
5728
  // src/commands/new-plan.ts
4743
5729
  function decideMode(opts, config2) {
4744
5730
  if (opts.api && opts.copy) {
@@ -4770,8 +5756,8 @@ async function runNewPlan(intakePath, opts) {
4770
5756
  }
4771
5757
  }
4772
5758
  function resolveFromPath(intakePath, stories, root) {
4773
- const resolved = path19.resolve(intakePath.trim());
4774
- if (!fs26.existsSync(resolved)) {
5759
+ const resolved = path23.resolve(intakePath.trim());
5760
+ if (!fs31.existsSync(resolved)) {
4775
5761
  throw new Error(
4776
5762
  `Intake not found: ${resolved}. Run \`squad list\` to see intakes, or \`squad new-story\` to create one first.`
4777
5763
  );
@@ -4783,7 +5769,7 @@ function resolveFromPath(intakePath, stories, root) {
4783
5769
  );
4784
5770
  }
4785
5771
  function relativeStoriesDir(root) {
4786
- return path19.relative(root, path19.join(root, ".squad", "stories"));
5772
+ return path23.relative(root, path23.join(root, ".squad", "stories"));
4787
5773
  }
4788
5774
  async function pickStory(stories, opts) {
4789
5775
  const candidates = opts.all ? stories : stories.filter((s) => !s.planFile);
@@ -4814,7 +5800,7 @@ async function pickStory(stories, opts) {
4814
5800
  value: s.intakePath
4815
5801
  }))
4816
5802
  });
4817
- return candidates.find((s) => path19.resolve(s.intakePath) === path19.resolve(pick));
5803
+ return candidates.find((s) => path23.resolve(s.intakePath) === path23.resolve(pick));
4818
5804
  }
4819
5805
  function formatLabel(s) {
4820
5806
  const head = `${s.feature} / ${s.id}`;
@@ -4860,13 +5846,13 @@ async function emitViaApi(story, paths, config2, opts) {
4860
5846
  `Missing ${providerEnvVar(planner.provider)}. Run \`squad config set planner\` to save a key to secrets, or export the env var, or run \`squad new-plan --copy\` without the API.`
4861
5847
  );
4862
5848
  }
4863
- const model = modelFor(planner.provider, "plan", planner.modelOverride);
4864
- const provider = providerFor(planner.provider);
5849
+ const modelId = modelFor(planner.provider, "plan", planner.modelOverride);
5850
+ const { model } = resolveModel(planner.provider, modelId, apiKey);
4865
5851
  const budget = new Budget(planner.budget);
4866
5852
  banner();
4867
5853
  step(`planning ${story.feature} / ${story.id}`);
4868
5854
  kv("provider", planner.provider);
4869
- kv("model", model);
5855
+ kv("model", modelId);
4870
5856
  kv(
4871
5857
  "budget",
4872
5858
  `${planner.budget.maxFileReads} reads \xB7 ${(planner.budget.maxContextBytes / 1024).toFixed(0)} KB \xB7 ${planner.budget.maxDurationSeconds}s`
@@ -4877,7 +5863,7 @@ async function emitViaApi(story, paths, config2, opts) {
4877
5863
  printPlannerApiCostNotice();
4878
5864
  }
4879
5865
  const mapSpinner = spinner("building repo map\u2026");
4880
- const repoMap = buildRepoMap(paths.root);
5866
+ const repoMap = buildRepoMap(paths.root, { format: "tree" });
4881
5867
  mapSpinner.succeed(
4882
5868
  `repo map ready (${repoMap.split("\n").length - 1} paths \xB7 ${(repoMap.length / 1024).toFixed(1)} KB)`
4883
5869
  );
@@ -4889,6 +5875,29 @@ async function emitViaApi(story, paths, config2, opts) {
4889
5875
  repoMap
4890
5876
  });
4891
5877
  const userPrompt = composeUserPrompt({ intakeContent: readFile(story.intakePath) });
5878
+ const scoutEnabled = !opts.noScout && planner.stages?.scout?.enabled !== false;
5879
+ const validationEnabled = !opts.noValidation && planner.validation?.enabled !== false;
5880
+ const strictValidation = !!opts.strictValidation || planner.validation?.strict === true;
5881
+ const maxScoutFiles = opts.maxScoutFiles ?? planner.stages?.scout?.maxFiles ?? 12;
5882
+ let scoutModelId = "";
5883
+ let scoutModelHandle;
5884
+ let scoutSystemPrompt;
5885
+ if (scoutEnabled) {
5886
+ scoutModelId = modelFor(
5887
+ planner.provider,
5888
+ "scout",
5889
+ planner.modelOverride,
5890
+ opts.scoutModel ?? planner.stages?.scout?.modelOverride
5891
+ );
5892
+ scoutModelHandle = resolveModel(planner.provider, scoutModelId, apiKey).model;
5893
+ scoutSystemPrompt = composeScoutSystemPrompt({
5894
+ projectRoots: config2.project.projectRoots ?? ["."],
5895
+ primaryLanguage: config2.project.primaryLanguage ?? "",
5896
+ trackerType: config2.tracker.type,
5897
+ repoMap,
5898
+ plansIndex: buildPlansIndex(paths)
5899
+ });
5900
+ }
4892
5901
  const sessionSpinner = { current: null };
4893
5902
  const startedAt = Date.now();
4894
5903
  const cacheEnabled = planner.cache?.enabled ?? true;
@@ -4897,11 +5906,43 @@ async function emitViaApi(story, paths, config2, opts) {
4897
5906
  const unsubscribe = bus.subscribe((e) => {
4898
5907
  switch (e.kind) {
4899
5908
  case "tool_call": {
4900
- const p = e.toolCall.input.path ?? "<unknown>";
5909
+ const name = e.toolCall.name;
5910
+ const inp = e.toolCall.input;
5911
+ let label;
5912
+ if (name === "read_file") {
5913
+ label = `read ${String(inp.path ?? "<unknown>")}`;
5914
+ } else if (name === "grep") {
5915
+ label = `grep ${String(inp.pattern ?? "?").slice(0, 48)}`;
5916
+ } else if (name === "list_dir") {
5917
+ label = `list_dir ${String(inp.path ?? "?")}`;
5918
+ } else {
5919
+ label = name;
5920
+ }
4901
5921
  sessionSpinner.current?.succeed(
4902
- `read ${p} (${(e.bytesLoaded / 1024).toFixed(1)} KB \xB7 ${(e.totalBytes / 1024).toFixed(1)} KB / ${(planner.budget.maxContextBytes / 1024).toFixed(0)} KB)`
5922
+ `${label} (${(e.bytesLoaded / 1024).toFixed(1)} KB \xB7 ${(e.totalBytes / 1024).toFixed(1)} KB / ${(planner.budget.maxContextBytes / 1024).toFixed(0)} KB)`
4903
5923
  );
4904
- sessionSpinner.current = spinner("reading next file\u2026");
5924
+ sessionSpinner.current = spinner("next tool\u2026");
5925
+ break;
5926
+ }
5927
+ case "stage_started": {
5928
+ sessionSpinner.current?.stop();
5929
+ if (e.stage === "scout") sessionSpinner.current = spinner("scouting\u2026");
5930
+ else if (e.stage === "draft") sessionSpinner.current = spinner("drafting\u2026");
5931
+ else if (e.stage === "validation") sessionSpinner.current = spinner("validating\u2026");
5932
+ break;
5933
+ }
5934
+ case "stage_complete": {
5935
+ if (!e.success && e.errorMessage && e.stage === "scout") {
5936
+ sessionSpinner.current?.stop();
5937
+ warning(`scout stage failed: ${e.errorMessage}`);
5938
+ }
5939
+ break;
5940
+ }
5941
+ case "scout_result": {
5942
+ const preview = e.selected.slice(0, 4).join(", ");
5943
+ const more = e.selected.length > 4 ? "\u2026" : "";
5944
+ sessionSpinner.current?.succeed(`scout picked ${e.selected.length} files: ${preview}${more}`);
5945
+ sessionSpinner.current = spinner("drafting\u2026");
4905
5946
  break;
4906
5947
  }
4907
5948
  case "assistant_text":
@@ -4937,9 +5978,9 @@ async function emitViaApi(story, paths, config2, opts) {
4937
5978
  try {
4938
5979
  result = await runPlanner({
4939
5980
  root: paths.root,
4940
- provider,
4941
5981
  model,
4942
- apiKey,
5982
+ provider: planner.provider,
5983
+ modelId,
4943
5984
  systemPrompt,
4944
5985
  userPrompt,
4945
5986
  budget,
@@ -4947,7 +5988,19 @@ async function emitViaApi(story, paths, config2, opts) {
4947
5988
  cacheEnabled,
4948
5989
  decideOnLimit,
4949
5990
  events: bus,
4950
- runId
5991
+ runId,
5992
+ stages: {
5993
+ scout: {
5994
+ enabled: scoutEnabled,
5995
+ model: scoutModelHandle,
5996
+ modelId: scoutModelId,
5997
+ maxFiles: maxScoutFiles,
5998
+ maxOutputTokens: planner.stages?.scout?.maxOutputTokens ?? 2048
5999
+ }
6000
+ },
6001
+ validation: { enabled: validationEnabled, strict: strictValidation },
6002
+ toolsEnabled: planner.tools,
6003
+ scoutSystemPrompt
4951
6004
  });
4952
6005
  } finally {
4953
6006
  unsubscribe();
@@ -4960,17 +6013,22 @@ async function emitViaApi(story, paths, config2, opts) {
4960
6013
  }
4961
6014
  const snap = budget.snapshot();
4962
6015
  const elapsedMs = Date.now() - startedAt;
4963
- const success2 = result.finishedNormally && !result.timedOut && !result.userCancelled;
6016
+ const issueCount = result.validation?.issues.length ?? 0;
6017
+ const issuesByKind = summariseIssuesByKind(result.validation?.issues ?? []);
6018
+ const validationBlocks = strictValidation && validationEnabled && issueCount > 0;
6019
+ const success2 = result.finishedNormally && !result.timedOut && !result.userCancelled && !validationBlocks;
4964
6020
  const header = buildMetadataHeader({
4965
6021
  provider: planner.provider,
4966
- model,
6022
+ model: modelId,
4967
6023
  reads: snap.reads,
4968
6024
  bytes: snap.bytes,
4969
6025
  inputTokens: snap.usage.inputTokens,
4970
6026
  outputTokens: snap.usage.outputTokens,
4971
6027
  durationMs: elapsedMs,
4972
- costUsd: snap.usage.costUsd,
4973
- planStatus: success2 ? void 0 : "partial"
6028
+ planStatus: success2 ? void 0 : "partial",
6029
+ scoutEnabled,
6030
+ validationEnabled,
6031
+ validationIssueCount: issueCount
4974
6032
  });
4975
6033
  const { planFile, sequenceNumber, overwrote } = writePlanFile({
4976
6034
  paths,
@@ -4980,18 +6038,18 @@ async function emitViaApi(story, paths, config2, opts) {
4980
6038
  metadataHeader: header,
4981
6039
  partial: !success2
4982
6040
  });
4983
- const relPlan = path19.relative(paths.root, planFile);
6041
+ const relPlan = path23.relative(paths.root, planFile);
4984
6042
  try {
4985
6043
  await writeLastRun(paths, {
4986
6044
  stats: result.stats,
4987
6045
  completedAt: (/* @__PURE__ */ new Date()).toISOString(),
4988
6046
  provider: planner.provider,
4989
- model
6047
+ model: modelId
4990
6048
  });
4991
6049
  await appendRun(paths, {
4992
6050
  runId,
4993
6051
  provider: planner.provider,
4994
- model,
6052
+ model: modelId,
4995
6053
  feature: story.feature,
4996
6054
  storyId: story.id,
4997
6055
  startedAt: new Date(startedAt).toISOString(),
@@ -5001,7 +6059,19 @@ async function emitViaApi(story, paths, config2, opts) {
5001
6059
  planFile: relPlan,
5002
6060
  stats: result.stats,
5003
6061
  cacheEnabled,
5004
- durationMs: elapsedMs
6062
+ durationMs: elapsedMs,
6063
+ scout: {
6064
+ enabled: scoutEnabled,
6065
+ selectedCount: result.scout?.selected.length,
6066
+ tokensUsed: result.scout?.tokensUsed,
6067
+ durationMs: result.scout?.durationMs
6068
+ },
6069
+ validation: {
6070
+ enabled: validationEnabled,
6071
+ issuesCount: issueCount,
6072
+ issuesByKind,
6073
+ durationMs: result.validation?.durationMs
6074
+ }
5005
6075
  });
5006
6076
  } catch {
5007
6077
  }
@@ -5009,11 +6079,19 @@ async function emitViaApi(story, paths, config2, opts) {
5009
6079
  summaryBox(success2 ? " plan generated " : " plan saved (incomplete) ", [
5010
6080
  { key: "file", value: relPlan },
5011
6081
  { key: "nn", value: String(sequenceNumber).padStart(2, "0") },
5012
- { key: "model", value: `${planner.provider}/${model}` },
6082
+ { key: "model", value: `${planner.provider}/${modelId}` },
6083
+ {
6084
+ key: "scout",
6085
+ value: scoutEnabled ? `${result.scout?.selected.length ?? 0} files \xB7 ${result.scout?.tokensUsed ?? 0} tok` : "disabled"
6086
+ },
6087
+ {
6088
+ key: "validation",
6089
+ value: validationEnabled ? `${issueCount} issue(s)` : "disabled"
6090
+ },
5013
6091
  { key: "reads", value: `${snap.reads} files \xB7 ${(snap.bytes / 1024).toFixed(1)} KB` },
5014
6092
  {
5015
6093
  key: "tokens",
5016
- value: `${snap.usage.inputTokens} in \xB7 ${snap.usage.outputTokens} out${snap.usage.costUsd !== void 0 ? ` \u2248 $${snap.usage.costUsd.toFixed(2)}` : ""}`
6094
+ value: `${snap.usage.inputTokens} in \xB7 ${snap.usage.outputTokens} out`
5017
6095
  },
5018
6096
  { key: "cache", value: formatPlannerCacheLine({ cacheEnabled, stats: result.stats }) },
5019
6097
  {
@@ -5023,25 +6101,45 @@ async function emitViaApi(story, paths, config2, opts) {
5023
6101
  { key: "time", value: `${Math.round(elapsedMs / 1e3)}s` },
5024
6102
  {
5025
6103
  key: "action",
5026
- value: success2 ? overwrote ? "overwrote existing plan" : "new plan" : "partial plan (review limits)"
6104
+ value: success2 ? overwrote ? "overwrote existing plan" : "new plan" : validationBlocks ? "partial plan (validation issues \u2014 review or use --no-validation)" : "partial plan (review limits)"
5027
6105
  }
5028
6106
  ]);
6107
+ if (issueCount > 0 && validationEnabled) {
6108
+ blank();
6109
+ warning(`Validation reported ${issueCount} issue(s) (heuristic; first 10 shown):`);
6110
+ for (const iss of result.validation.issues.slice(0, 10)) {
6111
+ info(
6112
+ ` [${iss.severity}] ${iss.kind}${iss.path ? ` ${iss.path}` : ""} \u2014 ${iss.detail}`
6113
+ );
6114
+ }
6115
+ if (issueCount > 10) {
6116
+ info(` (+${issueCount - 10} more \u2014 see metadata header issues=\u2026)`);
6117
+ }
6118
+ if (issueCount > 50) {
6119
+ warning("Large issue count may mean the validator is misfiring; try `squad new-plan --no-validation` to compare.");
6120
+ }
6121
+ }
5029
6122
  if (!success2) {
5030
- printPlannerRunIncompleteSummary(result, relPlan);
6123
+ if (!validationBlocks || !result.finishedNormally) {
6124
+ printPlannerRunIncompleteSummary(result, relPlan);
6125
+ } else {
6126
+ blank();
6127
+ warning("Plan saved as partial because strict validation reported issues.");
6128
+ }
5031
6129
  throw new SquadExit(2);
5032
6130
  }
5033
6131
  if (result.budgetExhausted) {
5034
6132
  info("Note: file-read budget was reached; the model finalised without further reads.");
5035
6133
  }
5036
6134
  blank();
5037
- info(`next \u2192 open a new agent chat and attach only ${path19.basename(planFile)}`);
6135
+ info(`next \u2192 open a new agent chat and attach only ${path23.basename(planFile)}`);
5038
6136
  }
5039
6137
  async function emitCopyPrompt(story, paths, config2, clipboard) {
5040
6138
  const intakeContent = readFile(story.intakePath);
5041
6139
  const composed = buildCopyPlanPromptMarkdown(config2, intakeContent);
5042
- const promptFile = path19.join(paths.squadDir, ".last-copy-prompt.md");
5043
- fs26.writeFileSync(promptFile, composed, "utf8");
5044
- const relPromptFile = path19.relative(paths.root, promptFile);
6140
+ const promptFile = path23.join(paths.squadDir, ".last-copy-prompt.md");
6141
+ fs31.writeFileSync(promptFile, composed, "utf8");
6142
+ const relPromptFile = path23.relative(paths.root, promptFile);
5045
6143
  const bytes = Buffer.byteLength(composed, "utf8");
5046
6144
  const estTokens = Math.round(bytes / 4);
5047
6145
  const clipResult = clipboard ? await copyToClipboard(composed) : { ok: false, reason: "clipboard disabled (--no-clipboard)" };
@@ -5099,17 +6197,17 @@ function printModelBanner(config2) {
5099
6197
  }
5100
6198
 
5101
6199
  // src/commands/status.ts
5102
- import fs27 from "fs";
6200
+ import fs32 from "fs";
5103
6201
  var kw = 28;
5104
6202
  function probeTrackerCredential(config2, overlay) {
5105
- const { client, error: error2 } = clientFor(config2, overlay);
6203
+ const { client, error } = clientFor(config2, overlay);
5106
6204
  if (client) {
5107
6205
  const fromEnv = config2.tracker.type === "jira" && Boolean(process.env.JIRA_API_TOKEN || process.env.SQUAD_TRACKER_API_KEY) || config2.tracker.type === "azure" && Boolean(process.env.AZURE_DEVOPS_PAT || process.env.SQUAD_TRACKER_API_KEY);
5108
6206
  return { state: "present", detail: fromEnv ? "env" : "secrets.yaml" };
5109
6207
  }
5110
6208
  return {
5111
6209
  state: "missing",
5112
- hint: error2?.detail ?? "Run `squad init` to enter credentials."
6210
+ hint: error?.detail ?? "Run `squad init` to enter credentials."
5113
6211
  };
5114
6212
  }
5115
6213
  function formatCredentialStatus(s) {
@@ -5125,7 +6223,7 @@ async function runStatus() {
5125
6223
  const scan = scanPlans(paths.plansDir);
5126
6224
  const storyCount = listStories(paths).length;
5127
6225
  const planCount = scan.usedNumbers.length;
5128
- const secrets = fs27.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
6226
+ const secrets = fs32.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
5129
6227
  const overlay = overlayTrackerEnv(secrets);
5130
6228
  banner();
5131
6229
  step("squad-kit status");
@@ -5179,13 +6277,13 @@ function buildTrackerLine(config2, overlay) {
5179
6277
  }
5180
6278
 
5181
6279
  // src/commands/list.ts
5182
- import path20 from "path";
6280
+ import path24 from "path";
5183
6281
  async function runList(opts) {
5184
6282
  const root = requireSquadRoot();
5185
6283
  const paths = buildPaths(root);
5186
6284
  const records = listStories(paths, { feature: opts.feature });
5187
6285
  const rows = records.map((r) => {
5188
- const planAbs = r.planFile ? path20.join(paths.plansDir, r.feature, r.planFile) : void 0;
6286
+ const planAbs = r.planFile ? path24.join(paths.plansDir, r.feature, r.planFile) : void 0;
5189
6287
  const meta = planAbs ? readPlanMetadata(planAbs) : void 0;
5190
6288
  return {
5191
6289
  feature: r.feature,
@@ -5224,8 +6322,8 @@ function stripAnsi(s) {
5224
6322
  }
5225
6323
 
5226
6324
  // src/commands/tracker-link.ts
5227
- import fs28 from "fs";
5228
- import path21 from "path";
6325
+ import fs33 from "fs";
6326
+ import path25 from "path";
5229
6327
  import { input as input4, select as select7 } from "@inquirer/prompts";
5230
6328
  async function runTrackerLink(storyPath, trackerId, opts = {}) {
5231
6329
  const root = requireSquadRoot();
@@ -5272,17 +6370,17 @@ async function runTrackerLink(storyPath, trackerId, opts = {}) {
5272
6370
  `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.`
5273
6371
  );
5274
6372
  }
5275
- const resolved = path21.resolve(resolvedStoryPath);
5276
- const intakeFile = resolved.endsWith("intake.md") ? resolved : path21.join(resolved, "intake.md");
5277
- if (!fs28.existsSync(intakeFile)) {
6373
+ const resolved = path25.resolve(resolvedStoryPath);
6374
+ const intakeFile = resolved.endsWith("intake.md") ? resolved : path25.join(resolved, "intake.md");
6375
+ if (!fs33.existsSync(intakeFile)) {
5278
6376
  throw new Error(
5279
6377
  `intake.md not found at ${intakeFile}. Run \`squad list\` to see valid paths, or \`squad new-story\` to create a story first.`
5280
6378
  );
5281
6379
  }
5282
- const content = fs28.readFileSync(intakeFile, "utf8");
6380
+ const content = fs33.readFileSync(intakeFile, "utf8");
5283
6381
  const updated = upsertTrackerId(content, resolvedTrackerId);
5284
- fs28.writeFileSync(intakeFile, updated, "utf8");
5285
- success(`Linked tracker id ${resolvedTrackerId} \u2192 ${path21.relative(root, intakeFile)}`);
6382
+ fs33.writeFileSync(intakeFile, updated, "utf8");
6383
+ success(`Linked tracker id ${resolvedTrackerId} \u2192 ${path25.relative(root, intakeFile)}`);
5286
6384
  blank();
5287
6385
  step("Next:");
5288
6386
  info("1) Review the intake \u2014 the Source block at the top now carries the tracker id.");
@@ -5300,39 +6398,39 @@ function upsertTrackerId(content, id) {
5300
6398
  }
5301
6399
 
5302
6400
  // src/commands/doctor-engine.ts
5303
- import fs29 from "fs";
5304
- import path22 from "path";
6401
+ import fs34 from "fs";
6402
+ import path26 from "path";
5305
6403
  async function gatherContext(paths) {
5306
6404
  const ctx = { hasLegacyPromptsDir: false };
5307
6405
  try {
5308
6406
  ctx.config = loadConfig(paths.configFile);
5309
- } catch (err) {
5310
- ctx.configError = err;
6407
+ } catch (err2) {
6408
+ ctx.configError = err2;
5311
6409
  }
5312
6410
  try {
5313
- ctx.secrets = fs29.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
5314
- } catch (err) {
5315
- ctx.secretsError = err;
6411
+ ctx.secrets = fs34.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
6412
+ } catch (err2) {
6413
+ ctx.secretsError = err2;
5316
6414
  }
5317
- ctx.hasLegacyPromptsDir = fs29.existsSync(paths.promptsDir);
6415
+ ctx.hasLegacyPromptsDir = fs34.existsSync(paths.promptsDir);
5318
6416
  return ctx;
5319
6417
  }
5320
6418
  function gitignoreHasManagedBlock(repoRoot) {
5321
- const gitignore = path22.join(repoRoot, ".gitignore");
5322
- return fs29.existsSync(gitignore) && fs29.readFileSync(gitignore, "utf8").includes(".squad/secrets.yaml");
6419
+ const gitignore = path26.join(repoRoot, ".gitignore");
6420
+ return fs34.existsSync(gitignore) && fs34.readFileSync(gitignore, "utf8").includes(".squad/secrets.yaml");
5323
6421
  }
5324
6422
  function gitignoreHasTrashPattern(repoRoot) {
5325
- const gitignore = path22.join(repoRoot, ".gitignore");
5326
- return fs29.existsSync(gitignore) && fs29.readFileSync(gitignore, "utf8").includes(SQUAD_TRASH_PATTERN);
6423
+ const gitignore = path26.join(repoRoot, ".gitignore");
6424
+ return fs34.existsSync(gitignore) && fs34.readFileSync(gitignore, "utf8").includes(SQUAD_TRASH_PATTERN);
5327
6425
  }
5328
6426
  async function checkDirStructure(paths, _ctx, fix) {
5329
- const need = [paths.squadDir, paths.storiesDir, paths.plansDir].filter((p) => !fs29.existsSync(p));
6427
+ const need = [paths.squadDir, paths.storiesDir, paths.plansDir].filter((p) => !fs34.existsSync(p));
5330
6428
  if (need.length === 0) {
5331
6429
  return { id: "dirs", name: ".squad/ directory structure", status: "ok" };
5332
6430
  }
5333
6431
  if (fix) {
5334
6432
  for (const p of need) {
5335
- fs29.mkdirSync(p, { recursive: true });
6433
+ fs34.mkdirSync(p, { recursive: true });
5336
6434
  }
5337
6435
  return { id: "dirs", name: ".squad/ directory structure", status: "ok", detail: "repaired" };
5338
6436
  }
@@ -5340,7 +6438,7 @@ async function checkDirStructure(paths, _ctx, fix) {
5340
6438
  id: "dirs",
5341
6439
  name: ".squad/ directory structure",
5342
6440
  status: "warn",
5343
- detail: `missing: ${need.map((p) => path22.relative(paths.root, p)).join(", ")}`,
6441
+ detail: `missing: ${need.map((p) => path26.relative(paths.root, p)).join(", ")}`,
5344
6442
  fixable: true,
5345
6443
  fixHint: "squad doctor --fix"
5346
6444
  };
@@ -5395,15 +6493,15 @@ async function checkSecretsPermissions(paths, _ctx, fix) {
5395
6493
  if (process.platform === "win32") {
5396
6494
  return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "skip", detail: "Windows" };
5397
6495
  }
5398
- if (!fs29.existsSync(paths.secretsFile)) {
6496
+ if (!fs34.existsSync(paths.secretsFile)) {
5399
6497
  return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok", detail: "not present" };
5400
6498
  }
5401
- const mode = fs29.statSync(paths.secretsFile).mode & 511;
6499
+ const mode = fs34.statSync(paths.secretsFile).mode & 511;
5402
6500
  if (mode === 384) {
5403
6501
  return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok" };
5404
6502
  }
5405
6503
  if (fix) {
5406
- fs29.chmodSync(paths.secretsFile, 384);
6504
+ fs34.chmodSync(paths.secretsFile, 384);
5407
6505
  return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok", detail: "repaired" };
5408
6506
  }
5409
6507
  return {
@@ -5424,7 +6522,7 @@ async function checkSecretsParseable(_paths, ctx) {
5424
6522
  detail: ctx.secretsError.message
5425
6523
  };
5426
6524
  }
5427
- if (!fs29.existsSync(_paths.secretsFile)) {
6525
+ if (!fs34.existsSync(_paths.secretsFile)) {
5428
6526
  return { id: "secrets-yaml", name: ".squad/secrets.yaml parseable", status: "ok", detail: "not present" };
5429
6527
  }
5430
6528
  return { id: "secrets-yaml", name: ".squad/secrets.yaml parseable", status: "ok" };
@@ -5484,14 +6582,6 @@ async function checkPlannerConfig(_paths, ctx) {
5484
6582
  detail: "planner budget limits must be > 0"
5485
6583
  };
5486
6584
  }
5487
- if (p.budget.maxCostUsd !== void 0 && p.budget.maxCostUsd <= 0) {
5488
- return {
5489
- id: "planner-config",
5490
- name: "planner configuration",
5491
- status: "fail",
5492
- detail: "planner.budget.maxCostUsd must be > 0 when set"
5493
- };
5494
- }
5495
6585
  return { id: "planner-config", name: "planner configuration", status: "ok" };
5496
6586
  }
5497
6587
  async function checkPlannerCredential(_paths, ctx) {
@@ -5566,12 +6656,12 @@ async function checkPlannerModel(_paths, ctx) {
5566
6656
  status: "ok",
5567
6657
  detail: `${model} (${provider})`
5568
6658
  };
5569
- } catch (err) {
6659
+ } catch (err2) {
5570
6660
  return {
5571
6661
  id: "planner-model",
5572
6662
  name: "planner model resolves at provider",
5573
6663
  status: "warn",
5574
- detail: err.message
6664
+ detail: err2.message
5575
6665
  };
5576
6666
  }
5577
6667
  }
@@ -5704,8 +6794,8 @@ async function checkTrackerConfig(_paths, ctx) {
5704
6794
  }
5705
6795
  return { id: "tracker-config", name: "tracker configuration", status: "ok" };
5706
6796
  }
5707
- function formatClientError(err) {
5708
- return `${err.message} ${err.detail}`.trim();
6797
+ function formatClientError(err2) {
6798
+ return `${err2.message} ${err2.detail}`.trim();
5709
6799
  }
5710
6800
  async function checkTrackerCredential(_paths, ctx) {
5711
6801
  if (!ctx.config) {
@@ -5716,13 +6806,13 @@ async function checkTrackerCredential(_paths, ctx) {
5716
6806
  }
5717
6807
  const secrets = ctx.secrets ?? {};
5718
6808
  const overlay = overlayTrackerEnv(secrets);
5719
- const { client, error: error2 } = clientFor(ctx.config, overlay);
5720
- if (error2) {
6809
+ const { client, error } = clientFor(ctx.config, overlay);
6810
+ if (error) {
5721
6811
  return {
5722
6812
  id: "tracker-cred",
5723
6813
  name: "tracker credential resolves",
5724
6814
  status: "fail",
5725
- detail: formatClientError(error2)
6815
+ detail: formatClientError(error)
5726
6816
  };
5727
6817
  }
5728
6818
  if (!client) {
@@ -5741,8 +6831,8 @@ async function checkTrackerConnectivity(paths, ctx) {
5741
6831
  }
5742
6832
  const secrets = ctx.secrets ?? {};
5743
6833
  const overlay = overlayTrackerEnv(secrets);
5744
- const { client, error: error2 } = clientFor(ctx.config, overlay);
5745
- if (error2 || !client) {
6834
+ const { client, error } = clientFor(ctx.config, overlay);
6835
+ if (error || !client) {
5746
6836
  return { id: "tracker-live", name: "tracker connectivity", status: "skip", detail: "no credential" };
5747
6837
  }
5748
6838
  if (client.name === "jira") {
@@ -5785,12 +6875,12 @@ async function checkTrackerConnectivity(paths, ctx) {
5785
6875
  async function guardCheck(checkName, fn) {
5786
6876
  try {
5787
6877
  return await fn();
5788
- } catch (err) {
6878
+ } catch (err2) {
5789
6879
  return {
5790
6880
  id: "unexpected",
5791
6881
  name: checkName,
5792
6882
  status: "fail",
5793
- detail: err.message
6883
+ detail: err2.message
5794
6884
  };
5795
6885
  }
5796
6886
  }
@@ -5918,23 +7008,23 @@ async function runDoctor(opts) {
5918
7008
  }
5919
7009
 
5920
7010
  // src/commands/migrate.ts
5921
- import fs30 from "fs";
5922
- import path23 from "path";
7011
+ import fs35 from "fs";
7012
+ import path27 from "path";
5923
7013
  import { confirm as confirm7 } from "@inquirer/prompts";
5924
7014
  var LEGACY_PROMPTS_MIGRATION = {
5925
7015
  id: "legacy-prompts",
5926
7016
  name: "Remove legacy .squad/prompts/",
5927
- isApplied: (paths) => !fs30.existsSync(paths.promptsDir),
5928
- describe: (paths) => `delete ${path23.relative(paths.root, paths.promptsDir)} (prompts are now bundled in squad-kit)`,
7017
+ isApplied: (paths) => !fs35.existsSync(paths.promptsDir),
7018
+ describe: (paths) => `delete ${path27.relative(paths.root, paths.promptsDir)} (prompts are now bundled in squad-kit)`,
5929
7019
  apply: (paths) => {
5930
7020
  const target = paths.promptsDir;
5931
- if (!target.includes(path23.sep + ".squad" + path23.sep + "prompts")) {
7021
+ if (!target.includes(path27.sep + ".squad" + path27.sep + "prompts")) {
5932
7022
  throw new Error(
5933
7023
  `Refusing to delete suspicious path: ${target}. This is a safety check \u2014 run \`squad doctor\` if .squad/ paths look wrong.`
5934
7024
  );
5935
7025
  }
5936
- if (fs30.existsSync(target)) {
5937
- fs30.rmSync(target, { recursive: true, force: true });
7026
+ if (fs35.existsSync(target)) {
7027
+ fs35.rmSync(target, { recursive: true, force: true });
5938
7028
  }
5939
7029
  }
5940
7030
  };
@@ -5942,8 +7032,8 @@ var GITIGNORE_MIGRATION = {
5942
7032
  id: "gitignore",
5943
7033
  name: "Ensure .gitignore managed block",
5944
7034
  isApplied: (paths) => {
5945
- const f = path23.join(paths.root, ".gitignore");
5946
- return fs30.existsSync(f) && fs30.readFileSync(f, "utf8").includes(".squad/secrets.yaml");
7035
+ const f = path27.join(paths.root, ".gitignore");
7036
+ return fs35.existsSync(f) && fs35.readFileSync(f, "utf8").includes(".squad/secrets.yaml");
5947
7037
  },
5948
7038
  describe: () => "add squad-kit managed block to .gitignore",
5949
7039
  apply: (paths) => {
@@ -5955,23 +7045,23 @@ var SECRETS_PERMS_MIGRATION = {
5955
7045
  name: "Tighten .squad/secrets.yaml permissions",
5956
7046
  isApplied: (paths) => {
5957
7047
  if (process.platform === "win32") return true;
5958
- if (!fs30.existsSync(paths.secretsFile)) return true;
5959
- const st = fs30.statSync(paths.secretsFile);
7048
+ if (!fs35.existsSync(paths.secretsFile)) return true;
7049
+ const st = fs35.statSync(paths.secretsFile);
5960
7050
  return (st.mode & 511) === 384;
5961
7051
  },
5962
- describe: (paths) => `chmod 0600 ${path23.relative(paths.root, paths.secretsFile)}`,
7052
+ describe: (paths) => `chmod 0600 ${path27.relative(paths.root, paths.secretsFile)}`,
5963
7053
  apply: (paths) => {
5964
7054
  if (process.platform === "win32") return;
5965
- if (fs30.existsSync(paths.secretsFile)) fs30.chmodSync(paths.secretsFile, 384);
7055
+ if (fs35.existsSync(paths.secretsFile)) fs35.chmodSync(paths.secretsFile, 384);
5966
7056
  }
5967
7057
  };
5968
7058
  var PLANNER_BUDGET_DEFAULTS_MIGRATION = {
5969
7059
  id: "planner-budget-defaults",
5970
7060
  name: "Backfill planner.budget defaults in config.yaml",
5971
7061
  isApplied: (paths) => {
5972
- if (!fs30.existsSync(paths.configFile)) return true;
7062
+ if (!fs35.existsSync(paths.configFile)) return true;
5973
7063
  try {
5974
- const raw = fs30.readFileSync(paths.configFile, "utf8");
7064
+ const raw = fs35.readFileSync(paths.configFile, "utf8");
5975
7065
  if (!/^\s*planner:/m.test(raw)) return true;
5976
7066
  if (/^\s*budget:/m.test(raw)) return true;
5977
7067
  const cfg = loadConfig(paths.configFile);
@@ -5990,9 +7080,9 @@ var CONFIG_NORMALIZE_MIGRATION = {
5990
7080
  id: "config-normalize",
5991
7081
  name: "Normalise .squad/config.yaml formatting",
5992
7082
  isApplied: (paths) => {
5993
- if (!fs30.existsSync(paths.configFile)) return true;
7083
+ if (!fs35.existsSync(paths.configFile)) return true;
5994
7084
  try {
5995
- const raw = fs30.readFileSync(paths.configFile, "utf8");
7085
+ const raw = fs35.readFileSync(paths.configFile, "utf8");
5996
7086
  const cfg = loadConfig(paths.configFile);
5997
7087
  return raw.trim() === serializeConfig(cfg).trim();
5998
7088
  } catch {
@@ -6001,7 +7091,7 @@ var CONFIG_NORMALIZE_MIGRATION = {
6001
7091
  },
6002
7092
  describe: () => "rewrite config.yaml with canonical YAML formatting (drops comments)",
6003
7093
  apply: (paths) => {
6004
- if (!fs30.existsSync(paths.configFile)) return;
7094
+ if (!fs35.existsSync(paths.configFile)) return;
6005
7095
  const cfg = loadConfig(paths.configFile);
6006
7096
  saveConfig(paths.configFile, cfg);
6007
7097
  }
@@ -6062,8 +7152,8 @@ async function runMigrate(opts) {
6062
7152
  await Promise.resolve(m.apply(paths));
6063
7153
  spin.succeed(`${m.name} \u2014 applied`);
6064
7154
  applied++;
6065
- } catch (err) {
6066
- spin.fail(`${m.name} \u2014 failed: ${err.message}`);
7155
+ } catch (err2) {
7156
+ spin.fail(`${m.name} \u2014 failed: ${err2.message}`);
6067
7157
  process.exit(1);
6068
7158
  }
6069
7159
  }
@@ -6082,8 +7172,8 @@ import process3 from "process";
6082
7172
  import { confirm as confirm8 } from "@inquirer/prompts";
6083
7173
 
6084
7174
  // src/core/package-info.ts
6085
- import fs31 from "fs";
6086
- import path24 from "path";
7175
+ import fs36 from "fs";
7176
+ import path28 from "path";
6087
7177
  function changelogBlobUrl(repositoryUrl, version) {
6088
7178
  let base = repositoryUrl;
6089
7179
  if (!base) base = "https://github.com/AzmSquad/squad-kit";
@@ -6094,9 +7184,9 @@ function changelogBlobUrl(repositoryUrl, version) {
6094
7184
  }
6095
7185
  function readInstalledPackage() {
6096
7186
  const root = packageRoot();
6097
- const pkgFile = path24.join(root, "package.json");
6098
- const pkg = JSON.parse(fs31.readFileSync(pkgFile, "utf8"));
6099
- const isDevInstall = fs31.existsSync(path24.join(root, "src"));
7187
+ const pkgFile = path28.join(root, "package.json");
7188
+ const pkg = JSON.parse(fs36.readFileSync(pkgFile, "utf8"));
7189
+ const isDevInstall = fs36.existsSync(path28.join(root, "src"));
6100
7190
  return {
6101
7191
  name: pkg.name,
6102
7192
  version: pkg.version,
@@ -6136,7 +7226,7 @@ function isNewer(a, b) {
6136
7226
  }
6137
7227
 
6138
7228
  // src/core/package-manager.ts
6139
- import path25 from "path";
7229
+ import path29 from "path";
6140
7230
  import process2 from "process";
6141
7231
  function detectPackageManager(installRoot) {
6142
7232
  const ua = process2.env.npm_config_user_agent;
@@ -6147,11 +7237,11 @@ function detectPackageManager(installRoot) {
6147
7237
  if (ua.startsWith("npm/")) return { pm: "npm", reason: "npm_config_user_agent" };
6148
7238
  }
6149
7239
  const root = installRoot.toLowerCase();
6150
- if (root.includes(`${path25.sep}pnpm${path25.sep}`) || root.includes(`${path25.sep}.pnpm${path25.sep}`))
7240
+ if (root.includes(`${path29.sep}pnpm${path29.sep}`) || root.includes(`${path29.sep}.pnpm${path29.sep}`))
6151
7241
  return { pm: "pnpm", reason: `install path contains pnpm` };
6152
- if (root.includes(`${path25.sep}.yarn${path25.sep}`) || root.includes(`${path25.sep}yarn${path25.sep}`))
7242
+ if (root.includes(`${path29.sep}.yarn${path29.sep}`) || root.includes(`${path29.sep}yarn${path29.sep}`))
6153
7243
  return { pm: "yarn", reason: `install path contains yarn` };
6154
- if (root.includes(`${path25.sep}.bun${path25.sep}`) || root.includes(`${path25.sep}bun${path25.sep}`))
7244
+ if (root.includes(`${path29.sep}.bun${path29.sep}`) || root.includes(`${path29.sep}bun${path29.sep}`))
6155
7245
  return { pm: "bun", reason: `install path contains bun` };
6156
7246
  return { pm: "npm", reason: "default fallback" };
6157
7247
  }
@@ -6185,9 +7275,9 @@ async function runUpgrade(opts) {
6185
7275
  try {
6186
7276
  const info2 = await fetchLatest(installed.name);
6187
7277
  latest = info2.latest;
6188
- } catch (err) {
7278
+ } catch (err2) {
6189
7279
  failure(
6190
- `Could not reach the npm registry: ${err.message}. Check your network, then run \`pnpm add -g squad-kit@latest\` (or \`npm i -g squad-kit@latest\`) manually.`
7280
+ `Could not reach the npm registry: ${err2.message}. Check your network, then run \`pnpm add -g squad-kit@latest\` (or \`npm i -g squad-kit@latest\`) manually.`
6191
7281
  );
6192
7282
  process3.exit(1);
6193
7283
  }
@@ -6247,7 +7337,7 @@ function spawnInherit(cmd, args) {
6247
7337
  return new Promise((resolve2, reject) => {
6248
7338
  const child = spawn2(cmd, args, { stdio: "inherit", shell: process3.platform === "win32" });
6249
7339
  child.on("exit", (code) => resolve2(code ?? 0));
6250
- child.on("error", (err) => reject(err));
7340
+ child.on("error", (err2) => reject(err2));
6251
7341
  });
6252
7342
  }
6253
7343
 
@@ -6264,25 +7354,25 @@ import { cors } from "hono/cors";
6264
7354
  import { secureHeaders } from "hono/secure-headers";
6265
7355
 
6266
7356
  // src/console/api/stories.ts
6267
- import fs32 from "fs";
6268
- import { z } from "zod";
7357
+ import fs37 from "fs";
7358
+ import { z as z5 } from "zod";
6269
7359
 
6270
7360
  // src/utils/path-safety.ts
6271
- import path26 from "path";
7361
+ import path30 from "path";
6272
7362
  function isInside(abs, parent) {
6273
- const a = path26.resolve(abs) + path26.sep;
6274
- const p = path26.resolve(parent) + path26.sep;
7363
+ const a = path30.resolve(abs) + path30.sep;
7364
+ const p = path30.resolve(parent) + path30.sep;
6275
7365
  return a.startsWith(p);
6276
7366
  }
6277
7367
 
6278
7368
  // src/console/api/stories.ts
6279
- var CreateBody = z.object({
6280
- feature: z.string().min(1).max(80),
6281
- title: z.string().max(200).optional(),
6282
- trackerId: z.string().max(80).optional()
7369
+ var CreateBody = z5.object({
7370
+ feature: z5.string().min(1).max(80),
7371
+ title: z5.string().max(200).optional(),
7372
+ trackerId: z5.string().max(80).optional()
6283
7373
  });
6284
- var PatchBody = z.object({
6285
- intakeContent: z.string().min(1).max(1e6)
7374
+ var PatchBody = z5.object({
7375
+ intakeContent: z5.string().min(1).max(1e6)
6286
7376
  });
6287
7377
  function safeFeatureParam(feature) {
6288
7378
  return feature.length > 0 && !feature.includes("/") && !feature.includes("..") && slugify(feature) === feature;
@@ -6311,7 +7401,7 @@ function mountStoriesApi(app, opts) {
6311
7401
  if (!isInside(hit.intakePath, opts.paths.storiesDir)) return c.json({ error: "forbidden" }, 403);
6312
7402
  let intakeContent = "";
6313
7403
  try {
6314
- intakeContent = fs32.readFileSync(hit.intakePath, "utf8");
7404
+ intakeContent = fs37.readFileSync(hit.intakePath, "utf8");
6315
7405
  } catch {
6316
7406
  }
6317
7407
  return c.json({
@@ -6355,7 +7445,7 @@ function mountStoriesApi(app, opts) {
6355
7445
  const hit = stories.find((s) => s.id === id);
6356
7446
  if (!hit) return c.json({ error: "not_found" }, 404);
6357
7447
  if (!isInside(hit.intakePath, opts.paths.storiesDir)) return c.json({ error: "forbidden" }, 403);
6358
- fs32.writeFileSync(hit.intakePath, body.data.intakeContent, "utf8");
7448
+ fs37.writeFileSync(hit.intakePath, body.data.intakeContent, "utf8");
6359
7449
  return c.json({ ok: true });
6360
7450
  });
6361
7451
  app.delete("/api/stories/:feature/:id", (c) => {
@@ -6372,14 +7462,14 @@ function mountStoriesApi(app, opts) {
6372
7462
  }
6373
7463
 
6374
7464
  // src/console/api/plans.ts
6375
- import fs33 from "fs";
6376
- import path27 from "path";
6377
- import { z as z2 } from "zod";
7465
+ import fs38 from "fs";
7466
+ import path31 from "path";
7467
+ import { z as z6 } from "zod";
6378
7468
  import { diffLines } from "diff";
6379
- var DiffQuery = z2.object({
6380
- feature: z2.string().min(1).max(80),
6381
- a: z2.string().min(1).max(200),
6382
- b: z2.string().min(1).max(200)
7469
+ var DiffQuery = z6.object({
7470
+ feature: z6.string().min(1).max(80),
7471
+ a: z6.string().min(1).max(200),
7472
+ b: z6.string().min(1).max(200)
6383
7473
  });
6384
7474
  function safeFeatureParam2(feature) {
6385
7475
  return feature.length > 0 && !feature.includes("/") && !feature.includes("..") && slugify(feature) === feature;
@@ -6388,11 +7478,11 @@ function mountPlansApi(app, opts) {
6388
7478
  app.get("/api/plans", (c) => {
6389
7479
  const out = [];
6390
7480
  for (const feature of listPlanFeatureNames(opts.paths)) {
6391
- const featureDir = path27.join(opts.paths.plansDir, feature);
6392
- if (!fs33.existsSync(featureDir)) continue;
6393
- for (const entry of fs33.readdirSync(featureDir)) {
7481
+ const featureDir = path31.join(opts.paths.plansDir, feature);
7482
+ if (!fs38.existsSync(featureDir)) continue;
7483
+ for (const entry of fs38.readdirSync(featureDir)) {
6394
7484
  if (!entry.endsWith(".md") || entry === "00-index.md") continue;
6395
- const abs = path27.join(featureDir, entry);
7485
+ const abs = path31.join(featureDir, entry);
6396
7486
  out.push({ feature, planFile: entry, metadata: readPlanMetadata(abs) });
6397
7487
  }
6398
7488
  }
@@ -6401,10 +7491,10 @@ function mountPlansApi(app, opts) {
6401
7491
  app.get("/api/plans/:feature/:planFile", (c) => {
6402
7492
  const { feature, planFile } = c.req.param();
6403
7493
  if (!safeFeatureParam2(feature)) return c.json({ error: "forbidden" }, 403);
6404
- const abs = path27.resolve(path27.join(opts.paths.plansDir, feature, planFile));
7494
+ const abs = path31.resolve(path31.join(opts.paths.plansDir, feature, planFile));
6405
7495
  if (!isInside(abs, opts.paths.plansDir)) return c.json({ error: "forbidden" }, 403);
6406
- if (!fs33.existsSync(abs)) return c.json({ error: "not_found" }, 404);
6407
- const content = fs33.readFileSync(abs, "utf8");
7496
+ if (!fs38.existsSync(abs)) return c.json({ error: "not_found" }, 404);
7497
+ const content = fs38.readFileSync(abs, "utf8");
6408
7498
  return c.json({
6409
7499
  feature,
6410
7500
  planFile,
@@ -6422,15 +7512,15 @@ function mountPlansApi(app, opts) {
6422
7512
  if (!parse.success) return c.json({ error: "invalid_query", issues: parse.error.issues }, 400);
6423
7513
  const { feature, a, b } = parse.data;
6424
7514
  if (!safeFeatureParam2(feature)) return c.json({ error: "forbidden" }, 403);
6425
- const featureDir = path27.join(opts.paths.plansDir, feature);
6426
- const aAbs = path27.join(featureDir, a);
6427
- const bAbs = path27.join(featureDir, b);
7515
+ const featureDir = path31.join(opts.paths.plansDir, feature);
7516
+ const aAbs = path31.join(featureDir, a);
7517
+ const bAbs = path31.join(featureDir, b);
6428
7518
  if (!isInside(aAbs, opts.paths.plansDir) || !isInside(bAbs, opts.paths.plansDir)) {
6429
7519
  return c.json({ error: "forbidden" }, 403);
6430
7520
  }
6431
- if (!fs33.existsSync(aAbs) || !fs33.existsSync(bAbs)) return c.json({ error: "not_found" }, 404);
6432
- const aText = fs33.readFileSync(aAbs, "utf8");
6433
- const bText = fs33.readFileSync(bAbs, "utf8");
7521
+ if (!fs38.existsSync(aAbs) || !fs38.existsSync(bAbs)) return c.json({ error: "not_found" }, 404);
7522
+ const aText = fs38.readFileSync(aAbs, "utf8");
7523
+ const bText = fs38.readFileSync(bAbs, "utf8");
6434
7524
  const changes = diffLines(aText, bText);
6435
7525
  return c.json({ feature, a, b, changes });
6436
7526
  });
@@ -6438,23 +7528,23 @@ function mountPlansApi(app, opts) {
6438
7528
  const trash = c.req.query("trash") === "1";
6439
7529
  const { feature, planFile } = c.req.param();
6440
7530
  if (!safeFeatureParam2(feature)) return c.json({ error: "forbidden" }, 403);
6441
- const abs = path27.join(opts.paths.plansDir, feature, planFile);
7531
+ const abs = path31.join(opts.paths.plansDir, feature, planFile);
6442
7532
  if (!isInside(abs, opts.paths.plansDir)) return c.json({ error: "forbidden" }, 403);
6443
- if (!fs33.existsSync(abs)) return c.json({ error: "not_found" }, 404);
7533
+ if (!fs38.existsSync(abs)) return c.json({ error: "not_found" }, 404);
6444
7534
  if (trash) {
6445
7535
  const result = trashOrDelete([abs], opts.paths.trashDir, true);
6446
- removeOverviewRowForPlanFile(path27.join(opts.paths.plansDir, feature), planFile);
7536
+ removeOverviewRowForPlanFile(path31.join(opts.paths.plansDir, feature), planFile);
6447
7537
  return c.json({ ok: true, trashed: result.trashed });
6448
7538
  }
6449
- fs33.rmSync(abs);
6450
- removeOverviewRowForPlanFile(path27.join(opts.paths.plansDir, feature), planFile);
7539
+ fs38.rmSync(abs);
7540
+ removeOverviewRowForPlanFile(path31.join(opts.paths.plansDir, feature), planFile);
6451
7541
  return c.json({ ok: true });
6452
7542
  });
6453
7543
  }
6454
7544
 
6455
7545
  // src/console/api/config.ts
6456
- import { z as z3 } from "zod";
6457
- var ConfigBody = z3.object({}).passthrough();
7546
+ import { z as z7 } from "zod";
7547
+ var ConfigBody = z7.object({}).passthrough();
6458
7548
  function mountConfigApi(app, opts) {
6459
7549
  app.get("/api/config", (c) => {
6460
7550
  const config2 = loadConfig(opts.paths.configFile);
@@ -6466,8 +7556,8 @@ function mountConfigApi(app, opts) {
6466
7556
  try {
6467
7557
  saveConfig(opts.paths.configFile, body.data);
6468
7558
  return c.json({ ok: true });
6469
- } catch (err) {
6470
- return c.json({ error: "invalid_config", detail: err.message }, 400);
7559
+ } catch (err2) {
7560
+ return c.json({ error: "invalid_config", detail: err2.message }, 400);
6471
7561
  }
6472
7562
  });
6473
7563
  }
@@ -6494,14 +7584,14 @@ function mountMetaApi(app, opts) {
6494
7584
 
6495
7585
  // src/console/api/runs.ts
6496
7586
  import { streamSSE } from "hono/streaming";
6497
- import { z as z4 } from "zod";
6498
- import path28 from "path";
6499
- import fs34 from "fs";
7587
+ import { z as z8 } from "zod";
7588
+ import path32 from "path";
7589
+ import fs39 from "fs";
6500
7590
  var active = /* @__PURE__ */ new Map();
6501
7591
  var EVENT_BUF_CAP = 512;
6502
- var StartBody = z4.object({
6503
- feature: z4.string().min(1),
6504
- storyId: z4.string().min(1)
7592
+ var StartBody = z8.object({
7593
+ feature: z8.string().min(1),
7594
+ storyId: z8.string().min(1)
6505
7595
  });
6506
7596
  function emptyStats(durationMs) {
6507
7597
  return {
@@ -6557,11 +7647,36 @@ function mountRunsApi(app, opts) {
6557
7647
  const done = (async () => {
6558
7648
  const cfgFresh = loadConfig(opts.paths.configFile);
6559
7649
  const planner = cfgFresh.planner;
6560
- const provider = providerFor(planner.provider);
6561
- const model = modelFor(planner.provider, "plan", planner.modelOverride);
7650
+ const modelId = modelFor(planner.provider, "plan", planner.modelOverride);
7651
+ const { model } = resolveModel(planner.provider, modelId, apiKey);
6562
7652
  const budget = new Budget(planner.budget);
6563
7653
  const cacheEnabled = planner.cache?.enabled ?? true;
6564
- const repoMap = buildRepoMap(opts.paths.root);
7654
+ const scoutEnabled = planner.stages?.scout?.enabled !== false;
7655
+ const validationEnabled = planner.validation?.enabled !== false;
7656
+ const strictValidation = planner.validation?.strict === true;
7657
+ const maxScoutFiles = planner.stages?.scout?.maxFiles ?? 12;
7658
+ let scoutModelId = "";
7659
+ let scoutModelHandle;
7660
+ let scoutSystemPrompt;
7661
+ if (scoutEnabled) {
7662
+ scoutModelId = modelFor(
7663
+ planner.provider,
7664
+ "scout",
7665
+ planner.modelOverride,
7666
+ planner.stages?.scout?.modelOverride
7667
+ );
7668
+ scoutModelHandle = resolveModel(planner.provider, scoutModelId, apiKey).model;
7669
+ }
7670
+ const repoMap = buildRepoMap(opts.paths.root, { format: "tree" });
7671
+ if (scoutEnabled) {
7672
+ scoutSystemPrompt = composeScoutSystemPrompt({
7673
+ projectRoots: cfgFresh.project.projectRoots ?? ["."],
7674
+ primaryLanguage: cfgFresh.project.primaryLanguage ?? "",
7675
+ trackerType: cfgFresh.tracker.type,
7676
+ repoMap,
7677
+ plansIndex: buildPlansIndex(opts.paths)
7678
+ });
7679
+ }
6565
7680
  const systemPrompt = composeSystemPrompt({
6566
7681
  projectRoots: cfgFresh.project.projectRoots ?? ["."],
6567
7682
  primaryLanguage: cfgFresh.project.primaryLanguage ?? "",
@@ -6569,15 +7684,15 @@ function mountRunsApi(app, opts) {
6569
7684
  repoMap
6570
7685
  });
6571
7686
  const userPrompt = composeUserPrompt({
6572
- intakeContent: fs34.readFileSync(story.intakePath, "utf8")
7687
+ intakeContent: fs39.readFileSync(story.intakePath, "utf8")
6573
7688
  });
6574
7689
  let result;
6575
7690
  try {
6576
7691
  result = await runPlanner({
6577
7692
  root: opts.paths.root,
6578
- provider,
6579
7693
  model,
6580
- apiKey,
7694
+ provider: planner.provider,
7695
+ modelId,
6581
7696
  systemPrompt,
6582
7697
  userPrompt,
6583
7698
  budget,
@@ -6585,14 +7700,26 @@ function mountRunsApi(app, opts) {
6585
7700
  cacheEnabled,
6586
7701
  events: bus,
6587
7702
  runId,
6588
- abort: controller.signal
7703
+ abort: controller.signal,
7704
+ stages: {
7705
+ scout: {
7706
+ enabled: scoutEnabled,
7707
+ model: scoutModelHandle,
7708
+ modelId: scoutModelId,
7709
+ maxFiles: maxScoutFiles,
7710
+ maxOutputTokens: planner.stages?.scout?.maxOutputTokens ?? 2048
7711
+ }
7712
+ },
7713
+ validation: { enabled: validationEnabled, strict: strictValidation },
7714
+ toolsEnabled: planner.tools,
7715
+ scoutSystemPrompt
6589
7716
  });
6590
- } catch (err) {
7717
+ } catch (err2) {
6591
7718
  const durationMs = Date.now() - startedAt;
6592
7719
  const last = eventBuffer[eventBuffer.length - 1];
6593
7720
  const isRateLimitAbort = last?.kind === "rate_limit" && last.phase === "aborted";
6594
7721
  if (!isRateLimitAbort) {
6595
- bus.emit({ kind: "error", runId, message: err.message });
7722
+ bus.emit({ kind: "error", runId, message: err2.message });
6596
7723
  }
6597
7724
  bus.emit({
6598
7725
  kind: "done",
@@ -6605,20 +7732,25 @@ function mountRunsApi(app, opts) {
6605
7732
  });
6606
7733
  return;
6607
7734
  }
6608
- const runSuccess = result.finishedNormally && !result.timedOut && !result.userCancelled;
7735
+ const issueCount = result.validation?.issues.length ?? 0;
7736
+ const issuesByKind = summariseIssuesByKind(result.validation?.issues ?? []);
7737
+ const validationBlocks = strictValidation && validationEnabled && issueCount > 0;
7738
+ const runSuccess = result.finishedNormally && !result.timedOut && !result.userCancelled && !validationBlocks;
6609
7739
  let planFile = null;
6610
7740
  if (result.planText.trim()) {
6611
7741
  const snap = budget.snapshot();
6612
7742
  const header = buildMetadataHeader({
6613
7743
  provider: planner.provider,
6614
- model,
7744
+ model: modelId,
6615
7745
  reads: snap.reads,
6616
7746
  bytes: snap.bytes,
6617
7747
  inputTokens: snap.usage.inputTokens,
6618
7748
  outputTokens: snap.usage.outputTokens,
6619
7749
  durationMs: Date.now() - startedAt,
6620
- costUsd: snap.usage.costUsd,
6621
- planStatus: runSuccess ? void 0 : "partial"
7750
+ planStatus: runSuccess ? void 0 : "partial",
7751
+ scoutEnabled,
7752
+ validationEnabled,
7753
+ validationIssueCount: issueCount
6622
7754
  });
6623
7755
  const out = writePlanFile({
6624
7756
  paths: opts.paths,
@@ -6628,19 +7760,19 @@ function mountRunsApi(app, opts) {
6628
7760
  metadataHeader: header,
6629
7761
  partial: !runSuccess
6630
7762
  });
6631
- planFile = path28.relative(opts.paths.root, out.planFile);
7763
+ planFile = path32.relative(opts.paths.root, out.planFile);
6632
7764
  }
6633
7765
  try {
6634
7766
  await writeLastRun(opts.paths, {
6635
7767
  stats: result.stats,
6636
7768
  completedAt: (/* @__PURE__ */ new Date()).toISOString(),
6637
7769
  provider: planner.provider,
6638
- model
7770
+ model: modelId
6639
7771
  });
6640
7772
  await appendRun(opts.paths, {
6641
7773
  runId,
6642
7774
  provider: planner.provider,
6643
- model,
7775
+ model: modelId,
6644
7776
  feature: story.feature,
6645
7777
  storyId: story.id,
6646
7778
  startedAt: new Date(startedAt).toISOString(),
@@ -6650,7 +7782,19 @@ function mountRunsApi(app, opts) {
6650
7782
  planFile,
6651
7783
  stats: result.stats,
6652
7784
  cacheEnabled,
6653
- durationMs: Date.now() - startedAt
7785
+ durationMs: Date.now() - startedAt,
7786
+ scout: {
7787
+ enabled: scoutEnabled,
7788
+ selectedCount: result.scout?.selected.length,
7789
+ tokensUsed: result.scout?.tokensUsed,
7790
+ durationMs: result.scout?.durationMs
7791
+ },
7792
+ validation: {
7793
+ enabled: validationEnabled,
7794
+ issuesCount: issueCount,
7795
+ issuesByKind,
7796
+ durationMs: result.validation?.durationMs
7797
+ }
6654
7798
  });
6655
7799
  } catch {
6656
7800
  }
@@ -6723,7 +7867,7 @@ function mountRunsApi(app, opts) {
6723
7867
  }
6724
7868
 
6725
7869
  // src/console/api/secrets.ts
6726
- import { z as z5 } from "zod";
7870
+ import { z as z9 } from "zod";
6727
7871
 
6728
7872
  // src/core/mask-token.ts
6729
7873
  function maskToken(t) {
@@ -6761,26 +7905,26 @@ function mountSecretsApi(app, opts) {
6761
7905
  }
6762
7906
  });
6763
7907
  });
6764
- const PutBody = z5.object({
6765
- planner: z5.object({
6766
- anthropic: z5.string().optional(),
6767
- openai: z5.string().optional(),
6768
- google: z5.string().optional()
7908
+ const PutBody = z9.object({
7909
+ planner: z9.object({
7910
+ anthropic: z9.string().optional(),
7911
+ openai: z9.string().optional(),
7912
+ google: z9.string().optional()
6769
7913
  }).optional(),
6770
- tracker: z5.object({
6771
- jira: z5.object({
6772
- host: z5.string().optional(),
6773
- email: z5.string().optional(),
6774
- token: z5.string().optional()
7914
+ tracker: z9.object({
7915
+ jira: z9.object({
7916
+ host: z9.string().optional(),
7917
+ email: z9.string().optional(),
7918
+ token: z9.string().optional()
6775
7919
  }).optional(),
6776
- azure: z5.object({
6777
- organization: z5.string().optional(),
6778
- project: z5.string().optional(),
6779
- pat: z5.string().optional()
7920
+ azure: z9.object({
7921
+ organization: z9.string().optional(),
7922
+ project: z9.string().optional(),
7923
+ pat: z9.string().optional()
6780
7924
  }).optional(),
6781
- github: z5.object({
6782
- host: z5.string().optional(),
6783
- pat: z5.string().optional()
7925
+ github: z9.object({
7926
+ host: z9.string().optional(),
7927
+ pat: z9.string().optional()
6784
7928
  }).optional()
6785
7929
  }).optional()
6786
7930
  });
@@ -6820,12 +7964,12 @@ function mountSecretsApi(app, opts) {
6820
7964
  }
6821
7965
 
6822
7966
  // src/console/api/tracker.ts
6823
- import { z as z6 } from "zod";
6824
- var SearchQuery = z6.object({ q: z6.string().max(500) });
6825
- var ImportBody = z6.object({
6826
- issueId: z6.string().min(1),
6827
- feature: z6.string().min(1).max(80),
6828
- withAttachments: z6.boolean().optional()
7967
+ import { z as z10 } from "zod";
7968
+ var SearchQuery = z10.object({ q: z10.string().max(500) });
7969
+ var ImportBody = z10.object({
7970
+ issueId: z10.string().min(1),
7971
+ feature: z10.string().min(1).max(80),
7972
+ withAttachments: z10.boolean().optional()
6829
7973
  });
6830
7974
  function mountTrackerApi(app, opts) {
6831
7975
  app.get("/api/tracker/search", async (c) => {
@@ -6833,18 +7977,18 @@ function mountTrackerApi(app, opts) {
6833
7977
  if (!q.success) return c.json({ error: "invalid_query" }, 400);
6834
7978
  const cfg = loadConfig(opts.paths.configFile);
6835
7979
  const secrets = overlayTrackerEnv(loadSecrets(opts.paths.secretsFile));
6836
- const { client, error: error2 } = clientFor(cfg, secrets);
7980
+ const { client, error } = clientFor(cfg, secrets);
6837
7981
  if (!client) {
6838
- return c.json({ error: error2?.kind ?? "unsupported", detail: error2?.detail ?? "" }, 400);
7982
+ return c.json({ error: error?.kind ?? "unsupported", detail: error?.detail ?? "" }, 400);
6839
7983
  }
6840
7984
  try {
6841
7985
  const results = await client.searchIssues(q.data.q);
6842
7986
  return c.json({ ok: true, results });
6843
- } catch (err) {
6844
- if (err instanceof TrackerError) {
6845
- return c.json({ ok: false, kind: err.kind, status: err.statusCode, detail: err.message }, 200);
7987
+ } catch (err2) {
7988
+ if (err2 instanceof TrackerError) {
7989
+ return c.json({ ok: false, kind: err2.kind, status: err2.statusCode, detail: err2.message }, 200);
6846
7990
  }
6847
- return c.json({ ok: false, detail: err.message }, 200);
7991
+ return c.json({ ok: false, detail: err2.message }, 200);
6848
7992
  }
6849
7993
  });
6850
7994
  app.post("/api/tracker/import", async (c) => {
@@ -6852,9 +7996,9 @@ function mountTrackerApi(app, opts) {
6852
7996
  if (!body.success) return c.json({ error: "invalid_body", issues: body.error.issues }, 400);
6853
7997
  const cfg = loadConfig(opts.paths.configFile);
6854
7998
  const secrets = overlayTrackerEnv(loadSecrets(opts.paths.secretsFile));
6855
- const { client, error: error2 } = clientFor(cfg, secrets);
7999
+ const { client, error } = clientFor(cfg, secrets);
6856
8000
  if (!client) {
6857
- return c.json({ error: error2?.kind ?? "unsupported", detail: error2?.detail ?? "" }, 400);
8001
+ return c.json({ error: error?.kind ?? "unsupported", detail: error?.detail ?? "" }, 400);
6858
8002
  }
6859
8003
  try {
6860
8004
  const issue = await client.fetchIssue(body.data.issueId);
@@ -6867,9 +8011,9 @@ function mountTrackerApi(app, opts) {
6867
8011
  config: cfg
6868
8012
  });
6869
8013
  return c.json(created, 201);
6870
- } catch (err) {
6871
- if (err instanceof TrackerError) return c.json({ error: err.kind, detail: err.message }, 502);
6872
- return c.json({ error: "fetch_failed", detail: err.message }, 500);
8014
+ } catch (err2) {
8015
+ if (err2 instanceof TrackerError) return c.json({ error: err2.kind, detail: err2.message }, 502);
8016
+ return c.json({ error: "fetch_failed", detail: err2.message }, 500);
6873
8017
  }
6874
8018
  });
6875
8019
  }
@@ -6966,17 +8110,17 @@ function countStories(stories) {
6966
8110
  }
6967
8111
 
6968
8112
  // src/console/api/projects.ts
6969
- import fs35 from "fs/promises";
6970
- import path29 from "path";
8113
+ import fs40 from "fs/promises";
8114
+ import path33 from "path";
6971
8115
  import os from "os";
6972
8116
  var MAX = 10;
6973
8117
  function recentFilePath() {
6974
- return path29.join(os.homedir(), ".squad", "recent-projects.json");
8118
+ return path33.join(os.homedir(), ".squad", "recent-projects.json");
6975
8119
  }
6976
8120
  async function loadRecent() {
6977
8121
  const RECENT_FILE = recentFilePath();
6978
8122
  try {
6979
- const raw = await fs35.readFile(RECENT_FILE, "utf8");
8123
+ const raw = await fs40.readFile(RECENT_FILE, "utf8");
6980
8124
  const parsed = JSON.parse(raw);
6981
8125
  return Array.isArray(parsed) ? parsed.slice(0, MAX) : [];
6982
8126
  } catch {
@@ -6985,8 +8129,8 @@ async function loadRecent() {
6985
8129
  }
6986
8130
  async function saveRecent(list) {
6987
8131
  const RECENT_FILE = recentFilePath();
6988
- await fs35.mkdir(path29.dirname(RECENT_FILE), { recursive: true });
6989
- await fs35.writeFile(RECENT_FILE, JSON.stringify(list.slice(0, MAX), null, 2) + "\n", "utf8");
8132
+ await fs40.mkdir(path33.dirname(RECENT_FILE), { recursive: true });
8133
+ await fs40.writeFile(RECENT_FILE, JSON.stringify(list.slice(0, MAX), null, 2) + "\n", "utf8");
6990
8134
  }
6991
8135
  function mountProjectsApi(app, opts) {
6992
8136
  app.get("/api/projects/recent", async (c) => c.json(await loadRecent()));
@@ -7015,8 +8159,8 @@ function mountApi(app, opts) {
7015
8159
  }
7016
8160
 
7017
8161
  // src/console/static-assets.ts
7018
- import fs36 from "fs";
7019
- import path30 from "path";
8162
+ import fs41 from "fs";
8163
+ import path34 from "path";
7020
8164
  import { fileURLToPath as fileURLToPath2 } from "url";
7021
8165
  import { Readable } from "stream";
7022
8166
  function pathnameForStatic(c) {
@@ -7046,18 +8190,18 @@ function mountStaticAssets(app, _opts) {
7046
8190
  }
7047
8191
  if (c.req.path.startsWith("/api/") || c.req.path.startsWith("/healthz")) return c.notFound();
7048
8192
  if (!distDir) return c.text("UI not built. Run `pnpm -C console-ui build`.", 404);
7049
- const indexHtml = path30.join(distDir, "index.html");
7050
- if (!fs36.existsSync(indexHtml)) return c.text("UI not built", 404);
7051
- return c.html(fs36.readFileSync(indexHtml, "utf8"), 200, { "cache-control": "no-store" });
8193
+ const indexHtml = path34.join(distDir, "index.html");
8194
+ if (!fs41.existsSync(indexHtml)) return c.text("UI not built", 404);
8195
+ return c.html(fs41.readFileSync(indexHtml, "utf8"), 200, { "cache-control": "no-store" });
7052
8196
  });
7053
8197
  }
7054
8198
  function assetResponse(c, distDir, pathname) {
7055
8199
  if (!distDir) return c.text("UI not built", 404);
7056
8200
  const rel = pathname.replace(/^\//, "");
7057
- const abs = path30.normalize(path30.join(distDir, rel));
8201
+ const abs = path34.normalize(path34.join(distDir, rel));
7058
8202
  if (!isInside2(abs, distDir)) return c.text("forbidden", 403);
7059
- if (!fs36.existsSync(abs) || !fs36.statSync(abs).isFile()) return c.text("not found", 404);
7060
- const body = Readable.toWeb(fs36.createReadStream(abs));
8203
+ if (!fs41.existsSync(abs) || !fs41.statSync(abs).isFile()) return c.text("not found", 404);
8204
+ const body = Readable.toWeb(fs41.createReadStream(abs));
7061
8205
  return new Response(body, {
7062
8206
  headers: {
7063
8207
  "content-type": contentTypeFor(abs),
@@ -7067,9 +8211,9 @@ function assetResponse(c, distDir, pathname) {
7067
8211
  }
7068
8212
  function serveFile(c, distDir, name, ct) {
7069
8213
  if (!distDir) return c.text("UI not built", 404);
7070
- const abs = path30.join(distDir, name);
7071
- if (!fs36.existsSync(abs)) return c.text("not found", 404);
7072
- return new Response(fs36.readFileSync(abs), {
8214
+ const abs = path34.join(distDir, name);
8215
+ if (!fs41.existsSync(abs)) return c.text("not found", 404);
8216
+ return new Response(fs41.readFileSync(abs), {
7073
8217
  headers: { "content-type": ct, "cache-control": "public, max-age=86400" }
7074
8218
  });
7075
8219
  }
@@ -7084,13 +8228,13 @@ function contentTypeFor(p) {
7084
8228
  return "application/octet-stream";
7085
8229
  }
7086
8230
  function isInside2(abs, parent) {
7087
- const a = path30.resolve(abs) + path30.sep;
7088
- const p = path30.resolve(parent) + path30.sep;
8231
+ const a = path34.resolve(abs) + path34.sep;
8232
+ const p = path34.resolve(parent) + path34.sep;
7089
8233
  return a.startsWith(p);
7090
8234
  }
7091
8235
  function resolveConsoleUiDist() {
7092
8236
  const fromEnv = process.env.SQUAD_CONSOLE_UI_DIST;
7093
- if (fromEnv && fs36.existsSync(path30.join(fromEnv, "index.html"))) return fromEnv;
8237
+ if (fromEnv && fs41.existsSync(path34.join(fromEnv, "index.html"))) return fromEnv;
7094
8238
  const candidates = [
7095
8239
  fileURLToPath2(new URL("./console-ui/", import.meta.url)),
7096
8240
  // dist/cli.js → dist/console-ui/
@@ -7102,7 +8246,7 @@ function resolveConsoleUiDist() {
7102
8246
  // src/console/*.ts → project/console-ui/dist
7103
8247
  ];
7104
8248
  for (const c of candidates) {
7105
- if (fs36.existsSync(path30.join(c, "index.html"))) return c;
8249
+ if (fs41.existsSync(path34.join(c, "index.html"))) return c;
7106
8250
  }
7107
8251
  return null;
7108
8252
  }
@@ -7178,13 +8322,13 @@ async function startConsoleServer(opts) {
7178
8322
  return bindPort;
7179
8323
  })();
7180
8324
  const shutdown = (onClose) => {
7181
- nodeServer.close((err) => {
7182
- onClose(err);
8325
+ nodeServer.close((err2) => {
8326
+ onClose(err2);
7183
8327
  });
7184
8328
  };
7185
8329
  const stop = () => {
7186
- shutdown((err) => {
7187
- if (err) warning(`server close: ${err.message}`);
8330
+ shutdown((err2) => {
8331
+ if (err2) warning(`server close: ${err2.message}`);
7188
8332
  resolveDone();
7189
8333
  });
7190
8334
  };
@@ -7194,9 +8338,9 @@ async function startConsoleServer(opts) {
7194
8338
  port: actualPort,
7195
8339
  done,
7196
8340
  close: () => new Promise((resolve2, reject) => {
7197
- shutdown((err) => {
7198
- if (err) {
7199
- reject(err);
8341
+ shutdown((err2) => {
8342
+ if (err2) {
8343
+ reject(err2);
7200
8344
  return;
7201
8345
  }
7202
8346
  resolve2();
@@ -7284,18 +8428,18 @@ async function runConsole(opts) {
7284
8428
  }
7285
8429
 
7286
8430
  // src/commands/rm/story.ts
7287
- import fs38 from "fs";
7288
- import path32 from "path";
8431
+ import fs43 from "fs";
8432
+ import path36 from "path";
7289
8433
  import { confirm as confirm10 } from "@inquirer/prompts";
7290
8434
 
7291
8435
  // src/commands/rm/feature.ts
7292
- import fs37 from "fs";
7293
- import path31 from "path";
8436
+ import fs42 from "fs";
8437
+ import path35 from "path";
7294
8438
  import { confirm as confirm9 } from "@inquirer/prompts";
7295
8439
  function countStoryDirs(storiesDir, feature) {
7296
- const d = path31.join(storiesDir, feature);
7297
- if (!fs37.existsSync(d)) return 0;
7298
- return fs37.readdirSync(d, { withFileTypes: true }).filter((x) => x.isDirectory()).length;
8440
+ const d = path35.join(storiesDir, feature);
8441
+ if (!fs42.existsSync(d)) return 0;
8442
+ return fs42.readdirSync(d, { withFileTypes: true }).filter((x) => x.isDirectory()).length;
7299
8443
  }
7300
8444
  async function runRmFeature(featureSlug, opts = {}) {
7301
8445
  const root = requireSquadRoot();
@@ -7329,17 +8473,17 @@ async function runRmFeature(featureSlug, opts = {}) {
7329
8473
  feature = await pickFeatureInteractive(paths);
7330
8474
  }
7331
8475
  }
7332
- const storyDir = path31.join(paths.storiesDir, feature);
7333
- const planDir = path31.join(paths.plansDir, feature);
8476
+ const storyDir = path35.join(paths.storiesDir, feature);
8477
+ const planDir = path35.join(paths.plansDir, feature);
7334
8478
  const nStories = countStoryDirs(paths.storiesDir, feature);
7335
8479
  const nPlans = listAllPlanListEntries(paths, feature).length;
7336
- const hasOverview = fs37.existsSync(path31.join(planDir, "00-overview.md")) ? 1 : 0;
8480
+ const hasOverview = fs42.existsSync(path35.join(planDir, "00-overview.md")) ? 1 : 0;
7337
8481
  const targets = [];
7338
- if (fs37.existsSync(storyDir)) {
7339
- targets.push({ label: path31.relative(root, storyDir), absPath: storyDir, kind: "dir" });
8482
+ if (fs42.existsSync(storyDir)) {
8483
+ targets.push({ label: path35.relative(root, storyDir), absPath: storyDir, kind: "dir" });
7340
8484
  }
7341
- if (fs37.existsSync(planDir)) {
7342
- targets.push({ label: path31.relative(root, planDir), absPath: planDir, kind: "dir" });
8485
+ if (fs42.existsSync(planDir)) {
8486
+ targets.push({ label: path35.relative(root, planDir), absPath: planDir, kind: "dir" });
7343
8487
  }
7344
8488
  divider("squad rm feature");
7345
8489
  info(`Feature: ${feature}`);
@@ -7354,7 +8498,7 @@ async function runRmFeature(featureSlug, opts = {}) {
7354
8498
  kv(t.kind, t.label, 6);
7355
8499
  }
7356
8500
  if (useTrash) {
7357
- info(`Items will move to ${path31.relative(process.cwd(), paths.trashDir)}/<timestamp>/`);
8501
+ info(`Items will move to ${path35.relative(process.cwd(), paths.trashDir)}/<timestamp>/`);
7358
8502
  } else if (!dryRun) {
7359
8503
  warning("This is a permanent delete. Pass --trash to move to .squad/.trash/ instead.");
7360
8504
  }
@@ -7387,7 +8531,7 @@ async function runRmFeature(featureSlug, opts = {}) {
7387
8531
  useTrash
7388
8532
  );
7389
8533
  if (result.trashed) {
7390
- success(`Feature removed. Items moved to ${path31.relative(root, result.trashed)}.`);
8534
+ success(`Feature removed. Items moved to ${path35.relative(root, result.trashed)}.`);
7391
8535
  } else {
7392
8536
  success("Feature removed (stories and plans directories).");
7393
8537
  }
@@ -7428,11 +8572,11 @@ async function runRmStory(storyPathOrId, opts = {}) {
7428
8572
  }
7429
8573
  }
7430
8574
  const targets = [
7431
- { label: path32.relative(root, story.storyDir), absPath: story.storyDir, kind: "dir" }
8575
+ { label: path36.relative(root, story.storyDir), absPath: story.storyDir, kind: "dir" }
7432
8576
  ];
7433
8577
  if (story.planFile) {
7434
- const planAbs = path32.join(paths.plansDir, story.feature, story.planFile);
7435
- targets.push({ label: path32.relative(root, planAbs), absPath: planAbs, kind: "file" });
8578
+ const planAbs = path36.join(paths.plansDir, story.feature, story.planFile);
8579
+ targets.push({ label: path36.relative(root, planAbs), absPath: planAbs, kind: "file" });
7436
8580
  }
7437
8581
  renderPreview("story", targets, {
7438
8582
  dryRun,
@@ -7457,14 +8601,14 @@ async function runRmStory(storyPathOrId, opts = {}) {
7457
8601
  }
7458
8602
  const result = deleteStoryRecord({ paths, story, trash: useTrash });
7459
8603
  if (result.trashed) {
7460
- success(`Removed (moved to ${path32.relative(root, result.trashed)}).`);
8604
+ success(`Removed (moved to ${path36.relative(root, result.trashed)}).`);
7461
8605
  } else {
7462
8606
  success("Removed story folder, plan file (if any), and overview row.");
7463
8607
  }
7464
- const featurePlanDir = path32.join(paths.plansDir, story.feature);
7465
- const overviewPath = path32.join(featurePlanDir, "00-overview.md");
7466
- if (fs38.existsSync(overviewPath)) {
7467
- const body = fs38.readFileSync(overviewPath, "utf8");
8608
+ const featurePlanDir = path36.join(paths.plansDir, story.feature);
8609
+ const overviewPath = path36.join(featurePlanDir, "00-overview.md");
8610
+ if (fs43.existsSync(overviewPath)) {
8611
+ const body = fs43.readFileSync(overviewPath, "utf8");
7468
8612
  const onlyShell = countOverviewDataRows(body) === 0;
7469
8613
  if (onlyShell && featureStoriesDirEmpty(paths, story.feature)) {
7470
8614
  if (canPrompt) {
@@ -7484,7 +8628,7 @@ async function runRmStory(storyPathOrId, opts = {}) {
7484
8628
  }
7485
8629
 
7486
8630
  // src/commands/rm/plan.ts
7487
- import path33 from "path";
8631
+ import path37 from "path";
7488
8632
  import { confirm as confirm11 } from "@inquirer/prompts";
7489
8633
  async function runRmPlan(planPathOrSequence, opts = {}) {
7490
8634
  const root = requireSquadRoot();
@@ -7520,7 +8664,7 @@ async function runRmPlan(planPathOrSequence, opts = {}) {
7520
8664
  }
7521
8665
  const storyRows = listStories(paths, { feature: plan.feature });
7522
8666
  const linked = storyRows.find((s) => s.planFile === plan.file);
7523
- const targets = [{ label: path33.relative(root, plan.abs), absPath: plan.abs, kind: "file" }];
8667
+ const targets = [{ label: path37.relative(root, plan.abs), absPath: plan.abs, kind: "file" }];
7524
8668
  renderPreview("plan", targets, {
7525
8669
  dryRun,
7526
8670
  trash: useTrash,
@@ -7543,8 +8687,8 @@ async function runRmPlan(planPathOrSequence, opts = {}) {
7543
8687
  }
7544
8688
  }
7545
8689
  trashOrDelete([plan.abs], paths.trashDir, useTrash);
7546
- removeOverviewRowForPlanFile(path33.join(paths.plansDir, plan.feature), plan.file);
7547
- const intakeHint = linked ? path33.relative(root, linked.intakePath) : `under .squad/stories/${plan.feature}/`;
8690
+ removeOverviewRowForPlanFile(path37.join(paths.plansDir, plan.feature), plan.file);
8691
+ const intakeHint = linked ? path37.relative(root, linked.intakePath) : `under .squad/stories/${plan.feature}/`;
7548
8692
  success(`Plan removed. Run \`squad new-plan ${intakeHint}\` to regenerate.`);
7549
8693
  }
7550
8694
 
@@ -7578,7 +8722,11 @@ program.command("new-story [feature-slug]").description("Scaffold a new story in
7578
8722
  "--no-tracker",
7579
8723
  "Skip tracker fetch AND drop the tracker-id requirement for this story"
7580
8724
  ).action(wrapArgs(runNewStory));
7581
- program.command("new-plan [intake-path]").description("Generate a plan (via direct API when configured, otherwise copy-paste)").option("--no-clipboard", "Do not copy the composed prompt to clipboard (copy-paste mode only)").option("--feature <slug>", "Filter picker by feature slug").option("--all", "Include already-planned intakes in the picker", false).option("-y, --yes", "Skip confirmation prompts", false).option("--api", "Force direct planner API (fails if not configured)", false).option("--copy", "Force copy-paste mode even when planner API is configured", false).action(wrapArgs(runNewPlan));
8725
+ program.command("new-plan [intake-path]").description("Generate a plan (via direct API when configured, otherwise copy-paste)").option("--no-clipboard", "Do not copy the composed prompt to clipboard (copy-paste mode only)").option("--feature <slug>", "Filter picker by feature slug").option("--all", "Include already-planned intakes in the picker", false).option("-y, --yes", "Skip confirmation prompts", false).option("--api", "Force direct planner API (fails if not configured)", false).option("--copy", "Force copy-paste mode even when planner API is configured", false).option("--no-scout", "Disable the scout stage (draft only)", false).option("--scout-model <id>", "Override scout model id (cheap tier default from PLANNER_MODEL_MAP)").option(
8726
+ "--max-scout-files <n>",
8727
+ "Maximum files the scout can pre-select (default from config or 12)",
8728
+ (v) => parseInt(v, 10)
8729
+ ).option("--no-validation", "Disable post-write validation", false).option("--strict-validation", "Write partial plan when validation finds issues", false).action(wrapArgs(runNewPlan));
7582
8730
  program.command("status").description("Show squad-kit workspace status").action(wrap(runStatus));
7583
8731
  program.command("doctor").description("Run a health check on the local .squad/ workspace and its external integrations").option("--fix", "Apply non-destructive repairs (gitignore, permissions, missing dirs)", false).option("--json", "Emit results as JSON to stdout (for scripting)", false).action(wrap(runDoctor));
7584
8732
  program.command("migrate").description("Apply one-shot structural migrations to bring .squad/ to the latest version").option("--dry-run", "Show what would change without touching the filesystem", false).option("-y, --yes", "Skip the confirmation prompt", false).action(wrap(runMigrate));
@@ -7608,24 +8756,24 @@ config.command("remove-credential <section>").description('Delete credentials fo
7608
8756
  (section, opts) => runConfigRemoveCredential(section, { yes: opts.yes })
7609
8757
  )
7610
8758
  );
7611
- program.parseAsync(process.argv).catch((err) => {
7612
- if (isSquadExit(err)) {
7613
- if (err.message.trim()) info(err.message);
7614
- process.exit(err.exitCode);
8759
+ program.parseAsync(process.argv).catch((err2) => {
8760
+ if (isSquadExit(err2)) {
8761
+ if (err2.message.trim()) info(err2.message);
8762
+ process.exit(err2.exitCode);
7615
8763
  }
7616
- renderError(err);
8764
+ renderError(err2);
7617
8765
  process.exit(1);
7618
8766
  });
7619
8767
  function wrap(fn) {
7620
8768
  return async (opts) => {
7621
8769
  try {
7622
8770
  await fn(opts);
7623
- } catch (err) {
7624
- if (isSquadExit(err)) {
7625
- if (err.message.trim()) info(err.message);
7626
- process.exit(err.exitCode);
8771
+ } catch (err2) {
8772
+ if (isSquadExit(err2)) {
8773
+ if (err2.message.trim()) info(err2.message);
8774
+ process.exit(err2.exitCode);
7627
8775
  }
7628
- renderError(err);
8776
+ renderError(err2);
7629
8777
  process.exit(1);
7630
8778
  }
7631
8779
  };
@@ -7634,12 +8782,12 @@ function wrapArgs(fn) {
7634
8782
  return async (...args) => {
7635
8783
  try {
7636
8784
  await fn(...args);
7637
- } catch (err) {
7638
- if (isSquadExit(err)) {
7639
- if (err.message.trim()) info(err.message);
7640
- process.exit(err.exitCode);
8785
+ } catch (err2) {
8786
+ if (isSquadExit(err2)) {
8787
+ if (err2.message.trim()) info(err2.message);
8788
+ process.exit(err2.exitCode);
7641
8789
  }
7642
- renderError(err);
8790
+ renderError(err2);
7643
8791
  process.exit(1);
7644
8792
  }
7645
8793
  };