squad-kit 0.2.0 → 0.2.2

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
@@ -2372,7 +2372,92 @@ function looksBinary(buf) {
2372
2372
  return false;
2373
2373
  }
2374
2374
 
2375
+ // src/planner/provider-errors.ts
2376
+ function detectModelNotFound(provider, model, status, rawBody) {
2377
+ if (status !== 404) return void 0;
2378
+ const body = rawBody.toLowerCase();
2379
+ const hints = ["not_found_error", "model_not_found", "not_found", "model:"];
2380
+ if (!hints.some((h) => body.includes(h))) return void 0;
2381
+ return { provider, model, status, rawBody };
2382
+ }
2383
+ function modelNotFoundMessage(err) {
2384
+ return [
2385
+ `The ${err.provider} planner model "${err.model}" is no longer available.`,
2386
+ "",
2387
+ "Recovery options:",
2388
+ ` 1. Run \`squad upgrade\` to install a newer squad-kit (or install manually from npm).`,
2389
+ ` 2. Run \`squad config set planner --provider openai\` (or another \`--provider\`) to switch models via a different provider, then save a key when prompted.`,
2390
+ ` 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.`,
2391
+ "",
2392
+ `Raw provider response: ${err.rawBody.slice(0, 200)}`
2393
+ ].join("\n");
2394
+ }
2395
+ function detectRateLimit(provider, status, headers, rawBody) {
2396
+ if (status !== 429) return void 0;
2397
+ return {
2398
+ provider,
2399
+ status: 429,
2400
+ retryAfterSec: parseRetryAfter(headers, rawBody),
2401
+ rawBody
2402
+ };
2403
+ }
2404
+ function parseRetryAfter(headers, body) {
2405
+ const raw = headerGet(headers, "retry-after");
2406
+ if (raw) {
2407
+ const sec = Number(raw);
2408
+ if (Number.isFinite(sec) && sec >= 0) return Math.ceil(sec);
2409
+ const asDate = Date.parse(raw);
2410
+ if (Number.isFinite(asDate)) {
2411
+ return Math.max(0, Math.ceil((asDate - Date.now()) / 1e3));
2412
+ }
2413
+ }
2414
+ const bodyMatch = /"retryDelay"\s*:\s*"(\d+)(?:\.\d+)?s"/i.exec(body);
2415
+ if (bodyMatch) {
2416
+ const sec = Number(bodyMatch[1]);
2417
+ if (Number.isFinite(sec) && sec >= 0) return sec;
2418
+ }
2419
+ return void 0;
2420
+ }
2421
+ function headerGet(h, name) {
2422
+ if (typeof h.get === "function") {
2423
+ return h.get(name);
2424
+ }
2425
+ const key = name.toLowerCase();
2426
+ const rec = h;
2427
+ for (const k of Object.keys(rec)) {
2428
+ if (k.toLowerCase() === key) return rec[k] ?? null;
2429
+ }
2430
+ return null;
2431
+ }
2432
+ function rateLimitMessage(err) {
2433
+ const waitHint = err.retryAfterSec ?? 60;
2434
+ const limitsUrl = {
2435
+ anthropic: "https://console.anthropic.com/settings/limits",
2436
+ openai: "https://platform.openai.com/settings/organization/limits",
2437
+ google: "https://aistudio.google.com/app/plan_information"
2438
+ };
2439
+ 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.`;
2440
+ const cap = err.maxRetrySec ?? 90;
2441
+ 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.";
2442
+ return [
2443
+ headline,
2444
+ retried,
2445
+ "",
2446
+ "Recovery options:",
2447
+ ` 1. Wait ${waitHint}s and rerun \`squad new-plan --api\`. Per-minute limits reset quickly.`,
2448
+ ` 2. Switch to a smaller planner model: \`squad config set planner\` (pick a cheaper model id).`,
2449
+ ` 3. Tighten \`planner.budget\` in \`.squad/config.yaml\` (smaller \`maxContextBytes\` / \`maxFileReads\`) so each request carries fewer tokens.`,
2450
+ ` 4. Upgrade your ${err.provider} tier: ${limitsUrl[err.provider]}.`,
2451
+ "",
2452
+ "Full runbook: docs/migrating-from-0.1.md (see \xA78. If something goes wrong).",
2453
+ "",
2454
+ `Raw provider response: ${err.rawBody.slice(0, 200)}`
2455
+ ].join("\n");
2456
+ }
2457
+
2375
2458
  // src/planner/loop.ts
2459
+ var MAX_RATE_LIMIT_RETRY_SEC = 90;
2460
+ var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2376
2461
  async function runPlanner(input6) {
2377
2462
  const maxIter = input6.maxIterations ?? 40;
2378
2463
  const turns = [{ role: "user", text: input6.userPrompt }];
@@ -2388,21 +2473,39 @@ async function runPlanner(input6) {
2388
2473
  if (input6.budget.overCost()) {
2389
2474
  return { planText: accumulatedText, budgetExhausted: true, timedOut: false, finishedNormally: false, iterations };
2390
2475
  }
2391
- const response = await input6.provider.send({
2476
+ const request = {
2392
2477
  systemPrompt: input6.systemPrompt,
2393
2478
  model: input6.model,
2394
2479
  tools: [READ_FILE_TOOL],
2395
2480
  turns,
2396
2481
  apiKey: input6.apiKey,
2397
2482
  maxOutputTokens: 8192
2398
- });
2483
+ };
2484
+ let response = await input6.provider.send(request);
2399
2485
  if (response.usage) {
2400
2486
  input6.budget.recordUsage(response.usage);
2401
2487
  input6.onUsage?.(response.usage);
2402
2488
  }
2489
+ let retriedRateLimit = false;
2490
+ let retrySkippedReason;
2491
+ if (response.stopReason === "error" && response.errorKind === "rate_limit") {
2492
+ const asked = response.retryAfterSec;
2493
+ if (asked !== void 0 && asked > MAX_RATE_LIMIT_RETRY_SEC) {
2494
+ retrySkippedReason = "retry_after_too_long";
2495
+ } else {
2496
+ const waitSec = Math.min(asked ?? 10, MAX_RATE_LIMIT_RETRY_SEC);
2497
+ input6.onRateLimit?.(waitSec);
2498
+ await (input6.sleep ?? defaultSleep)(waitSec * 1e3);
2499
+ response = await input6.provider.send(request);
2500
+ if (response.usage) {
2501
+ input6.budget.recordUsage(response.usage);
2502
+ input6.onUsage?.(response.usage);
2503
+ }
2504
+ retriedRateLimit = true;
2505
+ }
2506
+ }
2403
2507
  if (response.stopReason === "error") {
2404
- const base = response.rawError ?? "planner: provider error";
2405
- throw new Error(`${base} Run \`squad doctor\` to verify models and credentials.`);
2508
+ throw composePlannerError(input6.provider.name, response, retriedRateLimit, retrySkippedReason);
2406
2509
  }
2407
2510
  if (response.text) {
2408
2511
  accumulatedText += response.text;
@@ -2445,26 +2548,24 @@ async function runPlanner(input6) {
2445
2548
  }
2446
2549
  return { planText: accumulatedText, budgetExhausted, timedOut: false, finishedNormally: false, iterations };
2447
2550
  }
2448
-
2449
- // src/planner/provider-errors.ts
2450
- function detectModelNotFound(provider, model, status, rawBody) {
2451
- if (status !== 404) return void 0;
2452
- const body = rawBody.toLowerCase();
2453
- const hints = ["not_found_error", "model_not_found", "not_found", "model:"];
2454
- if (!hints.some((h) => body.includes(h))) return void 0;
2455
- return { provider, model, status, rawBody };
2456
- }
2457
- function modelNotFoundMessage(err) {
2458
- return [
2459
- `The ${err.provider} planner model "${err.model}" is no longer available.`,
2460
- "",
2461
- "Recovery options:",
2462
- ` 1. Run \`squad upgrade\` to install a newer squad-kit (or install manually from npm).`,
2463
- ` 2. Run \`squad config set planner --provider openai\` (or another \`--provider\`) to switch models via a different provider, then save a key when prompted.`,
2464
- ` 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.`,
2465
- "",
2466
- `Raw provider response: ${err.rawBody.slice(0, 200)}`
2467
- ].join("\n");
2551
+ function composePlannerError(providerName, response, retriedRateLimit, retrySkippedReason) {
2552
+ if (response.errorKind === "rate_limit") {
2553
+ return new Error(
2554
+ rateLimitMessage({
2555
+ provider: providerName,
2556
+ retryAfterSec: response.retryAfterSec,
2557
+ rawBody: response.rawError ?? "",
2558
+ retryAlreadyAttempted: retriedRateLimit,
2559
+ retrySkippedReason,
2560
+ maxRetrySec: MAX_RATE_LIMIT_RETRY_SEC
2561
+ })
2562
+ );
2563
+ }
2564
+ if (response.errorKind === "model_not_found") {
2565
+ return new Error(response.rawError ?? "planner: model not found");
2566
+ }
2567
+ const base = response.rawError ?? "planner: provider error";
2568
+ return new Error(`${base} Run \`squad doctor\` to diagnose, or retry \u2014 most 5xx errors are transient.`);
2468
2569
  }
2469
2570
 
2470
2571
  // src/planner/providers/anthropic.ts
@@ -2497,7 +2598,20 @@ async function callAnthropic(req) {
2497
2598
  const text = await res.text().catch(() => "");
2498
2599
  const nf = detectModelNotFound("anthropic", req.model, res.status, text);
2499
2600
  if (nf) {
2500
- return { stopReason: "error", rawError: modelNotFoundMessage(nf) };
2601
+ return {
2602
+ stopReason: "error",
2603
+ rawError: modelNotFoundMessage(nf),
2604
+ errorKind: "model_not_found"
2605
+ };
2606
+ }
2607
+ const rl = detectRateLimit("anthropic", res.status, res.headers, text);
2608
+ if (rl) {
2609
+ return {
2610
+ stopReason: "error",
2611
+ rawError: `anthropic 429: ${text.slice(0, 500)}`,
2612
+ errorKind: "rate_limit",
2613
+ retryAfterSec: rl.retryAfterSec
2614
+ };
2501
2615
  }
2502
2616
  return { stopReason: "error", rawError: `anthropic ${res.status}: ${text.slice(0, 500)}` };
2503
2617
  }
@@ -2567,7 +2681,20 @@ async function callOpenAI(req) {
2567
2681
  const text = await res.text().catch(() => "");
2568
2682
  const nf = detectModelNotFound("openai", req.model, res.status, text);
2569
2683
  if (nf) {
2570
- return { stopReason: "error", rawError: modelNotFoundMessage(nf) };
2684
+ return {
2685
+ stopReason: "error",
2686
+ rawError: modelNotFoundMessage(nf),
2687
+ errorKind: "model_not_found"
2688
+ };
2689
+ }
2690
+ const rl = detectRateLimit("openai", res.status, res.headers, text);
2691
+ if (rl) {
2692
+ return {
2693
+ stopReason: "error",
2694
+ rawError: `openai 429: ${text.slice(0, 500)}`,
2695
+ errorKind: "rate_limit",
2696
+ retryAfterSec: rl.retryAfterSec
2697
+ };
2571
2698
  }
2572
2699
  return { stopReason: "error", rawError: `openai ${res.status}: ${text.slice(0, 500)}` };
2573
2700
  }
@@ -2653,7 +2780,20 @@ async function callGoogle(req) {
2653
2780
  const text = await res.text().catch(() => "");
2654
2781
  const nf = detectModelNotFound("google", req.model, res.status, text);
2655
2782
  if (nf) {
2656
- return { stopReason: "error", rawError: modelNotFoundMessage(nf) };
2783
+ return {
2784
+ stopReason: "error",
2785
+ rawError: modelNotFoundMessage(nf),
2786
+ errorKind: "model_not_found"
2787
+ };
2788
+ }
2789
+ const rl = detectRateLimit("google", res.status, res.headers, text);
2790
+ if (rl) {
2791
+ return {
2792
+ stopReason: "error",
2793
+ rawError: `google 429: ${text.slice(0, 500)}`,
2794
+ errorKind: "rate_limit",
2795
+ retryAfterSec: rl.retryAfterSec
2796
+ };
2657
2797
  }
2658
2798
  return { stopReason: "error", rawError: `google ${res.status}: ${text.slice(0, 500)}` };
2659
2799
  }
@@ -3132,6 +3272,11 @@ async function emitViaApi(story, paths, config2, _opts) {
3132
3272
  onAssistantText: () => {
3133
3273
  sessionSpinner.current?.succeed("planner thinking complete (this chunk)");
3134
3274
  sessionSpinner.current = spinner("thinking\u2026");
3275
+ },
3276
+ onRateLimit: (waitSec) => {
3277
+ sessionSpinner.current?.stop();
3278
+ warning(`${planner.provider} rate limit hit \u2014 retrying in ${waitSec}s`);
3279
+ sessionSpinner.current = spinner("waiting for rate limit to reset\u2026");
3135
3280
  }
3136
3281
  });
3137
3282
  sessionSpinner.current?.stop();
@@ -3690,6 +3835,28 @@ async function checkPlannerModel(_paths, ctx) {
3690
3835
  };
3691
3836
  }
3692
3837
  }
3838
+ async function checkPlannerTierAwareness(_paths, ctx) {
3839
+ const id = "planner-tier";
3840
+ const name = "planner tier vs. model";
3841
+ if (!ctx.config || ctx.config.planner?.enabled !== true) {
3842
+ return { id, name, status: "skip", detail: "planner disabled" };
3843
+ }
3844
+ const provider = ctx.config.planner.provider;
3845
+ if (provider !== "anthropic") {
3846
+ return { id, name, status: "skip", detail: `not applicable for ${provider}` };
3847
+ }
3848
+ const planModel = modelFor(provider, "plan", ctx.config.planner.modelOverride);
3849
+ if (!/opus/i.test(planModel)) {
3850
+ return { id, name, status: "ok", detail: `${planModel} is comfortably under Tier 1` };
3851
+ }
3852
+ return {
3853
+ id,
3854
+ name,
3855
+ status: "warn",
3856
+ detail: `${planModel} on Anthropic Tier 1 (10k input tokens/min) is throttle-prone for plans over ~3 file reads`,
3857
+ fixHint: "Run `squad config set planner` and pick a Sonnet or Haiku id for planner.modelOverride.anthropic, or upgrade tier at https://console.anthropic.com/settings/limits. squad-kit will auto-retry a 429 once (waiting up to 90s), but repeated throttling means the plan model is simply too big for your quota."
3858
+ };
3859
+ }
3693
3860
  async function checkTrackerConfig(_paths, ctx) {
3694
3861
  if (!ctx.config) {
3695
3862
  return { id: "tracker-config", name: "tracker configuration", status: "skip", detail: "config unavailable" };
@@ -3811,6 +3978,7 @@ async function runAllChecks(paths, ctx, fix) {
3811
3978
  await add("planner configuration", () => checkPlannerConfig(paths, ctx));
3812
3979
  await add("planner credential resolves", () => checkPlannerCredential(paths, ctx));
3813
3980
  await add("planner model resolves at provider", () => checkPlannerModel(paths, ctx));
3981
+ await add("planner tier vs. model", () => checkPlannerTierAwareness(paths, ctx));
3814
3982
  await add("tracker configuration", () => checkTrackerConfig(paths, ctx));
3815
3983
  await add("tracker credential resolves", () => checkTrackerCredential(paths, ctx));
3816
3984
  await add("tracker connectivity", () => checkTrackerConnectivity(paths, ctx));
@@ -4704,7 +4872,7 @@ async function runRmPlan(planPathOrSequence, opts = {}) {
4704
4872
 
4705
4873
  // src/cli.ts
4706
4874
  var program = new Command();
4707
- program.name("squad").description("Plan once, execute cheap. A 3-step SDD workflow CLI.").version("0.2.0");
4875
+ program.name("squad").description("Plan once, execute cheap. A 3-step SDD workflow CLI.").version("0.2.2");
4708
4876
  program.command("init").description("Bootstrap .squad/ in the current directory").option("--agents <list>", "Comma-separated agent list: claude-code,cursor,copilot,gemini").option("--tracker <type>", "Tracker type: none|github|linear|jira|azure").option(
4709
4877
  "--tracker-workspace <hostOrOrg>",
4710
4878
  "Jira host or Azure org (non-interactive default; also written to config for jira/azure)"