squad-kit 0.7.2 → 0.9.0

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