@nurix/nustack 0.25.5 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -85,7 +85,7 @@ export async function runDoctor(options) {
85
85
  }
86
86
  else {
87
87
  const stamp = store.db.prepare("PRAGMA user_version").get().user_version;
88
- add("state", "Local store schema", "ok", `v${stamp}`);
88
+ add("state", "Local store schema", "ok", `schema ${stamp}`);
89
89
  }
90
90
  const repository = resolveRepositoryOrNull(cwd);
91
91
  if (!repository) {
@@ -9,7 +9,7 @@ import { countRecords, releaseUnboundRows } from "../lib/records/recordsTable.js
9
9
  import { drainSessionLegs } from "../lib/sessions/sessionEvents.js";
10
10
  import { pullAll, pullCursor, pushOutbox } from "../lib/sync/client.js";
11
11
  import { countOutbox, hasDueEntry, newestOutboxError } from "../lib/sync/outbox.js";
12
- import { cliVersion, machineStorePath, storeStampOf } from "../lib/store/open.js";
12
+ import { cliVersion, machineSchemaVersion, machineStorePath } from "../lib/store/open.js";
13
13
  const CLIENT_OUTDATED = "CLIENT_OUTDATED";
14
14
  function answeredRefusal(command, options, summaries) {
15
15
  for (const summary of summaries) {
@@ -133,7 +133,7 @@ export async function runStoreStatus(options) {
133
133
  const result = {
134
134
  path: machineStorePath(),
135
135
  version: cliVersion(),
136
- stamp: storeStampOf(cliVersion()),
136
+ stamp: machineSchemaVersion(),
137
137
  cursor: pullCursor(store),
138
138
  outbox: countOutbox(store.db, Date.now()),
139
139
  records: tableCount("harness_records"),
@@ -1,5 +1,41 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { desktopSettingsPath } from "./local_store/paths.js";
3
+ export const CLASSIFIERS = ["laya", "jev"];
4
+ export const TASK_KINDS = ["search", "explain", "review", "plan", "debug", "edit", "test", "docs", "run"];
5
+ export const READ_ONLY_TASKS = ["search", "explain", "review", "plan", "debug"];
6
+ export const ROUTE_MODES = ["auto", "local", "claude"];
7
+ export const CLAUDE_MODELS = ["haiku", "sonnet", "opus", "fable"];
8
+ export const DEFAULT_TASK_ROUTE = { mode: "auto", model: null };
9
+ const CLASSIFIER_URL_MAX = 200;
10
+ export function isLoopbackUrl(value) {
11
+ if (typeof value !== "string" || value.length > CLASSIFIER_URL_MAX || /\s/.test(value))
12
+ return false;
13
+ return ["http://127.0.0.1", "http://localhost", "http://[::1]"].some((host) => {
14
+ if (!value.startsWith(host))
15
+ return false;
16
+ const rest = value.slice(host.length);
17
+ return rest === "" || rest.startsWith(":") || rest.startsWith("/");
18
+ });
19
+ }
20
+ function taskRoutesOf(value) {
21
+ if (!value || typeof value !== "object" || Array.isArray(value))
22
+ return null;
23
+ const out = {};
24
+ for (const [key, raw] of Object.entries(value)) {
25
+ const kind = TASK_KINDS.find((known) => known === key);
26
+ if (kind === undefined || !raw || typeof raw !== "object")
27
+ return null;
28
+ const route = raw;
29
+ const mode = ROUTE_MODES.find((known) => known === route.mode);
30
+ if (mode === undefined || (mode === "local" && !READ_ONLY_TASKS.includes(kind)))
31
+ return null;
32
+ const model = route.model === undefined || route.model === null ? null : CLAUDE_MODELS.find((known) => known === route.model);
33
+ if (model === undefined)
34
+ return null;
35
+ out[kind] = { mode, model };
36
+ }
37
+ return out;
38
+ }
3
39
  export const GATE_THRESHOLD_TOKENS_MIN = 1_000;
4
40
  export const GATE_THRESHOLD_TOKENS_MAX = 200_000;
5
41
  export const DIGEST_BUDGET_MS_MIN = 5_000;
@@ -17,7 +53,7 @@ function sectionOf(value) {
17
53
  export function readDesktopSettings() {
18
54
  const defaults = {
19
55
  placement: { memory: DEFAULT_PLACEMENT, securityFindings: DEFAULT_PLACEMENT },
20
- routing: { localReader: false, gateThresholdTokens: null, digestBudgetMs: null, lane: false },
56
+ routing: { localReader: false, gateThresholdTokens: null, digestBudgetMs: null, lane: false, classifier: null, classifierUrl: null, taskRoutes: null },
21
57
  };
22
58
  let parsed;
23
59
  try {
@@ -40,6 +76,9 @@ export function readDesktopSettings() {
40
76
  gateThresholdTokens: boundedIntegerOf(routing.gateThresholdTokens, GATE_THRESHOLD_TOKENS_MIN, GATE_THRESHOLD_TOKENS_MAX),
41
77
  digestBudgetMs: boundedIntegerOf(routing.digestBudgetMs, DIGEST_BUDGET_MS_MIN, DIGEST_BUDGET_MS_MAX),
42
78
  lane: routing.lane === true,
79
+ classifier: CLASSIFIERS.find((known) => known === routing.classifier) ?? null,
80
+ classifierUrl: isLoopbackUrl(routing.classifierUrl) ? routing.classifierUrl : null,
81
+ taskRoutes: taskRoutesOf(routing.taskRoutes),
43
82
  },
44
83
  };
45
84
  }
@@ -6,6 +6,7 @@ import { readSettingsLayers } from "../lane/credential.js";
6
6
  import { LANE_AGENT_NAME, LANE_AGENT_ROUTE, LANE_MODEL_ID } from "../lane/install.js";
7
7
  import { isUnderPrefix, readProxy } from "../lane/proxyRecord.js";
8
8
  import { GateInputError } from "./readGate.js";
9
+ import { classifyTask, destinationFor } from "./taskRouter.js";
9
10
  export const READ_ONLY_TOOLS = new Set(["Read", "Grep", "Glob", "LS", "NotebookRead"]);
10
11
  export const PASS_ON_AGENT_PERMISSION_RULE = true;
11
12
  function parseAgentCall(text) {
@@ -111,7 +112,8 @@ export async function answerAgentCall(text, env, home = os.homedir()) {
111
112
  const proxy = readProxy();
112
113
  if (!proxy.live || !isUnderPrefix(baseUrl, proxy.prefix))
113
114
  return null;
114
- if (!readDesktopSettings().routing.lane)
115
+ const { routing } = readDesktopSettings();
116
+ if (!routing.lane)
115
117
  return null;
116
118
  const model = call.toolInput.model;
117
119
  if (model !== undefined && model !== null && model !== "haiku")
@@ -119,12 +121,27 @@ export async function answerAgentCall(text, env, home = os.homedir()) {
119
121
  const type = call.toolInput.subagent_type;
120
122
  if (typeof type !== "string")
121
123
  return null;
122
- if (type !== "Explore" && !isReadOnlyDefinition(type, call.cwd, home))
124
+ const isRuleLocal = type === "Explore" || isReadOnlyDefinition(type, call.cwd, home);
125
+ if (routing.classifier === null && !isRuleLocal)
123
126
  return null;
124
127
  if (PASS_ON_AGENT_PERMISSION_RULE && hasAgentPermissionRule(readSettingsLayers({ env, cwd: call.cwd, home })))
125
128
  return null;
126
129
  if (await isLaneLatched(call.sessionId))
127
130
  return null;
128
- const updatedInput = LANE_AGENT_ROUTE === "model" ? { ...call.toolInput, model: LANE_MODEL_ID } : { ...call.toolInput, subagent_type: LANE_AGENT_NAME };
131
+ if (routing.classifier !== null) {
132
+ const decision = await classifyTask(routing.classifier, routing.classifierUrl, taskState(call.toolInput), env);
133
+ if (decision === null)
134
+ return null;
135
+ const destination = destinationFor(decision, routing.taskRoutes);
136
+ if (destination.to === "local")
137
+ return allow({ ...call.toolInput, subagent_type: LANE_AGENT_NAME });
138
+ return destination.model !== null && (model === undefined || model === null) ? allow({ ...call.toolInput, model: destination.model }) : null;
139
+ }
140
+ return allow(LANE_AGENT_ROUTE === "model" ? { ...call.toolInput, model: LANE_MODEL_ID } : { ...call.toolInput, subagent_type: LANE_AGENT_NAME });
141
+ }
142
+ export function taskState(toolInput) {
143
+ return [toolInput.description, toolInput.prompt].filter((part) => typeof part === "string" && part !== "").join("\n\n");
144
+ }
145
+ function allow(updatedInput) {
129
146
  return `${JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput } })}\n`;
130
147
  }
@@ -0,0 +1,106 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { DEFAULT_TASK_ROUTE, READ_ONLY_TASKS } from "../desktop_settings.js";
4
+ const runFile = promisify(execFile);
5
+ export const LAYA_DEFAULT_BASE_URL = "http://127.0.0.1:11435";
6
+ export const JEV_BASE_URL = "https://api.typesafe.ai";
7
+ export const CLASSIFIER_MODELS = { laya: "laya", jev: "jev-latest" };
8
+ export const TASK_ROUTER_KEYCHAIN_SERVICE = "nustack-task-router";
9
+ export const TASK_ROUTER_KEYCHAIN_ACCOUNT = "typesafe-api-key";
10
+ export const CLASSIFY_TIMEOUT_MS = 1_500;
11
+ export const MIN_CONFIDENCE = 0.5;
12
+ export const STATE_MAX_CHARS = 4_000;
13
+ export const LOCAL_OK_THRESHOLD = 0.5;
14
+ export const TASK_KIND_CRITERIA = {
15
+ search: "Find files, code, symbols or usages in a codebase",
16
+ explain: "Read code or documents and explain or summarise them",
17
+ review: "Review or audit code for problems without changing it",
18
+ plan: "Design or plan a change before making it",
19
+ debug: "Diagnose why something fails, without fixing it",
20
+ edit: "Write, change, fix or refactor code or files",
21
+ test: "Write or update tests",
22
+ docs: "Write or update documentation",
23
+ run: "Run commands, builds, tests or scripts",
24
+ };
25
+ export function classifyRequest(classifier, state) {
26
+ return {
27
+ model: CLASSIFIER_MODELS[classifier],
28
+ state: state.slice(0, STATE_MAX_CHARS),
29
+ questions: {
30
+ task: { type: "choice", instructions: "What kind of work does this coding sub-agent task ask for?", criteria: TASK_KIND_CRITERIA },
31
+ local_ok: {
32
+ type: "noul",
33
+ instructions: "Could a small local model do this task well by reading files alone, with no edits and no commands?",
34
+ labels: { false: "needs Claude", true: "a small model can" },
35
+ },
36
+ },
37
+ };
38
+ }
39
+ export function readDecision(body) {
40
+ if (body === null || typeof body !== "object")
41
+ return null;
42
+ const answers = body.answers;
43
+ const answer = answers?.task;
44
+ if (!answer)
45
+ return null;
46
+ const kind = Object.keys(TASK_KIND_CRITERIA).find((known) => known === answer.choice);
47
+ const confidence = typeof answer.answer_confidence === "number" ? answer.answer_confidence : answer.confidence;
48
+ if (kind === undefined || typeof confidence !== "number" || confidence < MIN_CONFIDENCE)
49
+ return null;
50
+ const localOk = answers?.local_ok?.noul;
51
+ return { kind, confidence, localOk: typeof localOk === "number" ? localOk : null };
52
+ }
53
+ export function destinationFor(decision, routes) {
54
+ const route = routes?.[decision.kind] ?? DEFAULT_TASK_ROUTE;
55
+ const isReadOnly = READ_ONLY_TASKS.includes(decision.kind);
56
+ if (route.mode === "local" && isReadOnly)
57
+ return { to: "local" };
58
+ if (route.mode === "auto" && isReadOnly && (decision.localOk ?? 0) >= LOCAL_OK_THRESHOLD)
59
+ return { to: "local" };
60
+ return { to: "claude", model: route.model };
61
+ }
62
+ export async function readJevKey(env) {
63
+ const fromEnv = env.TYPESAFE_API_KEY?.trim();
64
+ if (fromEnv)
65
+ return fromEnv;
66
+ const bin = env.NUSTACK_KEYCHAIN_BIN?.trim() || (process.platform === "darwin" ? "/usr/bin/security" : null);
67
+ if (bin === null)
68
+ return null;
69
+ try {
70
+ const { stdout } = await runFile(bin, ["find-generic-password", "-s", TASK_ROUTER_KEYCHAIN_SERVICE, "-a", TASK_ROUTER_KEYCHAIN_ACCOUNT, "-w"], {
71
+ env,
72
+ timeout: 1_000,
73
+ });
74
+ return stdout.trim() || null;
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ export function classifierBase(classifier, classifierUrl, env) {
81
+ const base = classifier === "laya" ? (classifierUrl ?? LAYA_DEFAULT_BASE_URL) : env.TYPESAFE_BASE_URL?.trim() || JEV_BASE_URL;
82
+ return base.replace(/\/+$/, "");
83
+ }
84
+ export async function classifyTask(classifier, classifierUrl, state, env) {
85
+ const headers = { "content-type": "application/json" };
86
+ if (classifier === "jev") {
87
+ const key = await readJevKey(env);
88
+ if (key === null)
89
+ return null;
90
+ headers.authorization = `Bearer ${key}`;
91
+ }
92
+ try {
93
+ const response = await fetch(`${classifierBase(classifier, classifierUrl, env)}/v1/systemone`, {
94
+ method: "POST",
95
+ headers,
96
+ body: JSON.stringify(classifyRequest(classifier, state)),
97
+ signal: AbortSignal.timeout(CLASSIFY_TIMEOUT_MS),
98
+ });
99
+ if (!response.ok)
100
+ return null;
101
+ return readDecision(await response.json());
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
@@ -9,7 +9,7 @@ export const SUBAGENT_MODEL_ENV = "CLAUDE_CODE_SUBAGENT_MODEL";
9
9
  export const SUBAGENT_MODEL_OVERRIDES_ROUTE = false;
10
10
  export const LANE_SENTENCES = {
11
11
  unreleased: "The light-model lane is not released in this build.",
12
- "switch-off": "The lane is switched off in NuStack's Routing & agents settings.",
12
+ "switch-off": "The lane is switched off in Settings → Desktop app → Local models.",
13
13
  "proxy-down": "NuStack's lane proxy is not running; open the NuStack desktop app.",
14
14
  "bedrock-or-vertex": "This session runs on Bedrock or Vertex, which the lane does not carry.",
15
15
  "subscription-credential": "This session signs in with a Claude subscription, which the lane never carries.",
@@ -1,4 +1,4 @@
1
- import { cliVersion, storeStampOf } from "../store/open.js";
1
+ import { machineSchemaVersion } from "../store/open.js";
2
2
  import { withTransaction } from "./database.js";
3
3
  function mapRow(row) {
4
4
  return {
@@ -62,7 +62,7 @@ export function upsertDeviceState(store, patch) {
62
62
  active_organization_id: merged.activeOrganizationId,
63
63
  keychain_service: merged.keychainService,
64
64
  keychain_account: merged.keychainAccount,
65
- schema_version: storeStampOf(cliVersion()),
65
+ schema_version: machineSchemaVersion(),
66
66
  last_auth_refresh_at_ms: merged.lastAuthRefreshAtMs,
67
67
  created_at_ms: merged.createdAtMs,
68
68
  updated_at_ms: now,
@@ -4,18 +4,17 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { mkdirSecure } from "../secure_file.js";
6
6
  import { nustackHomeDir } from "../local_store/paths.js";
7
- import { StoreTooNewError, versionVerdict, wipeStaleStore } from "./wipe.js";
7
+ import { StoreTooNewError, restoreHeldFolders, versionVerdict, wipeStaleStore } from "./wipe.js";
8
8
  export function machineStorePath(home = os.homedir()) {
9
9
  return path.join(home, ".nustack", "nustack.db");
10
10
  }
11
11
  export function databaseFiles(dbFile) {
12
12
  return [dbFile, `${dbFile}-wal`, `${dbFile}-shm`];
13
13
  }
14
- export function storeStampOf(version) {
15
- const match = /^v?(\d+)\.(\d+)/.exec(version.trim());
16
- if (!match)
17
- throw new Error(`not a release number: ${version}`);
18
- return Number(match[1]) * 1000 + Number(match[2]);
14
+ let schemaVersionCache;
15
+ export function machineSchemaVersion() {
16
+ schemaVersionCache ??= Number(readFileSync(new URL("../../../dist/machine.version", import.meta.url), "utf8").trim());
17
+ return schemaVersionCache;
19
18
  }
20
19
  let cliVersionCache;
21
20
  export function cliVersion() {
@@ -100,7 +99,8 @@ function settleSchema(db, stamp) {
100
99
  export async function openMachineDatabase(dbFile = machineStorePath()) {
101
100
  retireReplacedStores();
102
101
  await mkdirSecure(path.dirname(dbFile));
103
- const stamp = storeStampOf(cliVersion());
102
+ const stamp = machineSchemaVersion();
103
+ let held = [];
104
104
  for (let attempt = 0; attempt < 2; attempt += 1) {
105
105
  const db = await withSuppressedSqliteWarning(() => openSqliteFile(dbFile));
106
106
  try {
@@ -108,6 +108,8 @@ export async function openMachineDatabase(dbFile = machineStorePath()) {
108
108
  db.exec("PRAGMA journal_mode = WAL");
109
109
  db.exec("PRAGMA foreign_keys = OFF");
110
110
  if (settleSchema(db, stamp)) {
111
+ if (held.length > 0)
112
+ restoreHeldFolders(db, held);
111
113
  await chmod(dbFile, 0o600).catch(() => { });
112
114
  return db;
113
115
  }
@@ -116,7 +118,7 @@ export async function openMachineDatabase(dbFile = machineStorePath()) {
116
118
  db.close();
117
119
  throw error;
118
120
  }
119
- await wipeStaleStore(db, dbFile);
121
+ held = await wipeStaleStore(db, dbFile);
120
122
  }
121
123
  throw new Error(`the machine store at ${dbFile} could not be recreated`);
122
124
  }
@@ -126,7 +128,7 @@ export async function openMachineDatabaseReadOnly(dbFile = machineStorePath()) {
126
128
  const db = await withSuppressedSqliteWarning(() => openSqliteFile(dbFile, { readOnly: true }));
127
129
  try {
128
130
  db.exec("PRAGMA busy_timeout = 2000");
129
- if (userVersionOf(db) === storeStampOf(cliVersion()))
131
+ if (userVersionOf(db) === machineSchemaVersion())
130
132
  return db;
131
133
  }
132
134
  catch {
@@ -6,19 +6,32 @@ export function versionVerdict(found, own) {
6
6
  return "same";
7
7
  return found > own ? "newer" : "older";
8
8
  }
9
- const releaseOf = (stamp) => `${Math.floor(stamp / 1000)}.${stamp % 1000}`;
10
9
  export class StoreTooNewError extends Error {
11
10
  code = "STORE_TOO_NEW";
12
11
  foundStamp;
13
12
  ownStamp;
14
13
  constructor(foundStamp, ownStamp) {
15
- super(`This machine's NuStack store was created by NuStack ${releaseOf(foundStamp)}, newer than this CLI (${releaseOf(ownStamp)}). Update NuStack: ${UPGRADE_COMMAND}`);
14
+ super(`This machine's NuStack store is schema ${foundStamp}, newer than this CLI reads (${ownStamp}). Update NuStack: ${UPGRADE_COMMAND}`);
16
15
  this.name = "StoreTooNewError";
17
16
  this.foundStamp = foundStamp;
18
17
  this.ownStamp = ownStamp;
19
18
  }
20
19
  }
20
+ function heldFolders(db) {
21
+ try {
22
+ return db.prepare("SELECT path, label, project_id, added_at FROM folder_roster").all();
23
+ }
24
+ catch {
25
+ return [];
26
+ }
27
+ }
28
+ export function restoreHeldFolders(db, held) {
29
+ const insert = db.prepare("INSERT OR IGNORE INTO folder_roster (path, label, project_id, added_at) VALUES (?, ?, ?, ?)");
30
+ for (const row of held)
31
+ insert.run(row.path, row.label, row.project_id, row.added_at);
32
+ }
21
33
  export async function wipeStaleStore(db, dbFile) {
34
+ const held = heldFolders(db);
22
35
  try {
23
36
  const pending = db.prepare("SELECT count(*) AS n FROM sync_outbox").get();
24
37
  if (Number(pending?.n ?? 0) > 0) {
@@ -31,4 +44,5 @@ export async function wipeStaleStore(db, dbFile) {
31
44
  db.close();
32
45
  for (const file of [dbFile, `${dbFile}-wal`, `${dbFile}-shm`])
33
46
  rmSync(file, { force: true });
47
+ return held;
34
48
  }
@@ -18,7 +18,19 @@ const REQUEST_TIMEOUT_MS = 10_000;
18
18
  const MAX_PUSH_PASSES = 50;
19
19
  const MAX_PULL_PAGES = 500;
20
20
  const DEVICE_PROJECTION_FIELDS = ["origin", "type", "subject", "state", "outcome", "ownerAgentDefinitionId"];
21
- const outdatedMessage = (message) => `${message} — this CLI (${cliVersion()}) is behind the server. Update NuStack: ${UPGRADE_COMMAND}`;
21
+ function releaseOf(version) {
22
+ const match = version ? /^v?(\d+)\.(\d+)/.exec(version.trim()) : null;
23
+ return match ? [Number(match[1]), Number(match[2])] : null;
24
+ }
25
+ export function outdatedMessage(message, serverVersion, clientVersion = cliVersion()) {
26
+ const client = releaseOf(clientVersion);
27
+ const server = releaseOf(serverVersion);
28
+ const isAhead = client !== null && server !== null && (client[0] > server[0] || (client[0] === server[0] && client[1] > server[1]));
29
+ if (isAhead) {
30
+ return `${message} — this CLI (${clientVersion}) is newer than the server (${serverVersion}); sync resumes once the server is updated, or install the server's release: npm i -g @nurix/nustack@${server[0]}.${server[1]}`;
31
+ }
32
+ return `${message} — this CLI (${clientVersion}) is behind the server. Update NuStack: ${UPGRADE_COMMAND}`;
33
+ }
22
34
  async function request(url, init, bearer, timeoutMs) {
23
35
  try {
24
36
  await assertCredentialOrigin(url);
@@ -47,7 +59,8 @@ async function request(url, init, bearer, timeoutMs) {
47
59
  if (!response.ok) {
48
60
  const code = typeof body?.error?.code === "string" ? body.error.code : null;
49
61
  const detail = typeof body?.error?.message === "string" ? body.error.message : typeof body?.message === "string" ? body.message : `HTTP ${response.status}`;
50
- return { ok: false, status: response.status, code, message: `${code ?? `HTTP ${response.status}`}: ${detail}` };
62
+ const serverVersion = typeof body?.error?.details?.minimumVersion === "string" ? body.error.details.minimumVersion : null;
63
+ return { ok: false, status: response.status, code, message: `${code ?? `HTTP ${response.status}`}: ${detail}`, serverVersion };
51
64
  }
52
65
  if (!body || body.data === undefined || body.data === null)
53
66
  return { ok: false, status: response.status, code: null, message: "the server answered no data" };
@@ -176,7 +189,7 @@ async function pushBatch(store, base, batch, bearer, summary, timeoutMs) {
176
189
  const now = Date.now();
177
190
  if (!answer.ok) {
178
191
  if (answer.status === 426) {
179
- summary.outdated = outdatedMessage(answer.message);
192
+ summary.outdated = outdatedMessage(answer.message, answer.serverVersion);
180
193
  summary.lastError = summary.outdated;
181
194
  return false;
182
195
  }
@@ -311,7 +324,7 @@ export async function pullAll(store, globals, options = {}) {
311
324
  withApplying(store.db, () => writeSyncState(store.db, "cursor", "0"));
312
325
  continue;
313
326
  }
314
- summary.lastError = answer.status === 426 ? outdatedMessage(answer.message) : answer.message;
327
+ summary.lastError = answer.status === 426 ? outdatedMessage(answer.message, answer.serverVersion) : answer.message;
315
328
  if (answer.status === 426)
316
329
  summary.outdated = summary.lastError;
317
330
  return summary;
package/dist/machine.sql CHANGED
@@ -108,6 +108,10 @@ CREATE TABLE `projects` (
108
108
 
109
109
 
110
110
 
111
+ `repository_name` text,
112
+
113
+
114
+
111
115
 
112
116
 
113
117
 
@@ -529,6 +533,8 @@ CREATE UNIQUE INDEX `projects_init_idempotency_key_unique` ON `projects` (`init_
529
533
 
530
534
  CREATE UNIQUE INDEX `projects_public_slug_unique` ON `projects` (`public_slug`) WHERE `public_slug` IS NOT NULL;
531
535
 
536
+ CREATE UNIQUE INDEX `projects_repository_name_unique` ON `projects` (`repository_name`) WHERE `repository_name` IS NOT NULL;
537
+
532
538
  CREATE INDEX `projects_github_installation_repo_idx` ON `projects` (`github_installation_id`, `github_repo_full_name`);
533
539
 
534
540
  CREATE INDEX `projects_harness_idx` ON `projects` (`harness_id`);
@@ -0,0 +1 @@
1
+ 101
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/nustack",
3
- "version": "0.25.5",
3
+ "version": "0.26.1",
4
4
  "description": "The nustack CLI — bootstrap NuStack services into a repo via the NuStack discovery plane.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -35,9 +35,9 @@
35
35
  "typescript": "^5.7.2",
36
36
  "vitest": "^4.1.11",
37
37
  "@nurix/harness": "0.0.0",
38
+ "@nurix/contracts": "3.0.2",
38
39
  "@nurix/nustack.studio": "0.0.0",
39
- "@nurix/nustack.ui": "0.0.0",
40
- "@nurix/contracts": "3.0.1"
40
+ "@nurix/nustack.ui": "0.0.0"
41
41
  },
42
42
  "publishConfig": {
43
43
  "access": "public"