cascade-ai 0.18.0 → 0.19.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/index.cjs CHANGED
@@ -236,7 +236,7 @@ var init_audit_logger = __esm({
236
236
  });
237
237
 
238
238
  // src/constants.ts
239
- var CASCADE_VERSION = "0.18.0";
239
+ var CASCADE_VERSION = "0.19.0";
240
240
  var CASCADE_CONFIG_DIR = ".cascade";
241
241
  var CASCADE_MD_FILE = "CASCADE.md";
242
242
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -1039,6 +1039,23 @@ var OpenAIProvider = class extends BaseProvider {
1039
1039
  };
1040
1040
 
1041
1041
  // src/providers/azure.ts
1042
+ function azureModelForDeployment(cfg) {
1043
+ if (cfg.type !== "azure" || !cfg.deploymentName?.trim()) return null;
1044
+ const id = cfg.deploymentName.trim();
1045
+ return {
1046
+ id,
1047
+ name: cfg.label?.trim() || id,
1048
+ provider: "azure",
1049
+ contextWindow: 128e3,
1050
+ isVisionCapable: false,
1051
+ inputCostPer1kTokens: 25e-4,
1052
+ outputCostPer1kTokens: 0.01,
1053
+ maxOutputTokens: 16e3,
1054
+ supportsStreaming: true,
1055
+ isLocal: false,
1056
+ supportsToolUse: true
1057
+ };
1058
+ }
1042
1059
  var AzureOpenAIProvider = class extends OpenAIProvider {
1043
1060
  constructor(config, model) {
1044
1061
  const rawUrl = config.baseUrl ?? AZURE_BASE_URL_TEMPLATE.replace("{resource}", "YOUR_RESOURCE");
@@ -1059,7 +1076,8 @@ var AzureOpenAIProvider = class extends OpenAIProvider {
1059
1076
  });
1060
1077
  }
1061
1078
  async listModels() {
1062
- return [this.model];
1079
+ const fromDeployment = azureModelForDeployment(this.config);
1080
+ return [fromDeployment ?? this.model];
1063
1081
  }
1064
1082
  async isAvailable() {
1065
1083
  try {
@@ -2568,6 +2586,12 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
2568
2586
  const results = await Promise.all(ocConfigs.map((cfg) => this.discoverOpenAICompatibleModels(cfg)));
2569
2587
  if (results.some(Boolean)) this.selector.markProviderAvailable("openai-compatible");
2570
2588
  }
2589
+ if (availableProviders.has("azure")) {
2590
+ for (const cfg of config.providers) {
2591
+ const model = azureModelForDeployment(cfg);
2592
+ if (model) this.selector.addDynamicModel(model);
2593
+ }
2594
+ }
2571
2595
  for (const tier of ["T1", "T2", "T3"]) {
2572
2596
  const override = tier === "T1" ? config.models.t1 : tier === "T2" ? config.models.t2 : config.models.t3;
2573
2597
  if (!override || override === "auto") continue;
@@ -3174,7 +3198,7 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
3174
3198
  ensureProvider(model, configs) {
3175
3199
  const key = `${model.provider}:${model.id}`;
3176
3200
  if (this.providers.has(key)) return;
3177
- const cfg = configs.find((c) => c.type === model.provider) ?? { type: model.provider };
3201
+ const cfg = (model.provider === "azure" ? configs.find((c) => c.type === "azure" && c.deploymentName === model.id) : void 0) ?? configs.find((c) => c.type === model.provider) ?? { type: model.provider };
3178
3202
  const provider = this.createProvider(cfg, model);
3179
3203
  this.providers.set(key, provider);
3180
3204
  }
@@ -3334,6 +3358,12 @@ var BaseTier = class extends EventEmitter__default.default {
3334
3358
  * which would interleave, are not tagged.
3335
3359
  */
3336
3360
  isPresenter = false;
3361
+ /**
3362
+ * The model actually serving this tier (`provider:id`), once resolved —
3363
+ * rides on every tier:status event so the desktop can show which model ran
3364
+ * which node (Cockpit node panel / Why panel).
3365
+ */
3366
+ servingModel;
3337
3367
  constructor(role, id, parentId) {
3338
3368
  super();
3339
3369
  this.role = role;
@@ -3358,11 +3388,16 @@ var BaseTier = class extends EventEmitter__default.default {
3358
3388
  label: this.label,
3359
3389
  status,
3360
3390
  timestamp,
3361
- output
3391
+ output,
3392
+ model: this.servingModel
3362
3393
  };
3363
3394
  this.emit("status", event);
3364
3395
  this.emit("tier:status", event);
3365
3396
  }
3397
+ /** Record the model serving this tier; future status events carry it. */
3398
+ setServingModel(model) {
3399
+ this.servingModel = model || void 0;
3400
+ }
3366
3401
  setLabel(label) {
3367
3402
  this.label = label;
3368
3403
  }
@@ -3385,7 +3420,8 @@ var BaseTier = class extends EventEmitter__default.default {
3385
3420
  currentAction: update.currentAction,
3386
3421
  progressPct: update.progressPct,
3387
3422
  timestamp,
3388
- output: update.output
3423
+ output: update.output,
3424
+ model: this.servingModel
3389
3425
  });
3390
3426
  }
3391
3427
  buildMessage(type, to, payload) {
@@ -3751,6 +3787,21 @@ Available tools: ${tools.map((t) => t.name).join(", ")}.
3751
3787
  When you have enough information, stop calling tools and write your final answer.`;
3752
3788
  }
3753
3789
 
3790
+ // src/utils/truncate.ts
3791
+ function truncateForContext(text, maxChars = 12e3) {
3792
+ if (text.length <= maxChars) return text;
3793
+ const headLen = Math.floor(maxChars * 0.75);
3794
+ const tailLen = maxChars - headLen;
3795
+ const head = text.slice(0, headLen);
3796
+ const tail = text.slice(-tailLen);
3797
+ const elided = text.length - headLen - tailLen;
3798
+ return `${head}
3799
+
3800
+ [... ${elided.toLocaleString()} characters elided to keep context small \u2014 re-read the file with a line range if you need the middle ...]
3801
+
3802
+ ${tail}`;
3803
+ }
3804
+
3754
3805
  // src/core/tiers/t3-worker.ts
3755
3806
  var CriticalToolError = class extends Error {
3756
3807
  constructor(message, toolName) {
@@ -4058,6 +4109,7 @@ Now execute your subtask using this context where relevant.`
4058
4109
  } catch {
4059
4110
  }
4060
4111
  const effectiveModel = subtaskModel ?? this.router.getModelForTier("T3");
4112
+ if (effectiveModel) this.setServingModel(`${effectiveModel.provider}:${effectiveModel.id}`);
4061
4113
  const useTextTools = effectiveModel?.supportsToolUse === false && tools.length > 0;
4062
4114
  let sentFullTextContract = false;
4063
4115
  let textContractSignature = "";
@@ -4155,7 +4207,7 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
4155
4207
  const toolResult = await this.executeTool(tc);
4156
4208
  await this.context.addMessage({
4157
4209
  role: "tool",
4158
- content: toolResult,
4210
+ content: truncateForContext(toolResult),
4159
4211
  toolCallId: tc.id
4160
4212
  });
4161
4213
  }
@@ -4412,15 +4464,17 @@ ${assignment.expectedOutput}`;
4412
4464
  };
4413
4465
  }
4414
4466
  requiresArtifact() {
4467
+ if (this.assignment?.files?.length) return true;
4415
4468
  const haystack = `${this.assignment?.description ?? ""}
4416
4469
  ${this.assignment?.expectedOutput ?? ""}`;
4417
4470
  return /\b[\w./-]+\.(pdf|md|html|txt|json|csv|py|js|ts|tsx|jsx|docx?|png|jpg|jpeg|svg|gif)\b/i.test(haystack) || /save (?:a|the)? file|create (?:a|the)? file|write (?:a|the)? file/i.test(haystack);
4418
4471
  }
4419
4472
  extractArtifactPaths(assignment) {
4473
+ const declared = (assignment.files ?? []).map((f) => f.trim()).filter((f) => f.includes("."));
4420
4474
  const haystack = `${assignment.description}
4421
4475
  ${assignment.expectedOutput}`;
4422
4476
  const matches = haystack.match(/\b[\w./-]+\.(pdf|md|html|txt|json|csv|py|js|ts|tsx|jsx|docx?|png|jpg|jpeg|svg|gif)\b/gi) ?? [];
4423
- return [...new Set(matches.map((m) => m.trim()))];
4477
+ return [.../* @__PURE__ */ new Set([...declared, ...matches.map((m) => m.trim())])];
4424
4478
  }
4425
4479
  async verifyArtifacts(assignment) {
4426
4480
  const artifactPaths = this.extractArtifactPaths(assignment);
@@ -4543,7 +4597,9 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
4543
4597
  Assignment: ${assignment.description}
4544
4598
  Expected output: ${assignment.expectedOutput}
4545
4599
  Constraints: ${assignment.constraints.join("; ")}
4546
-
4600
+ ${assignment.acceptance?.length ? `Acceptance criteria \u2014 ALL must be satisfied for "completeness" to pass:
4601
+ ${assignment.acceptance.map((a) => `- ${a}`).join("\n")}
4602
+ ` : ""}
4547
4603
  Output to test:
4548
4604
  ${output}
4549
4605
 
@@ -4632,17 +4688,27 @@ Your subtask:
4632
4688
  - Title: ${assignment.subtaskTitle}
4633
4689
  - Description: ${assignment.description}
4634
4690
  - Expected output: ${assignment.expectedOutput}
4635
- - Constraints: ${assignment.constraints.join("; ")}`;
4691
+ - Constraints: ${assignment.constraints.join("; ")}${assignment.files?.length ? `
4692
+ - Files you own (create/edit ONLY these): ${assignment.files.join(", ")}` : ""}${assignment.acceptance?.length ? `
4693
+ - Definition of done: ${assignment.acceptance.join("; ")}` : ""}`;
4636
4694
  }
4637
4695
  buildInitialPrompt(assignment) {
4638
4696
  return `Execute the following subtask completely:
4639
4697
 
4640
4698
  **${assignment.subtaskTitle}**
4641
-
4699
+ ${assignment.contextBrief ? `
4700
+ Context: ${assignment.contextBrief}
4701
+ ` : ""}
4642
4702
  ${assignment.description}
4643
4703
 
4644
4704
  Expected output: ${assignment.expectedOutput}
4645
-
4705
+ ${assignment.files?.length ? `
4706
+ Files you own (create or edit exactly these paths):
4707
+ ${assignment.files.map((f) => `- ${f}`).join("\n")}
4708
+ ` : ""}${assignment.acceptance?.length ? `
4709
+ Definition of done (your output must satisfy ALL of these):
4710
+ ${assignment.acceptance.map((a) => `- ${a}`).join("\n")}
4711
+ ` : ""}
4646
4712
  Constraints:
4647
4713
  ${assignment.constraints.map((c) => `- ${c}`).join("\n")}
4648
4714
 
@@ -5123,6 +5189,8 @@ var T2Manager = class extends BaseTier {
5123
5189
  this.assignment = assignment;
5124
5190
  this.taskId = taskId;
5125
5191
  this.setLabel(assignment.sectionTitle);
5192
+ const m = this.router.getModelForTier("T2");
5193
+ if (m) this.setServingModel(`${m.provider}:${m.id}`);
5126
5194
  this.setStatus("ACTIVE");
5127
5195
  this.sendStatusUpdate({
5128
5196
  progressPct: 0,
@@ -5209,7 +5277,7 @@ Guidance (must be followed): ${decision.note}`
5209
5277
  // ── Private ──────────────────────────────────
5210
5278
  async decomposeSection(assignment) {
5211
5279
  const peerPlans = this.peerSyncBuffer.filter((p) => p.content?.type === "T2_PLAN_ANNOUNCEMENT").map((p) => `[Peer ${p.fromId} Plan]: ${p.content.sectionTitle} - ${p.content.subtaskTitles?.join(", ")}`).join("\n");
5212
- const prompt = `Decompose this section into 2-5 concrete subtasks for T3 workers.
5280
+ const prompt = `Decompose this section into 1-4 concrete subtasks for T3 workers \u2014 the FEWEST that fully cover it (one subtask is the correct answer for a small section).
5213
5281
 
5214
5282
  Section: ${assignment.sectionTitle}
5215
5283
  Description: ${assignment.description}
@@ -5228,6 +5296,9 @@ Return a JSON array of subtask objects, each with:
5228
5296
  - peerT3Ids: string[] (empty for now)
5229
5297
  - dependsOn: string[] (array of subtaskIds this task depends on to start)
5230
5298
  - executionMode: "parallel|sequential" (default is parallel)
5299
+ - files: string[] (the EXACT relative paths this subtask creates or edits)
5300
+ - acceptance: string[] (1-3 mechanically checkable done-criteria: file exists / contains X / command exits 0)
5301
+ - contextBrief: string (1-3 short sentences with ALL the background the worker needs \u2014 it sees nothing else)
5231
5302
 
5232
5303
  Return ONLY the JSON array.`;
5233
5304
  const messages = [{ role: "user", content: prompt }];
@@ -5825,6 +5896,8 @@ var T1Administrator = class extends BaseTier {
5825
5896
  this.signal = signal;
5826
5897
  this.taskId = crypto3.randomUUID();
5827
5898
  this.setLabel("Administrator");
5899
+ const m = this.router.getModelForTier("T1");
5900
+ if (m) this.setServingModel(`${m.provider}:${m.id}`);
5828
5901
  this.setStatus("ACTIVE");
5829
5902
  this.taskGoal = userPrompt;
5830
5903
  this.sendStatusUpdate({
@@ -6071,10 +6144,13 @@ Return JSON where SECTIONS can declare dependencies on other SECTIONS:
6071
6144
  "description": "Run npm init",
6072
6145
  "expectedOutput": "package.json created",
6073
6146
  "constraints": [],
6074
- "dependsOn": []
6147
+ "dependsOn": [],
6148
+ "files": ["package.json"], // \u2190 exact paths this subtask owns
6149
+ "acceptance": ["package.json exists and parses as JSON"], // \u2190 objectively checkable
6150
+ "contextBrief": "Fresh Node 20 project; npm available." // \u2190 ALL the background the worker gets
6075
6151
  }]
6076
6152
  }, {
6077
- "sectionId": "s2",
6153
+ "sectionId": "s2",
6078
6154
  "sectionTitle": "Write Tests",
6079
6155
  "description": "Write tests for the project",
6080
6156
  "expectedOutput": "Tests passing",
@@ -6084,7 +6160,13 @@ Return JSON where SECTIONS can declare dependencies on other SECTIONS:
6084
6160
  }]
6085
6161
  }
6086
6162
  Use dependsOn at the SECTION level when a whole T2 Manager needs the output of a previous T2 Manager.
6087
- Leave dependsOn empty for sections that can run immediately in parallel.`;
6163
+ Leave dependsOn empty for sections that can run immediately in parallel.
6164
+
6165
+ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute from their slice ALONE):
6166
+ - "files": the exact relative paths the subtask creates or edits. Never vague ("some files"); always concrete.
6167
+ - "acceptance": 1-3 checks a reviewer could verify mechanically (file exists / contains X / command exits 0). These define done.
6168
+ - "contextBrief": 1-3 short sentences with the ONLY background the worker needs. It sees nothing else about the task, so make the brief self-sufficient \u2014 but never pad it.
6169
+ - RIGHT-SIZE the plan: use the FEWEST sections and workers that fully cover the task. One section with 1-2 subtasks is the CORRECT plan for a small task; padding a plan with filler sections wastes the user's money.`;
6088
6170
  const messages = [{ role: "user", content: decompositionPrompt }];
6089
6171
  const result = await this.router.generate("T1", {
6090
6172
  messages,
@@ -7386,30 +7468,56 @@ async function searchTavily(query, apiKey, maxResults) {
7386
7468
  engine: "tavily"
7387
7469
  }));
7388
7470
  }
7389
- async function searchDuckDuckGoLite(query, maxResults) {
7390
- const resp = await fetch(`https://lite.duckduckgo.com/lite/?q=${encodeURIComponent(query)}`, {
7391
- headers: { "User-Agent": "Mozilla/5.0 (compatible; Cascade-AI/1.0)" },
7392
- signal: AbortSignal.timeout(1e4)
7393
- });
7394
- if (!resp.ok) throw new Error(`DuckDuckGo Lite returned HTTP ${resp.status}`);
7395
- const html = await resp.text();
7396
- const linkPattern = /<a[^>]+class="result-link"[^>]+href="([^"]+)"[^>]*>([^<]+)<\/a>/g;
7397
- const snippetPattern = /<td[^>]+class="result-snippet"[^>]*>([\s\S]*?)<\/td>/g;
7398
- const links = [];
7399
- const snippets = [];
7471
+ var BROWSER_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
7472
+ function unwrapDdgRedirect(href) {
7473
+ try {
7474
+ const url = new URL(href.startsWith("//") ? `https:${href}` : href, "https://duckduckgo.com");
7475
+ if (/(^|\.)duckduckgo\.com$/i.test(url.hostname) && url.pathname.startsWith("/l/")) {
7476
+ const target = url.searchParams.get("uddg");
7477
+ if (target) return decodeURIComponent(target);
7478
+ }
7479
+ return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : href;
7480
+ } catch {
7481
+ return href;
7482
+ }
7483
+ }
7484
+ function stripTags(html) {
7485
+ return html.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
7486
+ }
7487
+ function decodeEntities(text) {
7488
+ return text.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#x27;|&#39;/g, "'").replace(/&nbsp;/g, " ");
7489
+ }
7490
+ function parseDdgAnchors(html, anchorClass, snippetClass) {
7491
+ const anchorRe = new RegExp(`<a\\b[^>]*class=["']?[^"'>]*\\b${anchorClass}\\b[^"'>]*["']?[^>]*>([\\s\\S]*?)<\\/a>`, "gi");
7492
+ const snippetRe = new RegExp(`class=["']?[^"'>]*\\b${snippetClass}\\b[^"'>]*["']?[^>]*>([\\s\\S]*?)<\\/(?:td|a|div|span)>`, "gi");
7493
+ const results = [];
7400
7494
  let m;
7401
- while ((m = linkPattern.exec(html)) !== null) {
7402
- links.push({ url: m[1], title: m[2].trim() });
7495
+ while ((m = anchorRe.exec(html)) !== null) {
7496
+ const tag = m[0];
7497
+ const href = /href=["']([^"']+)["']/i.exec(tag)?.[1];
7498
+ const title = decodeEntities(stripTags(m[1] ?? ""));
7499
+ if (!href || !title) continue;
7500
+ results.push({ title, url: unwrapDdgRedirect(decodeEntities(href)), snippet: "" });
7501
+ }
7502
+ const snippets = [];
7503
+ while ((m = snippetRe.exec(html)) !== null) {
7504
+ snippets.push(decodeEntities(stripTags(m[1] ?? "")));
7403
7505
  }
7404
- while ((m = snippetPattern.exec(html)) !== null) {
7405
- snippets.push(m[1].replace(/<[^>]+>/g, "").trim());
7506
+ for (let i = 0; i < results.length; i++) {
7507
+ if (snippets[i]) results[i].snippet = snippets[i];
7406
7508
  }
7407
- return links.slice(0, maxResults).map((link, i) => ({
7408
- title: link.title,
7409
- url: link.url,
7410
- snippet: snippets[i] ?? "",
7411
- engine: "duckduckgo-lite"
7412
- }));
7509
+ return results;
7510
+ }
7511
+ async function searchDuckDuckGo(query, maxResults, variant) {
7512
+ const base = variant === "html" ? "https://html.duckduckgo.com/html/?q=" : "https://lite.duckduckgo.com/lite/?q=";
7513
+ const resp = await fetch(`${base}${encodeURIComponent(query)}`, {
7514
+ headers: { "User-Agent": BROWSER_UA, Accept: "text/html" },
7515
+ signal: AbortSignal.timeout(1e4)
7516
+ });
7517
+ if (!resp.ok) throw new Error(`DuckDuckGo ${variant} returned HTTP ${resp.status}`);
7518
+ const html = await resp.text();
7519
+ const parsed = variant === "html" ? parseDdgAnchors(html, "result__a", "result__snippet") : parseDdgAnchors(html, "result-link", "result-snippet");
7520
+ return parsed.slice(0, maxResults).map((r) => ({ ...r, engine: `duckduckgo-${variant}` }));
7413
7521
  }
7414
7522
  var WebSearchTool = class extends BaseTool {
7415
7523
  name = "web_search";
@@ -7468,12 +7576,14 @@ var WebSearchTool = class extends BaseTool {
7468
7576
  errors.push(`Tavily: ${err instanceof Error ? err.message : String(err)}`);
7469
7577
  }
7470
7578
  }
7471
- try {
7472
- results = await searchDuckDuckGoLite(query, maxResults);
7473
- if (results.length > 0) return this.formatResults(query, results);
7474
- errors.push("DuckDuckGo Lite: returned 0 results");
7475
- } catch (err) {
7476
- errors.push(`DuckDuckGo Lite: ${err instanceof Error ? err.message : String(err)}`);
7579
+ for (const variant of ["html", "lite"]) {
7580
+ try {
7581
+ results = await searchDuckDuckGo(query, maxResults, variant);
7582
+ if (results.length > 0) return this.formatResults(query, results);
7583
+ errors.push(`DuckDuckGo ${variant}: returned 0 results`);
7584
+ } catch (err) {
7585
+ errors.push(`DuckDuckGo ${variant}: ${err instanceof Error ? err.message : String(err)}`);
7586
+ }
7477
7587
  }
7478
7588
  const configHint = !this.config.searxngUrl && !this.config.braveApiKey && !this.config.tavilyApiKey ? "\nTip: Configure a search backend for better results:\n \u2022 Self-hosted: set SEARXNG_URL in your environment\n \u2022 Brave Search API: set BRAVE_SEARCH_API_KEY\n \u2022 Tavily API: set TAVILY_API_KEY" : "";
7479
7589
  return [
@@ -8417,8 +8527,11 @@ var CascadeConfigSchema = zod.z.object({
8417
8527
  * Cascade Auto: when true, the TaskAnalyzer selects the optimal model for each
8418
8528
  * tier based on task type and complexity, overriding the static priority lists.
8419
8529
  * Heuristic-first with AI inference fallback (adds ~0–500ms per task).
8530
+ * ON by default since v0.19.0 — "Auto" without it was just a static priority
8531
+ * list, not the benchmark-value routing the docs describe. Explicit per-tier
8532
+ * model pins are unaffected; disable via config/Settings → Advanced.
8420
8533
  */
8421
- cascadeAuto: zod.z.boolean().default(false),
8534
+ cascadeAuto: zod.z.boolean().default(true),
8422
8535
  /**
8423
8536
  * Cascade Auto trade-off bias when picking a model for a task:
8424
8537
  * - 'balanced' (default): quality × cost-efficiency — cheap models win
@@ -10131,13 +10244,30 @@ ${last.partialOutput}` : "");
10131
10244
  * explicit multi-part structure, so ordinary single-file asks (handled as
10132
10245
  * Simple/Moderate) don't get over-escalated.
10133
10246
  */
10134
- looksClearlyComplex(prompt) {
10247
+ /** Shared build/scale signals for the complexity floors below. */
10248
+ buildSignals(prompt) {
10135
10249
  const p = prompt.trim();
10136
- if (p.length < 24) return false;
10250
+ if (p.length < 24) return { buildVerb: false, scaleCount: 0, multiPart: false };
10137
10251
  const buildVerb = /\b(?:build|implement|create|develop|design|scaffold|refactor|migrate|architect|set up|integrate)\b/i.test(p);
10138
- const scaleNoun = /\b(?:app(?:lication)?|system|platform|service|api|backend|frontend|full[- ]?stack|website|dashboard|pipeline|microservices?|database schema|authentication|end[- ]to[- ]end|codebase|project|multiple files|several (?:files|modules|components)|test suite)\b/i.test(p);
10252
+ const scaleCount = (p.match(/\b(?:app(?:lication)?|system|platform|service|api|backend|frontend|full[- ]?stack|website|dashboard|pipeline|microservices?|database schema|authentication|end[- ]to[- ]end|codebase|project|multiple files|several (?:files|modules|components)|test suite)\b/gi) ?? []).length;
10139
10253
  const multiPart = /(?:\b(?:and|then|also|plus|as well as)\b.*\b(?:and|then|also)\b)|(?:^|\n)\s*(?:[-*]|\d+[.)])\s+/i.test(p);
10140
- return buildVerb && (scaleNoun || multiPart);
10254
+ return { buildVerb, scaleCount, multiPart };
10255
+ }
10256
+ /**
10257
+ * A build prompt with REAL scale: multiple system-level deliverables, or a
10258
+ * deliverable plus explicitly multi-part phrasing. Only these floor to the
10259
+ * full T1→T2→T3 hierarchy — "create a todo app" is a build prompt too, but
10260
+ * flooring every small build to Complex was the #1 token bomb (3-5 managers
10261
+ * × workers for a task one worker handles).
10262
+ */
10263
+ looksClearlyComplex(prompt) {
10264
+ const s = this.buildSignals(prompt);
10265
+ return s.buildVerb && (s.scaleCount >= 2 || s.scaleCount >= 1 && s.multiPart);
10266
+ }
10267
+ /** A small single-deliverable build — real work, but one manager's worth. */
10268
+ looksLikeModerateBuild(prompt) {
10269
+ const s = this.buildSignals(prompt);
10270
+ return s.buildVerb && (s.scaleCount >= 1 || s.multiPart);
10141
10271
  }
10142
10272
  // Cache glob scan results per workspace path to avoid repeated I/O.
10143
10273
  static globCache = /* @__PURE__ */ new Map();
@@ -10225,6 +10355,9 @@ ${prompt}` : prompt;
10225
10355
  if (verdict !== "Complex" && this.looksClearlyComplex(prompt)) {
10226
10356
  this.recordDecision("complexity", `Complex \u2014 heuristic floor over classifier "${verdict}": explicit multi-step build/implementation signals (T1 engaged)`);
10227
10357
  verdict = "Complex";
10358
+ } else if (verdict === "Simple" && this.looksLikeModerateBuild(prompt)) {
10359
+ this.recordDecision("complexity", 'Moderate \u2014 heuristic floor over classifier "Simple": build signals without multi-system scale (single manager)');
10360
+ verdict = "Moderate";
10228
10361
  } else {
10229
10362
  this.recordDecision("complexity", `${verdict} \u2014 classifier: ${reason || "no reason given"}`);
10230
10363
  }