squad-kit 0.8.0 → 0.9.1

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,357 @@ 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
+ const anthropicThinking = input5.provider === "anthropic" ? { anthropic: { thinking: { type: "enabled", budgetTokens: 1024 } } } : void 0;
4222
+ let attempt = 0;
4223
+ while (true) {
4224
+ try {
4225
+ const { object, usage, providerMetadata } = await generateObject({
4226
+ model: input5.model,
4227
+ system: input5.systemPrompt,
4228
+ prompt: input5.userPrompt,
4229
+ schema: ScoutOutputSchema,
4230
+ maxTokens,
4231
+ abortSignal: input5.abort,
4232
+ ...anthropicThinking ? { providerOptions: anthropicThinking } : {}
4233
+ });
4234
+ const u = usageFromLanguageModelStep(usage, providerMetadata);
4235
+ input5.budget.recordUsage(u);
4236
+ return {
4237
+ output: object,
4238
+ usage: { inputTokens: u.inputTokens, outputTokens: u.outputTokens }
4239
+ };
4240
+ } catch (e) {
4241
+ if (NoObjectGeneratedError.isInstance(e)) {
4242
+ return null;
4243
+ }
4244
+ if (APICallError.isInstance(e) && attempt === 0) {
4245
+ const status = e.statusCode ?? 0;
4246
+ if (status === 429) {
4247
+ const ra = parseRetryAfterSec(e.responseHeaders ?? {});
4248
+ if (ra !== void 0 && ra <= 90) {
4249
+ await new Promise((r) => setTimeout(r, ra * 1e3));
4250
+ attempt += 1;
4251
+ continue;
4252
+ }
4253
+ }
4254
+ }
4255
+ throw e;
4256
+ }
4257
+ }
4258
+ }
4259
+
4260
+ // src/planner/validation.ts
4261
+ import fs26 from "fs";
4262
+ import path18 from "path";
4263
+ 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;
4264
+ function summariseIssuesByKind(issues) {
4265
+ const out = {};
4266
+ for (const i of issues) {
4267
+ out[i.kind] = (out[i.kind] ?? 0) + 1;
4268
+ }
4269
+ return out;
4270
+ }
4271
+ function extractBacktickPaths(planText) {
4272
+ const claims = [];
4273
+ const re = /`([^`\n]+)`/g;
4274
+ let m;
4275
+ while ((m = re.exec(planText)) !== null) {
4276
+ const inner = m[1].trim();
4277
+ if (!inner.includes("/") || inner.startsWith("http")) continue;
4278
+ if (inner.includes("story ") || inner.includes("Story ")) continue;
4279
+ const seg = inner.split(/[\s:]/)[0];
4280
+ if (!seg || !CODE_EXT.test(seg)) continue;
4281
+ claims.push({ path: seg.replace(/^\.\//, ""), isCreate: false, excerpt: m[0] });
4282
+ }
4283
+ return claims;
4284
+ }
4285
+ function extractFileHeadingPaths(planText) {
4286
+ const claims = [];
4287
+ const fileRe = /\*\*\s*(?:File|Create file)\s*:\s*`([^`\n]+)`\s*\*\*/gi;
4288
+ let m;
4289
+ while ((m = fileRe.exec(planText)) !== null) {
4290
+ const p = m[1].trim().replace(/^\.\//, "");
4291
+ const isCreate = /create file/i.test(m[0]);
4292
+ claims.push({ path: p, isCreate, excerpt: m[0].slice(0, 120) });
4293
+ }
4294
+ return claims;
4295
+ }
4296
+ function mergePathClaims(planText) {
4297
+ const map = /* @__PURE__ */ new Map();
4298
+ for (const c of [...extractFileHeadingPaths(planText), ...extractBacktickPaths(planText)]) {
4299
+ const key = `${c.isCreate}:${c.path}`;
4300
+ if (!map.has(key)) map.set(key, c);
4301
+ }
4302
+ return [...map.values()];
4303
+ }
4304
+ var LINE_RANGE_RE = /(?:~?\s*lines?\s*|lines\s+)(\d+)\s*[–-]\s*(\d+)/gi;
4305
+ var LINE_SINGLE_WORD_RE = /\bline\s+(\d+)\b|\(L(\d+)\)/gi;
4306
+ var PATH_COLON_LINE_RE = /`([^`\n]+\.[a-z0-9]{1,6})`\s*:\s*(\d+)\b/gi;
4307
+ function pathNear(planText, idx) {
4308
+ const before = planText.slice(Math.max(0, idx - 200), idx);
4309
+ const pathMatch = before.match(/`([^`\n]+\.[a-z0-9]{1,6})`/i);
4310
+ const filePath = pathMatch?.[1]?.trim().replace(/^\.\//, "");
4311
+ return filePath;
4312
+ }
4313
+ function trigrams(s) {
4314
+ const x = s.toLowerCase();
4315
+ const g = /* @__PURE__ */ new Set();
4316
+ for (let i = 0; i + 3 <= x.length; i++) {
4317
+ g.add(x.slice(i, i + 3));
4318
+ }
4319
+ return g;
4320
+ }
4321
+ function trigramSimilarity(a, b) {
4322
+ const A = trigrams(a);
4323
+ const B = trigrams(b);
4324
+ if (A.size === 0 || B.size === 0) return 0;
4325
+ let inter = 0;
4326
+ for (const t of A) {
4327
+ if (B.has(t)) inter += 1;
4328
+ }
4329
+ return inter / Math.sqrt(A.size * B.size);
4330
+ }
4331
+ function suggestPaths(root, missingRel, max = 3) {
4332
+ const targetBase = path18.basename(missingRel).toLowerCase();
4333
+ const ig = buildRepoIgnore(root);
4334
+ const candidates = [];
4335
+ const walk2 = (dir) => {
4336
+ if (candidates.length >= 2e3) return;
4337
+ let ents;
4338
+ try {
4339
+ ents = fs26.readdirSync(dir, { withFileTypes: true });
4340
+ } catch {
4341
+ return;
4342
+ }
4343
+ for (const ent of ents) {
4344
+ if (candidates.length >= 2e3) return;
4345
+ const abs = path18.join(dir, ent.name);
4346
+ const rel = path18.relative(root, abs).split(path18.sep).join("/");
4347
+ if (!rel || ig.ignores(rel) || ent.isDirectory() && ig.ignores(`${rel}/`)) continue;
4348
+ if (ent.isDirectory()) walk2(abs);
4349
+ else if (ent.isFile()) candidates.push(rel);
4350
+ }
4351
+ };
4352
+ walk2(root);
4353
+ const rows = [];
4354
+ for (const rel of candidates) {
4355
+ const base = path18.basename(rel).toLowerCase();
4356
+ let score = 0;
4357
+ if (base === targetBase) score = 1e3;
4358
+ else if (base.startsWith(targetBase) || targetBase.startsWith(base)) score = 500;
4359
+ else if (base.includes(targetBase) || targetBase.includes(base)) score = 200;
4360
+ score += trigramSimilarity(targetBase, base) * 50;
4361
+ if (score > 0) rows.push({ rel, score });
4362
+ }
4363
+ rows.sort((a, b) => b.score - a.score || a.rel.localeCompare(b.rel));
4364
+ const uniq = [];
4365
+ for (const r of rows) {
4366
+ if (r.rel === missingRel.replace(/^\.\//, "")) continue;
4367
+ if (!uniq.includes(r.rel)) uniq.push(r.rel);
4368
+ if (uniq.length >= max) break;
4369
+ }
4370
+ return uniq;
4371
+ }
4372
+ 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;
4373
+ function collectSymbols(src, maxLines = 200) {
4374
+ const head = src.split(/\r?\n/).slice(0, maxLines).join("\n");
4375
+ const syms = /* @__PURE__ */ new Set();
4376
+ SYMBOL_DEF_RE.lastIndex = 0;
4377
+ let m;
4378
+ while ((m = SYMBOL_DEF_RE.exec(head)) !== null) {
4379
+ syms.add(m[1]);
4380
+ }
4381
+ return [...syms];
4382
+ }
4383
+ function leadingCharScore(sym, missing) {
4384
+ let i = 0;
4385
+ const a = sym.toLowerCase();
4386
+ const b = missing.toLowerCase();
4387
+ while (i < a.length && i < b.length && a[i] === b[i]) i++;
4388
+ return i * 10 + trigramSimilarity(sym, missing) * 30;
4389
+ }
4390
+ function suggestSymbols(fileText, missingSym, max = 3) {
4391
+ const defs = collectSymbols(fileText);
4392
+ 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));
4393
+ const out = [];
4394
+ for (const r of ranked) {
4395
+ if (!out.includes(r.s)) out.push(r.s);
4396
+ if (out.length >= max) break;
4397
+ }
4398
+ return out;
4399
+ }
4400
+ function parseMetadataLine(planText) {
4401
+ const lines = planText.split(/\r?\n/);
4402
+ for (let i = 0; i < Math.min(5, lines.length); i++) {
4403
+ const line2 = (lines[i] ?? "").trim();
4404
+ if (!line2.startsWith("<!--") || !line2.includes("squad-kit:")) continue;
4405
+ if (!line2.endsWith("-->")) {
4406
+ return {
4407
+ severity: "warning",
4408
+ kind: "malformed_metadata",
4409
+ detail: "Metadata comment is not a single-line <!-- squad-kit: \u2026 -->",
4410
+ excerpt: line2.slice(0, 120)
4411
+ };
4412
+ }
4413
+ const inner = line2.replace(/^<!--\s*squad-kit:\s*/i, "").replace(/\s*-->$/, "");
4414
+ if (!inner.trim()) {
4415
+ return {
4416
+ severity: "warning",
4417
+ kind: "malformed_metadata",
4418
+ detail: "Empty squad-kit metadata body",
4419
+ excerpt: line2
4420
+ };
4421
+ }
4422
+ return void 0;
4423
+ }
4424
+ return void 0;
4425
+ }
4426
+ function validatePlan(input5) {
4427
+ const issues = [];
4428
+ const metaIssue = parseMetadataLine(input5.planText);
4429
+ if (metaIssue) issues.push(metaIssue);
4430
+ const cache = /* @__PURE__ */ new Map();
4431
+ const readCached = (rel) => {
4432
+ if (cache.has(rel)) return cache.get(rel);
4433
+ const resolved = path18.resolve(input5.root, rel);
4434
+ const rc = path18.relative(input5.root, resolved);
4435
+ if (rc.startsWith("..") || path18.isAbsolute(rc)) return void 0;
4436
+ if (!fs26.existsSync(resolved) || !fs26.statSync(resolved).isFile()) return void 0;
4437
+ try {
4438
+ const buf = fs26.readFileSync(resolved);
4439
+ const isBinary = buf.includes(0);
4440
+ const text = isBinary ? "" : buf.toString("utf8");
4441
+ const lines = isBinary ? 0 : text.split(/\r?\n/).length;
4442
+ const entry = { text, lines, isBinary };
4443
+ cache.set(rel, entry);
4444
+ return entry;
4445
+ } catch {
4446
+ return void 0;
4447
+ }
4448
+ };
4449
+ const pathClaims = mergePathClaims(input5.planText);
4450
+ for (const c of pathClaims) {
4451
+ if (c.isCreate) continue;
4452
+ const resolved = path18.resolve(input5.root, c.path);
4453
+ const rel = path18.relative(input5.root, resolved);
4454
+ if (rel.startsWith("..") || path18.isAbsolute(rel)) continue;
4455
+ if (!fs26.existsSync(resolved)) {
4456
+ const suggestions = suggestPaths(input5.root, c.path);
4457
+ issues.push({
4458
+ severity: "warning",
4459
+ kind: "missing_path",
4460
+ path: c.path,
4461
+ detail: "Path cited in plan does not exist in the workspace" + (suggestions.length ? ` \u2014 did you mean: ${suggestions.join(", ")}?` : ""),
4462
+ excerpt: c.excerpt
4463
+ });
4464
+ }
4465
+ }
4466
+ const rangeText = input5.planText;
4467
+ LINE_RANGE_RE.lastIndex = 0;
4468
+ let rm2;
4469
+ while ((rm2 = LINE_RANGE_RE.exec(rangeText)) !== null) {
4470
+ const lo = parseInt(rm2[1], 10);
4471
+ const hi = parseInt(rm2[2], 10);
4472
+ if (!Number.isFinite(lo) || !Number.isFinite(hi)) continue;
4473
+ const filePath = pathNear(rangeText, rm2.index);
4474
+ if (!filePath) continue;
4475
+ const cached = readCached(filePath);
4476
+ const lines = cached?.isBinary ? void 0 : cached?.lines;
4477
+ if (lines !== void 0 && hi > lines) {
4478
+ issues.push({
4479
+ severity: "warning",
4480
+ kind: "line_range_too_large",
4481
+ path: filePath,
4482
+ detail: `Range ends at line ${hi} but file has ${lines} lines`,
4483
+ excerpt: rm2[0].slice(0, 80)
4484
+ });
4485
+ }
4486
+ }
4487
+ LINE_SINGLE_WORD_RE.lastIndex = 0;
4488
+ while ((rm2 = LINE_SINGLE_WORD_RE.exec(rangeText)) !== null) {
4489
+ const n = parseInt(rm2[1] || rm2[2] || "", 10);
4490
+ if (!Number.isFinite(n)) continue;
4491
+ const filePath = pathNear(rangeText, rm2.index);
4492
+ if (!filePath) continue;
4493
+ const cached = readCached(filePath);
4494
+ const lines = cached?.isBinary ? void 0 : cached?.lines;
4495
+ if (lines !== void 0 && n > lines) {
4496
+ issues.push({
4497
+ severity: "warning",
4498
+ kind: "line_range_too_large",
4499
+ path: filePath,
4500
+ detail: `Range ends at line ${n} but file has ${lines} lines`,
4501
+ excerpt: rm2[0].slice(0, 80)
4502
+ });
4503
+ }
4504
+ }
4505
+ PATH_COLON_LINE_RE.lastIndex = 0;
4506
+ while ((rm2 = PATH_COLON_LINE_RE.exec(rangeText)) !== null) {
4507
+ const fp = rm2[1].trim().replace(/^\.\//, "");
4508
+ const n = parseInt(rm2[2], 10);
4509
+ if (!Number.isFinite(n)) continue;
4510
+ const cached = readCached(fp);
4511
+ const lines = cached?.isBinary ? void 0 : cached?.lines;
4512
+ if (lines !== void 0 && n > lines) {
4513
+ issues.push({
4514
+ severity: "warning",
4515
+ kind: "line_range_too_large",
4516
+ path: fp,
4517
+ detail: `Range ends at line ${n} but file has ${lines} lines`,
4518
+ excerpt: rm2[0].slice(0, 80)
4519
+ });
4520
+ }
4521
+ }
4522
+ const textByLine = input5.planText.split(/\r?\n/);
4523
+ for (let li = 0; li < textByLine.length; li++) {
4524
+ const line2 = textByLine[li] ?? "";
4525
+ const near = [textByLine[li - 1] ?? "", line2, textByLine[li + 1] ?? ""].join("\n");
4526
+ const pathNearCell = near.match(/`([^`\n/]+\/[^`\n]+?\.[a-z0-9]{1,6})`/i);
4527
+ if (!pathNearCell) continue;
4528
+ const fp = pathNearCell[1].replace(/^\.\//, "");
4529
+ const cached = readCached(fp);
4530
+ if (!cached || cached.isBinary) continue;
4531
+ const symRe = /`([A-Za-z_][A-Za-z0-9_]{2,50})`/g;
4532
+ let sm;
4533
+ while ((sm = symRe.exec(line2)) !== null) {
4534
+ const sym = sm[1];
4535
+ if (["true", "false", "null", "undefined", "string", "number", "boolean", "void", "never"].includes(
4536
+ sym
4537
+ )) {
4538
+ continue;
4539
+ }
4540
+ if (!cached.text.includes(sym)) {
4541
+ const suggestions = suggestSymbols(cached.text, sym);
4542
+ issues.push({
4543
+ severity: "warning",
4544
+ kind: "symbol_not_found",
4545
+ path: fp,
4546
+ detail: `Symbol \`${sym}\` not found as substring in file` + (suggestions.length ? ` \u2014 did you mean: ${suggestions.map((s) => `\`${s}\``).join(", ")}?` : ""),
4547
+ excerpt: sm[0]
4548
+ });
4549
+ }
4550
+ }
4551
+ }
4552
+ return issues;
4553
+ }
4554
+
3876
4555
  // src/planner/loop.ts
3877
4556
  var MAX_RATE_LIMIT_RETRY_SEC = 90;
3878
4557
  var defaultSleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
@@ -3910,8 +4589,147 @@ function buildPlannerRunStats(budget, turns, runStartedAt) {
3910
4589
  durationMs: Date.now() - runStartedAt
3911
4590
  };
3912
4591
  }
3913
- function readBudgetishError(result) {
3914
- return result.isError && (/budget/i.test(result.content) || /max file reads/i.test(result.content));
4592
+ function buildScoutedContextSection(root, budget, selectedFiles, bus, runId, readRanges) {
4593
+ const normalizeRel = (p) => p.replace(/^\.\//, "");
4594
+ const rangeByPath = /* @__PURE__ */ new Map();
4595
+ for (const rr of readRanges ?? []) {
4596
+ rangeByPath.set(normalizeRel(rr.path), rr);
4597
+ }
4598
+ const ordered = [];
4599
+ const seenPath = /* @__PURE__ */ new Set();
4600
+ for (const f of selectedFiles) {
4601
+ const c = normalizeRel(f);
4602
+ if (!seenPath.has(c)) {
4603
+ seenPath.add(c);
4604
+ ordered.push(c);
4605
+ }
4606
+ }
4607
+ for (const p of rangeByPath.keys()) {
4608
+ if (!seenPath.has(p)) {
4609
+ seenPath.add(p);
4610
+ ordered.push(p);
4611
+ }
4612
+ }
4613
+ const blocks = ["\n## Scouted context (already loaded)\n"];
4614
+ for (const cleaned of ordered) {
4615
+ const rr = rangeByPath.get(cleaned);
4616
+ if (rr) {
4617
+ const result = readFileTool(root, budget, { path: cleaned, offset: rr.offset, limit: rr.limit });
4618
+ const hi = rr.offset + rr.limit - 1;
4619
+ if (result.isError) {
4620
+ blocks.push(
4621
+ `
4622
+ ### \`${cleaned}\` (lines ${rr.offset}\u2013${hi})
4623
+
4624
+ _${result.content}_
4625
+ `
4626
+ );
4627
+ } else {
4628
+ blocks.push(
4629
+ `
4630
+ ### \`${cleaned}\` (lines ${rr.offset}\u2013${hi})
4631
+
4632
+ \`\`\`
4633
+ ${result.content}
4634
+ \`\`\`
4635
+ `
4636
+ );
4637
+ }
4638
+ continue;
4639
+ }
4640
+ const resolved = path19.resolve(root, cleaned);
4641
+ const rc = path19.relative(root, resolved);
4642
+ if (rc.startsWith("..") || path19.isAbsolute(rc)) {
4643
+ bus.emit({
4644
+ kind: "validation_issue",
4645
+ runId,
4646
+ severity: "warning",
4647
+ issueKind: "missing_path",
4648
+ path: cleaned,
4649
+ detail: "Scout-selected path escapes the project root"
4650
+ });
4651
+ continue;
4652
+ }
4653
+ if (!fs27.existsSync(resolved)) {
4654
+ bus.emit({
4655
+ kind: "validation_issue",
4656
+ runId,
4657
+ severity: "warning",
4658
+ issueKind: "missing_path",
4659
+ path: cleaned,
4660
+ detail: "Scout selected this path but it does not exist on disk"
4661
+ });
4662
+ continue;
4663
+ }
4664
+ const st = fs27.statSync(resolved);
4665
+ if (!st.isFile()) continue;
4666
+ const n = Math.min(st.size, MAX_BYTES_PER_FULL_READ);
4667
+ const cap = budget.canRead(n);
4668
+ if (!cap.ok) {
4669
+ blocks.push(`
4670
+ ### \`${cleaned}\`
4671
+
4672
+ _(skipped: ${cap.reason})_
4673
+ `);
4674
+ continue;
4675
+ }
4676
+ let buf;
4677
+ try {
4678
+ buf = fs27.readFileSync(resolved);
4679
+ } catch {
4680
+ continue;
4681
+ }
4682
+ if (buf.length > MAX_BYTES_PER_FULL_READ) {
4683
+ const head = buf.subarray(0, MAX_BYTES_PER_FULL_READ);
4684
+ if (looksBinary(head)) {
4685
+ blocks.push(`
4686
+ ### \`${cleaned}\`
4687
+
4688
+ _(binary file skipped)_
4689
+ `);
4690
+ continue;
4691
+ }
4692
+ budget.recordRead(head.length);
4693
+ blocks.push(
4694
+ `
4695
+ ### \`${cleaned}\` (truncated: first ${MAX_BYTES_PER_FULL_READ}-byte head; full size ${buf.length} bytes)
4696
+
4697
+ \`\`\`
4698
+ ${head.toString("utf8")}
4699
+ \`\`\`
4700
+ `
4701
+ );
4702
+ continue;
4703
+ }
4704
+ if (looksBinary(buf)) {
4705
+ blocks.push(`
4706
+ ### \`${cleaned}\`
4707
+
4708
+ _(binary file skipped)_
4709
+ `);
4710
+ continue;
4711
+ }
4712
+ budget.recordRead(buf.length);
4713
+ blocks.push(`
4714
+ ### \`${cleaned}\`
4715
+
4716
+ \`\`\`
4717
+ ${buf.toString("utf8")}
4718
+ \`\`\`
4719
+ `);
4720
+ }
4721
+ return blocks.join("");
4722
+ }
4723
+ function scoutStageStats(usage, durationMs) {
4724
+ return {
4725
+ turns: 0,
4726
+ inputTokens: usage.inputTokens,
4727
+ outputTokens: usage.outputTokens,
4728
+ cacheCreationTokens: 0,
4729
+ cacheReadTokens: 0,
4730
+ cacheHitRatio: 0,
4731
+ durationMs
4732
+ };
3915
4733
  }
3916
4734
  function extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut) {
3917
4735
  input5.budget.extendSession();
@@ -3951,28 +4769,106 @@ function cancelledOutput(input5, bus, runId, accumulatedText, budgetExhausted, i
3951
4769
  userCancelled: true
3952
4770
  };
3953
4771
  }
3954
- async function runPlanner(input5) {
4772
+ function asToolInput(args) {
4773
+ if (args && typeof args === "object" && !Array.isArray(args)) return args;
4774
+ if (typeof args === "string") {
4775
+ try {
4776
+ return JSON.parse(args);
4777
+ } catch {
4778
+ return {};
4779
+ }
4780
+ }
4781
+ return {};
4782
+ }
4783
+ function normalizeToolExecutionResult(result) {
4784
+ if (typeof result === "string") {
4785
+ try {
4786
+ const j = JSON.parse(result);
4787
+ if (j && typeof j === "object" && !Array.isArray(j) && "content" in j) {
4788
+ const o = j;
4789
+ return {
4790
+ content: typeof o.content === "string" ? o.content : JSON.stringify(o.content ?? ""),
4791
+ isError: o.isError === true
4792
+ };
4793
+ }
4794
+ } catch {
4795
+ }
4796
+ return { content: result, isError: false };
4797
+ }
4798
+ if (result && typeof result === "object" && !Array.isArray(result) && "content" in result) {
4799
+ const o = result;
4800
+ return {
4801
+ content: typeof o.content === "string" ? o.content : JSON.stringify(o.content ?? ""),
4802
+ isError: o.isError === true
4803
+ };
4804
+ }
4805
+ return { content: typeof result === "undefined" ? "" : JSON.stringify(result), isError: false };
4806
+ }
4807
+ function applyStepToTurns(turns, step2) {
4808
+ const callsById = /* @__PURE__ */ new Map();
4809
+ const toolCalls = step2.toolCalls.map((tc) => {
4810
+ callsById.set(tc.toolCallId, tc.toolName);
4811
+ return { id: tc.toolCallId, name: tc.toolName, input: asToolInput(tc.args) };
4812
+ });
4813
+ turns.push({
4814
+ role: "assistant",
4815
+ ...step2.text ? { text: step2.text } : {},
4816
+ ...toolCalls.length ? { toolCalls } : {}
4817
+ });
4818
+ if (step2.toolResults.length) {
4819
+ const toolResults = step2.toolResults.map((tr) => {
4820
+ const norm = normalizeToolExecutionResult(tr.result);
4821
+ return {
4822
+ toolCallId: tr.toolCallId,
4823
+ name: callsById.get(tr.toolCallId) ?? "read_file",
4824
+ content: norm.content,
4825
+ ...norm.isError ? { isError: true } : {}
4826
+ };
4827
+ });
4828
+ turns.push({ role: "user", toolResults });
4829
+ }
4830
+ }
4831
+ function providerResponseFromApiError(provider, modelId, err2) {
4832
+ const body = err2.responseBody ?? "";
4833
+ const nf = detectModelNotFound(provider, modelId, err2.statusCode ?? 0, body);
4834
+ if (nf) {
4835
+ return {
4836
+ stopReason: "error",
4837
+ errorKind: "model_not_found",
4838
+ rawError: modelNotFoundMessage(nf)
4839
+ };
4840
+ }
4841
+ const rl = detectRateLimit(provider, err2.statusCode ?? 0, err2.responseHeaders ?? {}, body);
4842
+ if (rl) {
4843
+ return {
4844
+ stopReason: "error",
4845
+ errorKind: "rate_limit",
4846
+ retryAfterSec: rl.retryAfterSec,
4847
+ rawError: `${provider} ${err2.statusCode ?? "error"}: ${body.slice(0, 500)}`
4848
+ };
4849
+ }
4850
+ return {
4851
+ stopReason: "error",
4852
+ rawError: `${provider} ${err2.statusCode ?? "error"}: ${body.slice(0, 500)}`
4853
+ };
4854
+ }
4855
+ async function runDraftStage(input5) {
3955
4856
  const runStartedAt = Date.now();
3956
4857
  const bus = input5.events ?? new PlannerEventBus();
3957
4858
  const runId = input5.runId ?? newRunId();
3958
4859
  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
4860
  const baseMaxIter = input5.maxIterations ?? DEFAULT_PLANNER_MAX_ITERATIONS;
3967
4861
  const maxIter = { current: baseMaxIter };
3968
4862
  const baseMaxOut = input5.maxOutputTokens ?? DEFAULT_PLANNER_MAX_OUTPUT_TOKENS;
3969
4863
  const maxOut = { current: baseMaxOut };
3970
4864
  const turns = [{ role: "user", text: input5.userPrompt }];
4865
+ if (input5.scoutedSection?.trim()) {
4866
+ turns.push({ role: "user", text: composeScoutedUserMessage({ scoutedSection: input5.scoutedSection }) });
4867
+ }
3971
4868
  let accumulatedText = "";
3972
4869
  let iterations = 0;
3973
4870
  let budgetExhausted = false;
3974
4871
  let finishedNormally = false;
3975
- let prevRequestPrefix;
3976
4872
  const decide = input5.decideOnLimit;
3977
4873
  const sleepFn = input5.sleep ?? defaultSleep;
3978
4874
  const limitCtx = (kind) => ({
@@ -4043,126 +4939,169 @@ async function runPlanner(input5) {
4043
4939
  stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt)
4044
4940
  };
4045
4941
  }
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
4942
  if (iterations >= 2) {
4075
4943
  bus.emit({ kind: "turn_started", runId, turn: iterations });
4076
4944
  }
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,
4945
+ const tools = buildPlannerTools({
4946
+ root: input5.root,
4947
+ budget: input5.budget,
4948
+ enabled: {
4949
+ grep: input5.toolsEnabled?.grep ?? true,
4950
+ listDir: input5.toolsEnabled?.listDir ?? true,
4951
+ rangedRead: input5.toolsEnabled?.rangedRead ?? true
4952
+ },
4953
+ readHooks: {
4954
+ runId,
4955
+ turn: iterations,
4956
+ bus,
4957
+ onToolCall: input5.onToolCall,
4958
+ decideOnLimit: input5.decideOnLimit,
4959
+ getLimitCtx: () => limitCtx("file_or_context_reads"),
4960
+ extendSessionLimits: () => extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut),
4961
+ getAccumulatedText: () => accumulatedText,
4962
+ setBudgetExhausted: (v) => {
4963
+ budgetExhausted = v;
4964
+ }
4965
+ }
4966
+ });
4967
+ const messages = turnsToCoreMessages(input5.systemPrompt, turns, {
4084
4968
  cacheEnabled,
4085
- abort: input5.abort
4086
- };
4969
+ provider: input5.provider
4970
+ });
4087
4971
  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;
4972
+ let attempt = 0;
4973
+ let stepResult;
4110
4974
  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);
4975
+ let retriedRateLimit = false;
4976
+ const callStream = async () => {
4977
+ const { streamText } = await import("ai");
4978
+ const stream = streamText({
4979
+ model: input5.model,
4980
+ messages,
4981
+ tools,
4982
+ toolChoice: "auto",
4983
+ maxSteps: 1,
4984
+ maxRetries: 0,
4985
+ maxTokens: maxOut.current,
4986
+ abortSignal: input5.abort,
4987
+ // ai@4.x defaults `temperature: 0` (prepareCallSettings) and @ai-sdk/anthropic@1.x
4988
+ // always passes it to the wire unless extended thinking is on. Anthropic Opus 4.7+
4989
+ // rejects temperature entirely. Enabling thinking on Anthropic strips temperature
4990
+ // via the adapter and gives the planner reasoning budget for free.
4991
+ ...input5.provider === "anthropic" && {
4992
+ providerOptions: {
4993
+ anthropic: { thinking: { type: "enabled", budgetTokens: 2048 } }
4138
4994
  }
4139
- throw e;
4995
+ },
4996
+ onChunk: ({ chunk }) => {
4997
+ if (chunk.type === "text-delta") {
4998
+ accumulatedText += chunk.textDelta;
4999
+ input5.onAssistantText?.(chunk.textDelta);
5000
+ bus.emit({ kind: "assistant_text", runId, turn: iterations, delta: chunk.textDelta });
5001
+ }
5002
+ },
5003
+ onStepFinish: (step2) => {
5004
+ const u = usageFromLanguageModelStep(step2.usage, step2.providerMetadata);
5005
+ recordUsageAndEmit(bus, input5, runId, iterations, u);
5006
+ }
5007
+ });
5008
+ for await (const part of stream.fullStream) {
5009
+ const p = part;
5010
+ if (p.type === "error") {
5011
+ throw p.error;
4140
5012
  }
5013
+ }
5014
+ const steps = await stream.steps;
5015
+ const last = steps[steps.length - 1];
5016
+ if (!last) throw new Error("planner: empty model step");
5017
+ stepResult = {
5018
+ text: last.text,
5019
+ finishReason: last.finishReason,
5020
+ toolCalls: last.toolCalls.flatMap(
5021
+ (tc) => tc ? [
5022
+ {
5023
+ toolCallId: tc.toolCallId,
5024
+ toolName: tc.toolName,
5025
+ args: tc.args
5026
+ }
5027
+ ] : []
5028
+ ),
5029
+ toolResults: last.toolResults.flatMap(
5030
+ (tr) => tr ? [{ toolCallId: tr.toolCallId, result: tr.result }] : []
5031
+ ),
5032
+ usage: last.usage,
5033
+ providerMetadata: last.providerMetadata
5034
+ };
5035
+ };
5036
+ try {
5037
+ while (true) {
4141
5038
  try {
4142
- response = await input5.provider.send(request);
5039
+ await callStream();
5040
+ break;
4143
5041
  } catch (e) {
5042
+ if (PlannerUserCancelledError.is(e)) {
5043
+ return {
5044
+ planText: e.planText,
5045
+ budgetExhausted: false,
5046
+ timedOut: false,
5047
+ finishedNormally: false,
5048
+ iterations,
5049
+ stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
5050
+ incompleteKind: "budget_reads",
5051
+ userCancelled: true
5052
+ };
5053
+ }
4144
5054
  if (isAbortError(e)) {
4145
5055
  return cancelledOutput(input5, bus, runId, accumulatedText, budgetExhausted, iterations, runStartedAt);
4146
5056
  }
5057
+ if (APICallError2.isInstance(e)) {
5058
+ const synthetic = providerResponseFromApiError(input5.provider, input5.modelId, e);
5059
+ if (synthetic.errorKind === "rate_limit") {
5060
+ const asked = synthetic.retryAfterSec;
5061
+ const emitRateLimit = (phase, waitSec) => {
5062
+ bus.emit({
5063
+ kind: "rate_limit",
5064
+ runId,
5065
+ turn: iterations,
5066
+ retryAfterSec: synthetic.retryAfterSec,
5067
+ waitSec,
5068
+ capSec: MAX_RATE_LIMIT_RETRY_SEC,
5069
+ phase,
5070
+ provider: input5.provider,
5071
+ rawBody: (synthetic.rawError ?? "").slice(0, 200)
5072
+ });
5073
+ };
5074
+ if (asked !== void 0 && asked > MAX_RATE_LIMIT_RETRY_SEC) {
5075
+ emitRateLimit("aborted", asked);
5076
+ retrySkippedReason = "retry_after_too_long";
5077
+ throw composePlannerError(input5.provider, synthetic, false, retrySkippedReason);
5078
+ }
5079
+ if (attempt === 0) {
5080
+ const waitSec = Math.min(asked ?? 10, MAX_RATE_LIMIT_RETRY_SEC);
5081
+ emitRateLimit("retrying", waitSec);
5082
+ input5.onRateLimit?.(waitSec);
5083
+ await sleepWithAbort(waitSec * 1e3, sleepFn, input5.abort);
5084
+ attempt += 1;
5085
+ retriedRateLimit = true;
5086
+ continue;
5087
+ }
5088
+ throw composePlannerError(input5.provider, synthetic, true, retrySkippedReason);
5089
+ }
5090
+ throw composePlannerError(input5.provider, synthetic, retriedRateLimit, retrySkippedReason);
5091
+ }
4147
5092
  throw e;
4148
5093
  }
4149
- if (response.usage) {
4150
- recordUsageAndEmit(bus, input5, runId, iterations, response.usage);
4151
- }
4152
- retriedRateLimit = true;
4153
5094
  }
5095
+ } catch (e) {
5096
+ if (isAbortError(e)) {
5097
+ return cancelledOutput(input5, bus, runId, accumulatedText, budgetExhausted, iterations, runStartedAt);
5098
+ }
5099
+ throw e;
4154
5100
  }
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 });
5101
+ const finishReason = stepResult.finishReason;
5102
+ const toolCallsLen = stepResult.toolCalls.length;
5103
+ if (finishReason === "length" && toolCallsLen === 0) {
5104
+ bus.emit({ kind: "turn_complete", runId, turn: iterations, stopReason: "max_tokens" });
4166
5105
  if (decide) {
4167
5106
  const d = await decide(limitCtx("max_output_tokens"));
4168
5107
  if (d === "cancel") {
@@ -4178,7 +5117,7 @@ async function runPlanner(input5) {
4178
5117
  };
4179
5118
  }
4180
5119
  extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut);
4181
- turns.push({ role: "assistant", text: response.text ?? "" });
5120
+ applyStepToTurns(turns, stepResult);
4182
5121
  turns.push({ role: "user", text: PLANNER_MARKDOWN_CONTINUATION_USER });
4183
5122
  iterations -= 1;
4184
5123
  continue;
@@ -4193,8 +5132,13 @@ async function runPlanner(input5) {
4193
5132
  incompleteKind: "max_output_tokens"
4194
5133
  };
4195
5134
  }
4196
- if (response.stopReason === "end_turn" || !response.toolCalls?.length) {
4197
- bus.emit({ kind: "turn_complete", runId, turn: iterations, stopReason: response.stopReason });
5135
+ if (finishReason === "error" || finishReason === "content-filter") {
5136
+ throw new Error(
5137
+ stepResult.text || `planner: model reported ${finishReason}. Run \`squad doctor\` to diagnose, or retry \u2014 most 5xx errors are transient.`
5138
+ );
5139
+ }
5140
+ if ((finishReason === "stop" || finishReason === "unknown") && toolCallsLen === 0) {
5141
+ bus.emit({ kind: "turn_complete", runId, turn: iterations, stopReason: finishReason });
4198
5142
  finishedNormally = true;
4199
5143
  return {
4200
5144
  planText: accumulatedText,
@@ -4205,81 +5149,174 @@ async function runPlanner(input5) {
4205
5149
  stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt)
4206
5150
  };
4207
5151
  }
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
5152
+ if (toolCallsLen === 0) {
5153
+ bus.emit({ kind: "turn_complete", runId, turn: iterations, stopReason: finishReason });
5154
+ finishedNormally = true;
5155
+ return {
5156
+ planText: accumulatedText,
5157
+ budgetExhausted,
5158
+ timedOut: false,
5159
+ finishedNormally,
5160
+ iterations,
5161
+ stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt)
5162
+ };
5163
+ }
5164
+ applyStepToTurns(turns, stepResult);
5165
+ if (budgetExhausted) {
5166
+ turns.push({
5167
+ role: "user",
5168
+ 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
5169
  });
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;
5170
+ }
5171
+ bus.emit({ kind: "turn_complete", runId, turn: iterations, stopReason: finishReason });
5172
+ }
5173
+ }
5174
+ async function runPlanner(input5) {
5175
+ const bus = input5.events ?? new PlannerEventBus();
5176
+ const runId = input5.runId ?? newRunId();
5177
+ const cacheEnabled = input5.cacheEnabled ?? true;
5178
+ bus.emit({
5179
+ kind: "started",
5180
+ runId,
5181
+ provider: input5.provider,
5182
+ model: input5.modelId,
5183
+ cacheEnabled
5184
+ });
5185
+ const merged = { ...input5, events: bus, runId };
5186
+ const systemPrompt = input5.systemPrompt;
5187
+ let scoutSummary;
5188
+ let scoutStats;
5189
+ let scoutedSection;
5190
+ const scoutOn = input5.stages?.scout?.enabled !== false;
5191
+ const scoutModel = input5.stages?.scout?.model;
5192
+ const maxScoutFiles = input5.stages?.scout?.maxFiles ?? 12;
5193
+ const scoutMaxOut = input5.stages?.scout?.maxOutputTokens ?? 2048;
5194
+ const scoutModelIdStr = input5.stages?.scout?.modelId ?? "";
5195
+ if (scoutOn && scoutModel) {
5196
+ if (!input5.scoutSystemPrompt?.trim()) {
5197
+ throw new Error(
5198
+ "planner: scout enabled but `scoutSystemPrompt` was not provided. Compose it via composeScoutSystemPrompt() or set stages.scout.enabled = false."
5199
+ );
5200
+ }
5201
+ const t0 = Date.now();
5202
+ bus.emit({ kind: "stage_started", runId, stage: "scout" });
5203
+ try {
5204
+ const scoutRes = await runScout({
5205
+ model: scoutModel,
5206
+ provider: input5.provider,
5207
+ systemPrompt: input5.scoutSystemPrompt,
5208
+ userPrompt: input5.userPrompt,
5209
+ budget: input5.budget,
5210
+ abort: input5.abort,
5211
+ maxTokens: scoutMaxOut
5212
+ });
5213
+ const durationMs = Date.now() - t0;
5214
+ if (scoutRes) {
5215
+ const files = scoutRes.output.selectedFiles.slice(0, maxScoutFiles);
5216
+ const tokensUsed = scoutRes.usage.inputTokens + scoutRes.usage.outputTokens;
5217
+ scoutSummary = {
5218
+ selected: files,
5219
+ reasoning: scoutRes.output.reasoning,
5220
+ durationMs,
5221
+ tokensUsed
5222
+ };
5223
+ scoutStats = scoutStageStats(scoutRes.usage, durationMs);
5224
+ bus.emit({
5225
+ kind: "scout_result",
5226
+ runId,
5227
+ selected: files,
5228
+ reasoning: scoutRes.output.reasoning
5229
+ });
5230
+ scoutedSection = buildScoutedContextSection(
5231
+ input5.root,
5232
+ input5.budget,
5233
+ files,
5234
+ bus,
5235
+ runId,
5236
+ scoutRes.output.readRanges
5237
+ );
5238
+ bus.emit({
5239
+ kind: "stage_complete",
5240
+ runId,
5241
+ stage: "scout",
5242
+ success: true,
5243
+ durationMs,
5244
+ tokensUsed
5245
+ });
5246
+ } else {
5247
+ bus.emit({
5248
+ kind: "stage_complete",
5249
+ runId,
5250
+ stage: "scout",
5251
+ success: false,
5252
+ durationMs
5253
+ });
5254
+ }
5255
+ } catch (err2) {
5256
+ const msg = err2 instanceof Error ? err2.message : String(err2);
5257
+ if (APICallError2.isInstance(err2)) {
5258
+ const status = err2.statusCode ?? 0;
5259
+ if (status === 401 || status === 403) {
5260
+ throw err2;
5261
+ }
5262
+ if (detectModelNotFound(input5.provider, scoutModelIdStr, status, err2.responseBody ?? "")) {
5263
+ throw err2;
4263
5264
  }
4264
5265
  }
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."
5266
+ bus.emit({
5267
+ kind: "stage_complete",
5268
+ runId,
5269
+ stage: "scout",
5270
+ success: false,
5271
+ durationMs: Date.now() - t0,
5272
+ errorMessage: msg.slice(0, 200)
4272
5273
  });
4273
5274
  }
4274
- bus.emit({ kind: "turn_complete", runId, turn: iterations, stopReason: response.stopReason });
4275
5275
  }
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;
5276
+ const draftStarted = Date.now();
5277
+ bus.emit({ kind: "stage_started", runId, stage: "draft" });
5278
+ const draft = await runDraftStage({ ...merged, systemPrompt, scoutedSection });
5279
+ bus.emit({
5280
+ kind: "stage_complete",
5281
+ runId,
5282
+ stage: "draft",
5283
+ success: draft.finishedNormally && !draft.timedOut && !draft.userCancelled,
5284
+ durationMs: Date.now() - draftStarted
5285
+ });
5286
+ const validationOn = input5.validation?.enabled !== false;
5287
+ let issues = [];
5288
+ let validationDurationMs = 0;
5289
+ if (validationOn && draft.planText.trim()) {
5290
+ const v0 = Date.now();
5291
+ bus.emit({ kind: "stage_started", runId, stage: "validation" });
5292
+ issues = validatePlan({ root: input5.root, planText: draft.planText });
5293
+ for (const iss of issues.slice(0, 100)) {
5294
+ bus.emit({
5295
+ kind: "validation_issue",
5296
+ runId,
5297
+ severity: iss.severity,
5298
+ issueKind: iss.kind,
5299
+ path: iss.path,
5300
+ detail: iss.detail,
5301
+ excerpt: iss.excerpt
5302
+ });
5303
+ }
5304
+ validationDurationMs = Date.now() - v0;
5305
+ bus.emit({
5306
+ kind: "stage_complete",
5307
+ runId,
5308
+ stage: "validation",
5309
+ success: true,
5310
+ durationMs: validationDurationMs
5311
+ });
4281
5312
  }
4282
- return n;
5313
+ return {
5314
+ ...draft,
5315
+ stats: draft.stats,
5316
+ stagesStats: { scout: scoutStats, draft: draft.stats },
5317
+ scout: scoutSummary,
5318
+ validation: validationOn ? { issues, durationMs: validationDurationMs } : { issues: [], durationMs: 0 }
5319
+ };
4283
5320
  }
4284
5321
  function composePlannerError(providerName, response, retriedRateLimit, retrySkippedReason) {
4285
5322
  if (response.errorKind === "rate_limit") {
@@ -4318,45 +5355,39 @@ function kindTitle(kind) {
4318
5355
  return "Model round limit";
4319
5356
  case "wall_clock":
4320
5357
  return "Wall-clock time limit";
4321
- case "cost_cap":
4322
- return "Estimated cost cap";
4323
5358
  case "file_or_context_reads":
4324
5359
  return "File read or context size limit";
4325
5360
  }
4326
5361
  }
4327
- function kindDetail(ctx) {
5362
+ function tokenSummary(ctx) {
4328
5363
  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.";
5364
+ return `Tokens so far: ${snap.usage.inputTokens} in / ${snap.usage.outputTokens} out for this session.`;
5365
+ }
5366
+ function kindDetail(ctx) {
4330
5367
  switch (ctx.kind) {
4331
5368
  case "max_output_tokens":
4332
5369
  return [
4333
5370
  `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
5371
  "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
5372
+ tokenSummary(ctx)
4336
5373
  ];
4337
5374
  case "max_iterations":
4338
5375
  return [
4339
5376
  `The planner reached its round cap (${ctx.maxIterations} model turns) without a clean stop.`,
4340
5377
  "Continuing raises the round cap and file/time budgets for this run by another full slice from your config.",
4341
- cost
5378
+ tokenSummary(ctx)
4342
5379
  ];
4343
5380
  case "wall_clock":
4344
5381
  return [
4345
5382
  "The configured wall-clock budget for this planning session elapsed before the model finished.",
4346
5383
  "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
5384
+ tokenSummary(ctx)
4354
5385
  ];
4355
5386
  case "file_or_context_reads":
4356
5387
  return [
4357
5388
  "A `read_file` call could not run because the file-read count or total read-bytes budget was exhausted.",
4358
5389
  "Continuing extends read, context, time, round, and output limits for this session so the model can read again or finish without reads.",
4359
- cost
5390
+ tokenSummary(ctx)
4360
5391
  ];
4361
5392
  }
4362
5393
  }
@@ -4380,14 +5411,17 @@ function printPlannerLimitNextSteps() {
4380
5411
  }
4381
5412
 
4382
5413
  // src/planner/providers/index.ts
4383
- function providerFor(name) {
4384
- switch (name) {
5414
+ import { createAnthropic } from "@ai-sdk/anthropic";
5415
+ import { createOpenAI } from "@ai-sdk/openai";
5416
+ import { createGoogleGenerativeAI } from "@ai-sdk/google";
5417
+ function resolveModel(provider, modelId, apiKey) {
5418
+ switch (provider) {
4385
5419
  case "anthropic":
4386
- return anthropicProvider;
5420
+ return { provider, model: createAnthropic({ apiKey })(modelId), modelId };
4387
5421
  case "openai":
4388
- return openaiProvider;
5422
+ return { provider, model: createOpenAI({ apiKey })(modelId), modelId };
4389
5423
  case "google":
4390
- return googleProvider;
5424
+ return { provider, model: createGoogleGenerativeAI({ apiKey })(modelId), modelId };
4391
5425
  }
4392
5426
  }
4393
5427
 
@@ -4398,7 +5432,6 @@ var Budget = class {
4398
5432
  this.maxFileReadsAllowed = cfg.maxFileReads;
4399
5433
  this.maxContextBytesAllowed = cfg.maxContextBytes;
4400
5434
  this.maxWallSeconds = cfg.maxDurationSeconds;
4401
- this.maxCostUsdCeiling = cfg.maxCostUsd;
4402
5435
  }
4403
5436
  cfg;
4404
5437
  reads = 0;
@@ -4408,15 +5441,11 @@ var Budget = class {
4408
5441
  maxFileReadsAllowed;
4409
5442
  maxContextBytesAllowed;
4410
5443
  maxWallSeconds;
4411
- maxCostUsdCeiling;
4412
5444
  /** Add another slice of the original config limits (used when the user chooses to continue a planner session). */
4413
5445
  extendSession() {
4414
5446
  this.maxFileReadsAllowed += this.cfg.maxFileReads;
4415
5447
  this.maxContextBytesAllowed += this.cfg.maxContextBytes;
4416
5448
  this.maxWallSeconds += this.cfg.maxDurationSeconds;
4417
- if (this.cfg.maxCostUsd !== void 0) {
4418
- this.maxCostUsdCeiling = (this.maxCostUsdCeiling ?? 0) + this.cfg.maxCostUsd;
4419
- }
4420
5449
  }
4421
5450
  canRead(nextFileBytes) {
4422
5451
  if (this.reads >= this.maxFileReadsAllowed) {
@@ -4427,6 +5456,13 @@ var Budget = class {
4427
5456
  }
4428
5457
  return { ok: true };
4429
5458
  }
5459
+ /** Whether another tool invocation is allowed under the read-count budget (bytes checked per tool). */
5460
+ canExecuteTool() {
5461
+ if (this.reads >= this.maxFileReadsAllowed) {
5462
+ return { ok: false, reason: `max file reads (${this.maxFileReadsAllowed}) reached` };
5463
+ }
5464
+ return { ok: true };
5465
+ }
4430
5466
  recordRead(bytes) {
4431
5467
  this.reads += 1;
4432
5468
  this.bytes += bytes;
@@ -4434,17 +5470,12 @@ var Budget = class {
4434
5470
  recordUsage(u) {
4435
5471
  this.totalUsage.inputTokens += u.inputTokens;
4436
5472
  this.totalUsage.outputTokens += u.outputTokens;
4437
- if (u.costUsd !== void 0) this.totalUsage.costUsd = (this.totalUsage.costUsd ?? 0) + u.costUsd;
4438
5473
  this.totalUsage.cacheCreationTokens = (this.totalUsage.cacheCreationTokens ?? 0) + (u.cacheCreationTokens ?? 0);
4439
5474
  this.totalUsage.cacheReadTokens = (this.totalUsage.cacheReadTokens ?? 0) + (u.cacheReadTokens ?? 0);
4440
5475
  }
4441
5476
  timedOut() {
4442
5477
  return (Date.now() - this.startedAt) / 1e3 > this.maxWallSeconds;
4443
5478
  }
4444
- overCost() {
4445
- if (this.maxCostUsdCeiling === void 0) return false;
4446
- return (this.totalUsage.costUsd ?? 0) > this.maxCostUsdCeiling;
4447
- }
4448
5479
  snapshot() {
4449
5480
  return {
4450
5481
  reads: this.reads,
@@ -4455,103 +5486,9 @@ var Budget = class {
4455
5486
  }
4456
5487
  };
4457
5488
 
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
5489
  // src/planner/writer.ts
4553
- import fs24 from "fs";
4554
- import path17 from "path";
5490
+ import fs28 from "fs";
5491
+ import path20 from "path";
4555
5492
  function planFileSlugParts(config2, story) {
4556
5493
  const baseSlug = slugify(story.id);
4557
5494
  const trackerId = config2.naming.includeTrackerId && config2.tracker.type !== "none" ? trackerIdForFilename(config2.tracker.type, story.id) : "";
@@ -4560,8 +5497,8 @@ function planFileSlugParts(config2, story) {
4560
5497
  }
4561
5498
  function writePlanFile(input5) {
4562
5499
  const { paths, config: config2, story } = input5;
4563
- const featurePlanDir = path17.join(paths.plansDir, story.feature);
4564
- fs24.mkdirSync(featurePlanDir, { recursive: true });
5500
+ const featurePlanDir = path20.join(paths.plansDir, story.feature);
5501
+ fs28.mkdirSync(featurePlanDir, { recursive: true });
4565
5502
  if (input5.partial) {
4566
5503
  return writePartialPlanFile(input5, featurePlanDir, paths, config2, story);
4567
5504
  }
@@ -4576,14 +5513,14 @@ function writePlanFile(input5) {
4576
5513
  } else {
4577
5514
  nn = scanPlans(paths.plansDir).nextGlobal;
4578
5515
  const { baseSlug, trackerPart } = planFileSlugParts(config2, story);
4579
- targetPath = path17.join(featurePlanDir, `${formatSequence(nn)}-story-${baseSlug}${trackerPart}.md`);
5516
+ targetPath = path20.join(featurePlanDir, `${formatSequence(nn)}-story-${baseSlug}${trackerPart}.md`);
4580
5517
  }
4581
5518
  const body = `${input5.metadataHeader.trimEnd()}
4582
5519
 
4583
5520
  ${input5.planBodyMarkdown.trimStart()}
4584
5521
  `;
4585
- fs24.writeFileSync(targetPath, body, "utf8");
4586
- upsertOverviewRow(featurePlanDir, story.feature, nn, path17.basename(targetPath), story, false);
5522
+ fs28.writeFileSync(targetPath, body, "utf8");
5523
+ upsertOverviewRow(featurePlanDir, story.feature, nn, path20.basename(targetPath), story, false);
4587
5524
  return { planFile: targetPath, sequenceNumber: nn, overwrote };
4588
5525
  }
4589
5526
  function writePartialPlanFile(input5, featurePlanDir, paths, config2, story) {
@@ -4598,42 +5535,42 @@ function writePartialPlanFile(input5, featurePlanDir, paths, config2, story) {
4598
5535
  } else {
4599
5536
  nn = scanPlans(paths.plansDir).nextGlobal;
4600
5537
  const { baseSlug, trackerPart } = planFileSlugParts(config2, story);
4601
- targetPath = path17.join(featurePlanDir, `${formatSequence(nn)}-story-${baseSlug}${trackerPart}.partial.md`);
5538
+ targetPath = path20.join(featurePlanDir, `${formatSequence(nn)}-story-${baseSlug}${trackerPart}.partial.md`);
4602
5539
  }
4603
5540
  const body = `${input5.metadataHeader.trimEnd()}
4604
5541
 
4605
5542
  ${input5.planBodyMarkdown.trimStart()}
4606
5543
  `;
4607
- fs24.writeFileSync(targetPath, body, "utf8");
4608
- upsertOverviewRow(featurePlanDir, story.feature, nn, path17.basename(targetPath), story, true);
5544
+ fs28.writeFileSync(targetPath, body, "utf8");
5545
+ upsertOverviewRow(featurePlanDir, story.feature, nn, path20.basename(targetPath), story, true);
4609
5546
  return { planFile: targetPath, sequenceNumber: nn, overwrote };
4610
5547
  }
4611
5548
  function findExistingPlan(featureDir, storyId) {
4612
- if (!fs24.existsSync(featureDir)) return void 0;
5549
+ if (!fs28.existsSync(featureDir)) return void 0;
4613
5550
  const pattern = /^(\d{2,})-story-.+\.md$/;
4614
- for (const entry of fs24.readdirSync(featureDir)) {
5551
+ for (const entry of fs28.readdirSync(featureDir)) {
4615
5552
  if (entry.endsWith(".partial.md")) continue;
4616
5553
  const m = entry.match(pattern);
4617
5554
  if (!m) continue;
4618
- if (entry.includes(storyId)) return { absPath: path17.join(featureDir, entry), nn: parseInt(m[1], 10) };
5555
+ if (entry.includes(storyId)) return { absPath: path20.join(featureDir, entry), nn: parseInt(m[1], 10) };
4619
5556
  }
4620
5557
  return void 0;
4621
5558
  }
4622
5559
  function findExistingPartialPlan(featureDir, storyId) {
4623
- if (!fs24.existsSync(featureDir)) return void 0;
5560
+ if (!fs28.existsSync(featureDir)) return void 0;
4624
5561
  const pattern = /^(\d{2,})-story-.+\.partial\.md$/;
4625
- for (const entry of fs24.readdirSync(featureDir)) {
5562
+ for (const entry of fs28.readdirSync(featureDir)) {
4626
5563
  const m = entry.match(pattern);
4627
5564
  if (!m) continue;
4628
- if (entry.includes(storyId)) return { absPath: path17.join(featureDir, entry), nn: parseInt(m[1], 10) };
5565
+ if (entry.includes(storyId)) return { absPath: path20.join(featureDir, entry), nn: parseInt(m[1], 10) };
4629
5566
  }
4630
5567
  return void 0;
4631
5568
  }
4632
5569
  function upsertOverviewRow(featureDir, feature, nn, filename, story, isPartial) {
4633
- const overviewPath = path17.join(featureDir, "00-overview.md");
5570
+ const overviewPath = path20.join(featureDir, "00-overview.md");
4634
5571
  const titleCell = isPartial ? `${story.titleHint ?? story.id} (partial)` : story.titleHint ?? story.id;
4635
5572
  const row = `| ${formatSequence(nn)} | \`${filename}\` | ${titleCell} | ${story.id} | \u2014 |`;
4636
- if (!fs24.existsSync(overviewPath)) {
5573
+ if (!fs28.existsSync(overviewPath)) {
4637
5574
  const content = `# ${feature} \u2014 plan overview
4638
5575
 
4639
5576
  Entry point for the **${feature}** feature. Stories execute in order by their \`NN\` prefix.
@@ -4644,10 +5581,10 @@ Entry point for the **${feature}** feature. Stories execute in order by their \`
4644
5581
  |----|------|-------|------------|------------|
4645
5582
  ${row}
4646
5583
  `;
4647
- fs24.writeFileSync(overviewPath, content, "utf8");
5584
+ fs28.writeFileSync(overviewPath, content, "utf8");
4648
5585
  return;
4649
5586
  }
4650
- const current = fs24.readFileSync(overviewPath, "utf8");
5587
+ const current = fs28.readFileSync(overviewPath, "utf8");
4651
5588
  const lines = current.split("\n");
4652
5589
  const existingIdx = lines.findIndex((l) => l.includes(`\`${filename}\``) || l.includes(`| ${formatSequence(nn)} |`));
4653
5590
  if (existingIdx >= 0) {
@@ -4656,7 +5593,7 @@ ${row}
4656
5593
  const tableEnd = findTableEnd(lines);
4657
5594
  lines.splice(tableEnd, 0, row);
4658
5595
  }
4659
- fs24.writeFileSync(overviewPath, lines.join("\n"), "utf8");
5596
+ fs28.writeFileSync(overviewPath, lines.join("\n"), "utf8");
4660
5597
  }
4661
5598
  function findTableEnd(lines) {
4662
5599
  let inTable = false;
@@ -4675,9 +5612,17 @@ function buildMetadataHeader(args) {
4675
5612
  `generated by ${args.provider}/${args.model}`,
4676
5613
  `at ${(/* @__PURE__ */ new Date()).toISOString()}`,
4677
5614
  `${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
5615
+ `${args.inputTokens} in / ${args.outputTokens} out tokens`
4680
5616
  ].filter(Boolean);
5617
+ if (args.scoutEnabled !== void 0) {
5618
+ parts.push(`scout=${args.scoutEnabled ? "on" : "off"}`);
5619
+ }
5620
+ if (args.validationEnabled !== void 0) {
5621
+ parts.push(`validation=${args.validationEnabled ? "on" : "off"}`);
5622
+ }
5623
+ if (args.validationIssueCount !== void 0) {
5624
+ parts.push(`issues=${args.validationIssueCount}`);
5625
+ }
4681
5626
  const line1 = `<!-- squad-kit: ${parts.join(", ")} -->`;
4682
5627
  if (args.planStatus === "partial") {
4683
5628
  return `${line1}
@@ -4701,16 +5646,16 @@ function buildCopyPlanPromptMarkdown(config2, intakeContent) {
4701
5646
  }
4702
5647
 
4703
5648
  // src/core/last-run.ts
4704
- import fs25 from "fs/promises";
4705
- import path18 from "path";
5649
+ import fs29 from "fs/promises";
5650
+ import path21 from "path";
4706
5651
  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");
5652
+ const file = path21.join(paths.squadDir, ".last-run.json");
5653
+ await fs29.writeFile(file, JSON.stringify({ ...rec, version: 1 }, null, 2) + "\n", "utf8");
4709
5654
  }
4710
5655
  async function readLastRun(paths) {
4711
- const file = path18.join(paths.squadDir, ".last-run.json");
5656
+ const file = path21.join(paths.squadDir, ".last-run.json");
4712
5657
  try {
4713
- const raw = await fs25.readFile(file, "utf8");
5658
+ const raw = await fs29.readFile(file, "utf8");
4714
5659
  const parsed = JSON.parse(raw);
4715
5660
  if (parsed.version !== 1) return void 0;
4716
5661
  return parsed;
@@ -4739,6 +5684,59 @@ function formatPlannerCacheLine(opts) {
4739
5684
  return "cache miss 0% \u2014 run `squad doctor` to diagnose";
4740
5685
  }
4741
5686
 
5687
+ // src/core/plans-index.ts
5688
+ import fs30 from "fs";
5689
+ import path22 from "path";
5690
+ var MAX_ENTRIES = 30;
5691
+ function firstMarkdownH1(src) {
5692
+ const lines = src.split(/\r?\n/);
5693
+ for (const line2 of lines) {
5694
+ const m = /^\s*#\s+(.+?)\s*$/.exec(line2);
5695
+ if (m) return m[1].trim();
5696
+ }
5697
+ return void 0;
5698
+ }
5699
+ function collectPlanMarkdownFiles(plansDir) {
5700
+ const out = [];
5701
+ const walk2 = (dir) => {
5702
+ let ents;
5703
+ try {
5704
+ ents = fs30.readdirSync(dir, { withFileTypes: true });
5705
+ } catch {
5706
+ return;
5707
+ }
5708
+ for (const ent of ents) {
5709
+ const abs = path22.join(dir, ent.name);
5710
+ if (ent.isDirectory()) {
5711
+ walk2(abs);
5712
+ } else if (ent.isFile() && ent.name.endsWith(".md")) {
5713
+ if (ent.name === "00-index.md" || ent.name === "00-overview.md") continue;
5714
+ out.push(abs);
5715
+ }
5716
+ }
5717
+ };
5718
+ if (fs30.existsSync(plansDir)) walk2(plansDir);
5719
+ out.sort((a, b) => a.localeCompare(b));
5720
+ return out.slice(0, MAX_ENTRIES);
5721
+ }
5722
+ function buildPlansIndex(paths) {
5723
+ const files = collectPlanMarkdownFiles(paths.plansDir);
5724
+ if (files.length === 0) return "(no prior plans yet)";
5725
+ const bullets = [];
5726
+ for (const abs of files) {
5727
+ const rel = path22.relative(paths.root, abs).split(path22.sep).join("/");
5728
+ let title = "";
5729
+ try {
5730
+ const raw = fs30.readFileSync(abs, "utf8");
5731
+ title = firstMarkdownH1(raw) ?? path22.basename(abs);
5732
+ } catch {
5733
+ title = path22.basename(abs);
5734
+ }
5735
+ bullets.push(`- ${rel} \u2014 "${title}"`);
5736
+ }
5737
+ return bullets.join("\n");
5738
+ }
5739
+
4742
5740
  // src/commands/new-plan.ts
4743
5741
  function decideMode(opts, config2) {
4744
5742
  if (opts.api && opts.copy) {
@@ -4770,8 +5768,8 @@ async function runNewPlan(intakePath, opts) {
4770
5768
  }
4771
5769
  }
4772
5770
  function resolveFromPath(intakePath, stories, root) {
4773
- const resolved = path19.resolve(intakePath.trim());
4774
- if (!fs26.existsSync(resolved)) {
5771
+ const resolved = path23.resolve(intakePath.trim());
5772
+ if (!fs31.existsSync(resolved)) {
4775
5773
  throw new Error(
4776
5774
  `Intake not found: ${resolved}. Run \`squad list\` to see intakes, or \`squad new-story\` to create one first.`
4777
5775
  );
@@ -4783,7 +5781,7 @@ function resolveFromPath(intakePath, stories, root) {
4783
5781
  );
4784
5782
  }
4785
5783
  function relativeStoriesDir(root) {
4786
- return path19.relative(root, path19.join(root, ".squad", "stories"));
5784
+ return path23.relative(root, path23.join(root, ".squad", "stories"));
4787
5785
  }
4788
5786
  async function pickStory(stories, opts) {
4789
5787
  const candidates = opts.all ? stories : stories.filter((s) => !s.planFile);
@@ -4814,7 +5812,7 @@ async function pickStory(stories, opts) {
4814
5812
  value: s.intakePath
4815
5813
  }))
4816
5814
  });
4817
- return candidates.find((s) => path19.resolve(s.intakePath) === path19.resolve(pick));
5815
+ return candidates.find((s) => path23.resolve(s.intakePath) === path23.resolve(pick));
4818
5816
  }
4819
5817
  function formatLabel(s) {
4820
5818
  const head = `${s.feature} / ${s.id}`;
@@ -4860,13 +5858,13 @@ async function emitViaApi(story, paths, config2, opts) {
4860
5858
  `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
5859
  );
4862
5860
  }
4863
- const model = modelFor(planner.provider, "plan", planner.modelOverride);
4864
- const provider = providerFor(planner.provider);
5861
+ const modelId = modelFor(planner.provider, "plan", planner.modelOverride);
5862
+ const { model } = resolveModel(planner.provider, modelId, apiKey);
4865
5863
  const budget = new Budget(planner.budget);
4866
5864
  banner();
4867
5865
  step(`planning ${story.feature} / ${story.id}`);
4868
5866
  kv("provider", planner.provider);
4869
- kv("model", model);
5867
+ kv("model", modelId);
4870
5868
  kv(
4871
5869
  "budget",
4872
5870
  `${planner.budget.maxFileReads} reads \xB7 ${(planner.budget.maxContextBytes / 1024).toFixed(0)} KB \xB7 ${planner.budget.maxDurationSeconds}s`
@@ -4877,7 +5875,7 @@ async function emitViaApi(story, paths, config2, opts) {
4877
5875
  printPlannerApiCostNotice();
4878
5876
  }
4879
5877
  const mapSpinner = spinner("building repo map\u2026");
4880
- const repoMap = buildRepoMap(paths.root);
5878
+ const repoMap = buildRepoMap(paths.root, { format: "tree" });
4881
5879
  mapSpinner.succeed(
4882
5880
  `repo map ready (${repoMap.split("\n").length - 1} paths \xB7 ${(repoMap.length / 1024).toFixed(1)} KB)`
4883
5881
  );
@@ -4889,6 +5887,29 @@ async function emitViaApi(story, paths, config2, opts) {
4889
5887
  repoMap
4890
5888
  });
4891
5889
  const userPrompt = composeUserPrompt({ intakeContent: readFile(story.intakePath) });
5890
+ const scoutEnabled = !opts.noScout && planner.stages?.scout?.enabled !== false;
5891
+ const validationEnabled = !opts.noValidation && planner.validation?.enabled !== false;
5892
+ const strictValidation = !!opts.strictValidation || planner.validation?.strict === true;
5893
+ const maxScoutFiles = opts.maxScoutFiles ?? planner.stages?.scout?.maxFiles ?? 12;
5894
+ let scoutModelId = "";
5895
+ let scoutModelHandle;
5896
+ let scoutSystemPrompt;
5897
+ if (scoutEnabled) {
5898
+ scoutModelId = modelFor(
5899
+ planner.provider,
5900
+ "scout",
5901
+ planner.modelOverride,
5902
+ opts.scoutModel ?? planner.stages?.scout?.modelOverride
5903
+ );
5904
+ scoutModelHandle = resolveModel(planner.provider, scoutModelId, apiKey).model;
5905
+ scoutSystemPrompt = composeScoutSystemPrompt({
5906
+ projectRoots: config2.project.projectRoots ?? ["."],
5907
+ primaryLanguage: config2.project.primaryLanguage ?? "",
5908
+ trackerType: config2.tracker.type,
5909
+ repoMap,
5910
+ plansIndex: buildPlansIndex(paths)
5911
+ });
5912
+ }
4892
5913
  const sessionSpinner = { current: null };
4893
5914
  const startedAt = Date.now();
4894
5915
  const cacheEnabled = planner.cache?.enabled ?? true;
@@ -4897,11 +5918,43 @@ async function emitViaApi(story, paths, config2, opts) {
4897
5918
  const unsubscribe = bus.subscribe((e) => {
4898
5919
  switch (e.kind) {
4899
5920
  case "tool_call": {
4900
- const p = e.toolCall.input.path ?? "<unknown>";
5921
+ const name = e.toolCall.name;
5922
+ const inp = e.toolCall.input;
5923
+ let label;
5924
+ if (name === "read_file") {
5925
+ label = `read ${String(inp.path ?? "<unknown>")}`;
5926
+ } else if (name === "grep") {
5927
+ label = `grep ${String(inp.pattern ?? "?").slice(0, 48)}`;
5928
+ } else if (name === "list_dir") {
5929
+ label = `list_dir ${String(inp.path ?? "?")}`;
5930
+ } else {
5931
+ label = name;
5932
+ }
4901
5933
  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)`
5934
+ `${label} (${(e.bytesLoaded / 1024).toFixed(1)} KB \xB7 ${(e.totalBytes / 1024).toFixed(1)} KB / ${(planner.budget.maxContextBytes / 1024).toFixed(0)} KB)`
4903
5935
  );
4904
- sessionSpinner.current = spinner("reading next file\u2026");
5936
+ sessionSpinner.current = spinner("next tool\u2026");
5937
+ break;
5938
+ }
5939
+ case "stage_started": {
5940
+ sessionSpinner.current?.stop();
5941
+ if (e.stage === "scout") sessionSpinner.current = spinner("scouting\u2026");
5942
+ else if (e.stage === "draft") sessionSpinner.current = spinner("drafting\u2026");
5943
+ else if (e.stage === "validation") sessionSpinner.current = spinner("validating\u2026");
5944
+ break;
5945
+ }
5946
+ case "stage_complete": {
5947
+ if (!e.success && e.errorMessage && e.stage === "scout") {
5948
+ sessionSpinner.current?.stop();
5949
+ warning(`scout stage failed: ${e.errorMessage}`);
5950
+ }
5951
+ break;
5952
+ }
5953
+ case "scout_result": {
5954
+ const preview = e.selected.slice(0, 4).join(", ");
5955
+ const more = e.selected.length > 4 ? "\u2026" : "";
5956
+ sessionSpinner.current?.succeed(`scout picked ${e.selected.length} files: ${preview}${more}`);
5957
+ sessionSpinner.current = spinner("drafting\u2026");
4905
5958
  break;
4906
5959
  }
4907
5960
  case "assistant_text":
@@ -4937,9 +5990,9 @@ async function emitViaApi(story, paths, config2, opts) {
4937
5990
  try {
4938
5991
  result = await runPlanner({
4939
5992
  root: paths.root,
4940
- provider,
4941
5993
  model,
4942
- apiKey,
5994
+ provider: planner.provider,
5995
+ modelId,
4943
5996
  systemPrompt,
4944
5997
  userPrompt,
4945
5998
  budget,
@@ -4947,7 +6000,19 @@ async function emitViaApi(story, paths, config2, opts) {
4947
6000
  cacheEnabled,
4948
6001
  decideOnLimit,
4949
6002
  events: bus,
4950
- runId
6003
+ runId,
6004
+ stages: {
6005
+ scout: {
6006
+ enabled: scoutEnabled,
6007
+ model: scoutModelHandle,
6008
+ modelId: scoutModelId,
6009
+ maxFiles: maxScoutFiles,
6010
+ maxOutputTokens: planner.stages?.scout?.maxOutputTokens ?? 2048
6011
+ }
6012
+ },
6013
+ validation: { enabled: validationEnabled, strict: strictValidation },
6014
+ toolsEnabled: planner.tools,
6015
+ scoutSystemPrompt
4951
6016
  });
4952
6017
  } finally {
4953
6018
  unsubscribe();
@@ -4960,17 +6025,22 @@ async function emitViaApi(story, paths, config2, opts) {
4960
6025
  }
4961
6026
  const snap = budget.snapshot();
4962
6027
  const elapsedMs = Date.now() - startedAt;
4963
- const success2 = result.finishedNormally && !result.timedOut && !result.userCancelled;
6028
+ const issueCount = result.validation?.issues.length ?? 0;
6029
+ const issuesByKind = summariseIssuesByKind(result.validation?.issues ?? []);
6030
+ const validationBlocks = strictValidation && validationEnabled && issueCount > 0;
6031
+ const success2 = result.finishedNormally && !result.timedOut && !result.userCancelled && !validationBlocks;
4964
6032
  const header = buildMetadataHeader({
4965
6033
  provider: planner.provider,
4966
- model,
6034
+ model: modelId,
4967
6035
  reads: snap.reads,
4968
6036
  bytes: snap.bytes,
4969
6037
  inputTokens: snap.usage.inputTokens,
4970
6038
  outputTokens: snap.usage.outputTokens,
4971
6039
  durationMs: elapsedMs,
4972
- costUsd: snap.usage.costUsd,
4973
- planStatus: success2 ? void 0 : "partial"
6040
+ planStatus: success2 ? void 0 : "partial",
6041
+ scoutEnabled,
6042
+ validationEnabled,
6043
+ validationIssueCount: issueCount
4974
6044
  });
4975
6045
  const { planFile, sequenceNumber, overwrote } = writePlanFile({
4976
6046
  paths,
@@ -4980,18 +6050,18 @@ async function emitViaApi(story, paths, config2, opts) {
4980
6050
  metadataHeader: header,
4981
6051
  partial: !success2
4982
6052
  });
4983
- const relPlan = path19.relative(paths.root, planFile);
6053
+ const relPlan = path23.relative(paths.root, planFile);
4984
6054
  try {
4985
6055
  await writeLastRun(paths, {
4986
6056
  stats: result.stats,
4987
6057
  completedAt: (/* @__PURE__ */ new Date()).toISOString(),
4988
6058
  provider: planner.provider,
4989
- model
6059
+ model: modelId
4990
6060
  });
4991
6061
  await appendRun(paths, {
4992
6062
  runId,
4993
6063
  provider: planner.provider,
4994
- model,
6064
+ model: modelId,
4995
6065
  feature: story.feature,
4996
6066
  storyId: story.id,
4997
6067
  startedAt: new Date(startedAt).toISOString(),
@@ -5001,7 +6071,19 @@ async function emitViaApi(story, paths, config2, opts) {
5001
6071
  planFile: relPlan,
5002
6072
  stats: result.stats,
5003
6073
  cacheEnabled,
5004
- durationMs: elapsedMs
6074
+ durationMs: elapsedMs,
6075
+ scout: {
6076
+ enabled: scoutEnabled,
6077
+ selectedCount: result.scout?.selected.length,
6078
+ tokensUsed: result.scout?.tokensUsed,
6079
+ durationMs: result.scout?.durationMs
6080
+ },
6081
+ validation: {
6082
+ enabled: validationEnabled,
6083
+ issuesCount: issueCount,
6084
+ issuesByKind,
6085
+ durationMs: result.validation?.durationMs
6086
+ }
5005
6087
  });
5006
6088
  } catch {
5007
6089
  }
@@ -5009,11 +6091,19 @@ async function emitViaApi(story, paths, config2, opts) {
5009
6091
  summaryBox(success2 ? " plan generated " : " plan saved (incomplete) ", [
5010
6092
  { key: "file", value: relPlan },
5011
6093
  { key: "nn", value: String(sequenceNumber).padStart(2, "0") },
5012
- { key: "model", value: `${planner.provider}/${model}` },
6094
+ { key: "model", value: `${planner.provider}/${modelId}` },
6095
+ {
6096
+ key: "scout",
6097
+ value: scoutEnabled ? `${result.scout?.selected.length ?? 0} files \xB7 ${result.scout?.tokensUsed ?? 0} tok` : "disabled"
6098
+ },
6099
+ {
6100
+ key: "validation",
6101
+ value: validationEnabled ? `${issueCount} issue(s)` : "disabled"
6102
+ },
5013
6103
  { key: "reads", value: `${snap.reads} files \xB7 ${(snap.bytes / 1024).toFixed(1)} KB` },
5014
6104
  {
5015
6105
  key: "tokens",
5016
- value: `${snap.usage.inputTokens} in \xB7 ${snap.usage.outputTokens} out${snap.usage.costUsd !== void 0 ? ` \u2248 $${snap.usage.costUsd.toFixed(2)}` : ""}`
6106
+ value: `${snap.usage.inputTokens} in \xB7 ${snap.usage.outputTokens} out`
5017
6107
  },
5018
6108
  { key: "cache", value: formatPlannerCacheLine({ cacheEnabled, stats: result.stats }) },
5019
6109
  {
@@ -5023,25 +6113,45 @@ async function emitViaApi(story, paths, config2, opts) {
5023
6113
  { key: "time", value: `${Math.round(elapsedMs / 1e3)}s` },
5024
6114
  {
5025
6115
  key: "action",
5026
- value: success2 ? overwrote ? "overwrote existing plan" : "new plan" : "partial plan (review limits)"
6116
+ value: success2 ? overwrote ? "overwrote existing plan" : "new plan" : validationBlocks ? "partial plan (validation issues \u2014 review or use --no-validation)" : "partial plan (review limits)"
5027
6117
  }
5028
6118
  ]);
6119
+ if (issueCount > 0 && validationEnabled) {
6120
+ blank();
6121
+ warning(`Validation reported ${issueCount} issue(s) (heuristic; first 10 shown):`);
6122
+ for (const iss of result.validation.issues.slice(0, 10)) {
6123
+ info(
6124
+ ` [${iss.severity}] ${iss.kind}${iss.path ? ` ${iss.path}` : ""} \u2014 ${iss.detail}`
6125
+ );
6126
+ }
6127
+ if (issueCount > 10) {
6128
+ info(` (+${issueCount - 10} more \u2014 see metadata header issues=\u2026)`);
6129
+ }
6130
+ if (issueCount > 50) {
6131
+ warning("Large issue count may mean the validator is misfiring; try `squad new-plan --no-validation` to compare.");
6132
+ }
6133
+ }
5029
6134
  if (!success2) {
5030
- printPlannerRunIncompleteSummary(result, relPlan);
6135
+ if (!validationBlocks || !result.finishedNormally) {
6136
+ printPlannerRunIncompleteSummary(result, relPlan);
6137
+ } else {
6138
+ blank();
6139
+ warning("Plan saved as partial because strict validation reported issues.");
6140
+ }
5031
6141
  throw new SquadExit(2);
5032
6142
  }
5033
6143
  if (result.budgetExhausted) {
5034
6144
  info("Note: file-read budget was reached; the model finalised without further reads.");
5035
6145
  }
5036
6146
  blank();
5037
- info(`next \u2192 open a new agent chat and attach only ${path19.basename(planFile)}`);
6147
+ info(`next \u2192 open a new agent chat and attach only ${path23.basename(planFile)}`);
5038
6148
  }
5039
6149
  async function emitCopyPrompt(story, paths, config2, clipboard) {
5040
6150
  const intakeContent = readFile(story.intakePath);
5041
6151
  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);
6152
+ const promptFile = path23.join(paths.squadDir, ".last-copy-prompt.md");
6153
+ fs31.writeFileSync(promptFile, composed, "utf8");
6154
+ const relPromptFile = path23.relative(paths.root, promptFile);
5045
6155
  const bytes = Buffer.byteLength(composed, "utf8");
5046
6156
  const estTokens = Math.round(bytes / 4);
5047
6157
  const clipResult = clipboard ? await copyToClipboard(composed) : { ok: false, reason: "clipboard disabled (--no-clipboard)" };
@@ -5099,17 +6209,17 @@ function printModelBanner(config2) {
5099
6209
  }
5100
6210
 
5101
6211
  // src/commands/status.ts
5102
- import fs27 from "fs";
6212
+ import fs32 from "fs";
5103
6213
  var kw = 28;
5104
6214
  function probeTrackerCredential(config2, overlay) {
5105
- const { client, error: error2 } = clientFor(config2, overlay);
6215
+ const { client, error } = clientFor(config2, overlay);
5106
6216
  if (client) {
5107
6217
  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
6218
  return { state: "present", detail: fromEnv ? "env" : "secrets.yaml" };
5109
6219
  }
5110
6220
  return {
5111
6221
  state: "missing",
5112
- hint: error2?.detail ?? "Run `squad init` to enter credentials."
6222
+ hint: error?.detail ?? "Run `squad init` to enter credentials."
5113
6223
  };
5114
6224
  }
5115
6225
  function formatCredentialStatus(s) {
@@ -5125,7 +6235,7 @@ async function runStatus() {
5125
6235
  const scan = scanPlans(paths.plansDir);
5126
6236
  const storyCount = listStories(paths).length;
5127
6237
  const planCount = scan.usedNumbers.length;
5128
- const secrets = fs27.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
6238
+ const secrets = fs32.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
5129
6239
  const overlay = overlayTrackerEnv(secrets);
5130
6240
  banner();
5131
6241
  step("squad-kit status");
@@ -5179,13 +6289,13 @@ function buildTrackerLine(config2, overlay) {
5179
6289
  }
5180
6290
 
5181
6291
  // src/commands/list.ts
5182
- import path20 from "path";
6292
+ import path24 from "path";
5183
6293
  async function runList(opts) {
5184
6294
  const root = requireSquadRoot();
5185
6295
  const paths = buildPaths(root);
5186
6296
  const records = listStories(paths, { feature: opts.feature });
5187
6297
  const rows = records.map((r) => {
5188
- const planAbs = r.planFile ? path20.join(paths.plansDir, r.feature, r.planFile) : void 0;
6298
+ const planAbs = r.planFile ? path24.join(paths.plansDir, r.feature, r.planFile) : void 0;
5189
6299
  const meta = planAbs ? readPlanMetadata(planAbs) : void 0;
5190
6300
  return {
5191
6301
  feature: r.feature,
@@ -5224,8 +6334,8 @@ function stripAnsi(s) {
5224
6334
  }
5225
6335
 
5226
6336
  // src/commands/tracker-link.ts
5227
- import fs28 from "fs";
5228
- import path21 from "path";
6337
+ import fs33 from "fs";
6338
+ import path25 from "path";
5229
6339
  import { input as input4, select as select7 } from "@inquirer/prompts";
5230
6340
  async function runTrackerLink(storyPath, trackerId, opts = {}) {
5231
6341
  const root = requireSquadRoot();
@@ -5272,17 +6382,17 @@ async function runTrackerLink(storyPath, trackerId, opts = {}) {
5272
6382
  `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
6383
  );
5274
6384
  }
5275
- const resolved = path21.resolve(resolvedStoryPath);
5276
- const intakeFile = resolved.endsWith("intake.md") ? resolved : path21.join(resolved, "intake.md");
5277
- if (!fs28.existsSync(intakeFile)) {
6385
+ const resolved = path25.resolve(resolvedStoryPath);
6386
+ const intakeFile = resolved.endsWith("intake.md") ? resolved : path25.join(resolved, "intake.md");
6387
+ if (!fs33.existsSync(intakeFile)) {
5278
6388
  throw new Error(
5279
6389
  `intake.md not found at ${intakeFile}. Run \`squad list\` to see valid paths, or \`squad new-story\` to create a story first.`
5280
6390
  );
5281
6391
  }
5282
- const content = fs28.readFileSync(intakeFile, "utf8");
6392
+ const content = fs33.readFileSync(intakeFile, "utf8");
5283
6393
  const updated = upsertTrackerId(content, resolvedTrackerId);
5284
- fs28.writeFileSync(intakeFile, updated, "utf8");
5285
- success(`Linked tracker id ${resolvedTrackerId} \u2192 ${path21.relative(root, intakeFile)}`);
6394
+ fs33.writeFileSync(intakeFile, updated, "utf8");
6395
+ success(`Linked tracker id ${resolvedTrackerId} \u2192 ${path25.relative(root, intakeFile)}`);
5286
6396
  blank();
5287
6397
  step("Next:");
5288
6398
  info("1) Review the intake \u2014 the Source block at the top now carries the tracker id.");
@@ -5300,39 +6410,39 @@ function upsertTrackerId(content, id) {
5300
6410
  }
5301
6411
 
5302
6412
  // src/commands/doctor-engine.ts
5303
- import fs29 from "fs";
5304
- import path22 from "path";
6413
+ import fs34 from "fs";
6414
+ import path26 from "path";
5305
6415
  async function gatherContext(paths) {
5306
6416
  const ctx = { hasLegacyPromptsDir: false };
5307
6417
  try {
5308
6418
  ctx.config = loadConfig(paths.configFile);
5309
- } catch (err) {
5310
- ctx.configError = err;
6419
+ } catch (err2) {
6420
+ ctx.configError = err2;
5311
6421
  }
5312
6422
  try {
5313
- ctx.secrets = fs29.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
5314
- } catch (err) {
5315
- ctx.secretsError = err;
6423
+ ctx.secrets = fs34.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
6424
+ } catch (err2) {
6425
+ ctx.secretsError = err2;
5316
6426
  }
5317
- ctx.hasLegacyPromptsDir = fs29.existsSync(paths.promptsDir);
6427
+ ctx.hasLegacyPromptsDir = fs34.existsSync(paths.promptsDir);
5318
6428
  return ctx;
5319
6429
  }
5320
6430
  function gitignoreHasManagedBlock(repoRoot) {
5321
- const gitignore = path22.join(repoRoot, ".gitignore");
5322
- return fs29.existsSync(gitignore) && fs29.readFileSync(gitignore, "utf8").includes(".squad/secrets.yaml");
6431
+ const gitignore = path26.join(repoRoot, ".gitignore");
6432
+ return fs34.existsSync(gitignore) && fs34.readFileSync(gitignore, "utf8").includes(".squad/secrets.yaml");
5323
6433
  }
5324
6434
  function gitignoreHasTrashPattern(repoRoot) {
5325
- const gitignore = path22.join(repoRoot, ".gitignore");
5326
- return fs29.existsSync(gitignore) && fs29.readFileSync(gitignore, "utf8").includes(SQUAD_TRASH_PATTERN);
6435
+ const gitignore = path26.join(repoRoot, ".gitignore");
6436
+ return fs34.existsSync(gitignore) && fs34.readFileSync(gitignore, "utf8").includes(SQUAD_TRASH_PATTERN);
5327
6437
  }
5328
6438
  async function checkDirStructure(paths, _ctx, fix) {
5329
- const need = [paths.squadDir, paths.storiesDir, paths.plansDir].filter((p) => !fs29.existsSync(p));
6439
+ const need = [paths.squadDir, paths.storiesDir, paths.plansDir].filter((p) => !fs34.existsSync(p));
5330
6440
  if (need.length === 0) {
5331
6441
  return { id: "dirs", name: ".squad/ directory structure", status: "ok" };
5332
6442
  }
5333
6443
  if (fix) {
5334
6444
  for (const p of need) {
5335
- fs29.mkdirSync(p, { recursive: true });
6445
+ fs34.mkdirSync(p, { recursive: true });
5336
6446
  }
5337
6447
  return { id: "dirs", name: ".squad/ directory structure", status: "ok", detail: "repaired" };
5338
6448
  }
@@ -5340,7 +6450,7 @@ async function checkDirStructure(paths, _ctx, fix) {
5340
6450
  id: "dirs",
5341
6451
  name: ".squad/ directory structure",
5342
6452
  status: "warn",
5343
- detail: `missing: ${need.map((p) => path22.relative(paths.root, p)).join(", ")}`,
6453
+ detail: `missing: ${need.map((p) => path26.relative(paths.root, p)).join(", ")}`,
5344
6454
  fixable: true,
5345
6455
  fixHint: "squad doctor --fix"
5346
6456
  };
@@ -5395,15 +6505,15 @@ async function checkSecretsPermissions(paths, _ctx, fix) {
5395
6505
  if (process.platform === "win32") {
5396
6506
  return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "skip", detail: "Windows" };
5397
6507
  }
5398
- if (!fs29.existsSync(paths.secretsFile)) {
6508
+ if (!fs34.existsSync(paths.secretsFile)) {
5399
6509
  return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok", detail: "not present" };
5400
6510
  }
5401
- const mode = fs29.statSync(paths.secretsFile).mode & 511;
6511
+ const mode = fs34.statSync(paths.secretsFile).mode & 511;
5402
6512
  if (mode === 384) {
5403
6513
  return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok" };
5404
6514
  }
5405
6515
  if (fix) {
5406
- fs29.chmodSync(paths.secretsFile, 384);
6516
+ fs34.chmodSync(paths.secretsFile, 384);
5407
6517
  return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok", detail: "repaired" };
5408
6518
  }
5409
6519
  return {
@@ -5424,7 +6534,7 @@ async function checkSecretsParseable(_paths, ctx) {
5424
6534
  detail: ctx.secretsError.message
5425
6535
  };
5426
6536
  }
5427
- if (!fs29.existsSync(_paths.secretsFile)) {
6537
+ if (!fs34.existsSync(_paths.secretsFile)) {
5428
6538
  return { id: "secrets-yaml", name: ".squad/secrets.yaml parseable", status: "ok", detail: "not present" };
5429
6539
  }
5430
6540
  return { id: "secrets-yaml", name: ".squad/secrets.yaml parseable", status: "ok" };
@@ -5484,14 +6594,6 @@ async function checkPlannerConfig(_paths, ctx) {
5484
6594
  detail: "planner budget limits must be > 0"
5485
6595
  };
5486
6596
  }
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
6597
  return { id: "planner-config", name: "planner configuration", status: "ok" };
5496
6598
  }
5497
6599
  async function checkPlannerCredential(_paths, ctx) {
@@ -5566,12 +6668,12 @@ async function checkPlannerModel(_paths, ctx) {
5566
6668
  status: "ok",
5567
6669
  detail: `${model} (${provider})`
5568
6670
  };
5569
- } catch (err) {
6671
+ } catch (err2) {
5570
6672
  return {
5571
6673
  id: "planner-model",
5572
6674
  name: "planner model resolves at provider",
5573
6675
  status: "warn",
5574
- detail: err.message
6676
+ detail: err2.message
5575
6677
  };
5576
6678
  }
5577
6679
  }
@@ -5704,8 +6806,8 @@ async function checkTrackerConfig(_paths, ctx) {
5704
6806
  }
5705
6807
  return { id: "tracker-config", name: "tracker configuration", status: "ok" };
5706
6808
  }
5707
- function formatClientError(err) {
5708
- return `${err.message} ${err.detail}`.trim();
6809
+ function formatClientError(err2) {
6810
+ return `${err2.message} ${err2.detail}`.trim();
5709
6811
  }
5710
6812
  async function checkTrackerCredential(_paths, ctx) {
5711
6813
  if (!ctx.config) {
@@ -5716,13 +6818,13 @@ async function checkTrackerCredential(_paths, ctx) {
5716
6818
  }
5717
6819
  const secrets = ctx.secrets ?? {};
5718
6820
  const overlay = overlayTrackerEnv(secrets);
5719
- const { client, error: error2 } = clientFor(ctx.config, overlay);
5720
- if (error2) {
6821
+ const { client, error } = clientFor(ctx.config, overlay);
6822
+ if (error) {
5721
6823
  return {
5722
6824
  id: "tracker-cred",
5723
6825
  name: "tracker credential resolves",
5724
6826
  status: "fail",
5725
- detail: formatClientError(error2)
6827
+ detail: formatClientError(error)
5726
6828
  };
5727
6829
  }
5728
6830
  if (!client) {
@@ -5741,8 +6843,8 @@ async function checkTrackerConnectivity(paths, ctx) {
5741
6843
  }
5742
6844
  const secrets = ctx.secrets ?? {};
5743
6845
  const overlay = overlayTrackerEnv(secrets);
5744
- const { client, error: error2 } = clientFor(ctx.config, overlay);
5745
- if (error2 || !client) {
6846
+ const { client, error } = clientFor(ctx.config, overlay);
6847
+ if (error || !client) {
5746
6848
  return { id: "tracker-live", name: "tracker connectivity", status: "skip", detail: "no credential" };
5747
6849
  }
5748
6850
  if (client.name === "jira") {
@@ -5785,12 +6887,12 @@ async function checkTrackerConnectivity(paths, ctx) {
5785
6887
  async function guardCheck(checkName, fn) {
5786
6888
  try {
5787
6889
  return await fn();
5788
- } catch (err) {
6890
+ } catch (err2) {
5789
6891
  return {
5790
6892
  id: "unexpected",
5791
6893
  name: checkName,
5792
6894
  status: "fail",
5793
- detail: err.message
6895
+ detail: err2.message
5794
6896
  };
5795
6897
  }
5796
6898
  }
@@ -5918,23 +7020,23 @@ async function runDoctor(opts) {
5918
7020
  }
5919
7021
 
5920
7022
  // src/commands/migrate.ts
5921
- import fs30 from "fs";
5922
- import path23 from "path";
7023
+ import fs35 from "fs";
7024
+ import path27 from "path";
5923
7025
  import { confirm as confirm7 } from "@inquirer/prompts";
5924
7026
  var LEGACY_PROMPTS_MIGRATION = {
5925
7027
  id: "legacy-prompts",
5926
7028
  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)`,
7029
+ isApplied: (paths) => !fs35.existsSync(paths.promptsDir),
7030
+ describe: (paths) => `delete ${path27.relative(paths.root, paths.promptsDir)} (prompts are now bundled in squad-kit)`,
5929
7031
  apply: (paths) => {
5930
7032
  const target = paths.promptsDir;
5931
- if (!target.includes(path23.sep + ".squad" + path23.sep + "prompts")) {
7033
+ if (!target.includes(path27.sep + ".squad" + path27.sep + "prompts")) {
5932
7034
  throw new Error(
5933
7035
  `Refusing to delete suspicious path: ${target}. This is a safety check \u2014 run \`squad doctor\` if .squad/ paths look wrong.`
5934
7036
  );
5935
7037
  }
5936
- if (fs30.existsSync(target)) {
5937
- fs30.rmSync(target, { recursive: true, force: true });
7038
+ if (fs35.existsSync(target)) {
7039
+ fs35.rmSync(target, { recursive: true, force: true });
5938
7040
  }
5939
7041
  }
5940
7042
  };
@@ -5942,8 +7044,8 @@ var GITIGNORE_MIGRATION = {
5942
7044
  id: "gitignore",
5943
7045
  name: "Ensure .gitignore managed block",
5944
7046
  isApplied: (paths) => {
5945
- const f = path23.join(paths.root, ".gitignore");
5946
- return fs30.existsSync(f) && fs30.readFileSync(f, "utf8").includes(".squad/secrets.yaml");
7047
+ const f = path27.join(paths.root, ".gitignore");
7048
+ return fs35.existsSync(f) && fs35.readFileSync(f, "utf8").includes(".squad/secrets.yaml");
5947
7049
  },
5948
7050
  describe: () => "add squad-kit managed block to .gitignore",
5949
7051
  apply: (paths) => {
@@ -5955,23 +7057,23 @@ var SECRETS_PERMS_MIGRATION = {
5955
7057
  name: "Tighten .squad/secrets.yaml permissions",
5956
7058
  isApplied: (paths) => {
5957
7059
  if (process.platform === "win32") return true;
5958
- if (!fs30.existsSync(paths.secretsFile)) return true;
5959
- const st = fs30.statSync(paths.secretsFile);
7060
+ if (!fs35.existsSync(paths.secretsFile)) return true;
7061
+ const st = fs35.statSync(paths.secretsFile);
5960
7062
  return (st.mode & 511) === 384;
5961
7063
  },
5962
- describe: (paths) => `chmod 0600 ${path23.relative(paths.root, paths.secretsFile)}`,
7064
+ describe: (paths) => `chmod 0600 ${path27.relative(paths.root, paths.secretsFile)}`,
5963
7065
  apply: (paths) => {
5964
7066
  if (process.platform === "win32") return;
5965
- if (fs30.existsSync(paths.secretsFile)) fs30.chmodSync(paths.secretsFile, 384);
7067
+ if (fs35.existsSync(paths.secretsFile)) fs35.chmodSync(paths.secretsFile, 384);
5966
7068
  }
5967
7069
  };
5968
7070
  var PLANNER_BUDGET_DEFAULTS_MIGRATION = {
5969
7071
  id: "planner-budget-defaults",
5970
7072
  name: "Backfill planner.budget defaults in config.yaml",
5971
7073
  isApplied: (paths) => {
5972
- if (!fs30.existsSync(paths.configFile)) return true;
7074
+ if (!fs35.existsSync(paths.configFile)) return true;
5973
7075
  try {
5974
- const raw = fs30.readFileSync(paths.configFile, "utf8");
7076
+ const raw = fs35.readFileSync(paths.configFile, "utf8");
5975
7077
  if (!/^\s*planner:/m.test(raw)) return true;
5976
7078
  if (/^\s*budget:/m.test(raw)) return true;
5977
7079
  const cfg = loadConfig(paths.configFile);
@@ -5990,9 +7092,9 @@ var CONFIG_NORMALIZE_MIGRATION = {
5990
7092
  id: "config-normalize",
5991
7093
  name: "Normalise .squad/config.yaml formatting",
5992
7094
  isApplied: (paths) => {
5993
- if (!fs30.existsSync(paths.configFile)) return true;
7095
+ if (!fs35.existsSync(paths.configFile)) return true;
5994
7096
  try {
5995
- const raw = fs30.readFileSync(paths.configFile, "utf8");
7097
+ const raw = fs35.readFileSync(paths.configFile, "utf8");
5996
7098
  const cfg = loadConfig(paths.configFile);
5997
7099
  return raw.trim() === serializeConfig(cfg).trim();
5998
7100
  } catch {
@@ -6001,7 +7103,7 @@ var CONFIG_NORMALIZE_MIGRATION = {
6001
7103
  },
6002
7104
  describe: () => "rewrite config.yaml with canonical YAML formatting (drops comments)",
6003
7105
  apply: (paths) => {
6004
- if (!fs30.existsSync(paths.configFile)) return;
7106
+ if (!fs35.existsSync(paths.configFile)) return;
6005
7107
  const cfg = loadConfig(paths.configFile);
6006
7108
  saveConfig(paths.configFile, cfg);
6007
7109
  }
@@ -6062,8 +7164,8 @@ async function runMigrate(opts) {
6062
7164
  await Promise.resolve(m.apply(paths));
6063
7165
  spin.succeed(`${m.name} \u2014 applied`);
6064
7166
  applied++;
6065
- } catch (err) {
6066
- spin.fail(`${m.name} \u2014 failed: ${err.message}`);
7167
+ } catch (err2) {
7168
+ spin.fail(`${m.name} \u2014 failed: ${err2.message}`);
6067
7169
  process.exit(1);
6068
7170
  }
6069
7171
  }
@@ -6082,8 +7184,8 @@ import process3 from "process";
6082
7184
  import { confirm as confirm8 } from "@inquirer/prompts";
6083
7185
 
6084
7186
  // src/core/package-info.ts
6085
- import fs31 from "fs";
6086
- import path24 from "path";
7187
+ import fs36 from "fs";
7188
+ import path28 from "path";
6087
7189
  function changelogBlobUrl(repositoryUrl, version) {
6088
7190
  let base = repositoryUrl;
6089
7191
  if (!base) base = "https://github.com/AzmSquad/squad-kit";
@@ -6094,9 +7196,9 @@ function changelogBlobUrl(repositoryUrl, version) {
6094
7196
  }
6095
7197
  function readInstalledPackage() {
6096
7198
  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"));
7199
+ const pkgFile = path28.join(root, "package.json");
7200
+ const pkg = JSON.parse(fs36.readFileSync(pkgFile, "utf8"));
7201
+ const isDevInstall = fs36.existsSync(path28.join(root, "src"));
6100
7202
  return {
6101
7203
  name: pkg.name,
6102
7204
  version: pkg.version,
@@ -6136,7 +7238,7 @@ function isNewer(a, b) {
6136
7238
  }
6137
7239
 
6138
7240
  // src/core/package-manager.ts
6139
- import path25 from "path";
7241
+ import path29 from "path";
6140
7242
  import process2 from "process";
6141
7243
  function detectPackageManager(installRoot) {
6142
7244
  const ua = process2.env.npm_config_user_agent;
@@ -6147,11 +7249,11 @@ function detectPackageManager(installRoot) {
6147
7249
  if (ua.startsWith("npm/")) return { pm: "npm", reason: "npm_config_user_agent" };
6148
7250
  }
6149
7251
  const root = installRoot.toLowerCase();
6150
- if (root.includes(`${path25.sep}pnpm${path25.sep}`) || root.includes(`${path25.sep}.pnpm${path25.sep}`))
7252
+ if (root.includes(`${path29.sep}pnpm${path29.sep}`) || root.includes(`${path29.sep}.pnpm${path29.sep}`))
6151
7253
  return { pm: "pnpm", reason: `install path contains pnpm` };
6152
- if (root.includes(`${path25.sep}.yarn${path25.sep}`) || root.includes(`${path25.sep}yarn${path25.sep}`))
7254
+ if (root.includes(`${path29.sep}.yarn${path29.sep}`) || root.includes(`${path29.sep}yarn${path29.sep}`))
6153
7255
  return { pm: "yarn", reason: `install path contains yarn` };
6154
- if (root.includes(`${path25.sep}.bun${path25.sep}`) || root.includes(`${path25.sep}bun${path25.sep}`))
7256
+ if (root.includes(`${path29.sep}.bun${path29.sep}`) || root.includes(`${path29.sep}bun${path29.sep}`))
6155
7257
  return { pm: "bun", reason: `install path contains bun` };
6156
7258
  return { pm: "npm", reason: "default fallback" };
6157
7259
  }
@@ -6185,9 +7287,9 @@ async function runUpgrade(opts) {
6185
7287
  try {
6186
7288
  const info2 = await fetchLatest(installed.name);
6187
7289
  latest = info2.latest;
6188
- } catch (err) {
7290
+ } catch (err2) {
6189
7291
  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.`
7292
+ `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
7293
  );
6192
7294
  process3.exit(1);
6193
7295
  }
@@ -6247,7 +7349,7 @@ function spawnInherit(cmd, args) {
6247
7349
  return new Promise((resolve2, reject) => {
6248
7350
  const child = spawn2(cmd, args, { stdio: "inherit", shell: process3.platform === "win32" });
6249
7351
  child.on("exit", (code) => resolve2(code ?? 0));
6250
- child.on("error", (err) => reject(err));
7352
+ child.on("error", (err2) => reject(err2));
6251
7353
  });
6252
7354
  }
6253
7355
 
@@ -6264,25 +7366,25 @@ import { cors } from "hono/cors";
6264
7366
  import { secureHeaders } from "hono/secure-headers";
6265
7367
 
6266
7368
  // src/console/api/stories.ts
6267
- import fs32 from "fs";
6268
- import { z } from "zod";
7369
+ import fs37 from "fs";
7370
+ import { z as z5 } from "zod";
6269
7371
 
6270
7372
  // src/utils/path-safety.ts
6271
- import path26 from "path";
7373
+ import path30 from "path";
6272
7374
  function isInside(abs, parent) {
6273
- const a = path26.resolve(abs) + path26.sep;
6274
- const p = path26.resolve(parent) + path26.sep;
7375
+ const a = path30.resolve(abs) + path30.sep;
7376
+ const p = path30.resolve(parent) + path30.sep;
6275
7377
  return a.startsWith(p);
6276
7378
  }
6277
7379
 
6278
7380
  // 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()
7381
+ var CreateBody = z5.object({
7382
+ feature: z5.string().min(1).max(80),
7383
+ title: z5.string().max(200).optional(),
7384
+ trackerId: z5.string().max(80).optional()
6283
7385
  });
6284
- var PatchBody = z.object({
6285
- intakeContent: z.string().min(1).max(1e6)
7386
+ var PatchBody = z5.object({
7387
+ intakeContent: z5.string().min(1).max(1e6)
6286
7388
  });
6287
7389
  function safeFeatureParam(feature) {
6288
7390
  return feature.length > 0 && !feature.includes("/") && !feature.includes("..") && slugify(feature) === feature;
@@ -6311,7 +7413,7 @@ function mountStoriesApi(app, opts) {
6311
7413
  if (!isInside(hit.intakePath, opts.paths.storiesDir)) return c.json({ error: "forbidden" }, 403);
6312
7414
  let intakeContent = "";
6313
7415
  try {
6314
- intakeContent = fs32.readFileSync(hit.intakePath, "utf8");
7416
+ intakeContent = fs37.readFileSync(hit.intakePath, "utf8");
6315
7417
  } catch {
6316
7418
  }
6317
7419
  return c.json({
@@ -6355,7 +7457,7 @@ function mountStoriesApi(app, opts) {
6355
7457
  const hit = stories.find((s) => s.id === id);
6356
7458
  if (!hit) return c.json({ error: "not_found" }, 404);
6357
7459
  if (!isInside(hit.intakePath, opts.paths.storiesDir)) return c.json({ error: "forbidden" }, 403);
6358
- fs32.writeFileSync(hit.intakePath, body.data.intakeContent, "utf8");
7460
+ fs37.writeFileSync(hit.intakePath, body.data.intakeContent, "utf8");
6359
7461
  return c.json({ ok: true });
6360
7462
  });
6361
7463
  app.delete("/api/stories/:feature/:id", (c) => {
@@ -6372,14 +7474,14 @@ function mountStoriesApi(app, opts) {
6372
7474
  }
6373
7475
 
6374
7476
  // src/console/api/plans.ts
6375
- import fs33 from "fs";
6376
- import path27 from "path";
6377
- import { z as z2 } from "zod";
7477
+ import fs38 from "fs";
7478
+ import path31 from "path";
7479
+ import { z as z6 } from "zod";
6378
7480
  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)
7481
+ var DiffQuery = z6.object({
7482
+ feature: z6.string().min(1).max(80),
7483
+ a: z6.string().min(1).max(200),
7484
+ b: z6.string().min(1).max(200)
6383
7485
  });
6384
7486
  function safeFeatureParam2(feature) {
6385
7487
  return feature.length > 0 && !feature.includes("/") && !feature.includes("..") && slugify(feature) === feature;
@@ -6388,11 +7490,11 @@ function mountPlansApi(app, opts) {
6388
7490
  app.get("/api/plans", (c) => {
6389
7491
  const out = [];
6390
7492
  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)) {
7493
+ const featureDir = path31.join(opts.paths.plansDir, feature);
7494
+ if (!fs38.existsSync(featureDir)) continue;
7495
+ for (const entry of fs38.readdirSync(featureDir)) {
6394
7496
  if (!entry.endsWith(".md") || entry === "00-index.md") continue;
6395
- const abs = path27.join(featureDir, entry);
7497
+ const abs = path31.join(featureDir, entry);
6396
7498
  out.push({ feature, planFile: entry, metadata: readPlanMetadata(abs) });
6397
7499
  }
6398
7500
  }
@@ -6401,10 +7503,10 @@ function mountPlansApi(app, opts) {
6401
7503
  app.get("/api/plans/:feature/:planFile", (c) => {
6402
7504
  const { feature, planFile } = c.req.param();
6403
7505
  if (!safeFeatureParam2(feature)) return c.json({ error: "forbidden" }, 403);
6404
- const abs = path27.resolve(path27.join(opts.paths.plansDir, feature, planFile));
7506
+ const abs = path31.resolve(path31.join(opts.paths.plansDir, feature, planFile));
6405
7507
  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");
7508
+ if (!fs38.existsSync(abs)) return c.json({ error: "not_found" }, 404);
7509
+ const content = fs38.readFileSync(abs, "utf8");
6408
7510
  return c.json({
6409
7511
  feature,
6410
7512
  planFile,
@@ -6422,15 +7524,15 @@ function mountPlansApi(app, opts) {
6422
7524
  if (!parse.success) return c.json({ error: "invalid_query", issues: parse.error.issues }, 400);
6423
7525
  const { feature, a, b } = parse.data;
6424
7526
  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);
7527
+ const featureDir = path31.join(opts.paths.plansDir, feature);
7528
+ const aAbs = path31.join(featureDir, a);
7529
+ const bAbs = path31.join(featureDir, b);
6428
7530
  if (!isInside(aAbs, opts.paths.plansDir) || !isInside(bAbs, opts.paths.plansDir)) {
6429
7531
  return c.json({ error: "forbidden" }, 403);
6430
7532
  }
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");
7533
+ if (!fs38.existsSync(aAbs) || !fs38.existsSync(bAbs)) return c.json({ error: "not_found" }, 404);
7534
+ const aText = fs38.readFileSync(aAbs, "utf8");
7535
+ const bText = fs38.readFileSync(bAbs, "utf8");
6434
7536
  const changes = diffLines(aText, bText);
6435
7537
  return c.json({ feature, a, b, changes });
6436
7538
  });
@@ -6438,23 +7540,23 @@ function mountPlansApi(app, opts) {
6438
7540
  const trash = c.req.query("trash") === "1";
6439
7541
  const { feature, planFile } = c.req.param();
6440
7542
  if (!safeFeatureParam2(feature)) return c.json({ error: "forbidden" }, 403);
6441
- const abs = path27.join(opts.paths.plansDir, feature, planFile);
7543
+ const abs = path31.join(opts.paths.plansDir, feature, planFile);
6442
7544
  if (!isInside(abs, opts.paths.plansDir)) return c.json({ error: "forbidden" }, 403);
6443
- if (!fs33.existsSync(abs)) return c.json({ error: "not_found" }, 404);
7545
+ if (!fs38.existsSync(abs)) return c.json({ error: "not_found" }, 404);
6444
7546
  if (trash) {
6445
7547
  const result = trashOrDelete([abs], opts.paths.trashDir, true);
6446
- removeOverviewRowForPlanFile(path27.join(opts.paths.plansDir, feature), planFile);
7548
+ removeOverviewRowForPlanFile(path31.join(opts.paths.plansDir, feature), planFile);
6447
7549
  return c.json({ ok: true, trashed: result.trashed });
6448
7550
  }
6449
- fs33.rmSync(abs);
6450
- removeOverviewRowForPlanFile(path27.join(opts.paths.plansDir, feature), planFile);
7551
+ fs38.rmSync(abs);
7552
+ removeOverviewRowForPlanFile(path31.join(opts.paths.plansDir, feature), planFile);
6451
7553
  return c.json({ ok: true });
6452
7554
  });
6453
7555
  }
6454
7556
 
6455
7557
  // src/console/api/config.ts
6456
- import { z as z3 } from "zod";
6457
- var ConfigBody = z3.object({}).passthrough();
7558
+ import { z as z7 } from "zod";
7559
+ var ConfigBody = z7.object({}).passthrough();
6458
7560
  function mountConfigApi(app, opts) {
6459
7561
  app.get("/api/config", (c) => {
6460
7562
  const config2 = loadConfig(opts.paths.configFile);
@@ -6466,8 +7568,8 @@ function mountConfigApi(app, opts) {
6466
7568
  try {
6467
7569
  saveConfig(opts.paths.configFile, body.data);
6468
7570
  return c.json({ ok: true });
6469
- } catch (err) {
6470
- return c.json({ error: "invalid_config", detail: err.message }, 400);
7571
+ } catch (err2) {
7572
+ return c.json({ error: "invalid_config", detail: err2.message }, 400);
6471
7573
  }
6472
7574
  });
6473
7575
  }
@@ -6494,14 +7596,14 @@ function mountMetaApi(app, opts) {
6494
7596
 
6495
7597
  // src/console/api/runs.ts
6496
7598
  import { streamSSE } from "hono/streaming";
6497
- import { z as z4 } from "zod";
6498
- import path28 from "path";
6499
- import fs34 from "fs";
7599
+ import { z as z8 } from "zod";
7600
+ import path32 from "path";
7601
+ import fs39 from "fs";
6500
7602
  var active = /* @__PURE__ */ new Map();
6501
7603
  var EVENT_BUF_CAP = 512;
6502
- var StartBody = z4.object({
6503
- feature: z4.string().min(1),
6504
- storyId: z4.string().min(1)
7604
+ var StartBody = z8.object({
7605
+ feature: z8.string().min(1),
7606
+ storyId: z8.string().min(1)
6505
7607
  });
6506
7608
  function emptyStats(durationMs) {
6507
7609
  return {
@@ -6557,11 +7659,36 @@ function mountRunsApi(app, opts) {
6557
7659
  const done = (async () => {
6558
7660
  const cfgFresh = loadConfig(opts.paths.configFile);
6559
7661
  const planner = cfgFresh.planner;
6560
- const provider = providerFor(planner.provider);
6561
- const model = modelFor(planner.provider, "plan", planner.modelOverride);
7662
+ const modelId = modelFor(planner.provider, "plan", planner.modelOverride);
7663
+ const { model } = resolveModel(planner.provider, modelId, apiKey);
6562
7664
  const budget = new Budget(planner.budget);
6563
7665
  const cacheEnabled = planner.cache?.enabled ?? true;
6564
- const repoMap = buildRepoMap(opts.paths.root);
7666
+ const scoutEnabled = planner.stages?.scout?.enabled !== false;
7667
+ const validationEnabled = planner.validation?.enabled !== false;
7668
+ const strictValidation = planner.validation?.strict === true;
7669
+ const maxScoutFiles = planner.stages?.scout?.maxFiles ?? 12;
7670
+ let scoutModelId = "";
7671
+ let scoutModelHandle;
7672
+ let scoutSystemPrompt;
7673
+ if (scoutEnabled) {
7674
+ scoutModelId = modelFor(
7675
+ planner.provider,
7676
+ "scout",
7677
+ planner.modelOverride,
7678
+ planner.stages?.scout?.modelOverride
7679
+ );
7680
+ scoutModelHandle = resolveModel(planner.provider, scoutModelId, apiKey).model;
7681
+ }
7682
+ const repoMap = buildRepoMap(opts.paths.root, { format: "tree" });
7683
+ if (scoutEnabled) {
7684
+ scoutSystemPrompt = composeScoutSystemPrompt({
7685
+ projectRoots: cfgFresh.project.projectRoots ?? ["."],
7686
+ primaryLanguage: cfgFresh.project.primaryLanguage ?? "",
7687
+ trackerType: cfgFresh.tracker.type,
7688
+ repoMap,
7689
+ plansIndex: buildPlansIndex(opts.paths)
7690
+ });
7691
+ }
6565
7692
  const systemPrompt = composeSystemPrompt({
6566
7693
  projectRoots: cfgFresh.project.projectRoots ?? ["."],
6567
7694
  primaryLanguage: cfgFresh.project.primaryLanguage ?? "",
@@ -6569,15 +7696,15 @@ function mountRunsApi(app, opts) {
6569
7696
  repoMap
6570
7697
  });
6571
7698
  const userPrompt = composeUserPrompt({
6572
- intakeContent: fs34.readFileSync(story.intakePath, "utf8")
7699
+ intakeContent: fs39.readFileSync(story.intakePath, "utf8")
6573
7700
  });
6574
7701
  let result;
6575
7702
  try {
6576
7703
  result = await runPlanner({
6577
7704
  root: opts.paths.root,
6578
- provider,
6579
7705
  model,
6580
- apiKey,
7706
+ provider: planner.provider,
7707
+ modelId,
6581
7708
  systemPrompt,
6582
7709
  userPrompt,
6583
7710
  budget,
@@ -6585,14 +7712,26 @@ function mountRunsApi(app, opts) {
6585
7712
  cacheEnabled,
6586
7713
  events: bus,
6587
7714
  runId,
6588
- abort: controller.signal
7715
+ abort: controller.signal,
7716
+ stages: {
7717
+ scout: {
7718
+ enabled: scoutEnabled,
7719
+ model: scoutModelHandle,
7720
+ modelId: scoutModelId,
7721
+ maxFiles: maxScoutFiles,
7722
+ maxOutputTokens: planner.stages?.scout?.maxOutputTokens ?? 2048
7723
+ }
7724
+ },
7725
+ validation: { enabled: validationEnabled, strict: strictValidation },
7726
+ toolsEnabled: planner.tools,
7727
+ scoutSystemPrompt
6589
7728
  });
6590
- } catch (err) {
7729
+ } catch (err2) {
6591
7730
  const durationMs = Date.now() - startedAt;
6592
7731
  const last = eventBuffer[eventBuffer.length - 1];
6593
7732
  const isRateLimitAbort = last?.kind === "rate_limit" && last.phase === "aborted";
6594
7733
  if (!isRateLimitAbort) {
6595
- bus.emit({ kind: "error", runId, message: err.message });
7734
+ bus.emit({ kind: "error", runId, message: err2.message });
6596
7735
  }
6597
7736
  bus.emit({
6598
7737
  kind: "done",
@@ -6605,20 +7744,25 @@ function mountRunsApi(app, opts) {
6605
7744
  });
6606
7745
  return;
6607
7746
  }
6608
- const runSuccess = result.finishedNormally && !result.timedOut && !result.userCancelled;
7747
+ const issueCount = result.validation?.issues.length ?? 0;
7748
+ const issuesByKind = summariseIssuesByKind(result.validation?.issues ?? []);
7749
+ const validationBlocks = strictValidation && validationEnabled && issueCount > 0;
7750
+ const runSuccess = result.finishedNormally && !result.timedOut && !result.userCancelled && !validationBlocks;
6609
7751
  let planFile = null;
6610
7752
  if (result.planText.trim()) {
6611
7753
  const snap = budget.snapshot();
6612
7754
  const header = buildMetadataHeader({
6613
7755
  provider: planner.provider,
6614
- model,
7756
+ model: modelId,
6615
7757
  reads: snap.reads,
6616
7758
  bytes: snap.bytes,
6617
7759
  inputTokens: snap.usage.inputTokens,
6618
7760
  outputTokens: snap.usage.outputTokens,
6619
7761
  durationMs: Date.now() - startedAt,
6620
- costUsd: snap.usage.costUsd,
6621
- planStatus: runSuccess ? void 0 : "partial"
7762
+ planStatus: runSuccess ? void 0 : "partial",
7763
+ scoutEnabled,
7764
+ validationEnabled,
7765
+ validationIssueCount: issueCount
6622
7766
  });
6623
7767
  const out = writePlanFile({
6624
7768
  paths: opts.paths,
@@ -6628,19 +7772,19 @@ function mountRunsApi(app, opts) {
6628
7772
  metadataHeader: header,
6629
7773
  partial: !runSuccess
6630
7774
  });
6631
- planFile = path28.relative(opts.paths.root, out.planFile);
7775
+ planFile = path32.relative(opts.paths.root, out.planFile);
6632
7776
  }
6633
7777
  try {
6634
7778
  await writeLastRun(opts.paths, {
6635
7779
  stats: result.stats,
6636
7780
  completedAt: (/* @__PURE__ */ new Date()).toISOString(),
6637
7781
  provider: planner.provider,
6638
- model
7782
+ model: modelId
6639
7783
  });
6640
7784
  await appendRun(opts.paths, {
6641
7785
  runId,
6642
7786
  provider: planner.provider,
6643
- model,
7787
+ model: modelId,
6644
7788
  feature: story.feature,
6645
7789
  storyId: story.id,
6646
7790
  startedAt: new Date(startedAt).toISOString(),
@@ -6650,7 +7794,19 @@ function mountRunsApi(app, opts) {
6650
7794
  planFile,
6651
7795
  stats: result.stats,
6652
7796
  cacheEnabled,
6653
- durationMs: Date.now() - startedAt
7797
+ durationMs: Date.now() - startedAt,
7798
+ scout: {
7799
+ enabled: scoutEnabled,
7800
+ selectedCount: result.scout?.selected.length,
7801
+ tokensUsed: result.scout?.tokensUsed,
7802
+ durationMs: result.scout?.durationMs
7803
+ },
7804
+ validation: {
7805
+ enabled: validationEnabled,
7806
+ issuesCount: issueCount,
7807
+ issuesByKind,
7808
+ durationMs: result.validation?.durationMs
7809
+ }
6654
7810
  });
6655
7811
  } catch {
6656
7812
  }
@@ -6723,7 +7879,7 @@ function mountRunsApi(app, opts) {
6723
7879
  }
6724
7880
 
6725
7881
  // src/console/api/secrets.ts
6726
- import { z as z5 } from "zod";
7882
+ import { z as z9 } from "zod";
6727
7883
 
6728
7884
  // src/core/mask-token.ts
6729
7885
  function maskToken(t) {
@@ -6761,26 +7917,26 @@ function mountSecretsApi(app, opts) {
6761
7917
  }
6762
7918
  });
6763
7919
  });
6764
- const PutBody = z5.object({
6765
- planner: z5.object({
6766
- anthropic: z5.string().optional(),
6767
- openai: z5.string().optional(),
6768
- google: z5.string().optional()
7920
+ const PutBody = z9.object({
7921
+ planner: z9.object({
7922
+ anthropic: z9.string().optional(),
7923
+ openai: z9.string().optional(),
7924
+ google: z9.string().optional()
6769
7925
  }).optional(),
6770
- tracker: z5.object({
6771
- jira: z5.object({
6772
- host: z5.string().optional(),
6773
- email: z5.string().optional(),
6774
- token: z5.string().optional()
7926
+ tracker: z9.object({
7927
+ jira: z9.object({
7928
+ host: z9.string().optional(),
7929
+ email: z9.string().optional(),
7930
+ token: z9.string().optional()
6775
7931
  }).optional(),
6776
- azure: z5.object({
6777
- organization: z5.string().optional(),
6778
- project: z5.string().optional(),
6779
- pat: z5.string().optional()
7932
+ azure: z9.object({
7933
+ organization: z9.string().optional(),
7934
+ project: z9.string().optional(),
7935
+ pat: z9.string().optional()
6780
7936
  }).optional(),
6781
- github: z5.object({
6782
- host: z5.string().optional(),
6783
- pat: z5.string().optional()
7937
+ github: z9.object({
7938
+ host: z9.string().optional(),
7939
+ pat: z9.string().optional()
6784
7940
  }).optional()
6785
7941
  }).optional()
6786
7942
  });
@@ -6820,12 +7976,12 @@ function mountSecretsApi(app, opts) {
6820
7976
  }
6821
7977
 
6822
7978
  // 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()
7979
+ import { z as z10 } from "zod";
7980
+ var SearchQuery = z10.object({ q: z10.string().max(500) });
7981
+ var ImportBody = z10.object({
7982
+ issueId: z10.string().min(1),
7983
+ feature: z10.string().min(1).max(80),
7984
+ withAttachments: z10.boolean().optional()
6829
7985
  });
6830
7986
  function mountTrackerApi(app, opts) {
6831
7987
  app.get("/api/tracker/search", async (c) => {
@@ -6833,18 +7989,18 @@ function mountTrackerApi(app, opts) {
6833
7989
  if (!q.success) return c.json({ error: "invalid_query" }, 400);
6834
7990
  const cfg = loadConfig(opts.paths.configFile);
6835
7991
  const secrets = overlayTrackerEnv(loadSecrets(opts.paths.secretsFile));
6836
- const { client, error: error2 } = clientFor(cfg, secrets);
7992
+ const { client, error } = clientFor(cfg, secrets);
6837
7993
  if (!client) {
6838
- return c.json({ error: error2?.kind ?? "unsupported", detail: error2?.detail ?? "" }, 400);
7994
+ return c.json({ error: error?.kind ?? "unsupported", detail: error?.detail ?? "" }, 400);
6839
7995
  }
6840
7996
  try {
6841
7997
  const results = await client.searchIssues(q.data.q);
6842
7998
  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);
7999
+ } catch (err2) {
8000
+ if (err2 instanceof TrackerError) {
8001
+ return c.json({ ok: false, kind: err2.kind, status: err2.statusCode, detail: err2.message }, 200);
6846
8002
  }
6847
- return c.json({ ok: false, detail: err.message }, 200);
8003
+ return c.json({ ok: false, detail: err2.message }, 200);
6848
8004
  }
6849
8005
  });
6850
8006
  app.post("/api/tracker/import", async (c) => {
@@ -6852,9 +8008,9 @@ function mountTrackerApi(app, opts) {
6852
8008
  if (!body.success) return c.json({ error: "invalid_body", issues: body.error.issues }, 400);
6853
8009
  const cfg = loadConfig(opts.paths.configFile);
6854
8010
  const secrets = overlayTrackerEnv(loadSecrets(opts.paths.secretsFile));
6855
- const { client, error: error2 } = clientFor(cfg, secrets);
8011
+ const { client, error } = clientFor(cfg, secrets);
6856
8012
  if (!client) {
6857
- return c.json({ error: error2?.kind ?? "unsupported", detail: error2?.detail ?? "" }, 400);
8013
+ return c.json({ error: error?.kind ?? "unsupported", detail: error?.detail ?? "" }, 400);
6858
8014
  }
6859
8015
  try {
6860
8016
  const issue = await client.fetchIssue(body.data.issueId);
@@ -6867,9 +8023,9 @@ function mountTrackerApi(app, opts) {
6867
8023
  config: cfg
6868
8024
  });
6869
8025
  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);
8026
+ } catch (err2) {
8027
+ if (err2 instanceof TrackerError) return c.json({ error: err2.kind, detail: err2.message }, 502);
8028
+ return c.json({ error: "fetch_failed", detail: err2.message }, 500);
6873
8029
  }
6874
8030
  });
6875
8031
  }
@@ -6966,17 +8122,17 @@ function countStories(stories) {
6966
8122
  }
6967
8123
 
6968
8124
  // src/console/api/projects.ts
6969
- import fs35 from "fs/promises";
6970
- import path29 from "path";
8125
+ import fs40 from "fs/promises";
8126
+ import path33 from "path";
6971
8127
  import os from "os";
6972
8128
  var MAX = 10;
6973
8129
  function recentFilePath() {
6974
- return path29.join(os.homedir(), ".squad", "recent-projects.json");
8130
+ return path33.join(os.homedir(), ".squad", "recent-projects.json");
6975
8131
  }
6976
8132
  async function loadRecent() {
6977
8133
  const RECENT_FILE = recentFilePath();
6978
8134
  try {
6979
- const raw = await fs35.readFile(RECENT_FILE, "utf8");
8135
+ const raw = await fs40.readFile(RECENT_FILE, "utf8");
6980
8136
  const parsed = JSON.parse(raw);
6981
8137
  return Array.isArray(parsed) ? parsed.slice(0, MAX) : [];
6982
8138
  } catch {
@@ -6985,8 +8141,8 @@ async function loadRecent() {
6985
8141
  }
6986
8142
  async function saveRecent(list) {
6987
8143
  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");
8144
+ await fs40.mkdir(path33.dirname(RECENT_FILE), { recursive: true });
8145
+ await fs40.writeFile(RECENT_FILE, JSON.stringify(list.slice(0, MAX), null, 2) + "\n", "utf8");
6990
8146
  }
6991
8147
  function mountProjectsApi(app, opts) {
6992
8148
  app.get("/api/projects/recent", async (c) => c.json(await loadRecent()));
@@ -7015,8 +8171,8 @@ function mountApi(app, opts) {
7015
8171
  }
7016
8172
 
7017
8173
  // src/console/static-assets.ts
7018
- import fs36 from "fs";
7019
- import path30 from "path";
8174
+ import fs41 from "fs";
8175
+ import path34 from "path";
7020
8176
  import { fileURLToPath as fileURLToPath2 } from "url";
7021
8177
  import { Readable } from "stream";
7022
8178
  function pathnameForStatic(c) {
@@ -7046,18 +8202,18 @@ function mountStaticAssets(app, _opts) {
7046
8202
  }
7047
8203
  if (c.req.path.startsWith("/api/") || c.req.path.startsWith("/healthz")) return c.notFound();
7048
8204
  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" });
8205
+ const indexHtml = path34.join(distDir, "index.html");
8206
+ if (!fs41.existsSync(indexHtml)) return c.text("UI not built", 404);
8207
+ return c.html(fs41.readFileSync(indexHtml, "utf8"), 200, { "cache-control": "no-store" });
7052
8208
  });
7053
8209
  }
7054
8210
  function assetResponse(c, distDir, pathname) {
7055
8211
  if (!distDir) return c.text("UI not built", 404);
7056
8212
  const rel = pathname.replace(/^\//, "");
7057
- const abs = path30.normalize(path30.join(distDir, rel));
8213
+ const abs = path34.normalize(path34.join(distDir, rel));
7058
8214
  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));
8215
+ if (!fs41.existsSync(abs) || !fs41.statSync(abs).isFile()) return c.text("not found", 404);
8216
+ const body = Readable.toWeb(fs41.createReadStream(abs));
7061
8217
  return new Response(body, {
7062
8218
  headers: {
7063
8219
  "content-type": contentTypeFor(abs),
@@ -7067,9 +8223,9 @@ function assetResponse(c, distDir, pathname) {
7067
8223
  }
7068
8224
  function serveFile(c, distDir, name, ct) {
7069
8225
  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), {
8226
+ const abs = path34.join(distDir, name);
8227
+ if (!fs41.existsSync(abs)) return c.text("not found", 404);
8228
+ return new Response(fs41.readFileSync(abs), {
7073
8229
  headers: { "content-type": ct, "cache-control": "public, max-age=86400" }
7074
8230
  });
7075
8231
  }
@@ -7084,13 +8240,13 @@ function contentTypeFor(p) {
7084
8240
  return "application/octet-stream";
7085
8241
  }
7086
8242
  function isInside2(abs, parent) {
7087
- const a = path30.resolve(abs) + path30.sep;
7088
- const p = path30.resolve(parent) + path30.sep;
8243
+ const a = path34.resolve(abs) + path34.sep;
8244
+ const p = path34.resolve(parent) + path34.sep;
7089
8245
  return a.startsWith(p);
7090
8246
  }
7091
8247
  function resolveConsoleUiDist() {
7092
8248
  const fromEnv = process.env.SQUAD_CONSOLE_UI_DIST;
7093
- if (fromEnv && fs36.existsSync(path30.join(fromEnv, "index.html"))) return fromEnv;
8249
+ if (fromEnv && fs41.existsSync(path34.join(fromEnv, "index.html"))) return fromEnv;
7094
8250
  const candidates = [
7095
8251
  fileURLToPath2(new URL("./console-ui/", import.meta.url)),
7096
8252
  // dist/cli.js → dist/console-ui/
@@ -7102,7 +8258,7 @@ function resolveConsoleUiDist() {
7102
8258
  // src/console/*.ts → project/console-ui/dist
7103
8259
  ];
7104
8260
  for (const c of candidates) {
7105
- if (fs36.existsSync(path30.join(c, "index.html"))) return c;
8261
+ if (fs41.existsSync(path34.join(c, "index.html"))) return c;
7106
8262
  }
7107
8263
  return null;
7108
8264
  }
@@ -7178,13 +8334,13 @@ async function startConsoleServer(opts) {
7178
8334
  return bindPort;
7179
8335
  })();
7180
8336
  const shutdown = (onClose) => {
7181
- nodeServer.close((err) => {
7182
- onClose(err);
8337
+ nodeServer.close((err2) => {
8338
+ onClose(err2);
7183
8339
  });
7184
8340
  };
7185
8341
  const stop = () => {
7186
- shutdown((err) => {
7187
- if (err) warning(`server close: ${err.message}`);
8342
+ shutdown((err2) => {
8343
+ if (err2) warning(`server close: ${err2.message}`);
7188
8344
  resolveDone();
7189
8345
  });
7190
8346
  };
@@ -7194,9 +8350,9 @@ async function startConsoleServer(opts) {
7194
8350
  port: actualPort,
7195
8351
  done,
7196
8352
  close: () => new Promise((resolve2, reject) => {
7197
- shutdown((err) => {
7198
- if (err) {
7199
- reject(err);
8353
+ shutdown((err2) => {
8354
+ if (err2) {
8355
+ reject(err2);
7200
8356
  return;
7201
8357
  }
7202
8358
  resolve2();
@@ -7284,18 +8440,18 @@ async function runConsole(opts) {
7284
8440
  }
7285
8441
 
7286
8442
  // src/commands/rm/story.ts
7287
- import fs38 from "fs";
7288
- import path32 from "path";
8443
+ import fs43 from "fs";
8444
+ import path36 from "path";
7289
8445
  import { confirm as confirm10 } from "@inquirer/prompts";
7290
8446
 
7291
8447
  // src/commands/rm/feature.ts
7292
- import fs37 from "fs";
7293
- import path31 from "path";
8448
+ import fs42 from "fs";
8449
+ import path35 from "path";
7294
8450
  import { confirm as confirm9 } from "@inquirer/prompts";
7295
8451
  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;
8452
+ const d = path35.join(storiesDir, feature);
8453
+ if (!fs42.existsSync(d)) return 0;
8454
+ return fs42.readdirSync(d, { withFileTypes: true }).filter((x) => x.isDirectory()).length;
7299
8455
  }
7300
8456
  async function runRmFeature(featureSlug, opts = {}) {
7301
8457
  const root = requireSquadRoot();
@@ -7329,17 +8485,17 @@ async function runRmFeature(featureSlug, opts = {}) {
7329
8485
  feature = await pickFeatureInteractive(paths);
7330
8486
  }
7331
8487
  }
7332
- const storyDir = path31.join(paths.storiesDir, feature);
7333
- const planDir = path31.join(paths.plansDir, feature);
8488
+ const storyDir = path35.join(paths.storiesDir, feature);
8489
+ const planDir = path35.join(paths.plansDir, feature);
7334
8490
  const nStories = countStoryDirs(paths.storiesDir, feature);
7335
8491
  const nPlans = listAllPlanListEntries(paths, feature).length;
7336
- const hasOverview = fs37.existsSync(path31.join(planDir, "00-overview.md")) ? 1 : 0;
8492
+ const hasOverview = fs42.existsSync(path35.join(planDir, "00-overview.md")) ? 1 : 0;
7337
8493
  const targets = [];
7338
- if (fs37.existsSync(storyDir)) {
7339
- targets.push({ label: path31.relative(root, storyDir), absPath: storyDir, kind: "dir" });
8494
+ if (fs42.existsSync(storyDir)) {
8495
+ targets.push({ label: path35.relative(root, storyDir), absPath: storyDir, kind: "dir" });
7340
8496
  }
7341
- if (fs37.existsSync(planDir)) {
7342
- targets.push({ label: path31.relative(root, planDir), absPath: planDir, kind: "dir" });
8497
+ if (fs42.existsSync(planDir)) {
8498
+ targets.push({ label: path35.relative(root, planDir), absPath: planDir, kind: "dir" });
7343
8499
  }
7344
8500
  divider("squad rm feature");
7345
8501
  info(`Feature: ${feature}`);
@@ -7354,7 +8510,7 @@ async function runRmFeature(featureSlug, opts = {}) {
7354
8510
  kv(t.kind, t.label, 6);
7355
8511
  }
7356
8512
  if (useTrash) {
7357
- info(`Items will move to ${path31.relative(process.cwd(), paths.trashDir)}/<timestamp>/`);
8513
+ info(`Items will move to ${path35.relative(process.cwd(), paths.trashDir)}/<timestamp>/`);
7358
8514
  } else if (!dryRun) {
7359
8515
  warning("This is a permanent delete. Pass --trash to move to .squad/.trash/ instead.");
7360
8516
  }
@@ -7387,7 +8543,7 @@ async function runRmFeature(featureSlug, opts = {}) {
7387
8543
  useTrash
7388
8544
  );
7389
8545
  if (result.trashed) {
7390
- success(`Feature removed. Items moved to ${path31.relative(root, result.trashed)}.`);
8546
+ success(`Feature removed. Items moved to ${path35.relative(root, result.trashed)}.`);
7391
8547
  } else {
7392
8548
  success("Feature removed (stories and plans directories).");
7393
8549
  }
@@ -7428,11 +8584,11 @@ async function runRmStory(storyPathOrId, opts = {}) {
7428
8584
  }
7429
8585
  }
7430
8586
  const targets = [
7431
- { label: path32.relative(root, story.storyDir), absPath: story.storyDir, kind: "dir" }
8587
+ { label: path36.relative(root, story.storyDir), absPath: story.storyDir, kind: "dir" }
7432
8588
  ];
7433
8589
  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" });
8590
+ const planAbs = path36.join(paths.plansDir, story.feature, story.planFile);
8591
+ targets.push({ label: path36.relative(root, planAbs), absPath: planAbs, kind: "file" });
7436
8592
  }
7437
8593
  renderPreview("story", targets, {
7438
8594
  dryRun,
@@ -7457,14 +8613,14 @@ async function runRmStory(storyPathOrId, opts = {}) {
7457
8613
  }
7458
8614
  const result = deleteStoryRecord({ paths, story, trash: useTrash });
7459
8615
  if (result.trashed) {
7460
- success(`Removed (moved to ${path32.relative(root, result.trashed)}).`);
8616
+ success(`Removed (moved to ${path36.relative(root, result.trashed)}).`);
7461
8617
  } else {
7462
8618
  success("Removed story folder, plan file (if any), and overview row.");
7463
8619
  }
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");
8620
+ const featurePlanDir = path36.join(paths.plansDir, story.feature);
8621
+ const overviewPath = path36.join(featurePlanDir, "00-overview.md");
8622
+ if (fs43.existsSync(overviewPath)) {
8623
+ const body = fs43.readFileSync(overviewPath, "utf8");
7468
8624
  const onlyShell = countOverviewDataRows(body) === 0;
7469
8625
  if (onlyShell && featureStoriesDirEmpty(paths, story.feature)) {
7470
8626
  if (canPrompt) {
@@ -7484,7 +8640,7 @@ async function runRmStory(storyPathOrId, opts = {}) {
7484
8640
  }
7485
8641
 
7486
8642
  // src/commands/rm/plan.ts
7487
- import path33 from "path";
8643
+ import path37 from "path";
7488
8644
  import { confirm as confirm11 } from "@inquirer/prompts";
7489
8645
  async function runRmPlan(planPathOrSequence, opts = {}) {
7490
8646
  const root = requireSquadRoot();
@@ -7520,7 +8676,7 @@ async function runRmPlan(planPathOrSequence, opts = {}) {
7520
8676
  }
7521
8677
  const storyRows = listStories(paths, { feature: plan.feature });
7522
8678
  const linked = storyRows.find((s) => s.planFile === plan.file);
7523
- const targets = [{ label: path33.relative(root, plan.abs), absPath: plan.abs, kind: "file" }];
8679
+ const targets = [{ label: path37.relative(root, plan.abs), absPath: plan.abs, kind: "file" }];
7524
8680
  renderPreview("plan", targets, {
7525
8681
  dryRun,
7526
8682
  trash: useTrash,
@@ -7543,8 +8699,8 @@ async function runRmPlan(planPathOrSequence, opts = {}) {
7543
8699
  }
7544
8700
  }
7545
8701
  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}/`;
8702
+ removeOverviewRowForPlanFile(path37.join(paths.plansDir, plan.feature), plan.file);
8703
+ const intakeHint = linked ? path37.relative(root, linked.intakePath) : `under .squad/stories/${plan.feature}/`;
7548
8704
  success(`Plan removed. Run \`squad new-plan ${intakeHint}\` to regenerate.`);
7549
8705
  }
7550
8706
 
@@ -7578,7 +8734,11 @@ program.command("new-story [feature-slug]").description("Scaffold a new story in
7578
8734
  "--no-tracker",
7579
8735
  "Skip tracker fetch AND drop the tracker-id requirement for this story"
7580
8736
  ).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));
8737
+ 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(
8738
+ "--max-scout-files <n>",
8739
+ "Maximum files the scout can pre-select (default from config or 12)",
8740
+ (v) => parseInt(v, 10)
8741
+ ).option("--no-validation", "Disable post-write validation", false).option("--strict-validation", "Write partial plan when validation finds issues", false).action(wrapArgs(runNewPlan));
7582
8742
  program.command("status").description("Show squad-kit workspace status").action(wrap(runStatus));
7583
8743
  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
8744
  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 +8768,24 @@ config.command("remove-credential <section>").description('Delete credentials fo
7608
8768
  (section, opts) => runConfigRemoveCredential(section, { yes: opts.yes })
7609
8769
  )
7610
8770
  );
7611
- program.parseAsync(process.argv).catch((err) => {
7612
- if (isSquadExit(err)) {
7613
- if (err.message.trim()) info(err.message);
7614
- process.exit(err.exitCode);
8771
+ program.parseAsync(process.argv).catch((err2) => {
8772
+ if (isSquadExit(err2)) {
8773
+ if (err2.message.trim()) info(err2.message);
8774
+ process.exit(err2.exitCode);
7615
8775
  }
7616
- renderError(err);
8776
+ renderError(err2);
7617
8777
  process.exit(1);
7618
8778
  });
7619
8779
  function wrap(fn) {
7620
8780
  return async (opts) => {
7621
8781
  try {
7622
8782
  await fn(opts);
7623
- } catch (err) {
7624
- if (isSquadExit(err)) {
7625
- if (err.message.trim()) info(err.message);
7626
- process.exit(err.exitCode);
8783
+ } catch (err2) {
8784
+ if (isSquadExit(err2)) {
8785
+ if (err2.message.trim()) info(err2.message);
8786
+ process.exit(err2.exitCode);
7627
8787
  }
7628
- renderError(err);
8788
+ renderError(err2);
7629
8789
  process.exit(1);
7630
8790
  }
7631
8791
  };
@@ -7634,12 +8794,12 @@ function wrapArgs(fn) {
7634
8794
  return async (...args) => {
7635
8795
  try {
7636
8796
  await fn(...args);
7637
- } catch (err) {
7638
- if (isSquadExit(err)) {
7639
- if (err.message.trim()) info(err.message);
7640
- process.exit(err.exitCode);
8797
+ } catch (err2) {
8798
+ if (isSquadExit(err2)) {
8799
+ if (err2.message.trim()) info(err2.message);
8800
+ process.exit(err2.exitCode);
7641
8801
  }
7642
- renderError(err);
8802
+ renderError(err2);
7643
8803
  process.exit(1);
7644
8804
  }
7645
8805
  };