@matteai/stma 0.2.1 → 0.11.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.
package/dist/index.js CHANGED
@@ -7,10 +7,10 @@ var __export = (target, all2) => {
7
7
 
8
8
  // src/index.ts
9
9
  import { createHash as createHash3, randomUUID } from "crypto";
10
- import { execFileSync, spawn } from "child_process";
11
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, readdirSync, writeFileSync as writeFileSync3 } from "fs";
12
- import os from "os";
13
- import path3 from "path";
10
+ import { execFileSync as execFileSync2, spawn as spawn2 } from "child_process";
11
+ import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
12
+ import os2 from "os";
13
+ import path5 from "path";
14
14
 
15
15
  // src/adapters.ts
16
16
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
@@ -102,15 +102,387 @@ function loadAdapterConfig(root) {
102
102
  return JSON.parse(readFileSync(file, "utf8"));
103
103
  }
104
104
 
105
+ // src/collect.ts
106
+ import { execFileSync } from "child_process";
107
+ import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync } from "fs";
108
+ import path2 from "path";
109
+ var firstVersion = (raw) => /(\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?)/.exec(raw)?.[1];
110
+ var bare = (raw) => firstVersion(raw) ?? raw.split(/\r?\n/)[0]?.trim();
111
+ var RUNTIME_PROBES = [
112
+ {
113
+ when: ["*"],
114
+ probes: [{ key: "git", command: "git", args: ["--version"], parse: firstVersion }]
115
+ },
116
+ {
117
+ when: ["requirements.txt", "pyproject.toml", "Pipfile", "setup.py", "setup.cfg", ".python-version", "tox.ini"],
118
+ probes: [
119
+ { key: "python", command: "python3", args: ["--version"], parse: firstVersion },
120
+ { key: "python", command: "python", args: ["--version"], parse: firstVersion }
121
+ ]
122
+ },
123
+ { when: ["go.mod", "go.work"], probes: [{ key: "go", command: "go", args: ["version"], parse: firstVersion }] },
124
+ {
125
+ when: ["Cargo.toml"],
126
+ probes: [{ key: "rust", command: "rustc", args: ["--version"], parse: firstVersion }]
127
+ },
128
+ {
129
+ when: ["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle"],
130
+ probes: [{ key: "java", command: "java", args: ["-version"], parse: firstVersion }]
131
+ },
132
+ {
133
+ when: ["Gemfile", ".ruby-version", "Rakefile"],
134
+ probes: [{ key: "ruby", command: "ruby", args: ["--version"], parse: firstVersion }]
135
+ },
136
+ {
137
+ when: ["composer.json"],
138
+ probes: [{ key: "php", command: "php", args: ["--version"], parse: firstVersion }]
139
+ },
140
+ {
141
+ when: ["global.json", "*.csproj", "*.fsproj", "*.sln"],
142
+ probes: [{ key: "dotnet", command: "dotnet", args: ["--version"], parse: bare }]
143
+ },
144
+ {
145
+ when: ["mix.exs"],
146
+ probes: [{ key: "elixir", command: "elixir", args: ["--version"], parse: firstVersion }]
147
+ },
148
+ {
149
+ when: ["pubspec.yaml"],
150
+ probes: [{ key: "dart", command: "dart", args: ["--version"], parse: firstVersion }]
151
+ },
152
+ {
153
+ // Containers are where "works on my machine" hides once the languages match.
154
+ when: ["Dockerfile", "docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"],
155
+ probes: [{ key: "docker", command: "docker", args: ["--version"], parse: firstVersion }]
156
+ }
157
+ ];
158
+ var PM_PROBES = [
159
+ { when: ["pnpm-lock.yaml"], probes: [{ key: "pnpm", command: "pnpm", args: ["--version"], parse: bare }] },
160
+ { when: ["yarn.lock"], probes: [{ key: "yarn", command: "yarn", args: ["--version"], parse: bare }] },
161
+ { when: ["bun.lockb", "bun.lock"], probes: [{ key: "bun", command: "bun", args: ["--version"], parse: bare }] },
162
+ {
163
+ when: ["requirements.txt", "pyproject.toml", "Pipfile", "setup.py"],
164
+ probes: [
165
+ { key: "pip", command: "pip3", args: ["--version"], parse: firstVersion },
166
+ { key: "pip", command: "pip", args: ["--version"], parse: firstVersion }
167
+ ]
168
+ },
169
+ { when: ["poetry.lock"], probes: [{ key: "poetry", command: "poetry", args: ["--version"], parse: firstVersion }] },
170
+ { when: ["uv.lock"], probes: [{ key: "uv", command: "uv", args: ["--version"], parse: firstVersion }] },
171
+ { when: ["Gemfile"], probes: [{ key: "bundler", command: "bundle", args: ["--version"], parse: firstVersion }] },
172
+ { when: ["composer.json"], probes: [{ key: "composer", command: "composer", args: ["--version"], parse: firstVersion }] },
173
+ { when: ["Cargo.toml"], probes: [{ key: "cargo", command: "cargo", args: ["--version"], parse: firstVersion }] },
174
+ { when: ["pom.xml"], probes: [{ key: "maven", command: "mvn", args: ["--version"], parse: firstVersion }] },
175
+ {
176
+ when: ["build.gradle", "build.gradle.kts", "gradlew"],
177
+ probes: [{ key: "gradle", command: "gradle", args: ["--version"], parse: firstVersion }]
178
+ },
179
+ { when: ["mix.exs"], probes: [{ key: "mix", command: "mix", args: ["--version"], parse: firstVersion }] }
180
+ ];
181
+ var LOCKFILE_NAMES = [
182
+ "package-lock.json",
183
+ "pnpm-lock.yaml",
184
+ "yarn.lock",
185
+ "npm-shrinkwrap.json",
186
+ "bun.lockb",
187
+ "bun.lock",
188
+ "poetry.lock",
189
+ "uv.lock",
190
+ "Pipfile.lock",
191
+ "pdm.lock",
192
+ "requirements.txt",
193
+ "go.sum",
194
+ "Cargo.lock",
195
+ "Gemfile.lock",
196
+ "composer.lock",
197
+ "packages.lock.json",
198
+ "gradle.lockfile",
199
+ "mix.lock",
200
+ "pubspec.lock",
201
+ "flake.lock"
202
+ ];
203
+ function hasMarker(dir, markers, entries) {
204
+ if (markers.includes("*")) return true;
205
+ const plain = markers.filter((m) => !m.startsWith("*."));
206
+ if (plain.some((m) => existsSync2(path2.join(dir, m)))) return true;
207
+ const globs = markers.filter((m) => m.startsWith("*.")).map((m) => m.slice(1));
208
+ if (globs.length === 0) return false;
209
+ const names = entries ?? safeList(dir);
210
+ return names.some((name) => globs.some((ext) => name.endsWith(ext)));
211
+ }
212
+ function safeList(dir) {
213
+ try {
214
+ return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile()).map((e) => e.name);
215
+ } catch {
216
+ return [];
217
+ }
218
+ }
219
+ function run(dir, probe) {
220
+ try {
221
+ const raw = execFileSync(probe.command, probe.args, {
222
+ cwd: dir,
223
+ encoding: "utf8",
224
+ // `java -version` prints to stderr; merging means one code path for both.
225
+ stdio: ["ignore", "pipe", "pipe"],
226
+ timeout: 4e3
227
+ });
228
+ const text = String(raw).trim();
229
+ return (probe.parse ? probe.parse(text) : text) || void 0;
230
+ } catch (error) {
231
+ const stderr = error.stderr;
232
+ if (stderr) {
233
+ const text = String(stderr).trim();
234
+ const parsed = probe.parse ? probe.parse(text) : text;
235
+ if (parsed) return parsed;
236
+ }
237
+ return void 0;
238
+ }
239
+ }
240
+ function collectGroup(dir, groups, entries) {
241
+ const out = {};
242
+ for (const group of groups) {
243
+ if (!hasMarker(dir, group.when, entries)) continue;
244
+ for (const probe of group.probes) {
245
+ if (out[probe.key]) continue;
246
+ const value = run(dir, probe);
247
+ if (value) out[probe.key] = value;
248
+ }
249
+ }
250
+ return out;
251
+ }
252
+ var ECOSYSTEM_NAMES = [
253
+ ["node", ["package.json"]],
254
+ ["python", ["requirements.txt", "pyproject.toml", "Pipfile", "setup.py"]],
255
+ ["go", ["go.mod", "go.work"]],
256
+ ["rust", ["Cargo.toml"]],
257
+ ["java", ["pom.xml", "build.gradle", "build.gradle.kts"]],
258
+ ["ruby", ["Gemfile"]],
259
+ ["php", ["composer.json"]],
260
+ ["dotnet", ["global.json", "*.csproj", "*.fsproj", "*.sln"]],
261
+ ["elixir", ["mix.exs"]],
262
+ ["dart", ["pubspec.yaml"]],
263
+ ["docker", ["Dockerfile", "docker-compose.yml", "compose.yaml"]]
264
+ ];
265
+ function scanEcosystems(dir) {
266
+ const entries = safeList(dir);
267
+ return {
268
+ runtimes: collectGroup(dir, RUNTIME_PROBES, entries),
269
+ packageManagers: collectGroup(dir, PM_PROBES, entries),
270
+ ecosystems: ECOSYSTEM_NAMES.filter(([, markers]) => hasMarker(dir, markers, entries)).map(
271
+ ([name]) => name
272
+ )
273
+ };
274
+ }
275
+ var DOTENV_TEMPLATE = /(^|\.)(example|sample|template|dist|defaults?)$/i;
276
+ function dotenvNames(dir) {
277
+ let entries;
278
+ try {
279
+ entries = readdirSync(dir, { withFileTypes: true });
280
+ } catch {
281
+ return [];
282
+ }
283
+ const names = [];
284
+ for (const entry of entries) {
285
+ if (!entry.isFile()) continue;
286
+ if (!/^\.env(?:\..+)?$/.test(entry.name)) continue;
287
+ if (DOTENV_TEMPLATE.test(entry.name)) continue;
288
+ let body;
289
+ try {
290
+ body = readFileSync2(path2.join(dir, entry.name), "utf8");
291
+ } catch {
292
+ continue;
293
+ }
294
+ for (const line of body.split(/\r?\n/)) {
295
+ const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
296
+ if (match) names.push(match[1]);
297
+ }
298
+ }
299
+ return names;
300
+ }
301
+
302
+ // src/news.ts
303
+ var NEWS_MIN_INTERVAL_MS = 6e4;
304
+ var NEWS_TIMEOUT_MS = 2500;
305
+ function dueForCheck(state, now = Date.now()) {
306
+ if (!state.lastCheckedAt) return true;
307
+ const last = Date.parse(state.lastCheckedAt);
308
+ return !Number.isFinite(last) || now - last >= NEWS_MIN_INTERVAL_MS;
309
+ }
310
+ var handoffKey = (handoff) => `${handoff.sessionId}:${handoff.at}`;
311
+ function unseen(news, state) {
312
+ const seen = new Set(state.announced ?? []);
313
+ return news.pendingHandoffs.filter((h) => !seen.has(handoffKey(h)));
314
+ }
315
+ function rememberAnnounced(state, handoffs) {
316
+ return [.../* @__PURE__ */ new Set([...state.announced ?? [], ...handoffs.map(handoffKey)])].slice(-50);
317
+ }
318
+ function renderNews(handoffs, unreadSessions) {
319
+ if (handoffs.length === 0 && unreadSessions === 0) return void 0;
320
+ const lines = [];
321
+ for (const handoff of handoffs.slice(0, 3)) {
322
+ const who = handoff.mine ? "your own agent on another machine" : handoff.from ?? "a teammate";
323
+ const where = handoff.resume?.branch ? ` on \`${handoff.resume.branch}\`` : "";
324
+ lines.push(`STMA \u2014 work is waiting: "${handoff.title}"${where}, handed over by ${who}.`);
325
+ const steps = handoff.resume?.steps ?? [];
326
+ if (steps.length > 0) lines.push(` Next: ${steps.slice(0, 2).join("; ")}`);
327
+ if (handoff.resume?.checkout) lines.push(` Take it: ${handoff.resume.checkout}`);
328
+ if (handoff.resume?.reclaim) {
329
+ lines.push(
330
+ ` Then re-claim the same scope: ${handoff.resume.reclaim.tool} ${JSON.stringify(
331
+ handoff.resume.reclaim.arguments
332
+ )}`
333
+ );
334
+ }
335
+ lines.push(` Read it in full with get_session {"session_id":"${handoff.sessionId}"}.`);
336
+ }
337
+ if (unreadSessions > 0) {
338
+ lines.push(
339
+ `STMA \u2014 ${unreadSessions} debug ${unreadSessions === 1 ? "session has" : "sessions have"} unread replies. Call inbox to read them.`
340
+ );
341
+ }
342
+ lines.push(
343
+ "Tell your human what is waiting and ask before acting on it \u2014 do not check out a branch or start a run unprompted."
344
+ );
345
+ return lines.join("\n");
346
+ }
347
+
105
348
  // src/hash.ts
106
349
  import { createHash } from "crypto";
107
350
  function gitBlobHash(content) {
108
351
  return createHash("sha1").update(`blob ${content.byteLength}\0`).update(content).digest("hex");
109
352
  }
110
353
 
354
+ // src/serve.ts
355
+ import { spawn } from "child_process";
356
+ import { createRequire } from "module";
357
+ import { existsSync as existsSync3 } from "fs";
358
+ import os from "os";
359
+ import path3 from "path";
360
+ import { fileURLToPath as fileURLToPath2 } from "url";
361
+
362
+ // src/version.ts
363
+ import { readFileSync as readFileSync3 } from "fs";
364
+ import { fileURLToPath } from "url";
365
+ function readVersion() {
366
+ try {
367
+ const manifest = fileURLToPath(new URL("../package.json", import.meta.url));
368
+ return JSON.parse(readFileSync3(manifest, "utf8")).version ?? "unknown";
369
+ } catch {
370
+ return "unknown";
371
+ }
372
+ }
373
+ var VERSION = readVersion();
374
+ var CLIENT_HEADER = "x-stma-client";
375
+ var clientHeaders = () => ({ [CLIENT_HEADER]: `stma/${VERSION}` });
376
+
377
+ // src/serve.ts
378
+ var SERVER_PACKAGE = "@matteai/stma-server";
379
+ function serverSpec(version2 = VERSION) {
380
+ return /^\d+\.\d+\.\d+$/.test(version2) ? `${SERVER_PACKAGE}@${version2}` : SERVER_PACKAGE;
381
+ }
382
+ var defaultDataDir = () => path3.join(os.homedir(), ".stma", "data");
383
+ function resolveServerEntry() {
384
+ const require2 = createRequire(import.meta.url);
385
+ try {
386
+ const manifest = require2.resolve(`${SERVER_PACKAGE}/package.json`);
387
+ const entry = path3.join(path3.dirname(manifest), "dist", "index.js");
388
+ if (existsSync3(entry)) return entry;
389
+ } catch {
390
+ }
391
+ const here = path3.dirname(fileURLToPath2(import.meta.url));
392
+ for (const candidate of [
393
+ path3.resolve(here, "../../server/dist/index.js"),
394
+ path3.resolve(here, "../../../server/dist/index.js")
395
+ ]) {
396
+ if (existsSync3(candidate)) return candidate;
397
+ }
398
+ return null;
399
+ }
400
+ function serveEnv(options) {
401
+ const base = `http://${options.host === "0.0.0.0" ? "localhost" : options.host}:${options.port}`;
402
+ return {
403
+ ...process.env,
404
+ NODE_ENV: "production",
405
+ HOST: options.host,
406
+ PORT: String(options.port),
407
+ BASE_URL: base,
408
+ EMBEDDED_DB: "1",
409
+ PGLITE_DIR: options.dataDir,
410
+ // Local accounts with open signup: the person who runs this is the person
411
+ // who should get the first account. Dev auth is deliberately NOT enabled —
412
+ // a passwordless login form is not a thing to hand somebody by default,
413
+ // even on localhost.
414
+ AUTH_LOCAL: "1",
415
+ SIGNUPS_OPEN: "1",
416
+ AUTH_2FA: "0"
417
+ };
418
+ }
419
+ function connectSnippet(base) {
420
+ return `claude mcp add --scope user --transport http stma ${base}/mcp --header "Authorization: Bearer stma_YOUR_TOKEN"`;
421
+ }
422
+ async function waitForHealth(base, child) {
423
+ const deadline = Date.now() + 9e4;
424
+ while (Date.now() < deadline) {
425
+ if (child.exitCode !== null) return false;
426
+ try {
427
+ const res = await fetch(`${base}/health`);
428
+ if (res.ok) return true;
429
+ } catch {
430
+ }
431
+ await new Promise((resolve) => setTimeout(resolve, 400));
432
+ }
433
+ return false;
434
+ }
435
+ async function serve(options) {
436
+ const base = `http://${options.host === "0.0.0.0" ? "localhost" : options.host}:${options.port}`;
437
+ const entry = resolveServerEntry();
438
+ const env = serveEnv(options);
439
+ const child = entry ? spawn(process.execPath, [entry], { env, stdio: ["ignore", "inherit", "inherit"] }) : (
440
+ // No local copy: fetch it once, exactly as npx would. Said out loud, because
441
+ // a command that silently downloads a server is not a command anyone should trust.
442
+ (console.log(`Fetching ${serverSpec()} (first run only)\u2026`), spawn("npx", ["-y", serverSpec()], {
443
+ env,
444
+ stdio: ["ignore", "inherit", "inherit"],
445
+ shell: process.platform === "win32"
446
+ }))
447
+ );
448
+ const stop = () => {
449
+ child.kill("SIGINT");
450
+ };
451
+ process.on("SIGINT", stop);
452
+ process.on("SIGTERM", stop);
453
+ child.on("exit", (code) => process.exit(code ?? 0));
454
+ const healthy = await waitForHealth(base, child);
455
+ if (!healthy) {
456
+ console.error(
457
+ child.exitCode !== null ? `
458
+ The server exited before it was ready.${entry ? "" : `
459
+ If ${SERVER_PACKAGE} could not be fetched, install it once: npm i -g ${SERVER_PACKAGE}`}` : `
460
+ The server did not answer ${base}/health in 90s. Something else may be on port ${options.port} \u2014 try --port.`
461
+ );
462
+ return;
463
+ }
464
+ console.log(
465
+ [
466
+ "",
467
+ ` STMA is running at ${base}`,
468
+ "",
469
+ ` 1. Create your account ${base}/signup`,
470
+ ` 2. Create a token ${base}/app/tokens`,
471
+ " 3. Connect your agent:",
472
+ "",
473
+ ` ${connectSnippet(base)}`,
474
+ "",
475
+ ` Guide ${base}/docs`,
476
+ ` Data ${options.dataDir}`,
477
+ " Stop Ctrl+C",
478
+ ""
479
+ ].join("\n")
480
+ );
481
+ }
482
+
111
483
  // src/policy.ts
112
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
113
- import path2 from "path";
484
+ import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
485
+ import path4 from "path";
114
486
 
115
487
  // ../../node_modules/zod/v3/external.js
116
488
  var external_exports = {};
@@ -590,8 +962,8 @@ function getErrorMap() {
590
962
 
591
963
  // ../../node_modules/zod/v3/helpers/parseUtil.js
592
964
  var makeIssue = (params) => {
593
- const { data, path: path4, errorMaps, issueData } = params;
594
- const fullPath = [...path4, ...issueData.path || []];
965
+ const { data, path: path6, errorMaps, issueData } = params;
966
+ const fullPath = [...path6, ...issueData.path || []];
595
967
  const fullIssue = {
596
968
  ...issueData,
597
969
  path: fullPath
@@ -707,11 +1079,11 @@ var errorUtil;
707
1079
 
708
1080
  // ../../node_modules/zod/v3/types.js
709
1081
  var ParseInputLazyPath = class {
710
- constructor(parent, value, path4, key) {
1082
+ constructor(parent, value, path6, key) {
711
1083
  this._cachedPath = [];
712
1084
  this.parent = parent;
713
1085
  this.data = value;
714
- this._path = path4;
1086
+ this._path = path6;
715
1087
  this._key = key;
716
1088
  }
717
1089
  get path() {
@@ -1090,11 +1462,11 @@ function datetimeRegex(args) {
1090
1462
  regex = `${regex}(${opts.join("|")})`;
1091
1463
  return new RegExp(`^${regex}$`);
1092
1464
  }
1093
- function isValidIP(ip, version) {
1094
- if ((version === "v4" || !version) && ipv4Regex.test(ip)) {
1465
+ function isValidIP(ip, version2) {
1466
+ if ((version2 === "v4" || !version2) && ipv4Regex.test(ip)) {
1095
1467
  return true;
1096
1468
  }
1097
- if ((version === "v6" || !version) && ipv6Regex.test(ip)) {
1469
+ if ((version2 === "v6" || !version2) && ipv6Regex.test(ip)) {
1098
1470
  return true;
1099
1471
  }
1100
1472
  return false;
@@ -1121,11 +1493,11 @@ function isValidJWT(jwt, alg) {
1121
1493
  return false;
1122
1494
  }
1123
1495
  }
1124
- function isValidCidr(ip, version) {
1125
- if ((version === "v4" || !version) && ipv4CidrRegex.test(ip)) {
1496
+ function isValidCidr(ip, version2) {
1497
+ if ((version2 === "v4" || !version2) && ipv4CidrRegex.test(ip)) {
1126
1498
  return true;
1127
1499
  }
1128
- if ((version === "v6" || !version) && ipv6CidrRegex.test(ip)) {
1500
+ if ((version2 === "v6" || !version2) && ipv6CidrRegex.test(ip)) {
1129
1501
  return true;
1130
1502
  }
1131
1503
  return false;
@@ -4154,7 +4526,7 @@ var coerce = {
4154
4526
  var NEVER = INVALID;
4155
4527
 
4156
4528
  // ../shared/src/snapshot.ts
4157
- var snapshotSchema = external_exports.object({
4529
+ var snapshotSchema = external_exports.strictObject({
4158
4530
  schemaVersion: external_exports.literal(1).default(1),
4159
4531
  os: external_exports.object({
4160
4532
  platform: external_exports.string().max(40),
@@ -4166,9 +4538,16 @@ var snapshotSchema = external_exports.object({
4166
4538
  runtimes: external_exports.record(external_exports.string().max(200)).default({}),
4167
4539
  /** e.g. { npm: "11.6.2", pnpm: "9.0.0" } */
4168
4540
  packageManagers: external_exports.record(external_exports.string().max(200)).default({}),
4169
- lockfiles: external_exports.array(external_exports.object({ path: external_exports.string().max(300), hash: external_exports.string().max(128) })).max(50).default([]),
4170
- /** Names only, never values. */
4171
- envVarNames: external_exports.array(external_exports.string().max(200)).max(1e3).default([]),
4541
+ lockfiles: external_exports.array(external_exports.object({ path: external_exports.string().max(300), hash: external_exports.string().max(128) })).max(50).default([]).refine((files) => new Set(files.map((f) => f.path)).size === files.length, {
4542
+ message: "lockfiles must not list the same path twice"
4543
+ }),
4544
+ /**
4545
+ * Names only, never values. Optional rather than defaulted to `[]`: an absent
4546
+ * list means "this machine was not asked", and a preflight that cannot tell
4547
+ * that from "this machine has nothing" reports every required variable as
4548
+ * missing on a snapshot nobody actually inspected.
4549
+ */
4550
+ envVarNames: external_exports.array(external_exports.string().max(200)).max(1e3).optional(),
4172
4551
  git: external_exports.object({
4173
4552
  branch: external_exports.string().max(300).optional(),
4174
4553
  sha: external_exports.string().max(64).optional(),
@@ -4214,12 +4593,36 @@ var workClaimSchema = external_exports.object({
4214
4593
  resourceKey: external_exports.string().trim().min(1).max(500),
4215
4594
  access: claimAccessModeSchema.default("write")
4216
4595
  });
4596
+ var QUOTA_STATES = ["ok", "warning", "critical"];
4597
+ var quotaStateSchema = external_exports.enum(QUOTA_STATES);
4598
+ var QUOTA_SOURCES = ["measured", "estimate"];
4599
+ var quotaSourceSchema = external_exports.enum(QUOTA_SOURCES);
4600
+ var agentQuotaSchema = external_exports.object({
4601
+ /** How much of the current window is spent, 0-100. */
4602
+ usedPct: external_exports.number().min(0).max(100),
4603
+ /** When the vendor window resets, if the client knows. */
4604
+ resetsAt: external_exports.string().datetime().optional(),
4605
+ /** What the allowance is called on the client, e.g. "claude 5h window". */
4606
+ label: external_exports.string().trim().max(80).optional(),
4607
+ /** Read from something real, or guessed. Absent means guessed. */
4608
+ source: quotaSourceSchema.default("estimate")
4609
+ });
4610
+ var AGENT_ROLES = [
4611
+ "generalist",
4612
+ "implementer",
4613
+ "reviewer",
4614
+ "tester",
4615
+ "planner",
4616
+ "ops"
4617
+ ];
4618
+ var agentRoleSchema = external_exports.enum(AGENT_ROLES);
4217
4619
  var registerAgentSchema = external_exports.object({
4218
4620
  name: external_exports.string().trim().min(1).max(80),
4219
4621
  clientType: agentClientTypeSchema.default("generic"),
4220
4622
  clientVersion: external_exports.string().trim().max(80).optional(),
4221
4623
  deviceFingerprint: external_exports.string().trim().min(8).max(128),
4222
- capabilities: external_exports.array(external_exports.string().trim().min(1).max(80)).max(50).default([])
4624
+ capabilities: external_exports.array(external_exports.string().trim().min(1).max(80)).max(50).default([]),
4625
+ role: agentRoleSchema.optional()
4223
4626
  });
4224
4627
  var startAgentRunSchema = external_exports.object({
4225
4628
  installationId: external_exports.string().uuid(),
@@ -4231,11 +4634,19 @@ var startAgentRunSchema = external_exports.object({
4231
4634
  branch: external_exports.string().trim().max(300).optional(),
4232
4635
  worktree: external_exports.string().trim().max(500).optional(),
4233
4636
  baseSha: external_exports.string().trim().max(64).optional(),
4234
- claims: external_exports.array(workClaimSchema).max(200).default([])
4637
+ claims: external_exports.array(workClaimSchema).max(200).default([]),
4638
+ /**
4639
+ * Runs that are deliberately parallel attempts at the same task. Agents in one
4640
+ * group never warn each other: fanning one prompt across three worktrees is the
4641
+ * normal way to work now, and reporting it as three collisions made the radar
4642
+ * useless exactly where it was busiest.
4643
+ */
4644
+ attemptGroup: external_exports.string().trim().min(1).max(120).optional()
4235
4645
  });
4236
4646
  var heartbeatAgentRunSchema = external_exports.object({
4237
4647
  status: external_exports.enum(["active", "waiting", "blocked"]).optional(),
4238
- claims: external_exports.array(workClaimSchema).max(200).optional()
4648
+ claims: external_exports.array(workClaimSchema).max(200).optional(),
4649
+ usage: agentQuotaSchema.optional()
4239
4650
  });
4240
4651
  var finishAgentRunSchema = external_exports.object({
4241
4652
  status: external_exports.enum(["completed", "failed"]).default("completed"),
@@ -4254,7 +4665,39 @@ var policyDocumentSchema = external_exports.object({
4254
4665
  environment: external_exports.object({
4255
4666
  requiredEnvVarNames: external_exports.array(external_exports.string().trim().min(1).max(200)).max(500).default([]),
4256
4667
  runtimes: external_exports.record(external_exports.string().trim().max(200)).default({})
4257
- }).default({ requiredEnvVarNames: [], runtimes: {} })
4668
+ }).default({ requiredEnvVarNames: [], runtimes: {} }),
4669
+ /**
4670
+ * How much a run may do on this ground before a person is in the loop.
4671
+ *
4672
+ * "Allow the agent or don't" is too coarse to be useful: the same agent that
4673
+ * should freely edit a test file should not quietly rewrite a migration
4674
+ * chain. The tier belongs to the *work*, not to the agent — so it is declared
4675
+ * per claim type, which is the only thing a run states before it starts.
4676
+ *
4677
+ * This tells; it does not block. Claims are advisory here by design, and a
4678
+ * gate an agent can route around is worse than a sentence it can read.
4679
+ */
4680
+ autonomy: external_exports.object({
4681
+ /** Claim types a run must get a human to agree to before changing. */
4682
+ requireApprovalFor: external_exports.array(external_exports.enum(CLAIM_RESOURCE_TYPES)).max(8).default([])
4683
+ }).default({ requireApprovalFor: [] }),
4684
+ /**
4685
+ * Small batches, as a rule rather than as advice.
4686
+ *
4687
+ * DORA's clearest countermeasure to AI-sized changes is keeping them small,
4688
+ * and the cheapest moment to say so is before the work starts — the run has
4689
+ * already declared its scope by then, so the number is knowable and the
4690
+ * suggestion ("split it") is still free to act on.
4691
+ *
4692
+ * 0 means no budget, which is the default: a limit nobody chose should not
4693
+ * start warning people.
4694
+ */
4695
+ changeBudget: external_exports.object({
4696
+ /** Total claims one run may declare. */
4697
+ maxScopeItems: external_exports.number().int().min(0).max(200).default(0),
4698
+ /** Path claims specifically — the ones that turn into review minutes. */
4699
+ maxPaths: external_exports.number().int().min(0).max(200).default(0)
4700
+ }).default({ maxScopeItems: 0, maxPaths: 0 })
4258
4701
  });
4259
4702
 
4260
4703
  // ../shared/src/fingerprint.ts
@@ -4271,6 +4714,48 @@ function sortValue(value) {
4271
4714
  var canonicalJson = (value) => JSON.stringify(sortValue(value));
4272
4715
  var fingerprintJson = (value) => createHash2("sha256").update(canonicalJson(value)).digest("hex");
4273
4716
 
4717
+ // ../shared/src/delivery.ts
4718
+ var TICKET_SYSTEMS = ["jira", "github", "azure-boards", "none"];
4719
+ var DEPLOY_TRIGGERS = ["merge", "tag", "manual"];
4720
+ var MERGE_STRATEGIES = ["merge", "squash", "rebase"];
4721
+ var flowEnvironmentSchema = external_exports.object({
4722
+ /** "stage", "uat", "prod" — also the CI environment name. */
4723
+ name: external_exports.string().trim().min(1).max(40).regex(/^[a-zA-Z0-9._-]+$/, "letters, digits, dots, dashes"),
4724
+ /** What sends a build here: every merge, a version tag, or a person. */
4725
+ deployOn: external_exports.enum(DEPLOY_TRIGGERS).default("manual"),
4726
+ /** Somebody signs off before the deploy runs (approval gate on the CI environment). */
4727
+ approval: external_exports.boolean().default(false)
4728
+ });
4729
+ var deliveryFlowSchema = external_exports.object({
4730
+ /** One sentence a newcomer reads first. */
4731
+ intro: external_exports.string().trim().max(300).default(""),
4732
+ ticket: external_exports.object({
4733
+ system: external_exports.enum(TICKET_SYSTEMS).default("none"),
4734
+ /** Example key, e.g. "PROJ-123" — the shape, not a real ticket. */
4735
+ keyPattern: external_exports.string().trim().max(60).default(""),
4736
+ /** Work must not start without a ticket. */
4737
+ required: external_exports.boolean().default(false)
4738
+ }).default({}),
4739
+ branch: external_exports.object({
4740
+ /** Naming rule with placeholders: {ticket}, {slug}, {type}. */
4741
+ pattern: external_exports.string().trim().min(1).max(120).default("feature/{ticket}-{slug}"),
4742
+ /** The branch work forks from and merges back to. */
4743
+ from: external_exports.string().trim().min(1).max(60).default("main")
4744
+ }).default({}),
4745
+ /** Commands that must pass before a PR is opened; also the CI check stage. */
4746
+ checks: external_exports.array(external_exports.string().trim().min(1).max(200)).max(20).default([]),
4747
+ review: external_exports.object({
4748
+ /** Approvals a PR needs before merge. 0 means none required. */
4749
+ approvals: external_exports.number().int().min(0).max(10).default(1)
4750
+ }).default({}),
4751
+ mergeStrategy: external_exports.enum(MERGE_STRATEGIES).default("squash"),
4752
+ /** The road to production, in order. Empty means CI-only, no deploys. */
4753
+ environments: external_exports.array(flowEnvironmentSchema).max(8).default([]),
4754
+ /** Extra house rules that fit no field above. */
4755
+ notes: external_exports.array(external_exports.string().trim().min(1).max(300)).max(20).default([])
4756
+ });
4757
+ var EMPTY_DELIVERY_FLOW = deliveryFlowSchema.parse({});
4758
+
4274
4759
  // src/policy.ts
4275
4760
  function managedPolicyBlock(document, hash) {
4276
4761
  const lines = [
@@ -4302,7 +4787,7 @@ function managedPolicyBlock(document, hash) {
4302
4787
  const environment2 = [
4303
4788
  ...document.environment.requiredEnvVarNames.map((name) => `- Required env var: ${name}`),
4304
4789
  ...Object.entries(document.environment.runtimes).map(
4305
- ([runtime, version]) => `- Expected ${runtime} version: ${version}`
4790
+ ([runtime, version2]) => `- Expected ${runtime} version: ${version2}`
4306
4791
  )
4307
4792
  ];
4308
4793
  if (environment2.length) {
@@ -4313,8 +4798,8 @@ function managedPolicyBlock(document, hash) {
4313
4798
  `;
4314
4799
  }
4315
4800
  function writeManagedPolicy(file, block, prefix = "") {
4316
- mkdirSync2(path2.dirname(file), { recursive: true });
4317
- const existing = existsSync2(file) ? readFileSync2(file, "utf8") : prefix;
4801
+ mkdirSync2(path4.dirname(file), { recursive: true });
4802
+ const existing = existsSync4(file) ? readFileSync4(file, "utf8") : prefix;
4318
4803
  const marker = /<!-- STMA:BEGIN managed policy -->[\s\S]*?<!-- STMA:END managed policy -->\s*/;
4319
4804
  const next = marker.test(existing) ? existing.replace(marker, block) : `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${block}`;
4320
4805
  writeFileSync2(file, next, "utf8");
@@ -4322,7 +4807,7 @@ function writeManagedPolicy(file, block, prefix = "") {
4322
4807
  function appliedPolicyHash(root) {
4323
4808
  try {
4324
4809
  const persisted = JSON.parse(
4325
- readFileSync2(path2.join(root, ".stma", "effective-policy.json"), "utf8")
4810
+ readFileSync4(path4.join(root, ".stma", "effective-policy.json"), "utf8")
4326
4811
  );
4327
4812
  return persisted.document === void 0 ? void 0 : fingerprintJson(persisted.document);
4328
4813
  } catch {
@@ -4330,35 +4815,35 @@ function appliedPolicyHash(root) {
4330
4815
  }
4331
4816
  }
4332
4817
  function applyPolicy(root, document, hash, clientType) {
4333
- const stmaDir2 = path2.join(root, ".stma");
4818
+ const stmaDir2 = path4.join(root, ".stma");
4334
4819
  mkdirSync2(stmaDir2, { recursive: true });
4335
4820
  writeFileSync2(
4336
- path2.join(stmaDir2, "effective-policy.json"),
4821
+ path4.join(stmaDir2, "effective-policy.json"),
4337
4822
  `${JSON.stringify({ hash, document }, null, 2)}
4338
4823
  `,
4339
4824
  "utf8"
4340
4825
  );
4341
4826
  const block = managedPolicyBlock(document, hash);
4342
4827
  if (clientType === "claude-code") {
4343
- writeManagedPolicy(path2.join(root, "CLAUDE.md"), block);
4828
+ writeManagedPolicy(path4.join(root, "CLAUDE.md"), block);
4344
4829
  } else if (clientType === "cursor") {
4345
4830
  const frontmatter = "---\ndescription: STMA organization and project policy\nalwaysApply: true\n---\n\n";
4346
- writeManagedPolicy(path2.join(root, ".cursor", "rules", "stma-policy.mdc"), block, frontmatter);
4831
+ writeManagedPolicy(path4.join(root, ".cursor", "rules", "stma-policy.mdc"), block, frontmatter);
4347
4832
  } else {
4348
- writeManagedPolicy(path2.join(root, "AGENTS.md"), block);
4833
+ writeManagedPolicy(path4.join(root, "AGENTS.md"), block);
4349
4834
  }
4350
4835
  return appliedPolicyHash(root);
4351
4836
  }
4352
4837
 
4353
4838
  // src/index.ts
4354
4839
  var cwd = process.cwd();
4355
- var stmaDir = path3.join(cwd, ".stma");
4356
- var configPath = path3.join(stmaDir, "local.json");
4357
- var outboxPath = path3.join(stmaDir, "outbox.json");
4840
+ var stmaDir = path5.join(cwd, ".stma");
4841
+ var configPath = path5.join(stmaDir, "local.json");
4842
+ var outboxPath = path5.join(stmaDir, "outbox.json");
4358
4843
  function loadConfig() {
4359
- if (!existsSync3(configPath)) return {};
4844
+ if (!existsSync5(configPath)) return {};
4360
4845
  try {
4361
- return JSON.parse(readFileSync3(configPath, "utf8"));
4846
+ return JSON.parse(readFileSync5(configPath, "utf8"));
4362
4847
  } catch {
4363
4848
  fail(`Could not read ${configPath}. Fix or remove the invalid JSON file.`);
4364
4849
  }
@@ -4396,14 +4881,14 @@ function fail(message) {
4396
4881
  }
4397
4882
  function shell(command, args) {
4398
4883
  try {
4399
- return execFileSync(command, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
4884
+ return execFileSync2(command, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
4400
4885
  } catch {
4401
4886
  return void 0;
4402
4887
  }
4403
4888
  }
4404
4889
  function gitContext() {
4405
4890
  return {
4406
- repo: path3.basename(shell("git", ["rev-parse", "--show-toplevel"]) ?? cwd),
4891
+ repo: path5.basename(shell("git", ["rev-parse", "--show-toplevel"]) ?? cwd),
4407
4892
  branch: shell("git", ["branch", "--show-current"]),
4408
4893
  baseSha: shell("git", ["rev-parse", "HEAD"]),
4409
4894
  worktree: shell("git", ["rev-parse", "--show-toplevel"])
@@ -4428,38 +4913,21 @@ function parseClaim(value) {
4428
4913
  function sha256(data) {
4429
4914
  return createHash3("sha256").update(data).digest("hex");
4430
4915
  }
4431
- function dotenvNames() {
4432
- const candidates = readdirSync(cwd, { withFileTypes: true }).filter((entry) => entry.isFile() && /^\.env(?:\..+)?$/.test(entry.name)).map((entry) => entry.name);
4433
- const names = [];
4434
- for (const file of candidates) {
4435
- for (const line of readFileSync3(path3.join(cwd, file), "utf8").split(/\r?\n/)) {
4436
- const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
4437
- if (match) names.push(match[1]);
4438
- }
4439
- }
4440
- return names;
4441
- }
4442
4916
  function collectSnapshot() {
4443
- const lockfileNames = [
4444
- "package-lock.json",
4445
- "pnpm-lock.yaml",
4446
- "yarn.lock",
4447
- "poetry.lock",
4448
- "uv.lock",
4449
- "go.sum",
4450
- "Cargo.lock"
4451
- ];
4452
- const lockfiles = lockfileNames.filter((name) => existsSync3(path3.join(cwd, name))).map((name) => ({ path: name, hash: gitBlobHash(readFileSync3(path3.join(cwd, name))) }));
4917
+ const lockfiles = LOCKFILE_NAMES.filter((name) => existsSync5(path5.join(cwd, name))).map(
4918
+ (name) => ({ path: name, hash: gitBlobHash(readFileSync5(path5.join(cwd, name))) })
4919
+ );
4453
4920
  const git = gitContext();
4454
- const npmVersion = shell("npm", ["--version"]);
4921
+ const npmVersion = existsSync5(path5.join(cwd, "package.json")) ? shell("npm", ["--version"]) : void 0;
4922
+ const scan = scanEcosystems(cwd);
4455
4923
  return {
4456
4924
  schemaVersion: 1,
4457
- os: { platform: process.platform, release: os.release(), arch: process.arch },
4925
+ os: { platform: process.platform, release: os2.release(), arch: process.arch },
4458
4926
  shell: process.env.SHELL ?? process.env.ComSpec,
4459
- runtimes: { node: process.version.replace(/^v/, "") },
4460
- packageManagers: { ...npmVersion ? { npm: npmVersion } : {} },
4927
+ runtimes: { node: process.version.replace(/^v/, ""), ...scan.runtimes },
4928
+ packageManagers: { ...npmVersion ? { npm: npmVersion } : {}, ...scan.packageManagers },
4461
4929
  lockfiles,
4462
- envVarNames: [.../* @__PURE__ */ new Set([...Object.keys(process.env), ...dotenvNames()])].sort(),
4930
+ envVarNames: [.../* @__PURE__ */ new Set([...Object.keys(process.env), ...dotenvNames(cwd)])].sort(),
4463
4931
  git: {
4464
4932
  branch: git.branch,
4465
4933
  sha: git.baseSha,
@@ -4484,19 +4952,41 @@ async function apiRequest(config, endpoint, init = {}) {
4484
4952
  headers: {
4485
4953
  authorization: `Bearer ${token}`,
4486
4954
  "content-type": "application/json",
4955
+ // Say which client this is on every call. The server never requires it —
4956
+ // an older CLI has to keep working — but a version mix that is visible in
4957
+ // the logs is one nobody has to reconstruct from a bug report.
4958
+ ...clientHeaders(),
4487
4959
  ...init.headers
4488
4960
  }
4489
4961
  });
4490
4962
  const data = await response.json().catch(() => ({}));
4491
- if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`);
4963
+ if (!response.ok) {
4964
+ const failure = Object.assign(new Error(data.error ?? `HTTP ${response.status}`), {
4965
+ status: response.status
4966
+ });
4967
+ throw failure;
4968
+ }
4492
4969
  return data;
4493
4970
  }
4971
+ async function skewNote(server) {
4972
+ try {
4973
+ const res = await fetch(`${server}/health`, { signal: AbortSignal.timeout(2e3) });
4974
+ const health = await res.json();
4975
+ if (!health.version || health.version === VERSION) return "";
4976
+ return `
4977
+ The server reports version ${health.version}; this CLI is ${VERSION}. If that endpoint is newer than the server, upgrade it (npm i -g @matteai/stma-server) or use a CLI of the same version.`;
4978
+ } catch {
4979
+ return "";
4980
+ }
4981
+ }
4494
4982
  async function request(config, endpoint, init = {}) {
4495
4983
  try {
4496
4984
  return await apiRequest(config, endpoint, init);
4497
4985
  } catch (error) {
4498
4986
  const server = (process.env.STMA_URL ?? config.server ?? "http://localhost:3000").replace(/\/$/, "");
4499
- fail(`Request to ${server} failed: ${error instanceof Error ? error.message : String(error)}`);
4987
+ const status = error.status;
4988
+ const note = status === 404 ? await skewNote(server) : "";
4989
+ fail(`Request to ${server} failed: ${error instanceof Error ? error.message : String(error)}${note}`);
4500
4990
  }
4501
4991
  }
4502
4992
  function printConflicts(conflicts) {
@@ -4515,7 +5005,7 @@ async function register(flags) {
4515
5005
  const config = loadConfig();
4516
5006
  const name = required(flags, "name");
4517
5007
  const clientType = one(flags, "client") ?? "generic";
4518
- const rawDevice = `${os.hostname()}\0${os.userInfo().username}\0${process.platform}`;
5008
+ const rawDevice = `${os2.hostname()}\0${os2.userInfo().username}\0${process.platform}`;
4519
5009
  const deviceFingerprint = sha256(rawDevice);
4520
5010
  const result = await request(config, "/api/agent/installations/register", {
4521
5011
  method: "POST",
@@ -4524,7 +5014,8 @@ async function register(flags) {
4524
5014
  clientType,
4525
5015
  clientVersion: one(flags, "version"),
4526
5016
  deviceFingerprint,
4527
- capabilities: all(flags, "capability")
5017
+ capabilities: all(flags, "capability"),
5018
+ role: one(flags, "role")
4528
5019
  })
4529
5020
  });
4530
5021
  saveConfig({
@@ -4554,9 +5045,10 @@ async function startRun(flags) {
4554
5045
  intent: one(flags, "intent"),
4555
5046
  repo: one(flags, "repo") ?? project ?? git.repo,
4556
5047
  branch: one(flags, "branch") ?? git.branch,
4557
- worktree: git.worktree,
5048
+ worktree: one(flags, "worktree") ?? git.worktree,
4558
5049
  baseSha: git.baseSha,
4559
- claims
5050
+ claims,
5051
+ attemptGroup: one(flags, "attempt-group")
4560
5052
  })
4561
5053
  });
4562
5054
  saveConfig({
@@ -4589,10 +5081,53 @@ async function heartbeat(flags) {
4589
5081
  );
4590
5082
  const result = await request(config, `/api/agent/runs/${runId}/heartbeat`, {
4591
5083
  method: "POST",
4592
- body: JSON.stringify({ status: one(flags, "status"), claims })
5084
+ body: JSON.stringify({ status: one(flags, "status"), claims, usage: quotaFlags(flags) })
4593
5085
  });
4594
5086
  console.log(`Heartbeat: ${result.status}`);
4595
5087
  printConflicts(result.conflicts ?? []);
5088
+ printQuota(result);
5089
+ }
5090
+ function hookQuota(payload) {
5091
+ const fromPayload = payload.usage ?? payload.quota;
5092
+ if (fromPayload && typeof fromPayload === "object") {
5093
+ const u = fromPayload;
5094
+ const pct = Number(u.usedPct ?? u.used_pct ?? u.percent_used);
5095
+ if (Number.isFinite(pct) && pct >= 0 && pct <= 100) {
5096
+ return {
5097
+ usedPct: pct,
5098
+ resetsAt: typeof u.resetsAt === "string" ? u.resetsAt : void 0,
5099
+ label: typeof u.label === "string" ? u.label : void 0
5100
+ };
5101
+ }
5102
+ }
5103
+ const env = Number(process.env.STMA_USED_PCT);
5104
+ if (!Number.isFinite(env) || env < 0 || env > 100) return void 0;
5105
+ return {
5106
+ usedPct: env,
5107
+ resetsAt: process.env.STMA_QUOTA_RESETS_AT || void 0,
5108
+ label: process.env.STMA_QUOTA_LABEL || void 0
5109
+ };
5110
+ }
5111
+ function quotaFlags(flags) {
5112
+ const raw = one(flags, "used-pct");
5113
+ if (raw === void 0) return void 0;
5114
+ const usedPct = Number(raw);
5115
+ if (!Number.isFinite(usedPct) || usedPct < 0 || usedPct > 100) {
5116
+ fail("--used-pct must be a number between 0 and 100.");
5117
+ }
5118
+ return {
5119
+ usedPct,
5120
+ resetsAt: one(flags, "resets-at"),
5121
+ label: one(flags, "quota-label")
5122
+ };
5123
+ }
5124
+ function printQuota(result) {
5125
+ const quota = result?.quota;
5126
+ if (!quota || quota.state === "ok") return;
5127
+ console.log(
5128
+ `${quota.state === "critical" ? "QUOTA CRITICAL" : "Quota warning"}: ${quota.usedPct}% used${quota.label ? ` (${quota.label})` : ""}`
5129
+ );
5130
+ if (quota.advice) console.log(` ${quota.advice}`);
4596
5131
  }
4597
5132
  async function finish(flags) {
4598
5133
  const config = loadConfig();
@@ -4615,17 +5150,17 @@ async function listRuns(flags) {
4615
5150
  const team = one(flags, "team");
4616
5151
  const result = await request(config, `/api/agent/runs/active${team ? `?team=${encodeURIComponent(team)}` : ""}`);
4617
5152
  if (result.runs.length === 0) return console.log("No active runs.");
4618
- for (const run of result.runs) {
5153
+ for (const run2 of result.runs) {
4619
5154
  console.log(
4620
- `${run.id} ${run.owner}/${run.installation.name} ${run.team}/${run.project ?? "\u2014"} ${run.taskKey ?? "no-task"} ${run.status} ${run.branch ?? "\u2014"}`
5155
+ `${run2.id} ${run2.owner}/${run2.installation.name} ${run2.team}/${run2.project ?? "\u2014"} ${run2.taskKey ?? "no-task"} ${run2.status} ${run2.branch ?? "\u2014"}`
4621
5156
  );
4622
5157
  }
4623
5158
  }
4624
5159
  async function publishPolicy(flags) {
4625
5160
  const config = loadConfig();
4626
- const file = one(flags, "file") ?? path3.join(stmaDir, "policy.json");
4627
- if (!existsSync3(file)) fail(`Policy file not found: ${file}`);
4628
- const document = JSON.parse(readFileSync3(file, "utf8"));
5161
+ const file = one(flags, "file") ?? path5.join(stmaDir, "policy.json");
5162
+ if (!existsSync5(file)) fail(`Policy file not found: ${file}`);
5163
+ const document = JSON.parse(readFileSync5(file, "utf8"));
4629
5164
  const result = await request(config, "/api/control/policies", {
4630
5165
  method: "POST",
4631
5166
  body: JSON.stringify({
@@ -4676,7 +5211,7 @@ async function execRun(flags, command) {
4676
5211
  heartbeatBusy = true;
4677
5212
  void heartbeat(/* @__PURE__ */ new Map([["run", [runId]]])).finally(() => heartbeatBusy = false);
4678
5213
  }, 6e4);
4679
- const child = spawn(command[0], command.slice(1), { cwd, stdio: "inherit", env: process.env });
5214
+ const child = spawn2(command[0], command.slice(1), { cwd, stdio: "inherit", env: process.env });
4680
5215
  const code = await new Promise((resolve) => {
4681
5216
  child.on("exit", (value) => resolve(value ?? 1));
4682
5217
  child.on("error", () => resolve(1));
@@ -4697,7 +5232,7 @@ function adapterInstall(flags) {
4697
5232
  target,
4698
5233
  team: required(flags, "team"),
4699
5234
  project: one(flags, "project"),
4700
- agentName: one(flags, "name") ?? `${os.userInfo().username}-${target}`,
5235
+ agentName: one(flags, "name") ?? `${os2.userInfo().username}-${target}`,
4701
5236
  defaultTask: one(flags, "task"),
4702
5237
  defaultIntent: one(flags, "intent"),
4703
5238
  applyPolicy: one(flags, "policy") !== "false",
@@ -4729,7 +5264,7 @@ function adapterInstall(flags) {
4729
5264
  }
4730
5265
  function readHookPayload() {
4731
5266
  if (process.stdin.isTTY) return {};
4732
- const raw = readFileSync3(0, "utf8");
5267
+ const raw = readFileSync5(0, "utf8");
4733
5268
  if (!raw.trim()) return {};
4734
5269
  if (raw.length > 1e6) throw new Error("Hook input exceeds 1 MB.");
4735
5270
  const value = JSON.parse(raw);
@@ -4749,9 +5284,9 @@ function hookIntent(adapter, payload) {
4749
5284
  return adapter.defaultIntent;
4750
5285
  }
4751
5286
  function loadOutbox() {
4752
- if (!existsSync3(outboxPath)) return [];
5287
+ if (!existsSync5(outboxPath)) return [];
4753
5288
  try {
4754
- const data = JSON.parse(readFileSync3(outboxPath, "utf8"));
5289
+ const data = JSON.parse(readFileSync5(outboxPath, "utf8"));
4755
5290
  return Array.isArray(data) ? data.slice(-500) : [];
4756
5291
  } catch {
4757
5292
  return [];
@@ -4764,7 +5299,7 @@ function saveOutbox(events) {
4764
5299
  }
4765
5300
  async function ensureAdapterInstallation(adapter) {
4766
5301
  const local = loadConfig();
4767
- const rawDevice = `${os.hostname()}\0${os.userInfo().username}\0${process.platform}\0${adapter.target}`;
5302
+ const rawDevice = `${os2.hostname()}\0${os2.userInfo().username}\0${process.platform}\0${adapter.target}`;
4768
5303
  const result = await apiRequest(local, "/api/agent/installations/register", {
4769
5304
  method: "POST",
4770
5305
  body: JSON.stringify({
@@ -4858,10 +5393,19 @@ async function processHookEvent(event) {
4858
5393
  }
4859
5394
  if (!runId) return void 0;
4860
5395
  if (event.event === "heartbeat") {
4861
- await apiRequest(local, `/api/agent/runs/${runId}/heartbeat`, {
5396
+ const observed = nativeClaims();
5397
+ const usage = hookQuota(event.payload);
5398
+ const result = await apiRequest(local, `/api/agent/runs/${runId}/heartbeat`, {
4862
5399
  method: "POST",
4863
- body: JSON.stringify({ status: "active", claims: nativeClaims() })
5400
+ body: JSON.stringify({
5401
+ status: "active",
5402
+ ...observed.length > 0 ? { claims: observed } : {},
5403
+ ...usage ? { usage } : {}
5404
+ })
4864
5405
  });
5406
+ if (result?.quota && result.quota.state !== "ok" && result.quota.advice) {
5407
+ console.error(`[stma] ${result.quota.advice}`);
5408
+ }
4865
5409
  return void 0;
4866
5410
  }
4867
5411
  await apiRequest(local, `/api/agent/runs/${runId}/finish`, {
@@ -4893,6 +5437,10 @@ function hookOutput(target, notice) {
4893
5437
  console.log(JSON.stringify({ continue: true, user_message: notice }));
4894
5438
  }
4895
5439
  }
5440
+ function isPermanentHookFailure(error) {
5441
+ const status = error?.status;
5442
+ return typeof status === "number" && status >= 400 && status < 500;
5443
+ }
4896
5444
  async function adapterHook(flags) {
4897
5445
  const value = required(flags, "event");
4898
5446
  if (!["start", "heartbeat", "finish"].includes(value)) fail("Invalid adapter hook event.");
@@ -4923,25 +5471,117 @@ async function adapterHook(flags) {
4923
5471
  try {
4924
5472
  const result = await processHookEvent(item);
4925
5473
  if (item.id === current.id) notice = result;
4926
- } catch {
5474
+ } catch (error) {
5475
+ if (isPermanentHookFailure(error)) continue;
4927
5476
  failed = true;
4928
5477
  remaining.push(item);
4929
5478
  }
4930
5479
  }
4931
5480
  saveOutbox(remaining);
4932
- hookOutput(adapter.target, notice);
5481
+ const waiting = await newsNotice();
5482
+ hookOutput(adapter.target, [notice, waiting].filter(Boolean).join("\n\n") || void 0);
5483
+ }
5484
+ async function newsNotice() {
5485
+ try {
5486
+ const config = loadConfig();
5487
+ if (!dueForCheck({ lastCheckedAt: config.newsCheckedAt, announced: config.newsAnnounced })) {
5488
+ return void 0;
5489
+ }
5490
+ const { server, token } = connection(config);
5491
+ const controller = new AbortController();
5492
+ const timer = setTimeout(() => controller.abort(), NEWS_TIMEOUT_MS);
5493
+ let news;
5494
+ try {
5495
+ const response = await fetch(`${server}/api/agent/news`, {
5496
+ headers: { authorization: `Bearer ${token}`, ...clientHeaders() },
5497
+ signal: controller.signal
5498
+ });
5499
+ if (!response.ok) return void 0;
5500
+ news = await response.json();
5501
+ } finally {
5502
+ clearTimeout(timer);
5503
+ }
5504
+ const state = { lastCheckedAt: config.newsCheckedAt, announced: config.newsAnnounced };
5505
+ const fresh = unseen(news, state);
5506
+ saveConfig({
5507
+ ...config,
5508
+ newsCheckedAt: news.checkedAt,
5509
+ newsAnnounced: rememberAnnounced(state, fresh)
5510
+ });
5511
+ return renderNews(fresh, news.unreadSessions);
5512
+ } catch {
5513
+ return void 0;
5514
+ }
5515
+ }
5516
+ async function watch(flags) {
5517
+ const seconds = Number(one(flags, "interval") ?? 30);
5518
+ if (!Number.isFinite(seconds) || seconds < 10 || seconds > 3600) {
5519
+ fail(`--interval must be between 10 and 3600 seconds (got ${one(flags, "interval")}).`);
5520
+ }
5521
+ const config = loadConfig();
5522
+ const { server, token } = connection(config);
5523
+ const announced = /* @__PURE__ */ new Set();
5524
+ let firstPass = true;
5525
+ console.log(`stma: watching ${server} every ${seconds}s. Ctrl+C to stop.`);
5526
+ for (; ; ) {
5527
+ try {
5528
+ const response = await fetch(`${server}/api/agent/news`, {
5529
+ headers: { authorization: `Bearer ${token}`, ...clientHeaders() }
5530
+ });
5531
+ if (response.ok) {
5532
+ const news = await response.json();
5533
+ for (const handoff of news.pendingHandoffs) {
5534
+ const key = handoffKey(handoff);
5535
+ if (announced.has(key)) continue;
5536
+ announced.add(key);
5537
+ const who = handoff.mine ? "your other machine" : handoff.from ?? "a teammate";
5538
+ const line = `work waiting \u2014 "${handoff.title}" from ${who}${handoff.resume?.branch ? ` on ${handoff.resume.branch}` : ""}`;
5539
+ console.log(`${(/* @__PURE__ */ new Date()).toISOString().slice(11, 19)} ${line}`);
5540
+ if (!firstPass) notifyDesktop("STMA", line);
5541
+ }
5542
+ }
5543
+ } catch {
5544
+ }
5545
+ firstPass = false;
5546
+ await new Promise((resolve) => setTimeout(resolve, seconds * 1e3));
5547
+ }
5548
+ }
5549
+ function notifyDesktop(title, body) {
5550
+ try {
5551
+ if (process.platform === "darwin") {
5552
+ execFileSync2("osascript", ["-e", `display notification "${body}" with title "${title}"`], {
5553
+ stdio: "ignore"
5554
+ });
5555
+ } else if (process.platform === "win32") {
5556
+ execFileSync2(
5557
+ "powershell",
5558
+ ["-NoProfile", "-Command", `[console]::beep(880,150)`],
5559
+ { stdio: "ignore" }
5560
+ );
5561
+ } else {
5562
+ execFileSync2("notify-send", [title, body], { stdio: "ignore" });
5563
+ }
5564
+ } catch {
5565
+ }
4933
5566
  }
4934
5567
  function help() {
4935
- console.log(`STMA local control-plane CLI
5568
+ console.log(`STMA local control-plane CLI ${VERSION}
4936
5569
 
4937
5570
  Environment:
4938
5571
  STMA_URL=http://localhost:3000
4939
5572
  STMA_TOKEN=stma_...
4940
5573
 
4941
5574
  Commands:
4942
- stma agent register --name NAME [--client generic]
5575
+ stma serve [--port 3000] [--host 127.0.0.1] [--data DIR]
5576
+ Run a private instance on this machine \u2014 embedded database, no setup.
5577
+ stma watch [--interval 30]
5578
+ Say when work is handed to you, while you are not at the keyboard.
5579
+ stma agent register --name NAME [--client generic] [--role implementer|reviewer|tester|planner|ops]
4943
5580
  stma run start --team TEAM [--project PROJECT] [--task KEY] [--scope path]
5581
+ [--attempt-group KEY] parallel attempts at one task never warn each other
4944
5582
  stma run heartbeat [--status active|waiting|blocked]
5583
+ [--used-pct N] [--resets-at ISO] [--quota-label TEXT]
5584
+ report your own vendor allowance; STMA answers with when to hand off
4945
5585
  stma run finish [--status completed|failed]
4946
5586
  stma run list [--team TEAM]
4947
5587
  stma run exec --team TEAM [run options] -- <agent command>
@@ -4951,10 +5591,43 @@ Commands:
4951
5591
  stma env preflight --team TEAM --project PROJECT
4952
5592
  stma adapter install --target claude-code|codex|cursor --team TEAM [--project PROJECT]
4953
5593
  [--name NAME] [--command stma] [--policy=false] [--preflight=false] [--apply]
5594
+ stma version [--server] this CLI's version, and optionally the server's
4954
5595
  `);
4955
5596
  }
5597
+ async function version(flags) {
5598
+ console.log(`stma ${VERSION}`);
5599
+ if (!flags.has("server")) return;
5600
+ const server = (process.env.STMA_URL ?? loadConfig().server ?? "http://localhost:3000").replace(/\/$/, "");
5601
+ try {
5602
+ const res = await fetch(`${server}/health`, { signal: AbortSignal.timeout(5e3) });
5603
+ const health = await res.json();
5604
+ console.log(`server ${health.version ?? "unknown"} (${server})`);
5605
+ if (health.version && health.version !== VERSION) {
5606
+ console.log("note: client and server versions differ \u2014 features added since the older one will be missing.");
5607
+ }
5608
+ } catch (error) {
5609
+ console.log(`server unreachable (${server}): ${error instanceof Error ? error.message : String(error)}`);
5610
+ }
5611
+ }
4956
5612
  async function main() {
4957
- const [group, action, ...rest] = process.argv.slice(2);
5613
+ const argv = process.argv.slice(2);
5614
+ const [group, action, ...rest] = argv;
5615
+ if (group === "version" || group === "--version" || group === "-v") {
5616
+ return version(parseFlags(argv.slice(1)).flags);
5617
+ }
5618
+ if (group === "watch") return watch(parseFlags(argv.slice(1)).flags);
5619
+ if (group === "serve") {
5620
+ const serveFlags = parseFlags(argv.slice(1)).flags;
5621
+ const port = Number(one(serveFlags, "port") ?? 3e3);
5622
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
5623
+ fail(`--port must be a port number (got ${one(serveFlags, "port")}).`);
5624
+ }
5625
+ return serve({
5626
+ port,
5627
+ host: one(serveFlags, "host") ?? "127.0.0.1",
5628
+ dataDir: one(serveFlags, "data") ?? defaultDataDir()
5629
+ });
5630
+ }
4958
5631
  const { flags, passthrough } = parseFlags(rest);
4959
5632
  if (group === "agent" && action === "register") return register(flags);
4960
5633
  if (group === "run" && action === "start") return void await startRun(flags);