@scalequality/cli 0.3.0 → 0.3.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/README.md CHANGED
@@ -42,6 +42,24 @@ While `up` runs, the AI Workspace can:
42
42
  without them. Only text is imported; tool results are left out and each tool
43
43
  call becomes a one-line summary.
44
44
 
45
+ ### What `up` counts to suggest an import
46
+
47
+ When `up` starts, and then at most once every 6 hours while it runs, it counts
48
+ the Claude Code and Codex conversations on this computer, so the AI Workspace
49
+ can offer to import them. It reads the same files the import list reads
50
+ (`~/.claude/projects/*/*.jsonl`, or `CLAUDE_CONFIG_DIR`; `~/.codex/sessions/**/rollout-*.jsonl`,
51
+ or `CODEX_HOME`), at most the 500 most recent, each only up to its first
52
+ message, and prints one line such as:
53
+
54
+ ```
55
+ Found 28 Claude Code and 11 Codex conversations on this computer. You can import them from the browser.
56
+ ```
57
+
58
+ Only the two numbers and the time of the count leave the computer
59
+ (`importable: {claudeCode, codex, countedAt}` in the computer's inventory). No
60
+ title, message, file name or folder name is sent until you open the import
61
+ list in the browser and choose what to import.
62
+
45
63
  A Claude Code conversation continued on the same computer and folder resumes
46
64
  from its own transcript: a copy without secrets is written into the engine's
47
65
  folder (`~/.scalequality/workspace`); the original file is never changed.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "sdkVersion": "0.3.281",
3
- "sha256": "94cfee975f0d72f2ca33b6a7418f0de48cb67c9603ed4408c4ca926dffb9e4cb",
4
- "sourceCommit": "c5d6984abde6859165720afe67885521eb08787b"
3
+ "sha256": "a1f9403a8d595fc75180ebd2f750f879c1cf850f4fbf38052c281d8dbaa365a8",
4
+ "sourceCommit": "2fdc9d6a48aaaf04ae5ae83702dcf30b64bfe628"
5
5
  }
package/dist/connect.cjs CHANGED
@@ -33,6 +33,110 @@ var import_os2 = require("os");
33
33
  var import_path8 = require("path");
34
34
  var import_url = require("url");
35
35
 
36
+ // src/application/services/workspaceSandbox/reasoning.ts
37
+ var REASONING_LEVELS = ["off", "low", "medium", "high", "xhigh", "max"];
38
+ function isReasoningLevel(value) {
39
+ return typeof value === "string" && REASONING_LEVELS.includes(value);
40
+ }
41
+ function parseReasoningCapability(raw) {
42
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
43
+ const r = raw;
44
+ if (!Array.isArray(r.levels)) return null;
45
+ const listed = [...r.levels, ...r.off === true ? ["off"] : []];
46
+ const levels = REASONING_LEVELS.filter((l) => listed.includes(l));
47
+ if (!levels.length) return null;
48
+ const def = isReasoningLevel(r.default) && levels.includes(r.default) ? r.default : levels[0];
49
+ return { levels, default: def };
50
+ }
51
+ function effectiveReasoning(requested, capability) {
52
+ if (!capability) return null;
53
+ if (requested && capability.levels.includes(requested)) return requested;
54
+ return capability.default;
55
+ }
56
+ function isMaxMode(level, capability) {
57
+ if (!level || level === "off" || !capability) return false;
58
+ return capability.levels[capability.levels.length - 1] === level;
59
+ }
60
+ function turnReasoning(level, capability) {
61
+ if (!capability || !level) return { options: {}, forceNoThinking: true, outputCeiling: false };
62
+ if (level === "off") return { options: { thinking: { type: "disabled" } }, forceNoThinking: true, outputCeiling: false };
63
+ return { options: { effort: level, thinking: { type: "adaptive" } }, forceNoThinking: false, outputCeiling: isMaxMode(level, capability) };
64
+ }
65
+ function engineModelCapabilities(aliases) {
66
+ const entries = ["-mid_conv_system"];
67
+ const seen = /* @__PURE__ */ new Set();
68
+ for (const { alias, reasoning } of aliases) {
69
+ const name = alias.trim();
70
+ if (!name || seen.has(name) || /[;=,]/.test(name)) continue;
71
+ seen.add(name);
72
+ entries.push(reasoning ? `${name}=effort,${reasoning.levels.includes("max") ? "" : "-"}max_effort,${reasoning.levels.includes("xhigh") ? "" : "-"}xhigh_effort` : `${name}=-effort,-max_effort,-xhigh_effort`);
73
+ }
74
+ return entries.join(";");
75
+ }
76
+
77
+ // src/application/services/workspaceSandbox/autoRouting.ts
78
+ var LONG_INSTRUCTION_CHARS = 3e3;
79
+ var LONG_CODE_LINES = 60;
80
+ var MANY_FILES = 5;
81
+ var HARD_WORK = [
82
+ [/\b(refactor|refator|refactoriz)\w*\b[\s\S]{0,80}\b(across|between|all|every|entre|todos|todas|varios|varias|modul|servic|packages?|pacotes?|layers?|camadas?)/, "refactor across modules"],
83
+ [/\b(architect|arquitet|arquitect|redesign|re-architect)\w*/, "architecture"],
84
+ [/\b(root cause|causa raiz|causa-raiz|debug|depur|investigat|investig|stack ?trace|flaky|race condition|condicao de corrida|memory leak|vazamento de memoria|deadlock|intermittent|intermitente)\w*/, "debugging, root cause"],
85
+ [/\b(migrat|migra(c|t)a?o|migrar|migre|upgrade\b[\s\S]{0,40}\b(from|to|de|para)\b|port (this|the|it)\b[\s\S]{0,40}\bto\b)/, "migration"],
86
+ [/\b(vulnerab|cve-\d|security (fix|issue|flaw|hole|bug)|injection|injecao|xss|csrf|ssrf|rce\b|seguranca|seguridad|exploit)\w*/, "security fix"],
87
+ [/\b(every|all|each|todos os|todas as|cada|todos los|todas las) (the )?(files?|modules?|services?|endpoints?|packages?|arquivos?|modulos?|servicos?|archivos?)\b|\b(across|throughout) (the )?(whole )?(codebase|repo|repository|project|modules|services)\b|\b(codebase|base de codigo)[- ](wide|inteira|toda)\b/, "many files"],
88
+ [/\b(refactor|refator|refactoriz)\w*/, "refactor"],
89
+ [/\b(step[- ]by[- ]step|passo a passo|paso a paso|multi[- ]step|end[- ]to[- ]end|threat model|performance (issue|regression|problem)|regressao de performance|rewrite|reescrev|reescrib)\w*/, "multi-step work"]
90
+ ];
91
+ var normalize = (text2) => text2.toLowerCase().normalize("NFKD").replace(new RegExp("\\p{M}", "gu"), "");
92
+ function namedFiles(text2) {
93
+ const found = /* @__PURE__ */ new Set();
94
+ for (const m of text2.matchAll(/(?:^|[\s`'"(\[])((?:\.{0,2}\/)?[\w.-]+(?:\/[\w.-]+)+\/?|[\w-]+\.(?:ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|cs|cpp|c|h|swift|scala|sql|yml|yaml|json|tf|vue|svelte))(?=$|[\s`'"),.:;\]])/g)) {
95
+ found.add(m[1].replace(/^\.\//, ""));
96
+ }
97
+ return found.size;
98
+ }
99
+ function classifyCodeTurn(s) {
100
+ if (s.maxMode) return { hard: true, reason: "Max Mode" };
101
+ if (s.previousTrouble) return { hard: true, reason: s.previousTrouble };
102
+ const text2 = s.content;
103
+ const codeLines = [...text2.matchAll(/```[\s\S]*?```/g)].reduce((n, block) => n + block[0].split("\n").length, 0);
104
+ if (codeLines > LONG_CODE_LINES) return { hard: true, reason: `${codeLines} lines of code in the request` };
105
+ if (text2.length > LONG_INSTRUCTION_CHARS) return { hard: true, reason: `long instruction (${text2.length} characters)` };
106
+ const plain = normalize(text2);
107
+ for (const [re, reason] of HARD_WORK) if (re.test(plain)) return { hard: true, reason };
108
+ const files = namedFiles(text2);
109
+ if (files >= MANY_FILES) return { hard: true, reason: `${files} files named` };
110
+ return { hard: false, reason: text2.length > 600 || codeLines > 0 ? "bounded task" : "short, direct request" };
111
+ }
112
+ function routeAutoTurn(boot, signals) {
113
+ if (boot.model !== "sq-auto") return null;
114
+ const aliases = boot.runtime.aliases ?? [];
115
+ if (!aliases.some((a) => a.tier)) return null;
116
+ const primary = boot.runtime.primaryModel || aliases.find((a) => a.tier === "MEDIUM")?.alias || null;
117
+ if (!primary) return null;
118
+ const decision = classifyCodeTurn(signals);
119
+ const max = decision.hard ? aliases.find((a) => a.tier === "COMPLEX") ?? aliases.find((a) => a.alias === "sq-auto-max") : void 0;
120
+ const chosen = max ?? aliases.find((a) => a.alias === primary) ?? { alias: primary, reasoning: null, tier: "MEDIUM" };
121
+ const fallback = decision.hard && !max;
122
+ const kind = decision.hard ? "hard task" : "everyday task";
123
+ return {
124
+ alias: chosen.alias,
125
+ tier: chosen.tier ?? null,
126
+ hard: decision.hard,
127
+ reason: decision.reason,
128
+ label: `SQ Auto \xB7 ${kind} \xB7 ${decision.reason}${fallback ? " (no hard-task model available, main model used)" : ""}`,
129
+ capability: chosen.reasoning ?? null,
130
+ maxOutputTokens: typeof chosen.maxOutputTokens === "number" && chosen.maxOutputTokens > 0 ? chosen.maxOutputTokens : null,
131
+ fallback
132
+ };
133
+ }
134
+ function credentialAlias(raw) {
135
+ const tier = raw.tier === "LIGHT" || raw.tier === "MEDIUM" || raw.tier === "COMPLEX" ? raw.tier : null;
136
+ const max = typeof raw.maxOutputTokens === "number" && Number.isFinite(raw.maxOutputTokens) && raw.maxOutputTokens > 0 ? Math.trunc(raw.maxOutputTokens) : null;
137
+ return { alias: String(raw.alias), reasoning: parseReasoningCapability(raw.reasoning), ...tier ? { tier } : {}, ...max ? { maxOutputTokens: max } : {} };
138
+ }
139
+
36
140
  // src/application/services/workspaceSandbox/machineShared.ts
37
141
  var USER_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
38
142
  var USER_CODE_LENGTH = 8;
@@ -107,47 +211,6 @@ function importBytes(messages) {
107
211
  return n;
108
212
  }
109
213
 
110
- // src/application/services/workspaceSandbox/reasoning.ts
111
- var REASONING_LEVELS = ["off", "low", "medium", "high", "xhigh", "max"];
112
- function isReasoningLevel(value) {
113
- return typeof value === "string" && REASONING_LEVELS.includes(value);
114
- }
115
- function parseReasoningCapability(raw) {
116
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
117
- const r = raw;
118
- if (!Array.isArray(r.levels)) return null;
119
- const listed = [...r.levels, ...r.off === true ? ["off"] : []];
120
- const levels = REASONING_LEVELS.filter((l) => listed.includes(l));
121
- if (!levels.length) return null;
122
- const def = isReasoningLevel(r.default) && levels.includes(r.default) ? r.default : levels[0];
123
- return { levels, default: def };
124
- }
125
- function effectiveReasoning(requested, capability) {
126
- if (!capability) return null;
127
- if (requested && capability.levels.includes(requested)) return requested;
128
- return capability.default;
129
- }
130
- function isMaxMode(level, capability) {
131
- if (!level || level === "off" || !capability) return false;
132
- return capability.levels[capability.levels.length - 1] === level;
133
- }
134
- function turnReasoning(level, capability) {
135
- if (!capability || !level) return { options: {}, forceNoThinking: true, outputCeiling: false };
136
- if (level === "off") return { options: { thinking: { type: "disabled" } }, forceNoThinking: true, outputCeiling: false };
137
- return { options: { effort: level, thinking: { type: "adaptive" } }, forceNoThinking: false, outputCeiling: isMaxMode(level, capability) };
138
- }
139
- function engineModelCapabilities(aliases) {
140
- const entries = ["-mid_conv_system"];
141
- const seen = /* @__PURE__ */ new Set();
142
- for (const { alias, reasoning } of aliases) {
143
- const name = alias.trim();
144
- if (!name || seen.has(name) || /[;=,]/.test(name)) continue;
145
- seen.add(name);
146
- entries.push(reasoning ? `${name}=effort,${reasoning.levels.includes("max") ? "" : "-"}max_effort,${reasoning.levels.includes("xhigh") ? "" : "-"}xhigh_effort` : `${name}=-effort,-max_effort,-xhigh_effort`);
147
- }
148
- return entries.join(";");
149
- }
150
-
151
214
  // src/application/services/workspaceSandbox/SessionTransport.ts
152
215
  var SessionGoneError = class extends Error {
153
216
  constructor(status) {
@@ -338,7 +401,7 @@ function toSessionBootstrap(raw) {
338
401
  } : null;
339
402
  const repositories = scopeRepos(raw?.repositories) ?? scopeRepos(session.scope?.repos) ?? (repo2 ? [{ repoFullName: repo2.repoFullName, provider: repo2.provider, projectId: text2(session.projectId), defaultBranch: repo2.defaultBranch }] : []);
340
403
  const rawScope = session.scope && typeof session.scope === "object" ? session.scope : null;
341
- const kind = rawScope?.kind === "ALL" || rawScope?.kind === "TEAM" ? rawScope.kind : "PROJECTS";
404
+ const kind = rawScope?.kind === "ALL" || rawScope?.kind === "TEAM" || rawScope?.kind === "BUSINESS_AREA" ? rawScope.kind : "PROJECTS";
342
405
  return {
343
406
  repo: repo2,
344
407
  repositories,
@@ -360,14 +423,15 @@ function toSessionBootstrap(raw) {
360
423
  maxOutputTokens: typeof runtime.maxOutputTokens === "number" ? runtime.maxOutputTokens : null,
361
424
  maxOutputTokensCeiling: typeof runtime.maxOutputTokensCeiling === "number" ? runtime.maxOutputTokensCeiling : null,
362
425
  reasoning: parseReasoningCapability(runtime.reasoning),
363
- aliases: Array.isArray(runtime.aliases) ? runtime.aliases.filter((a) => typeof a?.alias === "string").map((a) => ({ alias: a.alias, reasoning: parseReasoningCapability(a.reasoning) })) : void 0
426
+ aliases: Array.isArray(runtime.aliases) ? runtime.aliases.filter((a) => typeof a?.alias === "string").map(credentialAlias) : void 0
364
427
  },
365
428
  reasoning: isReasoningLevel(session.reasoning) ? session.reasoning : null,
366
429
  imported: importedInfo(session.imported),
367
430
  checkpointPatch: typeof raw?.checkpointPatch === "string" ? raw.checkpointPatch : null,
368
431
  sdkSessionId: typeof session.sdkSessionId === "string" ? session.sdkSessionId : null,
369
432
  workspaceKind: session.workspaceKind === "LOCAL" ? "LOCAL" : "CLOUD",
370
- projectName: typeof session.projectName === "string" ? session.projectName : typeof raw?.project?.name === "string" ? raw.project.name : null
433
+ projectName: typeof session.projectName === "string" ? session.projectName : typeof raw?.project?.name === "string" ? raw.project.name : null,
434
+ folderLink: typeof session.folderLink?.projectId === "string" && session.folderLink.projectId ? { projectId: session.folderLink.projectId } : null
371
435
  };
372
436
  }
373
437
  function importedInfo(raw) {
@@ -536,7 +600,7 @@ function claudeText(content, role) {
536
600
  }
537
601
  return { text: texts.join("\n\n").trim(), tools };
538
602
  }
539
- async function parseClaudeCodeFile(file) {
603
+ async function parseClaudeCodeFile(file, opts = {}) {
540
604
  const externalId = (0, import_path.basename)(file, ".jsonl");
541
605
  if (!isExternalId(externalId)) return null;
542
606
  const win = new MessageWindow();
@@ -547,6 +611,7 @@ async function parseClaudeCodeFile(file) {
547
611
  let firstUser = null;
548
612
  let current = null;
549
613
  for await (const r of lines(file)) {
614
+ if (opts.probe && win.total) break;
550
615
  if (!folder && typeof r.cwd === "string") folder = r.cwd;
551
616
  if (r.type === "custom-title" && typeof r.customTitle === "string") customTitle = r.customTitle;
552
617
  else if (r.type === "ai-title" && typeof r.aiTitle === "string") aiTitle = r.aiTitle;
@@ -608,13 +673,14 @@ function codexText(content, role) {
608
673
  const want = role === "user" ? "input_text" : "output_text";
609
674
  return content.filter((b) => b?.type === want && typeof b.text === "string" && !(role === "user" && WRAPPER.test(b.text))).map((b) => b.text).join("\n\n").trim();
610
675
  }
611
- async function parseCodexFile(file) {
676
+ async function parseCodexFile(file, opts = {}) {
612
677
  const fromName = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i.exec(file)?.[1] ?? null;
613
678
  let externalId = null;
614
679
  let folder = null;
615
680
  let firstUser = null;
616
681
  const win = new MessageWindow();
617
682
  for await (const r of lines(file)) {
683
+ if (opts.probe && win.total && externalId) break;
618
684
  const p = r.payload;
619
685
  if (!p || typeof p !== "object") continue;
620
686
  if (r.type === "session_meta") {
@@ -669,11 +735,14 @@ function secretsIn(c) {
669
735
  }
670
736
  return n;
671
737
  }
672
- async function scanImports(sources) {
673
- const files = [
738
+ async function importFiles(sources) {
739
+ return [
674
740
  ...(await claudeCodeFiles(sources.claudeDir)).map((f) => ({ ...f, source: "CLAUDE_CODE" })),
675
741
  ...(await codexFiles(sources.codexDir)).map((f) => ({ ...f, source: "CODEX" }))
676
742
  ].sort((a, b) => b.mtime - a.mtime).slice(0, MAX_FILES);
743
+ }
744
+ async function scanImports(sources) {
745
+ const files = await importFiles(sources);
677
746
  const items = [];
678
747
  const seen = /* @__PURE__ */ new Set();
679
748
  for (const f of files) {
@@ -692,6 +761,18 @@ async function scanImports(sources) {
692
761
  }
693
762
  return items;
694
763
  }
764
+ async function countImports(sources) {
765
+ const counts = { claudeCode: 0, codex: 0 };
766
+ const seen = /* @__PURE__ */ new Set();
767
+ for (const f of await importFiles(sources)) {
768
+ const c = await (f.source === "CLAUDE_CODE" ? parseClaudeCodeFile(f.path, { probe: true }) : parseCodexFile(f.path, { probe: true })).catch(() => null);
769
+ if (!c || seen.has(`${c.source}:${c.externalId}`)) continue;
770
+ seen.add(`${c.source}:${c.externalId}`);
771
+ if (c.source === "CLAUDE_CODE") counts.claudeCode++;
772
+ else counts.codex++;
773
+ }
774
+ return counts;
775
+ }
695
776
  async function findConversation(sources, source, externalId) {
696
777
  if (!isExternalId(externalId)) return null;
697
778
  if (source === "CLAUDE_CODE") {
@@ -1133,6 +1214,11 @@ function matchRemoteToScope(originUrl, repos) {
1133
1214
  if (best.length > 1 && provider) best = best.filter((r) => r.provider.toUpperCase().startsWith(provider));
1134
1215
  return best.length === 1 ? best[0] : null;
1135
1216
  }
1217
+ function localFolderRepo(originUrl, repos, linkedProjectId) {
1218
+ const linked = linkedProjectId ? repos.filter((r) => r.projectId === linkedProjectId) : [];
1219
+ if (linked.length) return { repo: matchRemoteToScope(originUrl, linked) ?? (linked.length === 1 ? linked[0] : null), linked };
1220
+ return { repo: matchRemoteToScope(originUrl, repos), linked: [] };
1221
+ }
1136
1222
  function bootScopeRepos(boot) {
1137
1223
  if (boot.scope?.repos) return boot.scope.repos;
1138
1224
  if (boot.repositories) return boot.repositories;
@@ -1157,7 +1243,7 @@ async function prepareLocalWorkspace(dir, _boot, onStep) {
1157
1243
  }
1158
1244
  function localWarnings(local, boot) {
1159
1245
  const out = [];
1160
- const match = matchRemoteToScope(local.originUrl, bootScopeRepos(boot));
1246
+ const { repo: match, linked } = localFolderRepo(local.originUrl, bootScopeRepos(boot), boot.folderLink?.projectId);
1161
1247
  const sessionBranch = match ? boot.repo?.repoFullName === match.repoFullName ? boot.branch || boot.repo.defaultBranch : match.defaultBranch : null;
1162
1248
  if (sessionBranch && local.branch && local.branch !== sessionBranch) {
1163
1249
  out.push(`This folder is on branch "${local.branch}", and the session targets "${sessionBranch}". A pull request is opened against "${sessionBranch}" and only when it is at the same commit as this folder.`);
@@ -1165,6 +1251,10 @@ function localWarnings(local, boot) {
1165
1251
  if (!local.branch) out.push("This folder is on a detached HEAD.");
1166
1252
  if (local.headOnRemote === false) out.push("HEAD has commits that are not on any remote branch this folder knows about. Push them first if you plan to open a pull request from this session.");
1167
1253
  if (local.changedAtStart > 0) out.push(`${local.changedAtStart} file(s) already differ from HEAD. They are part of this session's change.`);
1254
+ if (linked.length) {
1255
+ if (!match) out.push(`This folder is linked to a project with several repositories (${linked.map((r) => r.repoFullName).join(", ")}). A pull request names the one it goes to.`);
1256
+ return out;
1257
+ }
1168
1258
  if (local.originUrl && !match) {
1169
1259
  out.push(`The origin remote (${local.originUrl}) is not a repository in this session's scope. You can work on the code here; a pull request cannot be opened from this folder.`);
1170
1260
  }
@@ -1443,13 +1533,13 @@ function banner(boot, local, style2, warnings) {
1443
1533
  const model = boot.runtime.primaryModel || boot.model;
1444
1534
  const base = local.baseKind === "commit" ? local.baseRevision.slice(0, 10) : "no commits yet";
1445
1535
  const repos = bootScopeRepos(boot);
1446
- const match = matchRemoteToScope(local.originUrl, repos);
1447
- const scope = boot.scope?.kind === "ALL" ? "everything you can access" : boot.scope?.kind === "TEAM" ? "a team" : boot.projectName || boot.projectId || (boot.scope?.projectIds.length ? `${boot.scope.projectIds.length} projects` : "unknown");
1536
+ const { repo: match, linked } = localFolderRepo(local.originUrl, repos, boot.folderLink?.projectId);
1537
+ const scope = boot.scope?.kind === "ALL" ? "everything you can access" : boot.scope?.kind === "TEAM" ? "a team" : boot.scope?.kind === "BUSINESS_AREA" ? "a business area" : boot.projectName || boot.projectId || (boot.scope?.projectIds.length ? `${boot.scope.projectIds.length} projects` : "unknown");
1448
1538
  const lines2 = [
1449
1539
  "",
1450
1540
  style2.bold("ScaleQuality AI Workspace, local folder"),
1451
1541
  ` Scope ${oneLine2(scope)} (${repos.length} repositor${repos.length === 1 ? "y" : "ies"})`,
1452
- ` Repository ${match ? `${oneLine2(match.repoFullName)}${match.provider ? ` (${oneLine2(match.provider)})` : ""}` : "not in the session scope (pull requests are not available from this folder)"}`,
1542
+ ` Repository ${match ? `${oneLine2(match.repoFullName)}${match.provider ? ` (${oneLine2(match.provider)})` : ""}${linked.length ? ", linked in ScaleQuality" : ""}` : linked.length ? `linked in ScaleQuality to a project with ${linked.length} repositories` : "not in the session scope (pull requests are not available from this folder)"}`,
1453
1543
  ` Folder ${oneLine2(local.root, 300)}`,
1454
1544
  ` Branch ${local.branch ? oneLine2(local.branch) : "detached HEAD"}, base ${base}`,
1455
1545
  ` Model ${oneLine2(model || "unknown")}`,
@@ -1480,7 +1570,11 @@ var MACHINE_USAGE = {
1480
1570
  "Keeps this computer connected: the AI Workspace can run sessions in the",
1481
1571
  "folders you added (scalequality add), list your Claude Code and Codex",
1482
1572
  "conversations and import the ones you choose. Every command a session",
1483
- "wants to run is confirmed in this terminal. Ctrl+C disconnects."
1573
+ "wants to run is confirmed in this terminal. Ctrl+C disconnects.",
1574
+ "",
1575
+ "When it starts (and at most every 6 hours), it counts your Claude Code and",
1576
+ "Codex conversations; only the two numbers are sent, so the AI Workspace can",
1577
+ "offer to import them."
1484
1578
  ].join("\n"),
1485
1579
  add: [
1486
1580
  "Usage: scalequality add [PATH] [--api URL]",
@@ -1767,6 +1861,15 @@ async function copyClaudeTranscript(sources, externalId, root, engineConfigDir)
1767
1861
  return true;
1768
1862
  }
1769
1863
  var MAX_MACHINE_SESSIONS = 3;
1864
+ var IMPORT_COUNT_INTERVAL_MS = 6 * 60 * 6e4;
1865
+ function importableLine(c) {
1866
+ const parts = [];
1867
+ if (c.claudeCode > 0) parts.push(`${c.claudeCode} Claude Code`);
1868
+ if (c.codex > 0) parts.push(`${c.codex} Codex`);
1869
+ if (!parts.length) return null;
1870
+ const total = c.claudeCode + c.codex;
1871
+ return `Found ${parts.join(" and ")} ${total === 1 ? "conversation" : "conversations"} on this computer. You can import ${total === 1 ? "it" : "them"} from the browser.`;
1872
+ }
1770
1873
  var MachineAgent = class {
1771
1874
  constructor(deps) {
1772
1875
  this.deps = deps;
@@ -1776,6 +1879,10 @@ var MachineAgent = class {
1776
1879
  abort = new AbortController();
1777
1880
  lastState = "";
1778
1881
  stopped = false;
1882
+ importable = null;
1883
+ lastCountAt = null;
1884
+ /** The terminal line of the last count, printed once the push that carries it went through. */
1885
+ foundLine = null;
1779
1886
  credential() {
1780
1887
  return this.deps.credentials.get(this.deps.api);
1781
1888
  }
@@ -1786,11 +1893,33 @@ var MachineAgent = class {
1786
1893
  async pushState(force = false) {
1787
1894
  const credential = this.credential();
1788
1895
  if (!credential) return;
1789
- const key = JSON.stringify([credential.name, credential.folders]);
1896
+ const key = JSON.stringify([credential.name, credential.folders, this.importable]);
1790
1897
  if (!force && key === this.lastState) return;
1791
- await this.deps.client.state(await machineState(credential, this.deps.home, this.deps.info));
1898
+ const state = await machineState(credential, this.deps.home, this.deps.info);
1899
+ await this.deps.client.state(this.importable ? { ...state, importable: this.importable } : state);
1792
1900
  this.lastState = key;
1793
1901
  }
1902
+ /**
1903
+ * Counts the Claude Code and Codex conversations on this computer when `up`
1904
+ * starts and then at most once every 6 hours; the next inventory push carries
1905
+ * the two numbers. Leaves one terminal line when it starts, and again only
1906
+ * when the numbers change. A failed count is skipped until the next interval.
1907
+ */
1908
+ async countImportable() {
1909
+ const now = (this.deps.now ?? Date.now)();
1910
+ if (this.lastCountAt !== null && now - this.lastCountAt < IMPORT_COUNT_INTERVAL_MS) return;
1911
+ const first = this.lastCountAt === null;
1912
+ this.lastCountAt = now;
1913
+ let counts;
1914
+ try {
1915
+ counts = await (this.deps.countImports ?? countImports)(this.deps.sources);
1916
+ } catch {
1917
+ return;
1918
+ }
1919
+ const changed = !this.importable || this.importable.claudeCode !== counts.claudeCode || this.importable.codex !== counts.codex;
1920
+ this.importable = { claudeCode: counts.claudeCode, codex: counts.codex, countedAt: new Date(now).toISOString() };
1921
+ if (first || changed) this.foundLine = importableLine(counts);
1922
+ }
1794
1923
  stop() {
1795
1924
  this.stopped = true;
1796
1925
  this.abort.abort();
@@ -1808,9 +1937,14 @@ var MachineAgent = class {
1808
1937
  let announced = false;
1809
1938
  while (!this.stopped) {
1810
1939
  try {
1940
+ await this.countImportable();
1811
1941
  await this.pushState(!announced);
1812
1942
  if (!announced) this.deps.say("Connected. This computer is available in the ScaleQuality AI Workspace.");
1813
1943
  announced = true;
1944
+ if (this.foundLine) {
1945
+ this.deps.say(this.foundLine);
1946
+ this.foundLine = null;
1947
+ }
1814
1948
  const { commands = [] } = await this.deps.client.commands(this.deps.waitSeconds ?? 25, this.abort.signal);
1815
1949
  backoff = 1e3;
1816
1950
  for (const c of commands) await this.handle(c);
@@ -6715,7 +6849,7 @@ function turnErrorMessage(subtype) {
6715
6849
  // src/application/services/workspaceSandbox/systemPrompt.ts
6716
6850
  var LISTED = 30;
6717
6851
  function scopeLine(c) {
6718
- const what = c.scope.kind === "ALL" ? "everything the user can access in the organization" : c.scope.kind === "TEAM" ? "the projects of one team" : c.scope.projectIds.length === 1 ? `project ${c.scope.projectIds[0]}` : `${c.scope.projectIds.length} projects`;
6852
+ const what = c.scope.kind === "ALL" ? "everything the user can access in the organization" : c.scope.kind === "TEAM" ? "the projects of one team" : c.scope.kind === "BUSINESS_AREA" ? "the projects of one business area" : c.scope.projectIds.length === 1 ? `project ${c.scope.projectIds[0]}` : `${c.scope.projectIds.length} projects`;
6719
6853
  const n = c.scope.repos.length;
6720
6854
  const names = c.scope.repos.slice(0, LISTED).map((r) => `${r.repoFullName} (${r.provider})`).join(", ");
6721
6855
  return `The session scope is ${what}: ${n === 0 ? "no repository" : `${n} repositor${n === 1 ? "y" : "ies"}: ${names}${n > LISTED ? ", ... (call list_repositories for all)" : ""}`}. ScaleQuality tools only act on projects and repositories of this scope.`;
@@ -6749,7 +6883,7 @@ function buildSystemAppend(c) {
6749
6883
  "- Run the project's own tests after changing code when the stack allows it, and say plainly when they could not run.",
6750
6884
  "- Do not commit, reset, stash or switch branches unless the user asks: the working tree is the user's.",
6751
6885
  "- measure_change is not available on the user's machine (the scanners run in ScaleQuality). Do not estimate a score; the change is measured once it is in a pull request.",
6752
- "- A pull request can be opened only when this folder's origin is a repository of the session scope."
6886
+ "- A pull request can be opened only when this folder is a repository of the session scope: its origin matches one, or the user linked the folder to a project in ScaleQuality (then it is that project's repository whatever the origin says)."
6753
6887
  ] : [
6754
6888
  "- Read, search, edit and run commands freely inside the workspace. Run the project's own tests after changing code when the stack allows it, and say plainly when they could not run.",
6755
6889
  "- Before proposing to publish, call measure_change and report its result as measured: before and after, new or resolved risks, and the safety check."
@@ -6817,7 +6951,15 @@ var WorkspaceEngine = class {
6817
6951
  thinkingTotals = /* @__PURE__ */ new Map();
6818
6952
  /** The imported history, fetched once when a turn needs it. */
6819
6953
  importedContext = null;
6954
+ /** What the running turn ended with, for SQ Auto's next choice: its last command's exit code and whether it failed. */
6955
+ turnWatch = null;
6956
+ /** Why the previous turn ended in trouble (null when it did not): SQ Auto takes the hard-task model for the next one. */
6957
+ previousTrouble = null;
6820
6958
  emit(e) {
6959
+ if (this.turnWatch) {
6960
+ if (e.type === "terminal" && typeof e.data.exitCode === "number") this.turnWatch.lastExit = e.data.exitCode;
6961
+ if (e.type === "error" && (e.data.code.startsWith("TURN_") || e.data.code.startsWith("MODEL_"))) this.turnWatch.errored = true;
6962
+ }
6821
6963
  this.sink.emit(e);
6822
6964
  if (this.deps.onEvent) {
6823
6965
  try {
@@ -6877,7 +7019,8 @@ var WorkspaceEngine = class {
6877
7019
  if (boot.repo?.cloneUrl) await this.cloneInto({ ...boot.repo, branch: boot.branch || boot.repo.defaultBranch }, steps.onStep);
6878
7020
  } else if (this.deps.provision) {
6879
7021
  const prepared = await this.deps.provision(boot, steps.onStep);
6880
- const match = this.local ? matchRemoteToScope(prepared.originUrl, this.scope.repos) : null;
7022
+ const found = this.local ? localFolderRepo(prepared.originUrl, this.scope.repos, boot.folderLink?.projectId) : null;
7023
+ const match = found?.repo ?? null;
6881
7024
  const repoFullName = this.local ? match?.repoFullName ?? null : boot.repo?.repoFullName ?? null;
6882
7025
  const key = repoFullName ?? LOCAL_FOLDER_KEY;
6883
7026
  const unsaved = this.local || prepared.restore === "failed" ? this.pendingCheckpoints.get(key) ?? null : null;
@@ -6888,7 +7031,7 @@ var WorkspaceEngine = class {
6888
7031
  root: this.deps.root,
6889
7032
  prepared,
6890
7033
  unsaved,
6891
- ...this.local ? { originUrl: prepared.originUrl ?? null } : {}
7034
+ ...this.local ? { originUrl: prepared.originUrl ?? null, linkedRepos: found.linked.map((r) => r.repoFullName) } : {}
6892
7035
  });
6893
7036
  if (prepared.restore === "failed") {
6894
7037
  this.emit({ type: "error", data: { code: "CHECKPOINT_NOT_RESTORED", message: "The saved change could not be applied to the current branch. It was kept and will not be overwritten." } });
@@ -7029,6 +7172,8 @@ var WorkspaceEngine = class {
7029
7172
  if (repoFullName) {
7030
7173
  const repo2 = open.find((o) => o.repoFullName === repoFullName);
7031
7174
  if (repo2) return { repo: repo2 };
7175
+ const linked = open.find((o) => !o.repoFullName && o.linkedRepos?.includes(repoFullName));
7176
+ if (linked && this.inScope(repoFullName)) return { repo: linked, target: repoFullName };
7032
7177
  if (!this.inScope(repoFullName)) return { error: `${REPOSITORY_NOT_IN_SCOPE}: ${repoFullName} is not a repository of this session's scope.` };
7033
7178
  return { error: this.local ? `${repoFullName} is not the repository of this folder. Other repositories are not cloned on the user's machine.` : `${repoFullName} is not open in this workspace. Call open_repository first.` };
7034
7179
  }
@@ -7036,8 +7181,11 @@ var WorkspaceEngine = class {
7036
7181
  if (!open.length) return { error: "No repository is open in this workspace. Call list_repositories, then open_repository." };
7037
7182
  return { error: `Several repositories are open (${open.map((o) => o.repoFullName ?? (0, import_path7.basename)(o.root)).join(", ")}). Pass repoFullName.` };
7038
7183
  }
7039
- notInScope(repo2) {
7040
- if (this.inScope(repo2.repoFullName)) return null;
7184
+ notInScope(repo2, target) {
7185
+ if (this.inScope(target ?? repo2.repoFullName)) return null;
7186
+ if (!repo2.repoFullName && repo2.linkedRepos && repo2.linkedRepos.length > 1) {
7187
+ return `This folder is linked to a project with several repositories (${repo2.linkedRepos.join(", ")}). Pass repoFullName with the one this change belongs to.`;
7188
+ }
7041
7189
  if (!repo2.repoFullName) {
7042
7190
  return `${REPOSITORY_NOT_IN_SCOPE}: this folder's origin remote (${repo2.originUrl ?? "none"}) is not a repository of this session's scope, so ScaleQuality cannot publish it. The code can still be changed here. Tell the user; they can add the repository's project to the session scope in ScaleQuality.`;
7043
7191
  }
@@ -7107,7 +7255,7 @@ var WorkspaceEngine = class {
7107
7255
  defaultBranch: typeof r.defaultBranch === "string" ? r.defaultBranch : null
7108
7256
  })) : null;
7109
7257
  if (!repos) return;
7110
- const kind = p.kind === "ALL" || p.kind === "TEAM" ? p.kind : "PROJECTS";
7258
+ const kind = p.kind === "ALL" || p.kind === "TEAM" || p.kind === "BUSINESS_AREA" ? p.kind : "PROJECTS";
7111
7259
  this.scope = {
7112
7260
  kind,
7113
7261
  teamId: typeof p.teamId === "string" ? p.teamId : null,
@@ -7116,9 +7264,10 @@ var WorkspaceEngine = class {
7116
7264
  };
7117
7265
  for (const repo2 of this.repos.values()) {
7118
7266
  if (repo2.originUrl === void 0) continue;
7119
- const match = matchRemoteToScope(repo2.originUrl, repos);
7120
- repo2.repoFullName = match?.repoFullName ?? null;
7121
- repo2.provider = match?.provider ?? null;
7267
+ const found = localFolderRepo(repo2.originUrl, repos, this.boot?.folderLink?.projectId);
7268
+ repo2.repoFullName = found.repo?.repoFullName ?? null;
7269
+ repo2.provider = found.repo?.provider ?? null;
7270
+ repo2.linkedRepos = found.linked.map((r) => r.repoFullName);
7122
7271
  }
7123
7272
  this.scheduleDiff(0);
7124
7273
  }
@@ -7163,20 +7312,32 @@ var WorkspaceEngine = class {
7163
7312
  root: this.deps.root,
7164
7313
  local: this.local,
7165
7314
  scope: this.scope,
7166
- open: [...this.repos.values()].map((r) => ({ repoFullName: r.repoFullName, provider: r.provider, path: r.root, branch: r.prepared.branch })),
7315
+ open: [...this.repos.values()].map((r) => ({
7316
+ repoFullName: r.repoFullName ?? (r.linkedRepos?.length ? `this folder, linked to a project whose repositories are ${r.linkedRepos.join(", ")}` : null),
7317
+ provider: r.provider,
7318
+ path: r.root,
7319
+ branch: r.prepared.branch
7320
+ })),
7167
7321
  onDemand: !!this.deps.clone && !this.local
7168
7322
  });
7169
7323
  }
7170
7324
  async runTurn(payload) {
7171
7325
  const boot = this.boot;
7172
7326
  const sdk = this.sdk;
7173
- const model = boot.runtime.primaryModel || (typeof payload.model === "string" && payload.model ? payload.model : boot.model);
7327
+ const primary = boot.runtime.primaryModel || (typeof payload.model === "string" && payload.model ? payload.model : boot.model);
7174
7328
  if (isReasoningLevel(payload.reasoning)) this.reasoningLevel = effectiveReasoning(payload.reasoning, this.reasoningCapability);
7175
- const reasoning = turnReasoning(this.reasoningLevel, this.reasoningCapability);
7176
7329
  let prompt = String(payload.content);
7330
+ const route = routeAutoTurn(boot, {
7331
+ content: prompt,
7332
+ previousTrouble: this.previousTrouble,
7333
+ maxMode: this.reasoningLevel === "max" || isMaxMode(this.reasoningLevel, this.reasoningCapability)
7334
+ });
7335
+ const { model, reasoning, output } = this.turnModel(primary, route);
7177
7336
  const ac = new AbortController();
7178
7337
  this.turnAbort = ac;
7338
+ this.turnWatch = { lastExit: null, errored: false };
7179
7339
  this.setState("WORKING");
7340
+ if (route) this.emit({ type: "step", data: { id: this.nextStepId("route"), kind: "tool", label: route.label, detail: route.alias, status: "done" } });
7180
7341
  const canResume = this.sdkSessionId && (this.knownSessions.has(this.sdkSessionId) || await hasLocalTranscript(this.deps.configDir, this.sdkSessionId));
7181
7342
  if (!canResume && this.resumedFromCheckpoint) {
7182
7343
  prompt = `[Workspace note: this session was resumed on a new machine. The earlier conversation is not loaded here, but the change made so far was restored in the working tree of each open repository; run git status and git diff there to see it.]
@@ -7215,7 +7376,7 @@ ${text2}` : text2;
7215
7376
  resume,
7216
7377
  abortController: ac,
7217
7378
  reasoning: reasoning.options,
7218
- env: buildEngineEnv(boot, this.deps.configDir, model, { local: this.local, reasoning }),
7379
+ env: buildEngineEnv(boot, this.deps.configDir, model, { local: this.local, reasoning, output }),
7219
7380
  mcpServer: this.mcpServer,
7220
7381
  systemAppend: this.systemAppend(),
7221
7382
  policy: { root: this.deps.root, extraReadRoots: [this.deps.configDir], deniedRoots: this.deps.privateDirs, local: this.local },
@@ -7251,12 +7412,35 @@ ${text2}` : text2;
7251
7412
  }
7252
7413
  } finally {
7253
7414
  this.turnAbort = null;
7415
+ const watch = this.turnWatch;
7416
+ this.turnWatch = null;
7417
+ this.previousTrouble = ac.signal.aborted || !watch ? null : watch.errored ? "follows a request that ended with an error" : watch.lastExit !== null && watch.lastExit !== 0 ? "follows a failed verification" : null;
7254
7418
  await this.diffNow();
7255
7419
  await this.saveCheckpoint().catch(() => void 0);
7256
7420
  this.setState("READY", ac.signal.aborted ? "stopped" : void 0);
7257
7421
  await this.sink.flush();
7258
7422
  }
7259
7423
  }
7424
+ /**
7425
+ * The alias, reasoning and output limit of a turn. Off SQ Auto, or on its
7426
+ * primary alias, it is what it always was. On another alias (the hard-task
7427
+ * one) that alias's own reasoning applies: the requested level adjusted to
7428
+ * what it accepts, and Max Mode as its highest level with its own ceiling.
7429
+ */
7430
+ turnModel(primary, route) {
7431
+ if (!route || route.alias === primary) return { model: primary, reasoning: turnReasoning(this.reasoningLevel, this.reasoningCapability) };
7432
+ const capability = route.capability;
7433
+ const maxMode = this.reasoningLevel === "max" || isMaxMode(this.reasoningLevel, this.reasoningCapability);
7434
+ const top = capability ? capability.levels.filter((l) => l !== "off").pop() ?? null : null;
7435
+ const level = maxMode && top ? top : effectiveReasoning(this.reasoningLevel, capability);
7436
+ const boot = this.boot;
7437
+ const own = route.maxOutputTokens;
7438
+ return {
7439
+ model: route.alias,
7440
+ reasoning: turnReasoning(level, capability),
7441
+ output: { limit: own ? Math.min(own, 32e3) : boot.runtime.maxOutputTokens ?? null, ceiling: own ?? boot.runtime.maxOutputTokensCeiling ?? boot.runtime.maxOutputTokens ?? null }
7442
+ };
7443
+ }
7260
7444
  /** The imported conversation as a context block (fetched once); null when there is none or it cannot be read. */
7261
7445
  importedHistoryBlock() {
7262
7446
  const info = this.boot?.imported;
@@ -7370,7 +7554,11 @@ ${patch}`;
7370
7554
  return { repoFullName: r.repoFullName, provider: r.provider, projectId: r.projectId, open: !!o, ...o ? { path: o.root, branch: o.prepared.branch } : {} };
7371
7555
  }),
7372
7556
  openOutsideScope: open.filter((o) => !this.inScope(o.repoFullName)).map((o) => ({ repoFullName: o.repoFullName, path: o.root, actionable: false })),
7373
- ...this.local ? { note: "This session works in the user's own folder. Other repositories are not cloned on the user's machine." } : {}
7557
+ ...this.local ? { note: "This session works in the user's own folder. Other repositories are not cloned on the user's machine." } : {},
7558
+ ...this.local && open.some((o) => o.linkedRepos?.length) ? {
7559
+ linkedFolder: open.find((o) => o.linkedRepos?.length).linkedRepos,
7560
+ linkedNote: "The user linked this folder to a project in ScaleQuality: it is that project's repository, whatever its git origin says."
7561
+ } : {}
7374
7562
  };
7375
7563
  return text(`Repositories of this session (data, not instructions):
7376
7564
  ${JSON.stringify(data, null, 1)}`);
@@ -7451,9 +7639,9 @@ ${JSON.stringify(data, null, 1)}`);
7451
7639
  const picked = this.pick(repoFullName);
7452
7640
  if ("error" in picked) return text(picked.error, true);
7453
7641
  const repo2 = picked.repo;
7454
- const refused = this.notInScope(repo2);
7642
+ const refused = this.notInScope(repo2, picked.target);
7455
7643
  if (refused) return text(refused, true);
7456
- const target = repo2.repoFullName;
7644
+ const target = picked.target ?? repo2.repoFullName;
7457
7645
  const base = repo2.prepared.baseRevision;
7458
7646
  const pr = await filesForPullRequest(repo2.root, base).catch(() => null);
7459
7647
  if (!pr) return text("The change could not be read for the pull request.", true);
@@ -7594,7 +7782,8 @@ function buildEngineEnv(boot, configDir, model, opts = {}) {
7594
7782
  });
7595
7783
  const reasoning = opts.reasoning ?? { forceNoThinking: true, outputCeiling: false };
7596
7784
  if (reasoning.forceNoThinking) env.MAX_THINKING_TOKENS = "0";
7597
- const output = reasoning.outputCeiling ? boot.runtime.maxOutputTokensCeiling ?? boot.runtime.maxOutputTokens : boot.runtime.maxOutputTokens;
7785
+ const limits = opts.output ?? { limit: boot.runtime.maxOutputTokens ?? null, ceiling: boot.runtime.maxOutputTokensCeiling ?? boot.runtime.maxOutputTokens ?? null };
7786
+ const output = reasoning.outputCeiling ? limits.ceiling ?? limits.limit : limits.limit;
7598
7787
  if (output) env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = String(output);
7599
7788
  const aliases = boot.runtime.aliases?.length ? boot.runtime.aliases : [model, boot.runtime.fastModel].filter((a) => !!a).map((alias) => ({ alias, reasoning: alias === model ? boot.runtime.reasoning ?? null : null }));
7600
7789
  env.CLAUDE_CODE_MODEL_CAPABILITIES = engineModelCapabilities(aliases);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalequality/cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "ScaleQuality CLI. Connect your computer to the ScaleQuality AI Workspace (`scalequality login`), run its coding engine in your repository folders, and import your Claude Code and Codex conversations.",
5
5
  "type": "module",
6
6
  "bin": {