@openthink/team 0.0.2 → 0.0.4

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
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Command as Command5 } from "commander";
4
+ import { Command as Command6 } from "commander";
5
5
 
6
6
  // src/commands/pull.ts
7
7
  import { existsSync as existsSync2, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
@@ -250,6 +250,7 @@ import { query } from "@anthropic-ai/claude-agent-sdk";
250
250
  // src/lib/models.ts
251
251
  var ROLE_PIPELINE_MODEL = "claude-opus-4-7";
252
252
  var NORMALISER_MODEL = "claude-sonnet-4-6";
253
+ var HAIKU_PRODUCT_MODEL = "claude-haiku-4-5";
253
254
  var PHASES = ["product", "spike", "implementation", "qa"];
254
255
  var DEFAULT_MODELS = {
255
256
  product: "claude-sonnet-4-6",
@@ -281,6 +282,55 @@ function resolveRoleModel(state, models) {
281
282
  if (pinned && pinned.length > 0) return pinned;
282
283
  return ROLE_PIPELINE_MODEL;
283
284
  }
285
+ function acceptanceCriteriaIsPopulated(body) {
286
+ const lines = body.split("\n");
287
+ let inSection = false;
288
+ let inHtmlComment = false;
289
+ const numberedBullet = /^\s*\d+\.\s+\S/;
290
+ for (const line of lines) {
291
+ if (!inSection) {
292
+ if (/^##\s+Acceptance Criteria\s*$/.test(line)) {
293
+ inSection = true;
294
+ }
295
+ continue;
296
+ }
297
+ if (/^##\s+/.test(line)) return false;
298
+ let scan = line;
299
+ while (scan.length > 0) {
300
+ if (inHtmlComment) {
301
+ const close = scan.indexOf("-->");
302
+ if (close === -1) {
303
+ scan = "";
304
+ break;
305
+ }
306
+ scan = scan.slice(close + 3);
307
+ inHtmlComment = false;
308
+ } else {
309
+ const open = scan.indexOf("<!--");
310
+ if (open === -1) break;
311
+ const before = scan.slice(0, open);
312
+ if (numberedBullet.test(before)) return true;
313
+ const rest = scan.slice(open + 4);
314
+ const close = rest.indexOf("-->");
315
+ if (close === -1) {
316
+ inHtmlComment = true;
317
+ scan = "";
318
+ break;
319
+ }
320
+ scan = rest.slice(close + 3);
321
+ }
322
+ }
323
+ if (inHtmlComment) continue;
324
+ if (numberedBullet.test(scan)) return true;
325
+ }
326
+ return false;
327
+ }
328
+ function resolveModelForTicket(args) {
329
+ if (args.state === "triage" && args.sourceType === "manual" && args.productDownshift && acceptanceCriteriaIsPopulated(args.body)) {
330
+ return HAIKU_PRODUCT_MODEL;
331
+ }
332
+ return resolveRoleModel(args.state, args.models);
333
+ }
284
334
 
285
335
  // src/lib/normalise.ts
286
336
  var SYSTEM_PROMPT = `You normalise unstructured work-item payloads (GitHub issues, Linear tickets, etc.) into well-formed product-vault tickets.
@@ -458,6 +508,7 @@ import {
458
508
  } from "fs";
459
509
  import { homedir } from "os";
460
510
  import { basename, isAbsolute, resolve, join as join2 } from "path";
511
+ var DEFAULT_PRODUCT_DOWNSHIFT = true;
461
512
  function configDir() {
462
513
  return join2(homedir(), ".open-team");
463
514
  }
@@ -484,14 +535,28 @@ function writeConfig(config) {
484
535
  default: config.default,
485
536
  stamp: config.stamp
486
537
  };
487
- if (Object.keys(config.models).length > 0) {
488
- onDisk.models = config.models;
538
+ const onDiskModels = { ...config.models };
539
+ if (config.productDownshift !== DEFAULT_PRODUCT_DOWNSHIFT) {
540
+ onDiskModels.productDownshift = config.productDownshift;
541
+ }
542
+ if (Object.keys(onDiskModels).length > 0) {
543
+ onDisk.models = onDiskModels;
544
+ }
545
+ if (!config.telemetry.enabled) {
546
+ onDisk.telemetry = config.telemetry;
489
547
  }
490
548
  const body = JSON.stringify(onDisk, null, 2) + "\n";
491
549
  writeFileSync(configPath(), body);
492
550
  }
493
551
  function emptyConfig() {
494
- return { vaults: {}, default: null, stamp: null, models: {} };
552
+ return {
553
+ vaults: {},
554
+ default: null,
555
+ stamp: null,
556
+ models: {},
557
+ productDownshift: DEFAULT_PRODUCT_DOWNSHIFT,
558
+ telemetry: { enabled: true }
559
+ };
495
560
  }
496
561
  function addVault(rawPath, options = {}) {
497
562
  const path = absolutise(rawPath);
@@ -590,9 +655,21 @@ function normalise(parsed) {
590
655
  vaults,
591
656
  default: def,
592
657
  stamp: normaliseStamp(obj.stamp),
593
- models: normaliseModels(obj.models)
658
+ models: normaliseModels(obj.models),
659
+ productDownshift: normaliseProductDownshift(obj.models),
660
+ telemetry: normaliseTelemetry(obj.telemetry)
594
661
  };
595
662
  }
663
+ function normaliseProductDownshift(value) {
664
+ if (!value || typeof value !== "object") return DEFAULT_PRODUCT_DOWNSHIFT;
665
+ const v = value;
666
+ return v.productDownshift !== false;
667
+ }
668
+ function normaliseTelemetry(value) {
669
+ if (!value || typeof value !== "object") return { enabled: true };
670
+ const v = value;
671
+ return { enabled: v.enabled !== false };
672
+ }
596
673
  function normaliseModels(value) {
597
674
  if (!value || typeof value !== "object") return {};
598
675
  const out = {};
@@ -697,6 +774,24 @@ function seedDefaultModelsIfEmpty() {
697
774
  writeConfig(config);
698
775
  return { action: "seeded", models: config.models };
699
776
  }
777
+ function getProductDownshift() {
778
+ return readConfig().productDownshift;
779
+ }
780
+ function setProductDownshift(enabled) {
781
+ const config = readConfig();
782
+ config.productDownshift = enabled;
783
+ writeConfig(config);
784
+ return config.productDownshift;
785
+ }
786
+ function getTelemetryEnabled() {
787
+ return readConfig().telemetry.enabled;
788
+ }
789
+ function setTelemetryEnabled(enabled) {
790
+ const config = readConfig();
791
+ config.telemetry = { enabled };
792
+ writeConfig(config);
793
+ return config.telemetry;
794
+ }
700
795
  function clearModel(phase) {
701
796
  if (!isPhase(phase)) {
702
797
  throw new Error(
@@ -1275,9 +1370,54 @@ enforce: ${s.enforce ? "on" : "off"}
1275
1370
  const lines = PHASES.map((p) => `${p.padEnd(15)} ${m[p] ?? "(unset)"}`);
1276
1371
  process.stdout.write(lines.join("\n") + "\n");
1277
1372
  });
1373
+ models.command("product-downshift <on|off|show>").description(
1374
+ "Toggle the AGT-107 Haiku-downshift heuristic for well-formed manual tickets (default: on)"
1375
+ ).action((flag) => {
1376
+ const lower = flag.toLowerCase();
1377
+ if (lower === "show") {
1378
+ process.stdout.write(`${getProductDownshift() ? "on" : "off"}
1379
+ `);
1380
+ return;
1381
+ }
1382
+ if (lower !== "on" && lower !== "off") {
1383
+ process.stderr.write(
1384
+ `oteam config models product-downshift: expected on|off|show, got "${flag}"
1385
+ `
1386
+ );
1387
+ process.exit(2);
1388
+ }
1389
+ const next = setProductDownshift(lower === "on");
1390
+ process.stdout.write(
1391
+ `\u2705 models.productDownshift = ${next ? "on" : "off"}
1392
+ `
1393
+ );
1394
+ });
1395
+ const telemetry = new Command("telemetry").description(
1396
+ "Manage per-phase telemetry recording (default: on)"
1397
+ );
1398
+ telemetry.command("set <on|off>").description("Turn per-phase telemetry recording on or off").action((flag) => {
1399
+ const lower = flag.toLowerCase();
1400
+ if (lower !== "on" && lower !== "off") {
1401
+ process.stderr.write(
1402
+ `oteam config telemetry set: expected on|off, got "${flag}"
1403
+ `
1404
+ );
1405
+ process.exit(2);
1406
+ }
1407
+ const next = setTelemetryEnabled(lower === "on");
1408
+ process.stdout.write(
1409
+ `\u2705 telemetry ${next.enabled ? "on" : "off"}
1410
+ `
1411
+ );
1412
+ });
1413
+ telemetry.command("show").description("Print whether telemetry recording is on").action(() => {
1414
+ process.stdout.write(`${getTelemetryEnabled() ? "on" : "off"}
1415
+ `);
1416
+ });
1278
1417
  config.addCommand(vault);
1279
1418
  config.addCommand(stamp);
1280
1419
  config.addCommand(models);
1420
+ config.addCommand(telemetry);
1281
1421
  return config;
1282
1422
  }
1283
1423
  function expectPhase(value) {
@@ -1961,18 +2101,345 @@ function openInEditor(path) {
1961
2101
  }
1962
2102
  }
1963
2103
 
1964
- // src/commands/ticket.ts
2104
+ // src/commands/telemetry.ts
1965
2105
  import { Command as Command4 } from "commander";
1966
- import { existsSync as existsSync7, mkdirSync as mkdirSync6, writeFileSync as writeFileSync6 } from "fs";
2106
+
2107
+ // src/lib/telemetry.ts
2108
+ import {
2109
+ appendFileSync,
2110
+ existsSync as existsSync7,
2111
+ mkdirSync as mkdirSync6,
2112
+ readFileSync as readFileSync7
2113
+ } from "fs";
2114
+ import { homedir as homedir4 } from "os";
2115
+ import { join as join10 } from "path";
2116
+
2117
+ // src/lib/claude-session.ts
2118
+ import { readFileSync as readFileSync6 } from "fs";
1967
2119
  import { join as join9 } from "path";
2120
+ function encodeProjectDir(cwd) {
2121
+ return cwd.replace(/\//g, "-");
2122
+ }
2123
+ function findSessionFile(claudeConfigDir, cwd, sessionId) {
2124
+ return join9(
2125
+ claudeConfigDir,
2126
+ "projects",
2127
+ encodeProjectDir(cwd),
2128
+ `${sessionId}.jsonl`
2129
+ );
2130
+ }
2131
+ function parseSessionFile(path) {
2132
+ let raw;
2133
+ try {
2134
+ raw = readFileSync6(path, "utf8");
2135
+ } catch {
2136
+ return { tokens: {}, outcome: null };
2137
+ }
2138
+ return parseSessionJsonl(raw);
2139
+ }
2140
+ function parseSessionJsonl(raw) {
2141
+ const tokens = {};
2142
+ let lastAssistantText = "";
2143
+ for (const line of raw.split("\n")) {
2144
+ if (line.length === 0) continue;
2145
+ let entry;
2146
+ try {
2147
+ entry = JSON.parse(line);
2148
+ } catch {
2149
+ continue;
2150
+ }
2151
+ if (!entry || typeof entry !== "object") continue;
2152
+ const e = entry;
2153
+ if (e.type !== "assistant") continue;
2154
+ const message = e.message;
2155
+ if (!message || typeof message !== "object") continue;
2156
+ const m = message;
2157
+ if (m.usage && typeof m.usage === "object") {
2158
+ const u = m.usage;
2159
+ addIfFinite(tokens, "input", u.input_tokens);
2160
+ addIfFinite(tokens, "output", u.output_tokens);
2161
+ addIfFinite(tokens, "cache-write", u.cache_creation_input_tokens);
2162
+ addIfFinite(tokens, "cache-read", u.cache_read_input_tokens);
2163
+ }
2164
+ const text = extractAssistantText(m.content);
2165
+ if (text.length > 0) lastAssistantText = text;
2166
+ }
2167
+ return { tokens, outcome: detectOutcome(lastAssistantText) };
2168
+ }
2169
+ function extractAssistantText(content) {
2170
+ if (typeof content === "string") return content;
2171
+ if (!Array.isArray(content)) return "";
2172
+ let out = "";
2173
+ for (const part of content) {
2174
+ if (!part || typeof part !== "object") continue;
2175
+ const p = part;
2176
+ if (p.type === "text" && typeof p.text === "string") {
2177
+ out += (out.length > 0 ? "\n" : "") + p.text;
2178
+ }
2179
+ }
2180
+ return out;
2181
+ }
2182
+ function detectOutcome(text) {
2183
+ if (text.includes("\u2705 DONE")) return "done";
2184
+ if (text.includes("\u23F8\uFE0F PAUSED")) return "paused";
2185
+ if (text.includes("\u{1F6D1} BLOCKED")) return "failed";
2186
+ return null;
2187
+ }
2188
+ function addIfFinite(target, key, value) {
2189
+ if (typeof value !== "number" || !Number.isFinite(value)) return;
2190
+ target[key] = (target[key] ?? 0) + value;
2191
+ }
2192
+
2193
+ // src/lib/telemetry.ts
2194
+ function telemetryDir() {
2195
+ const override = process.env.OTEAM_TELEMETRY_DIR;
2196
+ if (override && override.length > 0) return override;
2197
+ return join10(homedir4(), ".open-team", "telemetry");
2198
+ }
2199
+ function runsPath(dir = telemetryDir()) {
2200
+ return join10(dir, "runs.jsonl");
2201
+ }
2202
+ function recordPhase(input) {
2203
+ try {
2204
+ if (!getTelemetryEnabled()) return;
2205
+ const endedAt = input.endedAt ?? (/* @__PURE__ */ new Date()).toISOString();
2206
+ const wallClockMs = computeWallClockMs(input.startedAt, endedAt);
2207
+ const sessionFile = findSessionFile(
2208
+ resolveClaudeConfigDir(),
2209
+ input.cwd,
2210
+ input.sessionId
2211
+ );
2212
+ let tokens = {};
2213
+ let markerOutcome = null;
2214
+ if (existsSync7(sessionFile)) {
2215
+ const parsed = parseSessionFile(sessionFile);
2216
+ tokens = parsed.tokens;
2217
+ markerOutcome = parsed.outcome;
2218
+ }
2219
+ const outcome = input.exitCode !== 0 ? "failed" : markerOutcome ?? "unknown";
2220
+ const line = {
2221
+ ticket: input.ticket,
2222
+ phase: input.phase,
2223
+ model: input.model,
2224
+ "started-at": input.startedAt,
2225
+ "ended-at": endedAt,
2226
+ "wall-clock-ms": wallClockMs,
2227
+ tokens,
2228
+ outcome
2229
+ };
2230
+ const dir = telemetryDir();
2231
+ mkdirSync6(dir, { recursive: true });
2232
+ appendFileSync(runsPath(dir), JSON.stringify(line) + "\n");
2233
+ } catch (err) {
2234
+ const msg = err instanceof Error ? err.message : String(err);
2235
+ process.stderr.write(`oteam: telemetry record failed: ${msg}
2236
+ `);
2237
+ }
2238
+ }
2239
+ function resolveClaudeConfigDir() {
2240
+ const env = process.env.CLAUDE_CONFIG_DIR;
2241
+ if (env && env.length > 0) return env;
2242
+ return join10(homedir4(), ".claude");
2243
+ }
2244
+ function computeWallClockMs(startedAt, endedAt) {
2245
+ const startMs = Date.parse(startedAt);
2246
+ const endMs = Date.parse(endedAt);
2247
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return 0;
2248
+ return Math.max(0, endMs - startMs);
2249
+ }
2250
+ function readRuns(dir = telemetryDir()) {
2251
+ const path = runsPath(dir);
2252
+ if (!existsSync7(path)) return [];
2253
+ const raw = readFileSync7(path, "utf8");
2254
+ const out = [];
2255
+ for (const line of raw.split("\n")) {
2256
+ if (line.length === 0) continue;
2257
+ try {
2258
+ const parsed = JSON.parse(line);
2259
+ if (parsed && typeof parsed === "object") out.push(parsed);
2260
+ } catch {
2261
+ }
2262
+ }
2263
+ return out;
2264
+ }
2265
+ function summarize(runs, filter = {}) {
2266
+ const filtered = applyFilter(runs, filter);
2267
+ const buckets = /* @__PURE__ */ new Map();
2268
+ for (const r of filtered) {
2269
+ const key = `${r.phase} ${r.model}`;
2270
+ const bucket = buckets.get(key) ?? { phase: r.phase, model: r.model, rows: [] };
2271
+ bucket.rows.push(r);
2272
+ buckets.set(key, bucket);
2273
+ }
2274
+ const rows = [];
2275
+ for (const bucket of buckets.values()) {
2276
+ rows.push({
2277
+ phase: bucket.phase,
2278
+ model: bucket.model,
2279
+ count: bucket.rows.length,
2280
+ meanWallClockMs: meanWallClock(bucket.rows),
2281
+ meanTokens: meanTokens(bucket.rows)
2282
+ });
2283
+ }
2284
+ rows.sort(
2285
+ (a, b) => a.phase.localeCompare(b.phase) || a.model.localeCompare(b.model)
2286
+ );
2287
+ return rows;
2288
+ }
2289
+ function applyFilter(runs, filter) {
2290
+ let cutoffMs = null;
2291
+ if (typeof filter.days === "number" && filter.days > 0) {
2292
+ cutoffMs = Date.now() - filter.days * 24 * 60 * 60 * 1e3;
2293
+ }
2294
+ return runs.filter((r) => {
2295
+ if (filter.phase && r.phase !== filter.phase) return false;
2296
+ if (filter.model && r.model !== filter.model) return false;
2297
+ if (cutoffMs !== null) {
2298
+ const t = Date.parse(r["started-at"]);
2299
+ if (!Number.isFinite(t) || t < cutoffMs) return false;
2300
+ }
2301
+ return true;
2302
+ });
2303
+ }
2304
+ function meanWallClock(rows) {
2305
+ if (rows.length === 0) return 0;
2306
+ const sum = rows.reduce((acc, r) => acc + (r["wall-clock-ms"] ?? 0), 0);
2307
+ return Math.round(sum / rows.length);
2308
+ }
2309
+ function meanTokens(rows) {
2310
+ const keys = [
2311
+ "input",
2312
+ "output",
2313
+ "cache-read",
2314
+ "cache-write"
2315
+ ];
2316
+ const out = {};
2317
+ for (const k of keys) {
2318
+ let sum = 0;
2319
+ let n = 0;
2320
+ for (const r of rows) {
2321
+ const v = r.tokens?.[k];
2322
+ if (typeof v === "number" && Number.isFinite(v)) {
2323
+ sum += v;
2324
+ n += 1;
2325
+ }
2326
+ }
2327
+ if (n > 0) out[k] = Math.round(sum / n);
2328
+ }
2329
+ return out;
2330
+ }
2331
+ function tail(n, dir = telemetryDir()) {
2332
+ const all = readRuns(dir);
2333
+ if (n <= 0) return [];
2334
+ return all.slice(-n);
2335
+ }
2336
+
2337
+ // src/commands/telemetry.ts
2338
+ function buildTelemetryCommand() {
2339
+ const telemetry = new Command4("telemetry").description(
2340
+ "Per-phase wall-clock + token telemetry for role-pipeline spawns"
2341
+ );
2342
+ telemetry.command("summary").description(
2343
+ "Aggregate runs.jsonl by phase \xD7 model (count, mean wall-clock, mean tokens)"
2344
+ ).option("--days <n>", "Only include runs from the last N days", parsePositiveInt).option("--phase <name>", "Filter by phase (product|spike|implementation|qa)").option("--model <id>", "Filter by resolved model id").action(
2345
+ (opts) => {
2346
+ const runs = readRuns();
2347
+ if (runs.length === 0) {
2348
+ process.stdout.write(
2349
+ `(no telemetry yet \u2014 ${telemetryDir()}/runs.jsonl is empty or missing)
2350
+ `
2351
+ );
2352
+ return;
2353
+ }
2354
+ const rows = summarize(runs, opts);
2355
+ if (rows.length === 0) {
2356
+ process.stdout.write("(no rows match filters)\n");
2357
+ return;
2358
+ }
2359
+ process.stdout.write(formatSummary(rows) + "\n");
2360
+ }
2361
+ );
2362
+ telemetry.command("tail").description("Print the last N telemetry lines (default 20)").option("-n, --count <n>", "How many lines to print", parsePositiveInt, 20).action((opts) => {
2363
+ const lines = tail(opts.count);
2364
+ if (lines.length === 0) {
2365
+ process.stdout.write(
2366
+ `(no telemetry yet \u2014 ${telemetryDir()}/runs.jsonl is empty or missing)
2367
+ `
2368
+ );
2369
+ return;
2370
+ }
2371
+ for (const line of lines) {
2372
+ process.stdout.write(JSON.stringify(line) + "\n");
2373
+ }
2374
+ });
2375
+ telemetry.command("record", { hidden: true }).description("(internal) Record one phase's telemetry line").requiredOption("--ticket <id>", "Ticket id (e.g. AGT-108)").requiredOption("--phase <name>", "Phase name (product|spike|implementation|qa)").requiredOption("--model <id>", "Resolved model id passed to claude").requiredOption("--session <uuid>", "Session UUID passed to `claude --session-id`").requiredOption("--started-at <iso>", "ISO timestamp captured before spawning claude").requiredOption("--exit-code <n>", "claude's exit code", parseSignedInt).option("--cwd <path>", "Working directory the spawn ran in (defaults to $PWD)").action(
2376
+ (opts) => {
2377
+ recordPhase({
2378
+ ticket: opts.ticket,
2379
+ phase: opts.phase,
2380
+ model: opts.model,
2381
+ sessionId: opts.session,
2382
+ startedAt: opts.startedAt,
2383
+ exitCode: opts.exitCode,
2384
+ cwd: opts.cwd ?? process.cwd()
2385
+ });
2386
+ }
2387
+ );
2388
+ return telemetry;
2389
+ }
2390
+ function parsePositiveInt(raw) {
2391
+ const n = Number.parseInt(raw, 10);
2392
+ if (!Number.isFinite(n) || n <= 0) {
2393
+ process.stderr.write(`oteam telemetry: expected a positive integer, got "${raw}"
2394
+ `);
2395
+ process.exit(2);
2396
+ }
2397
+ return n;
2398
+ }
2399
+ function parseSignedInt(raw) {
2400
+ const n = Number.parseInt(raw, 10);
2401
+ if (!Number.isFinite(n)) {
2402
+ process.stderr.write(`oteam telemetry: expected an integer, got "${raw}"
2403
+ `);
2404
+ process.exit(2);
2405
+ }
2406
+ return n;
2407
+ }
2408
+ function formatSummary(rows) {
2409
+ const headers = ["phase", "model", "count", "mean_ms", "mean_in", "mean_out", "mean_cache_r", "mean_cache_w"];
2410
+ const data = rows.map((r) => [
2411
+ r.phase,
2412
+ r.model,
2413
+ String(r.count),
2414
+ String(r.meanWallClockMs),
2415
+ formatTokenField(r.meanTokens, "input"),
2416
+ formatTokenField(r.meanTokens, "output"),
2417
+ formatTokenField(r.meanTokens, "cache-read"),
2418
+ formatTokenField(r.meanTokens, "cache-write")
2419
+ ]);
2420
+ const widths = headers.map(
2421
+ (h, i) => Math.max(h.length, ...data.map((row) => (row[i] ?? "").length))
2422
+ );
2423
+ const fmt = (cells) => cells.map((c, i) => c.padEnd(widths[i] ?? 0)).join(" ").trimEnd();
2424
+ return [fmt(headers), ...data.map(fmt)].join("\n");
2425
+ }
2426
+ function formatTokenField(tokens, key) {
2427
+ const v = tokens[key];
2428
+ return typeof v === "number" ? String(v) : "-";
2429
+ }
2430
+
2431
+ // src/commands/ticket.ts
2432
+ import { Command as Command5 } from "commander";
2433
+ import { existsSync as existsSync8, mkdirSync as mkdirSync7, writeFileSync as writeFileSync6 } from "fs";
2434
+ import { join as join11 } from "path";
1968
2435
  function runTicketNew(opts) {
1969
2436
  const title = opts.title.trim();
1970
2437
  if (title.length === 0) {
1971
2438
  throw new Error("oteam ticket new: <title> must not be empty");
1972
2439
  }
1973
2440
  const vault = resolveVaultPath({ flagValue: opts.vault });
1974
- const triageDir = join9(vault, "tickets", "triage");
1975
- mkdirSync6(triageDir, { recursive: true });
2441
+ const triageDir = join11(vault, "tickets", "triage");
2442
+ mkdirSync7(triageDir, { recursive: true });
1976
2443
  const id = nextTicketID(vault);
1977
2444
  const slug = slugify(title);
1978
2445
  if (slug.length === 0) {
@@ -1980,8 +2447,8 @@ function runTicketNew(opts) {
1980
2447
  `oteam ticket new: title "${title}" produced an empty slug \u2014 use a title with at least one alphanumeric character`
1981
2448
  );
1982
2449
  }
1983
- const target = join9(triageDir, `${id}-${slug}.md`);
1984
- if (existsSync7(target)) {
2450
+ const target = join11(triageDir, `${id}-${slug}.md`);
2451
+ if (existsSync8(target)) {
1985
2452
  throw new Error(
1986
2453
  `oteam ticket new: target already exists at ${target} \u2014 ID scan collision`
1987
2454
  );
@@ -2003,7 +2470,7 @@ function collectLabel(value, prev = []) {
2003
2470
  return [...prev, value];
2004
2471
  }
2005
2472
  function buildTicketCommand() {
2006
- const ticket = new Command4("ticket").description(
2473
+ const ticket = new Command5("ticket").description(
2007
2474
  "Create vault tickets directly (without an external source)"
2008
2475
  );
2009
2476
  ticket.command("new <title>").description(
@@ -2036,13 +2503,14 @@ function buildTicketCommand() {
2036
2503
 
2037
2504
  // src/role-pipeline/runner.ts
2038
2505
  import { spawnSync as spawnSync4 } from "child_process";
2039
- import { writeFileSync as writeFileSync7 } from "fs";
2506
+ import { randomUUID } from "crypto";
2507
+ import { readFileSync as readFileSync9, writeFileSync as writeFileSync7 } from "fs";
2040
2508
  import { tmpdir } from "os";
2041
- import { resolve as resolve4, basename as basename5, dirname as dirname2, join as join12 } from "path";
2509
+ import { resolve as resolve4, basename as basename5, dirname as dirname2, join as join14 } from "path";
2042
2510
 
2043
2511
  // src/lib/kitty.ts
2044
2512
  import { spawnSync as spawnSync2 } from "child_process";
2045
- import { existsSync as existsSync8, readdirSync as readdirSync5 } from "fs";
2513
+ import { existsSync as existsSync9, readdirSync as readdirSync5 } from "fs";
2046
2514
  var SOCKET_BASENAME = "kitty-claudini";
2047
2515
  var KNOWN_INSTANCES = ["personal", "work"];
2048
2516
  function isMacOS() {
@@ -2085,7 +2553,7 @@ function findKittySocket(kittyPath, preferring) {
2085
2553
  candidates.push(...pidSuffixed);
2086
2554
  }
2087
2555
  for (const path of candidates) {
2088
- if (!existsSync8(path)) continue;
2556
+ if (!existsSync9(path)) continue;
2089
2557
  const socket = `unix:${path}`;
2090
2558
  const r = spawnSync2(kittyPath, ["@", "--to", socket, "ls"], {
2091
2559
  encoding: "utf8"
@@ -2151,9 +2619,9 @@ function shellEscape(s) {
2151
2619
  }
2152
2620
 
2153
2621
  // src/lib/workspace.ts
2154
- import { existsSync as existsSync9, mkdirSync as mkdirSync7, readdirSync as readdirSync6, rmSync } from "fs";
2622
+ import { existsSync as existsSync10, mkdirSync as mkdirSync8, readdirSync as readdirSync6, rmSync } from "fs";
2155
2623
  import { spawnSync as spawnSync3 } from "child_process";
2156
- import { basename as basename4, join as join10 } from "path";
2624
+ import { basename as basename4, join as join12 } from "path";
2157
2625
  function buildGithubUrl(repoSlug) {
2158
2626
  return `git@github.com:${repoSlug}.git`;
2159
2627
  }
@@ -2191,12 +2659,12 @@ function prepareAgentWorkspace(opts) {
2191
2659
  );
2192
2660
  }
2193
2661
  const root = opts.rootDir ?? WORKSPACE_ROOT;
2194
- mkdirSync7(root, { recursive: true });
2662
+ mkdirSync8(root, { recursive: true });
2195
2663
  if (opts.activeTicketIds) gcOrphanWorkspaces(root, opts.activeTicketIds);
2196
- const ticketDir = join10(root, opts.ticketId.toLowerCase());
2197
- const repoDir = join10(ticketDir, "repo");
2664
+ const ticketDir = join12(root, opts.ticketId.toLowerCase());
2665
+ const repoDir = join12(ticketDir, "repo");
2198
2666
  rmSync(ticketDir, { recursive: true, force: true });
2199
- mkdirSync7(ticketDir, { recursive: true });
2667
+ mkdirSync8(ticketDir, { recursive: true });
2200
2668
  const repoBasename = basename4(opts.repoSlug);
2201
2669
  const cloneRunner = opts.cloneRunner ?? defaultCloneRunner;
2202
2670
  if (opts.mode === "github") {
@@ -2247,7 +2715,7 @@ var defaultCloneRunner = (url, dest) => {
2247
2715
  };
2248
2716
  };
2249
2717
  function gcOrphanWorkspaces(root, activeTicketIds) {
2250
- if (!existsSync9(root)) return [];
2718
+ if (!existsSync10(root)) return [];
2251
2719
  const removed = [];
2252
2720
  let entries;
2253
2721
  try {
@@ -2258,7 +2726,7 @@ function gcOrphanWorkspaces(root, activeTicketIds) {
2258
2726
  for (const name of entries) {
2259
2727
  if (!ORPHAN_DIR_RE.test(name)) continue;
2260
2728
  if (activeTicketIds.has(name)) continue;
2261
- const target = join10(root, name);
2729
+ const target = join12(root, name);
2262
2730
  try {
2263
2731
  rmSync(target, { recursive: true, force: true });
2264
2732
  removed.push(target);
@@ -2269,22 +2737,22 @@ function gcOrphanWorkspaces(root, activeTicketIds) {
2269
2737
  }
2270
2738
 
2271
2739
  // src/role-pipeline/install-slash-command.ts
2272
- import { copyFileSync, existsSync as existsSync10, mkdirSync as mkdirSync8, readdirSync as readdirSync7, readFileSync as readFileSync6, statSync as statSync4 } from "fs";
2273
- import { homedir as homedir4 } from "os";
2274
- import { dirname, join as join11 } from "path";
2740
+ import { copyFileSync, existsSync as existsSync11, mkdirSync as mkdirSync9, readdirSync as readdirSync7, readFileSync as readFileSync8, statSync as statSync4 } from "fs";
2741
+ import { homedir as homedir5 } from "os";
2742
+ import { dirname, join as join13 } from "path";
2275
2743
  import { fileURLToPath } from "url";
2276
2744
  var moduleDir = dirname(fileURLToPath(import.meta.url));
2277
- var BUNDLED_PROMPT = join11(moduleDir, "assign-ticket.md");
2745
+ var BUNDLED_PROMPT = join13(moduleDir, "assign-ticket.md");
2278
2746
  function installRolePipelineSlashCommand() {
2279
- if (!existsSync10(BUNDLED_PROMPT)) return;
2280
- const bundled = readFileSync6(BUNDLED_PROMPT);
2747
+ if (!existsSync11(BUNDLED_PROMPT)) return;
2748
+ const bundled = readFileSync8(BUNDLED_PROMPT);
2281
2749
  const targets = resolveTargetDirs();
2282
2750
  for (const dir of targets) {
2283
2751
  try {
2284
- mkdirSync8(dir, { recursive: true });
2285
- const target = join11(dir, "assign-ticket.md");
2286
- if (existsSync10(target)) {
2287
- const current = readFileSync6(target);
2752
+ mkdirSync9(dir, { recursive: true });
2753
+ const target = join13(dir, "assign-ticket.md");
2754
+ if (existsSync11(target)) {
2755
+ const current = readFileSync8(target);
2288
2756
  if (current.equals(bundled)) continue;
2289
2757
  }
2290
2758
  copyFileSync(BUNDLED_PROMPT, target);
@@ -2293,23 +2761,23 @@ function installRolePipelineSlashCommand() {
2293
2761
  }
2294
2762
  }
2295
2763
  function resolveTargetDirs() {
2296
- const home = homedir4();
2764
+ const home = homedir5();
2297
2765
  const dirs = /* @__PURE__ */ new Set();
2298
- dirs.add(join11(home, ".claude", "commands"));
2766
+ dirs.add(join13(home, ".claude", "commands"));
2299
2767
  const configDir2 = process.env.CLAUDE_CONFIG_DIR;
2300
2768
  if (configDir2 && configDir2.length > 0) {
2301
- dirs.add(join11(configDir2, "commands"));
2769
+ dirs.add(join13(configDir2, "commands"));
2302
2770
  }
2303
2771
  try {
2304
2772
  for (const name of readdirSync7(home)) {
2305
2773
  if (!name.startsWith(".claude-")) continue;
2306
- const candidate = join11(home, name);
2774
+ const candidate = join13(home, name);
2307
2775
  let isDir = false;
2308
2776
  try {
2309
2777
  isDir = statSync4(candidate).isDirectory();
2310
2778
  } catch {
2311
2779
  }
2312
- if (isDir) dirs.add(join11(candidate, "commands"));
2780
+ if (isDir) dirs.add(join13(candidate, "commands"));
2313
2781
  }
2314
2782
  } catch {
2315
2783
  }
@@ -2382,10 +2850,34 @@ async function assignTicket(opts) {
2382
2850
  }
2383
2851
  }
2384
2852
  const projectContext = loadProjectContext(resolvedVault.path, ticket.project);
2385
- const model = resolveRoleModel(ticket.state, config.models);
2853
+ const ticketBody = readTicketBody(ticketPath);
2854
+ const model = resolveModelForTicket({
2855
+ state: ticket.state,
2856
+ sourceType: ticket.source.type,
2857
+ body: ticketBody,
2858
+ productDownshift: config.productDownshift,
2859
+ models: config.models
2860
+ });
2861
+ const haikuDownshift = model === HAIKU_PRODUCT_MODEL && ticket.state === "triage";
2862
+ const systemPrompt = composeSystemPrompt(ticket.id, projectContext, haikuDownshift);
2863
+ const phase = phaseForState(ticket.state);
2864
+ const telemetry = phase !== null && getTelemetryEnabled() ? {
2865
+ ticketId: ticket.id,
2866
+ phase,
2867
+ sessionId: randomUUID(),
2868
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
2869
+ } : null;
2386
2870
  const kittyPath = !opts.workInline && isMacOS() ? findKittyBinary() : null;
2387
2871
  if (!kittyPath) {
2388
- runInline(claudePath, ticketPath, resolvedVault.path, projectContext, workspace, model);
2872
+ runInline(
2873
+ claudePath,
2874
+ ticketPath,
2875
+ resolvedVault.path,
2876
+ systemPrompt,
2877
+ workspace,
2878
+ model,
2879
+ telemetry
2880
+ );
2389
2881
  return;
2390
2882
  }
2391
2883
  const monitored = opts.monitoredOrgs ?? readMonitoredOrgsFromEnv();
@@ -2396,7 +2888,15 @@ async function assignTicket(opts) {
2396
2888
  `oteam assign: no kitty socket reachable (preferring "${preferring}"); falling back to inline run.
2397
2889
  `
2398
2890
  );
2399
- runInline(claudePath, ticketPath, resolvedVault.path, projectContext, workspace, model);
2891
+ runInline(
2892
+ claudePath,
2893
+ ticketPath,
2894
+ resolvedVault.path,
2895
+ systemPrompt,
2896
+ workspace,
2897
+ model,
2898
+ telemetry
2899
+ );
2400
2900
  return;
2401
2901
  }
2402
2902
  const cwd = workspace?.path ?? dirname2(ticketPath);
@@ -2410,8 +2910,18 @@ async function assignTicket(opts) {
2410
2910
  const escapedTicket = shellEscape(ticketPath);
2411
2911
  const slashPrompt = `/assign-ticket ${escapedTicket}`;
2412
2912
  const escapedPrompt = shellEscape(slashPrompt);
2413
- const projectFlag = projectContext ? ` --append-system-prompt "$(cat '${shellEscape(projectContext.tmpFile)}')"` : "";
2414
- const shellCmd = `${envPrefix}exec '${escapedClaude}' --dangerously-skip-permissions --model ${shellEscape(model)}${projectFlag} '${escapedPrompt}'`;
2913
+ const projectFlag = systemPrompt ? ` --append-system-prompt "$(cat '${shellEscape(systemPrompt.tmpFile)}')"` : "";
2914
+ const sessionFlag = telemetry ? ` --session-id '${shellEscape(telemetry.sessionId)}'` : "";
2915
+ const claudeCmd = `'${escapedClaude}' --dangerously-skip-permissions --model ${shellEscape(model)}${sessionFlag}${projectFlag} '${escapedPrompt}'`;
2916
+ const telemetryTail = telemetry ? buildTelemetryTail({
2917
+ oteamPath: findToolOnPath("oteam") ?? "oteam",
2918
+ ticketId: telemetry.ticketId,
2919
+ phase: telemetry.phase,
2920
+ model,
2921
+ sessionId: telemetry.sessionId,
2922
+ startedAt: telemetry.startedAt
2923
+ }) : "";
2924
+ const shellCmd = `${envPrefix}${claudeCmd}${telemetryTail}`;
2415
2925
  const result = kittyLaunch({
2416
2926
  socket,
2417
2927
  title,
@@ -2425,16 +2935,32 @@ async function assignTicket(opts) {
2425
2935
  );
2426
2936
  }
2427
2937
  }
2428
- function runInline(claudePath, ticketPath, vaultPath, projectContext, workspace, model) {
2938
+ function buildTelemetryTail(input) {
2939
+ const oteam = `'${shellEscape(input.oteamPath)}'`;
2940
+ const args = [
2941
+ `--ticket '${shellEscape(input.ticketId)}'`,
2942
+ `--phase '${shellEscape(input.phase)}'`,
2943
+ `--model '${shellEscape(input.model)}'`,
2944
+ `--session '${shellEscape(input.sessionId)}'`,
2945
+ `--started-at '${shellEscape(input.startedAt)}'`,
2946
+ `--exit-code "$EC"`
2947
+ ].join(" ");
2948
+ return `; EC=$?; ${oteam} telemetry record ${args} >/dev/null 2>&1 || true; exit "$EC"`;
2949
+ }
2950
+ function runInline(claudePath, ticketPath, vaultPath, systemPrompt, workspace, model, telemetry) {
2429
2951
  const args = [
2430
2952
  "--dangerously-skip-permissions",
2431
2953
  "--model",
2432
2954
  model
2433
2955
  ];
2434
- if (projectContext) {
2435
- args.push("--append-system-prompt", projectContext.content);
2956
+ if (telemetry) {
2957
+ args.push("--session-id", telemetry.sessionId);
2958
+ }
2959
+ if (systemPrompt) {
2960
+ args.push("--append-system-prompt", systemPrompt.content);
2436
2961
  }
2437
2962
  args.push(`/assign-ticket ${ticketPath}`);
2963
+ const cwd = workspace?.path ?? process.cwd();
2438
2964
  const r = spawnSync4(
2439
2965
  claudePath,
2440
2966
  args,
@@ -2444,6 +2970,17 @@ function runInline(claudePath, ticketPath, vaultPath, projectContext, workspace,
2444
2970
  env: { ...process.env, PRODUCT_VAULT_PATH: vaultPath }
2445
2971
  }
2446
2972
  );
2973
+ if (telemetry) {
2974
+ recordPhase({
2975
+ ticket: telemetry.ticketId,
2976
+ phase: telemetry.phase,
2977
+ model,
2978
+ sessionId: telemetry.sessionId,
2979
+ startedAt: telemetry.startedAt,
2980
+ exitCode: r.status ?? -1,
2981
+ cwd
2982
+ });
2983
+ }
2447
2984
  if (r.status != null && r.status !== 0) process.exit(r.status);
2448
2985
  }
2449
2986
  function collectActiveTicketIds(vaultPath) {
@@ -2466,12 +3003,39 @@ function loadProjectContext(vaultPath, projectId) {
2466
3003
  );
2467
3004
  return null;
2468
3005
  }
2469
- const content = formatProjectContextForPrompt(project);
2470
- const safeId = projectId.replace(/[^a-zA-Z0-9._-]/g, "_");
2471
- const tmpFile = join12(tmpdir(), `oteam-project-${safeId}.md`);
3006
+ return formatProjectContextForPrompt(project);
3007
+ }
3008
+ function composeSystemPrompt(ticketId, projectContext, haikuDownshift) {
3009
+ const parts = [];
3010
+ if (projectContext) parts.push(projectContext);
3011
+ if (haikuDownshift) parts.push(haikuDownshiftPromptHint());
3012
+ if (parts.length === 0) return null;
3013
+ const content = parts.join("\n\n");
3014
+ const safeId = ticketId.replace(/[^a-zA-Z0-9._-]/g, "_");
3015
+ const tmpFile = join14(tmpdir(), `oteam-prompt-${safeId}.md`);
2472
3016
  writeFileSync7(tmpFile, content, "utf8");
2473
3017
  return { tmpFile, content };
2474
3018
  }
3019
+ function haikuDownshiftPromptHint() {
3020
+ return [
3021
+ "# Product agent: haiku-downshift heuristic active",
3022
+ "",
3023
+ "AGT-107: this ticket is a well-formed manual ticket (source.type=manual + populated `## Acceptance Criteria`), so the runner spawned you on Haiku 4.5 instead of the configured Product model. The heuristic exists to handle structural-cleanup cases cheaply; full synthesis still belongs on the configured Product model.",
3024
+ "",
3025
+ "When you advance the ticket, write the comment header as:",
3026
+ "",
3027
+ " ### YYYY-MM-DD \u2014 Product agent (haiku-downshift)",
3028
+ "",
3029
+ "instead of the standard `### YYYY-MM-DD \u2014 Product agent`. That makes the heuristic visible in the ticket's audit trail."
3030
+ ].join("\n");
3031
+ }
3032
+ function readTicketBody(path) {
3033
+ try {
3034
+ return readFileSync9(path, "utf8");
3035
+ } catch {
3036
+ return "";
3037
+ }
3038
+ }
2475
3039
  function findToolOnPath(name) {
2476
3040
  const r = spawnSync4("/usr/bin/env", ["which", name], { encoding: "utf8" });
2477
3041
  if (r.status !== 0) return null;
@@ -2485,7 +3049,7 @@ function readMonitoredOrgsFromEnv() {
2485
3049
  }
2486
3050
 
2487
3051
  // src/index.ts
2488
- var program = new Command5();
3052
+ var program = new Command6();
2489
3053
  program.name("oteam").description(
2490
3054
  "Source-agnostic vault-driven role pipeline for spawning Claude agents against tickets"
2491
3055
  ).version("0.0.1");
@@ -2564,6 +3128,7 @@ program.command("archive <ticket-id>").description("Move a done ticket to archiv
2564
3128
  program.addCommand(buildConfigCommand());
2565
3129
  program.addCommand(buildInitCommand());
2566
3130
  program.addCommand(buildProjectCommand());
3131
+ program.addCommand(buildTelemetryCommand());
2567
3132
  program.addCommand(buildTicketCommand());
2568
3133
  program.parseAsync(process.argv).catch((err) => {
2569
3134
  process.stderr.write(`oteam: ${err.message}