@openthink/team 0.0.1 → 0.0.3

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,37 @@ 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 PHASES = ["product", "spike", "implementation", "qa"];
254
+ var DEFAULT_MODELS = {
255
+ product: "claude-sonnet-4-6",
256
+ spike: "claude-opus-4-7",
257
+ implementation: "claude-sonnet-4-6",
258
+ qa: "claude-sonnet-4-6"
259
+ };
260
+ function isPhase(value) {
261
+ return PHASES.includes(value);
262
+ }
263
+ function phaseForState(state) {
264
+ switch (state) {
265
+ case "triage":
266
+ return "product";
267
+ case "refined":
268
+ return "spike";
269
+ case "in-progress":
270
+ return "implementation";
271
+ case "qa":
272
+ return "qa";
273
+ default:
274
+ return null;
275
+ }
276
+ }
277
+ function resolveRoleModel(state, models) {
278
+ const phase = phaseForState(state);
279
+ if (!phase) return ROLE_PIPELINE_MODEL;
280
+ const pinned = models?.[phase];
281
+ if (pinned && pinned.length > 0) return pinned;
282
+ return ROLE_PIPELINE_MODEL;
283
+ }
253
284
 
254
285
  // src/lib/normalise.ts
255
286
  var SYSTEM_PROMPT = `You normalise unstructured work-item payloads (GitHub issues, Linear tickets, etc.) into well-formed product-vault tickets.
@@ -435,7 +466,7 @@ function configPath() {
435
466
  }
436
467
  function readConfig() {
437
468
  const path = configPath();
438
- if (!existsSync(path)) return { vaults: {}, default: null };
469
+ if (!existsSync(path)) return emptyConfig();
439
470
  const raw = readFileSync(path, "utf8");
440
471
  let parsed;
441
472
  try {
@@ -448,9 +479,29 @@ function readConfig() {
448
479
  }
449
480
  function writeConfig(config) {
450
481
  mkdirSync(configDir(), { recursive: true });
451
- const body = JSON.stringify(config, null, 2) + "\n";
482
+ const onDisk = {
483
+ vaults: config.vaults,
484
+ default: config.default,
485
+ stamp: config.stamp
486
+ };
487
+ if (Object.keys(config.models).length > 0) {
488
+ onDisk.models = config.models;
489
+ }
490
+ if (!config.telemetry.enabled) {
491
+ onDisk.telemetry = config.telemetry;
492
+ }
493
+ const body = JSON.stringify(onDisk, null, 2) + "\n";
452
494
  writeFileSync(configPath(), body);
453
495
  }
496
+ function emptyConfig() {
497
+ return {
498
+ vaults: {},
499
+ default: null,
500
+ stamp: null,
501
+ models: {},
502
+ telemetry: { enabled: true }
503
+ };
504
+ }
454
505
  function addVault(rawPath, options = {}) {
455
506
  const path = absolutise(rawPath);
456
507
  const config = readConfig();
@@ -533,7 +584,9 @@ function findVaultRootForPath(filePath, config = readConfig()) {
533
584
  return null;
534
585
  }
535
586
  function normalise(parsed) {
536
- if (!parsed || typeof parsed !== "object") return { vaults: {}, default: null };
587
+ if (!parsed || typeof parsed !== "object") {
588
+ return emptyConfig();
589
+ }
537
590
  const obj = parsed;
538
591
  const vaults = {};
539
592
  if (obj.vaults && typeof obj.vaults === "object") {
@@ -542,7 +595,146 @@ function normalise(parsed) {
542
595
  }
543
596
  }
544
597
  const def = typeof obj.default === "string" && obj.default in vaults ? obj.default : null;
545
- return { vaults, default: def };
598
+ return {
599
+ vaults,
600
+ default: def,
601
+ stamp: normaliseStamp(obj.stamp),
602
+ models: normaliseModels(obj.models),
603
+ telemetry: normaliseTelemetry(obj.telemetry)
604
+ };
605
+ }
606
+ function normaliseTelemetry(value) {
607
+ if (!value || typeof value !== "object") return { enabled: true };
608
+ const v = value;
609
+ return { enabled: v.enabled !== false };
610
+ }
611
+ function normaliseModels(value) {
612
+ if (!value || typeof value !== "object") return {};
613
+ const out = {};
614
+ for (const [phase, modelId] of Object.entries(value)) {
615
+ if (!isPhase(phase)) continue;
616
+ if (typeof modelId !== "string") continue;
617
+ const trimmed = modelId.trim();
618
+ if (trimmed.length === 0) continue;
619
+ out[phase] = trimmed;
620
+ }
621
+ return out;
622
+ }
623
+ function normaliseStamp(value) {
624
+ if (value == null) return null;
625
+ if (typeof value !== "object") return null;
626
+ const s = value;
627
+ if (typeof s.host !== "string") return null;
628
+ const host = s.host.trim();
629
+ if (host.length === 0) return null;
630
+ return { host: stripTrailingSlash(host), enforce: s.enforce === true };
631
+ }
632
+ function stripTrailingSlash(s) {
633
+ return s.replace(/\/+$/, "");
634
+ }
635
+ function getStampConfig() {
636
+ return readConfig().stamp;
637
+ }
638
+ function setStampHost(host) {
639
+ const trimmed = host.trim();
640
+ if (trimmed.length === 0) {
641
+ throw new Error("stamp host cannot be empty \u2014 pass a value like ssh://git@host:port");
642
+ }
643
+ const config = readConfig();
644
+ const next = {
645
+ host: stripTrailingSlash(trimmed),
646
+ enforce: config.stamp?.enforce ?? false
647
+ };
648
+ config.stamp = next;
649
+ writeConfig(config);
650
+ return next;
651
+ }
652
+ function setStampEnforce(enforce) {
653
+ const config = readConfig();
654
+ if (enforce && (!config.stamp || config.stamp.host.length === 0)) {
655
+ throw new Error(
656
+ "cannot set stamp.enforce on with no stamp.host \u2014 run 'oteam config stamp set --host <url>' first"
657
+ );
658
+ }
659
+ const next = {
660
+ host: config.stamp?.host ?? "",
661
+ enforce
662
+ };
663
+ config.stamp = next;
664
+ writeConfig(config);
665
+ return next;
666
+ }
667
+ function setStamp(input) {
668
+ const trimmed = input.host.trim();
669
+ if (trimmed.length === 0) {
670
+ throw new Error("stamp host cannot be empty \u2014 pass a value like ssh://git@host:port");
671
+ }
672
+ const next = {
673
+ host: stripTrailingSlash(trimmed),
674
+ enforce: input.enforce
675
+ };
676
+ const config = readConfig();
677
+ config.stamp = next;
678
+ writeConfig(config);
679
+ return next;
680
+ }
681
+ function clearStamp() {
682
+ const config = readConfig();
683
+ config.stamp = null;
684
+ writeConfig(config);
685
+ }
686
+ function getModels() {
687
+ return readConfig().models;
688
+ }
689
+ function setModel(phase, modelId) {
690
+ const trimmed = modelId.trim();
691
+ if (trimmed.length === 0) {
692
+ throw new Error(
693
+ `model id for phase "${phase}" cannot be empty \u2014 pass a non-empty string`
694
+ );
695
+ }
696
+ if (!isPhase(phase)) {
697
+ throw new Error(
698
+ `unknown phase "${phase}" \u2014 supported: ${PHASES.join(", ")}`
699
+ );
700
+ }
701
+ const config = readConfig();
702
+ config.models = { ...config.models, [phase]: trimmed };
703
+ writeConfig(config);
704
+ return config.models;
705
+ }
706
+ function seedDefaultModelsIfEmpty() {
707
+ const config = readConfig();
708
+ if (Object.keys(config.models).length > 0) {
709
+ return { action: "preserved", models: config.models };
710
+ }
711
+ config.models = { ...DEFAULT_MODELS };
712
+ writeConfig(config);
713
+ return { action: "seeded", models: config.models };
714
+ }
715
+ function getTelemetryEnabled() {
716
+ return readConfig().telemetry.enabled;
717
+ }
718
+ function setTelemetryEnabled(enabled) {
719
+ const config = readConfig();
720
+ config.telemetry = { enabled };
721
+ writeConfig(config);
722
+ return config.telemetry;
723
+ }
724
+ function clearModel(phase) {
725
+ if (!isPhase(phase)) {
726
+ throw new Error(
727
+ `unknown phase "${phase}" \u2014 supported: ${PHASES.join(", ")}`
728
+ );
729
+ }
730
+ const config = readConfig();
731
+ if (phase in config.models) {
732
+ const next = { ...config.models };
733
+ delete next[phase];
734
+ config.models = next;
735
+ writeConfig(config);
736
+ }
737
+ return config.models;
546
738
  }
547
739
  function findEntry(config, nameOrPath) {
548
740
  if (config.vaults[nameOrPath]) return nameOrPath;
@@ -1031,26 +1223,213 @@ function buildConfigCommand() {
1031
1223
  return;
1032
1224
  }
1033
1225
  process.stdout.write(`${def}
1226
+ `);
1227
+ });
1228
+ const stamp = new Command("stamp").description(
1229
+ "Manage stamp-server integration (host + enforce flag)"
1230
+ );
1231
+ const stampSet = new Command("set").description("Set the stamp host and/or the enforce flag").option("--host <url>", "Stamp server host (e.g. ssh://git@host:port)").option("--enforce <on|off>", "Refuse repos not registered on stamp").action((opts) => {
1232
+ if (!opts.host && !opts.enforce) {
1233
+ process.stderr.write(
1234
+ "oteam config stamp set: pass --host <url> and/or --enforce <on|off>\n"
1235
+ );
1236
+ process.exit(2);
1237
+ }
1238
+ if (opts.host) {
1239
+ const next = setStampHost(opts.host);
1240
+ process.stdout.write(
1241
+ `\u2705 stamp.host = ${next.host} (enforce=${next.enforce ? "on" : "off"})
1242
+ `
1243
+ );
1244
+ }
1245
+ if (opts.enforce) {
1246
+ const flag = opts.enforce.toLowerCase();
1247
+ if (flag !== "on" && flag !== "off") {
1248
+ process.stderr.write(
1249
+ `oteam config stamp set: --enforce expects on|off, got "${opts.enforce}"
1250
+ `
1251
+ );
1252
+ process.exit(2);
1253
+ }
1254
+ const next = setStampEnforce(flag === "on");
1255
+ process.stdout.write(
1256
+ `\u2705 stamp.enforce = ${next.enforce ? "on" : "off"} (host=${next.host || "(unset)"})
1257
+ `
1258
+ );
1259
+ }
1260
+ });
1261
+ const stampClear = new Command("clear").description("Remove the stamp block from oteam config").action(() => {
1262
+ clearStamp();
1263
+ process.stdout.write("\u2705 stamp config cleared\n");
1264
+ });
1265
+ const stampShow = new Command("show").description("Print the current stamp config").action(() => {
1266
+ const s = getStampConfig();
1267
+ if (!s) {
1268
+ process.stdout.write("(stamp not configured)\n");
1269
+ return;
1270
+ }
1271
+ process.stdout.write(`host: ${s.host}
1272
+ enforce: ${s.enforce ? "on" : "off"}
1273
+ `);
1274
+ });
1275
+ stamp.addCommand(stampSet);
1276
+ stamp.addCommand(stampClear);
1277
+ stamp.addCommand(stampShow);
1278
+ const models = new Command("models").description(
1279
+ "Per-phase model overrides for the role pipeline (product|spike|implementation|qa)"
1280
+ );
1281
+ models.command("set <phase> <model-id>").description(
1282
+ `Pin a model id for one phase (phase: ${PHASES.join("|")})`
1283
+ ).action((phaseRaw, modelId) => {
1284
+ const phase = expectPhase(phaseRaw);
1285
+ const next = setModel(phase, modelId);
1286
+ process.stdout.write(
1287
+ `\u2705 models.${phase} = ${next[phase]}
1288
+ `
1289
+ );
1290
+ });
1291
+ models.command("clear <phase>").description("Remove the override for one phase (falls back to the role-pipeline default)").action((phaseRaw) => {
1292
+ const phase = expectPhase(phaseRaw);
1293
+ clearModel(phase);
1294
+ process.stdout.write(`\u2705 models.${phase} cleared
1295
+ `);
1296
+ });
1297
+ models.command("show").description("Print the current per-phase model overrides").action(() => {
1298
+ const m = getModels();
1299
+ const lines = PHASES.map((p) => `${p.padEnd(15)} ${m[p] ?? "(unset)"}`);
1300
+ process.stdout.write(lines.join("\n") + "\n");
1301
+ });
1302
+ const telemetry = new Command("telemetry").description(
1303
+ "Manage per-phase telemetry recording (default: on)"
1304
+ );
1305
+ telemetry.command("set <on|off>").description("Turn per-phase telemetry recording on or off").action((flag) => {
1306
+ const lower = flag.toLowerCase();
1307
+ if (lower !== "on" && lower !== "off") {
1308
+ process.stderr.write(
1309
+ `oteam config telemetry set: expected on|off, got "${flag}"
1310
+ `
1311
+ );
1312
+ process.exit(2);
1313
+ }
1314
+ const next = setTelemetryEnabled(lower === "on");
1315
+ process.stdout.write(
1316
+ `\u2705 telemetry ${next.enabled ? "on" : "off"}
1317
+ `
1318
+ );
1319
+ });
1320
+ telemetry.command("show").description("Print whether telemetry recording is on").action(() => {
1321
+ process.stdout.write(`${getTelemetryEnabled() ? "on" : "off"}
1034
1322
  `);
1035
1323
  });
1036
1324
  config.addCommand(vault);
1325
+ config.addCommand(stamp);
1326
+ config.addCommand(models);
1327
+ config.addCommand(telemetry);
1037
1328
  return config;
1038
1329
  }
1330
+ function expectPhase(value) {
1331
+ if (!isPhase(value)) {
1332
+ process.stderr.write(
1333
+ `oteam config models: unknown phase "${value}" \u2014 supported: ${PHASES.join(", ")}
1334
+ `
1335
+ );
1336
+ process.exit(2);
1337
+ }
1338
+ return value;
1339
+ }
1039
1340
 
1040
1341
  // src/commands/init.ts
1041
1342
  import { Command as Command2 } from "commander";
1042
- import { existsSync as existsSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
1043
- import { resolve as resolve2, join as join6 } from "path";
1343
+ import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
1344
+ import { resolve as resolve3, join as join7 } from "path";
1044
1345
  import readline from "readline";
1346
+
1347
+ // src/lib/workspace-tree.ts
1348
+ import {
1349
+ existsSync as existsSync3,
1350
+ mkdirSync as mkdirSync4,
1351
+ readdirSync as readdirSync3,
1352
+ writeFileSync as writeFileSync3
1353
+ } from "fs";
1354
+ import { homedir as homedir3 } from "os";
1355
+ import { join as join6, resolve as resolve2 } from "path";
1356
+ var SENTINEL_FILENAME = ".oteam-workspace";
1357
+ var WORKSPACE_SUBDIRS = [
1358
+ "tickets/triage",
1359
+ "tickets/refined",
1360
+ "tickets/in-progress",
1361
+ "tickets/qa",
1362
+ "tickets/blocked",
1363
+ "projects",
1364
+ "archive",
1365
+ "00-meta"
1366
+ ];
1367
+ var META_README_BODY = `# 00-meta
1368
+
1369
+ Workspace metadata. Templates, schema notes, and ad-hoc bookkeeping live
1370
+ here. Created by \`oteam init\` \u2014 safe to extend.
1371
+ `;
1372
+ var SENTINEL_BODY = `${JSON.stringify(
1373
+ { version: 1, createdBy: "@openthink/team" },
1374
+ null,
1375
+ 2
1376
+ )}
1377
+ `;
1378
+ function defaultWorkspacePath() {
1379
+ return join6(homedir3(), "openteam");
1380
+ }
1381
+ var WorkspaceConflictError = class extends Error {
1382
+ path;
1383
+ conflictingPaths;
1384
+ constructor(path, conflictingPaths) {
1385
+ const head = `oteam init: refusing to initialise workspace at ${path} \u2014 directory is non-empty and lacks the ${SENTINEL_FILENAME} marker.`;
1386
+ const list = conflictingPaths.slice(0, 10).map((p) => ` - ${p}`).join("\n");
1387
+ const more = conflictingPaths.length > 10 ? `
1388
+ ...and ${conflictingPaths.length - 10} more` : "";
1389
+ super(`${head}
1390
+ Conflicting entries:
1391
+ ${list}${more}`);
1392
+ this.name = "WorkspaceConflictError";
1393
+ this.path = path;
1394
+ this.conflictingPaths = conflictingPaths;
1395
+ }
1396
+ };
1397
+ function expandHome(input) {
1398
+ const home = homedir3();
1399
+ if (input === "~") return home;
1400
+ if (input.startsWith("~/")) return join6(home, input.slice(2));
1401
+ return input;
1402
+ }
1403
+ function bootstrapWorkspace(rawTarget) {
1404
+ const target = resolve2(expandHome(rawTarget));
1405
+ if (existsSync3(join6(target, SENTINEL_FILENAME))) {
1406
+ return { outcome: "already-initialised", path: target };
1407
+ }
1408
+ if (existsSync3(target)) {
1409
+ const visible = readdirSync3(target).filter((n) => !n.startsWith("."));
1410
+ if (visible.length > 0) {
1411
+ throw new WorkspaceConflictError(target, visible);
1412
+ }
1413
+ }
1414
+ mkdirSync4(target, { recursive: true });
1415
+ for (const sub of WORKSPACE_SUBDIRS) {
1416
+ mkdirSync4(join6(target, sub), { recursive: true });
1417
+ }
1418
+ writeFileSync3(join6(target, "00-meta", "README.md"), META_README_BODY);
1419
+ writeFileSync3(join6(target, SENTINEL_FILENAME), SENTINEL_BODY);
1420
+ return { outcome: "created", path: target };
1421
+ }
1422
+
1423
+ // src/commands/init.ts
1045
1424
  var BLOCK_BEGIN = "<!-- oteam:begin (managed by `oteam init` \u2014 do not edit between markers) -->";
1046
1425
  var BLOCK_END = "<!-- oteam:end -->";
1047
- var AGENTS_BODY = `## oteam \u2014 vault-driven role pipeline for Claude agents
1426
+ var AGENTS_BODY = `## oteam \u2014 workspace-driven role pipeline for Claude agents
1048
1427
 
1049
1428
  If the user asks you to **search, find, list, filter, count, or file
1050
- tickets**, or mentions a "vault", an "Obsidian vault", an \`AGT-NNN\` id, a
1051
- project, "ingesting GitHub issues or PRs", or driving tickets through a
1052
- "role pipeline" \u2014 \`oteam\` is the right tool. The vault is a directory of
1053
- markdown files (typically \`~/Documents/<vault>/tickets/<state>/AGT-NNN-*.md\`),
1429
+ tickets**, or mentions a "workspace", an "Obsidian vault", an \`AGT-NNN\` id,
1430
+ a project, "ingesting GitHub issues or PRs", or driving tickets through a
1431
+ "role pipeline" \u2014 \`oteam\` is the right tool. The workspace is a directory
1432
+ of markdown files (typically \`~/openteam/tickets/<state>/AGT-NNN-*.md\`),
1054
1433
  but **do not search it with \`find\` or \`grep\` directly.** The CLI knows the
1055
1434
  ticket schema and has structured + free-text filters; filesystem search
1056
1435
  does not, and you will fight false positives from incidental keyword
@@ -1068,13 +1447,13 @@ Other common verbs: \`oteam ticket new "<title>" [--project X]\` to file a
1068
1447
  ticket, \`oteam pull github owner/repo#NN\` to ingest a GitHub issue or PR,
1069
1448
  \`oteam assign <AGT-NNN>\` to drive a ticket through the role pipeline. Run
1070
1449
  \`oteam --help\` or \`oteam <command> --help\` for full details. If you don't
1071
- know whether a vault is configured, \`oteam config vault list\` tells you.
1450
+ know whether a workspace is configured, \`oteam config vault list\` tells you.
1072
1451
  `;
1073
1452
  var CLAUDE_BODY = `## oteam
1074
1453
 
1075
1454
  If the user asks to search, find, list, or file tickets, or mentions a
1076
- "vault", "Obsidian vault", an \`AGT-NNN\` id, or a role pipeline, use the
1077
- \`oteam\` CLI \u2014 **do not** \`find\`/\`grep\` the vault directly. Start with
1455
+ "workspace", "Obsidian vault", an \`AGT-NNN\` id, or a role pipeline, use the
1456
+ \`oteam\` CLI \u2014 **do not** \`find\`/\`grep\` the workspace directly. Start with
1078
1457
  \`oteam list --grep "<term>"\` or \`oteam list --match "<term>"\`. See
1079
1458
  \`AGENTS.md\` next to this file for the short summary and \`oteam --help\` for
1080
1459
  the full surface.
@@ -1089,8 +1468,8 @@ ${BLOCK_END}
1089
1468
  }
1090
1469
  function upsertBlock(filePath, body) {
1091
1470
  const block = renderBlock(body);
1092
- if (!existsSync3(filePath)) {
1093
- writeFileSync3(filePath, block, "utf8");
1471
+ if (!existsSync4(filePath)) {
1472
+ writeFileSync4(filePath, block, "utf8");
1094
1473
  return "created";
1095
1474
  }
1096
1475
  const existing = readFileSync4(filePath, "utf8");
@@ -1099,17 +1478,17 @@ function upsertBlock(filePath, body) {
1099
1478
  if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
1100
1479
  const before = existing.slice(0, beginIdx);
1101
1480
  const after = existing.slice(endIdx + BLOCK_END.length);
1102
- writeFileSync3(filePath, before + block.trimEnd() + after, "utf8");
1481
+ writeFileSync4(filePath, before + block.trimEnd() + after, "utf8");
1103
1482
  return "updated";
1104
1483
  }
1105
1484
  const separator = existing.endsWith("\n") ? "\n" : "\n\n";
1106
- writeFileSync3(filePath, existing + separator + block, "utf8");
1485
+ writeFileSync4(filePath, existing + separator + block, "utf8");
1107
1486
  return "appended";
1108
1487
  }
1109
- function expandHome(input) {
1488
+ function expandHome2(input) {
1110
1489
  const home = process.env.HOME ?? "";
1111
1490
  if (input === "~") return home;
1112
- if (input.startsWith("~/")) return join6(home, input.slice(2));
1491
+ if (input.startsWith("~/")) return join7(home, input.slice(2));
1113
1492
  return input;
1114
1493
  }
1115
1494
  function prompt(question, fallback) {
@@ -1125,35 +1504,133 @@ function prompt(question, fallback) {
1125
1504
  });
1126
1505
  });
1127
1506
  }
1507
+ function promptRaw(question) {
1508
+ return new Promise((resolvePrompt) => {
1509
+ const rl = readline.createInterface({
1510
+ input: process.stdin,
1511
+ output: process.stdout
1512
+ });
1513
+ rl.question(question, (answer) => {
1514
+ rl.close();
1515
+ resolvePrompt(answer.trim());
1516
+ });
1517
+ });
1518
+ }
1128
1519
  async function runInit(opts) {
1129
1520
  const home = process.env.HOME ?? "";
1130
- const defaultDir = home;
1131
- let targetDir;
1132
- if (opts.dir) {
1133
- targetDir = opts.dir;
1521
+ const defaultWorkspace = defaultWorkspacePath();
1522
+ if (opts.dir && opts.workspace && opts.dir !== opts.workspace) {
1523
+ throw new Error(
1524
+ `oteam init: --dir and --workspace disagree (${opts.dir} vs ${opts.workspace}); pass one`
1525
+ );
1526
+ }
1527
+ const workspaceFlag = opts.workspace ?? opts.dir;
1528
+ let workspaceDir;
1529
+ if (workspaceFlag) {
1530
+ workspaceDir = workspaceFlag;
1134
1531
  } else if (opts.yes) {
1135
- targetDir = defaultDir;
1532
+ workspaceDir = defaultWorkspace;
1136
1533
  } else {
1137
- targetDir = await prompt(
1138
- `Where should AGENTS.md / CLAUDE.md be written? (${defaultDir}) `,
1139
- defaultDir
1534
+ workspaceDir = await prompt(
1535
+ `Where should the oteam workspace live? (${defaultWorkspace}) `,
1536
+ defaultWorkspace
1140
1537
  );
1141
1538
  }
1142
- targetDir = resolve2(expandHome(targetDir));
1143
- if (!existsSync3(targetDir)) {
1144
- process.stderr.write(`oteam init: directory does not exist: ${targetDir}
1539
+ workspaceDir = resolve3(expandHome2(workspaceDir));
1540
+ const bootstrap = bootstrapWorkspace(workspaceDir);
1541
+ const registration = addVault(bootstrap.path);
1542
+ const currentDefault = listVaults().default;
1543
+ const models = seedDefaultModelsIfEmpty();
1544
+ const stamp = await runStampStep(opts);
1545
+ const docsDir = resolve3(expandHome2(opts.docsDir ?? home));
1546
+ if (!existsSync4(docsDir)) {
1547
+ process.stderr.write(`oteam init: docs directory does not exist: ${docsDir}
1145
1548
  `);
1146
1549
  process.exit(1);
1147
1550
  }
1148
- const agentsPath = join6(targetDir, "AGENTS.md");
1149
- const claudePath = join6(targetDir, "CLAUDE.md");
1551
+ const agentsPath = join7(docsDir, "AGENTS.md");
1552
+ const claudePath = join7(docsDir, "CLAUDE.md");
1150
1553
  const agents = upsertBlock(agentsPath, AGENTS_BODY);
1151
1554
  const claude = upsertBlock(claudePath, CLAUDE_BODY);
1152
1555
  return {
1556
+ workspace: {
1557
+ path: bootstrap.path,
1558
+ outcome: bootstrap.outcome,
1559
+ registeredAs: registration.name,
1560
+ promotedToDefault: registration.promotedToDefault,
1561
+ currentDefault
1562
+ },
1153
1563
  agents: { path: agentsPath, result: agents },
1154
- claude: { path: claudePath, result: claude }
1564
+ claude: { path: claudePath, result: claude },
1565
+ stamp,
1566
+ models
1155
1567
  };
1156
1568
  }
1569
+ async function runStampStep(opts) {
1570
+ if (opts.skipStamp || opts.yes) {
1571
+ return { action: "skipped" };
1572
+ }
1573
+ const existing = getStampConfig();
1574
+ let hostInput;
1575
+ if (opts.stampHost !== void 0) {
1576
+ hostInput = opts.stampHost.trim();
1577
+ } else {
1578
+ const hint = existing ? ` (current: ${existing.host}; press enter to keep)` : " (leave blank to skip)";
1579
+ hostInput = await promptRaw(
1580
+ `Configure a stamp server for signed-merge integration?
1581
+ Paste host (e.g. ssh://git@host:port)${hint}: `
1582
+ );
1583
+ }
1584
+ let nextHost;
1585
+ if (hostInput.length === 0) {
1586
+ nextHost = existing?.host ?? null;
1587
+ } else {
1588
+ nextHost = hostInput;
1589
+ }
1590
+ if (nextHost === null) {
1591
+ return { action: "unchanged", stamp: null };
1592
+ }
1593
+ let enforce;
1594
+ if (opts.stampEnforce !== void 0) {
1595
+ enforce = opts.stampEnforce;
1596
+ } else {
1597
+ const enforceHint = existing ? ` [${existing.enforce ? "Y/n" : "y/N"}]` : " [y/N]";
1598
+ const enforceAnswer = await promptRaw(
1599
+ `Refuse to operate on repos not registered on this stamp server?${enforceHint}: `
1600
+ );
1601
+ if (enforceAnswer.length === 0) {
1602
+ enforce = existing?.enforce ?? false;
1603
+ } else {
1604
+ enforce = /^(y|yes)$/i.test(enforceAnswer);
1605
+ }
1606
+ }
1607
+ if (existing && existing.host === nextHost && existing.enforce === enforce) {
1608
+ return { action: "unchanged", stamp: existing };
1609
+ }
1610
+ const result = setStamp({ host: nextHost, enforce });
1611
+ return { action: "set", stamp: result };
1612
+ }
1613
+ function modelsLine(result) {
1614
+ if (result.action === "preserved") {
1615
+ return "\u2139\uFE0F Models: existing customisation preserved";
1616
+ }
1617
+ const pairs = PHASES.map((phase) => `${phase}=${result.models[phase]}`).join(
1618
+ ", "
1619
+ );
1620
+ return `\u2705 Models: defaults seeded (${pairs})`;
1621
+ }
1622
+ function stampLine(outcome) {
1623
+ switch (outcome.action) {
1624
+ case "skipped":
1625
+ return null;
1626
+ case "unchanged":
1627
+ if (!outcome.stamp) return null;
1628
+ return `\u2139\uFE0F Stamp integration: host=${outcome.stamp.host} enforce=${outcome.stamp.enforce ? "on" : "off"} (no changes)`;
1629
+ case "set":
1630
+ if (!outcome.stamp) return "\u2705 Stamp integration: cleared";
1631
+ return `\u2705 Stamp integration: host=${outcome.stamp.host} enforce=${outcome.stamp.enforce ? "on" : "off"}`;
1632
+ }
1633
+ }
1157
1634
  function pastTense(action) {
1158
1635
  switch (action) {
1159
1636
  case "created":
@@ -1164,14 +1641,42 @@ function pastTense(action) {
1164
1641
  return "Appended oteam block to";
1165
1642
  }
1166
1643
  }
1644
+ function workspaceLine(ws) {
1645
+ if (ws.outcome === "already-initialised") {
1646
+ return `\u2139\uFE0F Workspace already initialised at ${ws.path} (registered as "${ws.registeredAs}")`;
1647
+ }
1648
+ const trail = ws.promotedToDefault ? "set as default" : ws.currentDefault && ws.currentDefault !== ws.registeredAs ? `current default is "${ws.currentDefault}" \u2014 pass \`oteam config vault default --set ${ws.registeredAs}\` to switch` : "registered";
1649
+ return `\u2705 Created workspace at ${ws.path} (registered as "${ws.registeredAs}"; ${trail})`;
1650
+ }
1167
1651
  function buildInitCommand() {
1168
1652
  return new Command2("init").description(
1169
- "Write oteam guidance to AGENTS.md (full) and CLAUDE.md (pointer) so agents discover oteam at session start"
1653
+ "Bootstrap an oteam workspace and write guidance to AGENTS.md / CLAUDE.md"
1170
1654
  ).option(
1171
1655
  "-d, --dir <path>",
1172
- "Target directory for AGENTS.md and CLAUDE.md (defaults to $HOME)"
1173
- ).option("-y, --yes", "Skip prompt, use defaults").action(async (opts) => {
1174
- const result = await runInit(opts);
1656
+ "Workspace location (default: ~/openteam)"
1657
+ ).option(
1658
+ "-w, --workspace <path>",
1659
+ "Workspace location (alias of --dir)"
1660
+ ).option(
1661
+ "--docs-dir <path>",
1662
+ "Where to write AGENTS.md / CLAUDE.md (default: $HOME)"
1663
+ ).option("-y, --yes", "Skip prompts, use defaults").option(
1664
+ "--skip-stamp",
1665
+ "Skip the stamp host/enforce prompts (leaves any existing config alone)"
1666
+ ).action(async (opts) => {
1667
+ let result;
1668
+ try {
1669
+ result = await runInit(opts);
1670
+ } catch (err) {
1671
+ if (err instanceof WorkspaceConflictError) {
1672
+ process.stderr.write(`${err.message}
1673
+ `);
1674
+ process.exit(1);
1675
+ }
1676
+ throw err;
1677
+ }
1678
+ process.stdout.write(`${workspaceLine(result.workspace)}
1679
+ `);
1175
1680
  process.stdout.write(
1176
1681
  `\u2705 ${pastTense(result.agents.result)} ${result.agents.path}
1177
1682
  `
@@ -1180,31 +1685,36 @@ function buildInitCommand() {
1180
1685
  `\u2705 ${pastTense(result.claude.result)} ${result.claude.path}
1181
1686
  `
1182
1687
  );
1688
+ process.stdout.write(`${modelsLine(result.models)}
1689
+ `);
1690
+ const stampMsg = stampLine(result.stamp);
1691
+ if (stampMsg) process.stdout.write(`${stampMsg}
1692
+ `);
1183
1693
  });
1184
1694
  }
1185
1695
 
1186
1696
  // src/commands/project.ts
1187
1697
  import { Command as Command3 } from "commander";
1188
1698
  import { spawnSync } from "child_process";
1189
- import { existsSync as existsSync5, mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
1699
+ import { existsSync as existsSync6, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
1190
1700
  import { basename as basename3 } from "path";
1191
1701
 
1192
1702
  // src/lib/projects.ts
1193
- import { existsSync as existsSync4, readFileSync as readFileSync5, readdirSync as readdirSync3, statSync as statSync3 } from "fs";
1194
- import { join as join7 } from "path";
1703
+ import { existsSync as existsSync5, readFileSync as readFileSync5, readdirSync as readdirSync4, statSync as statSync3 } from "fs";
1704
+ import { join as join8 } from "path";
1195
1705
  function projectsRoot(vaultPath) {
1196
- return join7(vaultPath, "projects");
1706
+ return join8(vaultPath, "projects");
1197
1707
  }
1198
1708
  function projectDir(vaultPath, id) {
1199
- return join7(projectsRoot(vaultPath), id);
1709
+ return join8(projectsRoot(vaultPath), id);
1200
1710
  }
1201
1711
  function projectReadmePath(vaultPath, id) {
1202
- return join7(projectDir(vaultPath, id), "README.md");
1712
+ return join8(projectDir(vaultPath, id), "README.md");
1203
1713
  }
1204
1714
  function readProject(vaultPath, id) {
1205
1715
  const dir = projectDir(vaultPath, id);
1206
1716
  const readme = projectReadmePath(vaultPath, id);
1207
- if (!existsSync4(readme)) return null;
1717
+ if (!existsSync5(readme)) return null;
1208
1718
  let raw;
1209
1719
  try {
1210
1720
  raw = readFileSync5(readme, "utf8");
@@ -1230,14 +1740,14 @@ function listProjects(vaultPath) {
1230
1740
  const root = projectsRoot(vaultPath);
1231
1741
  let entries = [];
1232
1742
  try {
1233
- entries = readdirSync3(root);
1743
+ entries = readdirSync4(root);
1234
1744
  } catch {
1235
1745
  return [];
1236
1746
  }
1237
1747
  const projects = [];
1238
1748
  for (const name of entries) {
1239
1749
  if (name.startsWith(".")) continue;
1240
- const dir = join7(root, name);
1750
+ const dir = join8(root, name);
1241
1751
  let isDir = false;
1242
1752
  try {
1243
1753
  isDir = statSync3(dir).isDirectory();
@@ -1294,7 +1804,7 @@ function bodyAfterFrontmatter(raw) {
1294
1804
  function listSiblings(dir) {
1295
1805
  let entries = [];
1296
1806
  try {
1297
- entries = readdirSync3(dir);
1807
+ entries = readdirSync4(dir);
1298
1808
  } catch {
1299
1809
  return [];
1300
1810
  }
@@ -1302,7 +1812,7 @@ function listSiblings(dir) {
1302
1812
  for (const name of entries) {
1303
1813
  if (name === "README.md") continue;
1304
1814
  if (name.startsWith(".")) continue;
1305
- const full = join7(dir, name);
1815
+ const full = join8(dir, name);
1306
1816
  let isFile = false;
1307
1817
  try {
1308
1818
  isFile = statSync3(full).isFile();
@@ -1356,15 +1866,15 @@ function runInit2(id, opts) {
1356
1866
  const vaultPath = resolveVaultPath({ flagValue: opts.vault });
1357
1867
  const dir = projectDir(vaultPath, id);
1358
1868
  const readme = projectReadmePath(vaultPath, id);
1359
- if (existsSync5(readme)) {
1869
+ if (existsSync6(readme)) {
1360
1870
  process.stderr.write(
1361
1871
  `oteam project init: ${readme} already exists \u2014 refusing to overwrite
1362
1872
  `
1363
1873
  );
1364
1874
  process.exit(1);
1365
1875
  }
1366
- mkdirSync4(dir, { recursive: true });
1367
- writeFileSync4(readme, projectFrontmatterTemplate(id), "utf8");
1876
+ mkdirSync5(dir, { recursive: true });
1877
+ writeFileSync5(readme, projectFrontmatterTemplate(id), "utf8");
1368
1878
  process.stdout.write(`\u2705 Created project ${id}
1369
1879
  ${readme}
1370
1880
  `);
@@ -1498,18 +2008,345 @@ function openInEditor(path) {
1498
2008
  }
1499
2009
  }
1500
2010
 
1501
- // src/commands/ticket.ts
2011
+ // src/commands/telemetry.ts
1502
2012
  import { Command as Command4 } from "commander";
1503
- import { existsSync as existsSync6, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
1504
- import { join as join8 } from "path";
2013
+
2014
+ // src/lib/telemetry.ts
2015
+ import {
2016
+ appendFileSync,
2017
+ existsSync as existsSync7,
2018
+ mkdirSync as mkdirSync6,
2019
+ readFileSync as readFileSync7
2020
+ } from "fs";
2021
+ import { homedir as homedir4 } from "os";
2022
+ import { join as join10 } from "path";
2023
+
2024
+ // src/lib/claude-session.ts
2025
+ import { readFileSync as readFileSync6 } from "fs";
2026
+ import { join as join9 } from "path";
2027
+ function encodeProjectDir(cwd) {
2028
+ return cwd.replace(/\//g, "-");
2029
+ }
2030
+ function findSessionFile(claudeConfigDir, cwd, sessionId) {
2031
+ return join9(
2032
+ claudeConfigDir,
2033
+ "projects",
2034
+ encodeProjectDir(cwd),
2035
+ `${sessionId}.jsonl`
2036
+ );
2037
+ }
2038
+ function parseSessionFile(path) {
2039
+ let raw;
2040
+ try {
2041
+ raw = readFileSync6(path, "utf8");
2042
+ } catch {
2043
+ return { tokens: {}, outcome: null };
2044
+ }
2045
+ return parseSessionJsonl(raw);
2046
+ }
2047
+ function parseSessionJsonl(raw) {
2048
+ const tokens = {};
2049
+ let lastAssistantText = "";
2050
+ for (const line of raw.split("\n")) {
2051
+ if (line.length === 0) continue;
2052
+ let entry;
2053
+ try {
2054
+ entry = JSON.parse(line);
2055
+ } catch {
2056
+ continue;
2057
+ }
2058
+ if (!entry || typeof entry !== "object") continue;
2059
+ const e = entry;
2060
+ if (e.type !== "assistant") continue;
2061
+ const message = e.message;
2062
+ if (!message || typeof message !== "object") continue;
2063
+ const m = message;
2064
+ if (m.usage && typeof m.usage === "object") {
2065
+ const u = m.usage;
2066
+ addIfFinite(tokens, "input", u.input_tokens);
2067
+ addIfFinite(tokens, "output", u.output_tokens);
2068
+ addIfFinite(tokens, "cache-write", u.cache_creation_input_tokens);
2069
+ addIfFinite(tokens, "cache-read", u.cache_read_input_tokens);
2070
+ }
2071
+ const text = extractAssistantText(m.content);
2072
+ if (text.length > 0) lastAssistantText = text;
2073
+ }
2074
+ return { tokens, outcome: detectOutcome(lastAssistantText) };
2075
+ }
2076
+ function extractAssistantText(content) {
2077
+ if (typeof content === "string") return content;
2078
+ if (!Array.isArray(content)) return "";
2079
+ let out = "";
2080
+ for (const part of content) {
2081
+ if (!part || typeof part !== "object") continue;
2082
+ const p = part;
2083
+ if (p.type === "text" && typeof p.text === "string") {
2084
+ out += (out.length > 0 ? "\n" : "") + p.text;
2085
+ }
2086
+ }
2087
+ return out;
2088
+ }
2089
+ function detectOutcome(text) {
2090
+ if (text.includes("\u2705 DONE")) return "done";
2091
+ if (text.includes("\u23F8\uFE0F PAUSED")) return "paused";
2092
+ if (text.includes("\u{1F6D1} BLOCKED")) return "failed";
2093
+ return null;
2094
+ }
2095
+ function addIfFinite(target, key, value) {
2096
+ if (typeof value !== "number" || !Number.isFinite(value)) return;
2097
+ target[key] = (target[key] ?? 0) + value;
2098
+ }
2099
+
2100
+ // src/lib/telemetry.ts
2101
+ function telemetryDir() {
2102
+ const override = process.env.OTEAM_TELEMETRY_DIR;
2103
+ if (override && override.length > 0) return override;
2104
+ return join10(homedir4(), ".open-team", "telemetry");
2105
+ }
2106
+ function runsPath(dir = telemetryDir()) {
2107
+ return join10(dir, "runs.jsonl");
2108
+ }
2109
+ function recordPhase(input) {
2110
+ try {
2111
+ if (!getTelemetryEnabled()) return;
2112
+ const endedAt = input.endedAt ?? (/* @__PURE__ */ new Date()).toISOString();
2113
+ const wallClockMs = computeWallClockMs(input.startedAt, endedAt);
2114
+ const sessionFile = findSessionFile(
2115
+ resolveClaudeConfigDir(),
2116
+ input.cwd,
2117
+ input.sessionId
2118
+ );
2119
+ let tokens = {};
2120
+ let markerOutcome = null;
2121
+ if (existsSync7(sessionFile)) {
2122
+ const parsed = parseSessionFile(sessionFile);
2123
+ tokens = parsed.tokens;
2124
+ markerOutcome = parsed.outcome;
2125
+ }
2126
+ const outcome = input.exitCode !== 0 ? "failed" : markerOutcome ?? "unknown";
2127
+ const line = {
2128
+ ticket: input.ticket,
2129
+ phase: input.phase,
2130
+ model: input.model,
2131
+ "started-at": input.startedAt,
2132
+ "ended-at": endedAt,
2133
+ "wall-clock-ms": wallClockMs,
2134
+ tokens,
2135
+ outcome
2136
+ };
2137
+ const dir = telemetryDir();
2138
+ mkdirSync6(dir, { recursive: true });
2139
+ appendFileSync(runsPath(dir), JSON.stringify(line) + "\n");
2140
+ } catch (err) {
2141
+ const msg = err instanceof Error ? err.message : String(err);
2142
+ process.stderr.write(`oteam: telemetry record failed: ${msg}
2143
+ `);
2144
+ }
2145
+ }
2146
+ function resolveClaudeConfigDir() {
2147
+ const env = process.env.CLAUDE_CONFIG_DIR;
2148
+ if (env && env.length > 0) return env;
2149
+ return join10(homedir4(), ".claude");
2150
+ }
2151
+ function computeWallClockMs(startedAt, endedAt) {
2152
+ const startMs = Date.parse(startedAt);
2153
+ const endMs = Date.parse(endedAt);
2154
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return 0;
2155
+ return Math.max(0, endMs - startMs);
2156
+ }
2157
+ function readRuns(dir = telemetryDir()) {
2158
+ const path = runsPath(dir);
2159
+ if (!existsSync7(path)) return [];
2160
+ const raw = readFileSync7(path, "utf8");
2161
+ const out = [];
2162
+ for (const line of raw.split("\n")) {
2163
+ if (line.length === 0) continue;
2164
+ try {
2165
+ const parsed = JSON.parse(line);
2166
+ if (parsed && typeof parsed === "object") out.push(parsed);
2167
+ } catch {
2168
+ }
2169
+ }
2170
+ return out;
2171
+ }
2172
+ function summarize(runs, filter = {}) {
2173
+ const filtered = applyFilter(runs, filter);
2174
+ const buckets = /* @__PURE__ */ new Map();
2175
+ for (const r of filtered) {
2176
+ const key = `${r.phase} ${r.model}`;
2177
+ const bucket = buckets.get(key) ?? { phase: r.phase, model: r.model, rows: [] };
2178
+ bucket.rows.push(r);
2179
+ buckets.set(key, bucket);
2180
+ }
2181
+ const rows = [];
2182
+ for (const bucket of buckets.values()) {
2183
+ rows.push({
2184
+ phase: bucket.phase,
2185
+ model: bucket.model,
2186
+ count: bucket.rows.length,
2187
+ meanWallClockMs: meanWallClock(bucket.rows),
2188
+ meanTokens: meanTokens(bucket.rows)
2189
+ });
2190
+ }
2191
+ rows.sort(
2192
+ (a, b) => a.phase.localeCompare(b.phase) || a.model.localeCompare(b.model)
2193
+ );
2194
+ return rows;
2195
+ }
2196
+ function applyFilter(runs, filter) {
2197
+ let cutoffMs = null;
2198
+ if (typeof filter.days === "number" && filter.days > 0) {
2199
+ cutoffMs = Date.now() - filter.days * 24 * 60 * 60 * 1e3;
2200
+ }
2201
+ return runs.filter((r) => {
2202
+ if (filter.phase && r.phase !== filter.phase) return false;
2203
+ if (filter.model && r.model !== filter.model) return false;
2204
+ if (cutoffMs !== null) {
2205
+ const t = Date.parse(r["started-at"]);
2206
+ if (!Number.isFinite(t) || t < cutoffMs) return false;
2207
+ }
2208
+ return true;
2209
+ });
2210
+ }
2211
+ function meanWallClock(rows) {
2212
+ if (rows.length === 0) return 0;
2213
+ const sum = rows.reduce((acc, r) => acc + (r["wall-clock-ms"] ?? 0), 0);
2214
+ return Math.round(sum / rows.length);
2215
+ }
2216
+ function meanTokens(rows) {
2217
+ const keys = [
2218
+ "input",
2219
+ "output",
2220
+ "cache-read",
2221
+ "cache-write"
2222
+ ];
2223
+ const out = {};
2224
+ for (const k of keys) {
2225
+ let sum = 0;
2226
+ let n = 0;
2227
+ for (const r of rows) {
2228
+ const v = r.tokens?.[k];
2229
+ if (typeof v === "number" && Number.isFinite(v)) {
2230
+ sum += v;
2231
+ n += 1;
2232
+ }
2233
+ }
2234
+ if (n > 0) out[k] = Math.round(sum / n);
2235
+ }
2236
+ return out;
2237
+ }
2238
+ function tail(n, dir = telemetryDir()) {
2239
+ const all = readRuns(dir);
2240
+ if (n <= 0) return [];
2241
+ return all.slice(-n);
2242
+ }
2243
+
2244
+ // src/commands/telemetry.ts
2245
+ function buildTelemetryCommand() {
2246
+ const telemetry = new Command4("telemetry").description(
2247
+ "Per-phase wall-clock + token telemetry for role-pipeline spawns"
2248
+ );
2249
+ telemetry.command("summary").description(
2250
+ "Aggregate runs.jsonl by phase \xD7 model (count, mean wall-clock, mean tokens)"
2251
+ ).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(
2252
+ (opts) => {
2253
+ const runs = readRuns();
2254
+ if (runs.length === 0) {
2255
+ process.stdout.write(
2256
+ `(no telemetry yet \u2014 ${telemetryDir()}/runs.jsonl is empty or missing)
2257
+ `
2258
+ );
2259
+ return;
2260
+ }
2261
+ const rows = summarize(runs, opts);
2262
+ if (rows.length === 0) {
2263
+ process.stdout.write("(no rows match filters)\n");
2264
+ return;
2265
+ }
2266
+ process.stdout.write(formatSummary(rows) + "\n");
2267
+ }
2268
+ );
2269
+ 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) => {
2270
+ const lines = tail(opts.count);
2271
+ if (lines.length === 0) {
2272
+ process.stdout.write(
2273
+ `(no telemetry yet \u2014 ${telemetryDir()}/runs.jsonl is empty or missing)
2274
+ `
2275
+ );
2276
+ return;
2277
+ }
2278
+ for (const line of lines) {
2279
+ process.stdout.write(JSON.stringify(line) + "\n");
2280
+ }
2281
+ });
2282
+ 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(
2283
+ (opts) => {
2284
+ recordPhase({
2285
+ ticket: opts.ticket,
2286
+ phase: opts.phase,
2287
+ model: opts.model,
2288
+ sessionId: opts.session,
2289
+ startedAt: opts.startedAt,
2290
+ exitCode: opts.exitCode,
2291
+ cwd: opts.cwd ?? process.cwd()
2292
+ });
2293
+ }
2294
+ );
2295
+ return telemetry;
2296
+ }
2297
+ function parsePositiveInt(raw) {
2298
+ const n = Number.parseInt(raw, 10);
2299
+ if (!Number.isFinite(n) || n <= 0) {
2300
+ process.stderr.write(`oteam telemetry: expected a positive integer, got "${raw}"
2301
+ `);
2302
+ process.exit(2);
2303
+ }
2304
+ return n;
2305
+ }
2306
+ function parseSignedInt(raw) {
2307
+ const n = Number.parseInt(raw, 10);
2308
+ if (!Number.isFinite(n)) {
2309
+ process.stderr.write(`oteam telemetry: expected an integer, got "${raw}"
2310
+ `);
2311
+ process.exit(2);
2312
+ }
2313
+ return n;
2314
+ }
2315
+ function formatSummary(rows) {
2316
+ const headers = ["phase", "model", "count", "mean_ms", "mean_in", "mean_out", "mean_cache_r", "mean_cache_w"];
2317
+ const data = rows.map((r) => [
2318
+ r.phase,
2319
+ r.model,
2320
+ String(r.count),
2321
+ String(r.meanWallClockMs),
2322
+ formatTokenField(r.meanTokens, "input"),
2323
+ formatTokenField(r.meanTokens, "output"),
2324
+ formatTokenField(r.meanTokens, "cache-read"),
2325
+ formatTokenField(r.meanTokens, "cache-write")
2326
+ ]);
2327
+ const widths = headers.map(
2328
+ (h, i) => Math.max(h.length, ...data.map((row) => (row[i] ?? "").length))
2329
+ );
2330
+ const fmt = (cells) => cells.map((c, i) => c.padEnd(widths[i] ?? 0)).join(" ").trimEnd();
2331
+ return [fmt(headers), ...data.map(fmt)].join("\n");
2332
+ }
2333
+ function formatTokenField(tokens, key) {
2334
+ const v = tokens[key];
2335
+ return typeof v === "number" ? String(v) : "-";
2336
+ }
2337
+
2338
+ // src/commands/ticket.ts
2339
+ import { Command as Command5 } from "commander";
2340
+ import { existsSync as existsSync8, mkdirSync as mkdirSync7, writeFileSync as writeFileSync6 } from "fs";
2341
+ import { join as join11 } from "path";
1505
2342
  function runTicketNew(opts) {
1506
2343
  const title = opts.title.trim();
1507
2344
  if (title.length === 0) {
1508
2345
  throw new Error("oteam ticket new: <title> must not be empty");
1509
2346
  }
1510
2347
  const vault = resolveVaultPath({ flagValue: opts.vault });
1511
- const triageDir = join8(vault, "tickets", "triage");
1512
- mkdirSync5(triageDir, { recursive: true });
2348
+ const triageDir = join11(vault, "tickets", "triage");
2349
+ mkdirSync7(triageDir, { recursive: true });
1513
2350
  const id = nextTicketID(vault);
1514
2351
  const slug = slugify(title);
1515
2352
  if (slug.length === 0) {
@@ -1517,8 +2354,8 @@ function runTicketNew(opts) {
1517
2354
  `oteam ticket new: title "${title}" produced an empty slug \u2014 use a title with at least one alphanumeric character`
1518
2355
  );
1519
2356
  }
1520
- const target = join8(triageDir, `${id}-${slug}.md`);
1521
- if (existsSync6(target)) {
2357
+ const target = join11(triageDir, `${id}-${slug}.md`);
2358
+ if (existsSync8(target)) {
1522
2359
  throw new Error(
1523
2360
  `oteam ticket new: target already exists at ${target} \u2014 ID scan collision`
1524
2361
  );
@@ -1533,14 +2370,14 @@ function runTicketNew(opts) {
1533
2370
  priority: opts.priority ?? "medium",
1534
2371
  labels: opts.labels ?? []
1535
2372
  });
1536
- writeFileSync5(target, body, "utf8");
2373
+ writeFileSync6(target, body, "utf8");
1537
2374
  return { ticketID: id, path: target };
1538
2375
  }
1539
2376
  function collectLabel(value, prev = []) {
1540
2377
  return [...prev, value];
1541
2378
  }
1542
2379
  function buildTicketCommand() {
1543
- const ticket = new Command4("ticket").description(
2380
+ const ticket = new Command5("ticket").description(
1544
2381
  "Create vault tickets directly (without an external source)"
1545
2382
  );
1546
2383
  ticket.command("new <title>").description(
@@ -1573,13 +2410,14 @@ function buildTicketCommand() {
1573
2410
 
1574
2411
  // src/role-pipeline/runner.ts
1575
2412
  import { spawnSync as spawnSync4 } from "child_process";
1576
- import { writeFileSync as writeFileSync6 } from "fs";
2413
+ import { randomUUID } from "crypto";
2414
+ import { writeFileSync as writeFileSync7 } from "fs";
1577
2415
  import { tmpdir } from "os";
1578
- import { resolve as resolve3, basename as basename5, dirname as dirname2, join as join12 } from "path";
2416
+ import { resolve as resolve4, basename as basename5, dirname as dirname2, join as join14 } from "path";
1579
2417
 
1580
2418
  // src/lib/kitty.ts
1581
2419
  import { spawnSync as spawnSync2 } from "child_process";
1582
- import { existsSync as existsSync7, readdirSync as readdirSync4 } from "fs";
2420
+ import { existsSync as existsSync9, readdirSync as readdirSync5 } from "fs";
1583
2421
  var SOCKET_BASENAME = "kitty-claudini";
1584
2422
  var KNOWN_INSTANCES = ["personal", "work"];
1585
2423
  function isMacOS() {
@@ -1611,7 +2449,7 @@ function findKittySocket(kittyPath, preferring) {
1611
2449
  let pidSuffixed = [];
1612
2450
  try {
1613
2451
  const prefix = `${SOCKET_BASENAME}-`;
1614
- pidSuffixed = readdirSync4("/tmp").filter((n) => n.startsWith(prefix)).map((n) => `/tmp/${n}`).filter((p) => !candidates.includes(p));
2452
+ pidSuffixed = readdirSync5("/tmp").filter((n) => n.startsWith(prefix)).map((n) => `/tmp/${n}`).filter((p) => !candidates.includes(p));
1615
2453
  } catch {
1616
2454
  }
1617
2455
  if (preferring) {
@@ -1622,7 +2460,7 @@ function findKittySocket(kittyPath, preferring) {
1622
2460
  candidates.push(...pidSuffixed);
1623
2461
  }
1624
2462
  for (const path of candidates) {
1625
- if (!existsSync7(path)) continue;
2463
+ if (!existsSync9(path)) continue;
1626
2464
  const socket = `unix:${path}`;
1627
2465
  const r = spawnSync2(kittyPath, ["@", "--to", socket, "ls"], {
1628
2466
  encoding: "utf8"
@@ -1688,48 +2526,12 @@ function shellEscape(s) {
1688
2526
  }
1689
2527
 
1690
2528
  // src/lib/workspace.ts
1691
- import { existsSync as existsSync9, mkdirSync as mkdirSync6, readdirSync as readdirSync5, rmSync } from "fs";
2529
+ import { existsSync as existsSync10, mkdirSync as mkdirSync8, readdirSync as readdirSync6, rmSync } from "fs";
1692
2530
  import { spawnSync as spawnSync3 } from "child_process";
1693
- import { basename as basename4, join as join10 } from "path";
1694
-
1695
- // src/lib/stamp.ts
1696
- import { existsSync as existsSync8, readFileSync as readFileSync6 } from "fs";
1697
- import { homedir as homedir3 } from "os";
1698
- import { join as join9 } from "path";
1699
- function stampServerConfigPath() {
1700
- return join9(homedir3(), ".stamp", "server.yml");
1701
- }
1702
- function readStampServerConfig() {
1703
- const path = stampServerConfigPath();
1704
- if (!existsSync8(path)) return null;
1705
- const raw = readFileSync6(path, "utf8");
1706
- let host;
1707
- let port;
1708
- for (const line of raw.split(/\r?\n/)) {
1709
- const m = /^(host|port):\s*(.+?)\s*$/.exec(line);
1710
- if (!m) continue;
1711
- const value = m[2] ?? "";
1712
- if (m[1] === "host") host = value;
1713
- else if (m[1] === "port") {
1714
- const n = Number.parseInt(value, 10);
1715
- if (Number.isFinite(n) && n > 0) port = n;
1716
- }
1717
- }
1718
- if (!host || !port) {
1719
- throw new Error(
1720
- `${path} is missing required keys (host + port) \u2014 got host=${host ?? "(unset)"} port=${port ?? "(unset)"}`
1721
- );
1722
- }
1723
- return { host, port };
1724
- }
1725
- function buildStampUrl(config, repoBasename) {
1726
- return `ssh://git@${config.host}:${config.port}/srv/git/${repoBasename}.git`;
1727
- }
2531
+ import { basename as basename4, join as join12 } from "path";
1728
2532
  function buildGithubUrl(repoSlug) {
1729
2533
  return `git@github.com:${repoSlug}.git`;
1730
2534
  }
1731
-
1732
- // src/lib/workspace.ts
1733
2535
  var WORKSPACE_ROOT = "/tmp/open-team-issues";
1734
2536
  var TICKET_ID_RE = /^AGT-\d+$/;
1735
2537
  var ORPHAN_DIR_RE = /^agt-\d+$/;
@@ -1747,7 +2549,9 @@ var StampGateError = class extends Error {
1747
2549
  ` Reason: ${args.reason}`,
1748
2550
  ` Fix: provision the repo on the stamp server with`,
1749
2551
  ` stamp provision ${basename4(args.repoSlug)}`,
1750
- ` Or pass --no-stamp to bypass this gate (not recommended; see README).`
2552
+ ` Or turn enforcement off:`,
2553
+ ` oteam config stamp set --enforce off`,
2554
+ ` Or pass --no-stamp to bypass this gate for a single run.`
1751
2555
  );
1752
2556
  super(lines.join("\n"));
1753
2557
  this.name = "StampGateError";
@@ -1762,34 +2566,31 @@ function prepareAgentWorkspace(opts) {
1762
2566
  );
1763
2567
  }
1764
2568
  const root = opts.rootDir ?? WORKSPACE_ROOT;
1765
- mkdirSync6(root, { recursive: true });
2569
+ mkdirSync8(root, { recursive: true });
1766
2570
  if (opts.activeTicketIds) gcOrphanWorkspaces(root, opts.activeTicketIds);
1767
- const ticketDir = join10(root, opts.ticketId.toLowerCase());
1768
- const repoDir = join10(ticketDir, "repo");
2571
+ const ticketDir = join12(root, opts.ticketId.toLowerCase());
2572
+ const repoDir = join12(ticketDir, "repo");
1769
2573
  rmSync(ticketDir, { recursive: true, force: true });
1770
- mkdirSync6(ticketDir, { recursive: true });
2574
+ mkdirSync8(ticketDir, { recursive: true });
1771
2575
  const repoBasename = basename4(opts.repoSlug);
1772
2576
  const cloneRunner = opts.cloneRunner ?? defaultCloneRunner;
1773
- if (opts.noStamp) {
2577
+ if (opts.mode === "github") {
1774
2578
  const url = buildGithubUrl(opts.repoSlug);
1775
2579
  const r2 = cloneRunner(url, repoDir);
1776
2580
  if (r2.status !== 0) {
1777
2581
  throw new Error(
1778
- `oteam assign: --no-stamp fallback clone failed (git clone ${url}):
2582
+ `oteam assign: github clone failed (git clone ${url}):
1779
2583
  ${r2.stderr.trim() || "(no stderr)"}`
1780
2584
  );
1781
2585
  }
1782
2586
  return { path: repoDir, originUrl: url, source: "github" };
1783
2587
  }
1784
- const stampConfig = readStampServerConfig();
1785
- if (!stampConfig) {
1786
- throw new StampGateError({
1787
- repoSlug: opts.repoSlug,
1788
- stampUrl: null,
1789
- reason: `${stampServerConfigPath()} not found \u2014 no stamp server is configured`
1790
- });
2588
+ if (!opts.stampHost || opts.stampHost.trim().length === 0) {
2589
+ throw new Error(
2590
+ "prepareAgentWorkspace: mode='stamp' requires opts.stampHost (run 'oteam config stamp set --host <url>')"
2591
+ );
1791
2592
  }
1792
- const stampUrl = buildStampUrl(stampConfig, repoBasename);
2593
+ const stampUrl = buildStampCloneUrl(opts.stampHost, repoBasename);
1793
2594
  const r = cloneRunner(stampUrl, repoDir);
1794
2595
  if (r.status !== 0) {
1795
2596
  throw new StampGateError({
@@ -1801,6 +2602,9 @@ ${r2.stderr.trim() || "(no stderr)"}`
1801
2602
  }
1802
2603
  return { path: repoDir, originUrl: stampUrl, source: "stamp" };
1803
2604
  }
2605
+ function buildStampCloneUrl(host, repoBasename) {
2606
+ return `${host}/srv/git/${repoBasename}.git`;
2607
+ }
1804
2608
  function stampGateReason(r) {
1805
2609
  const stderr = r.stderr.trim();
1806
2610
  if (!stderr) return `git clone exited ${r.status}`;
@@ -1818,18 +2622,18 @@ var defaultCloneRunner = (url, dest) => {
1818
2622
  };
1819
2623
  };
1820
2624
  function gcOrphanWorkspaces(root, activeTicketIds) {
1821
- if (!existsSync9(root)) return [];
2625
+ if (!existsSync10(root)) return [];
1822
2626
  const removed = [];
1823
2627
  let entries;
1824
2628
  try {
1825
- entries = readdirSync5(root);
2629
+ entries = readdirSync6(root);
1826
2630
  } catch {
1827
2631
  return [];
1828
2632
  }
1829
2633
  for (const name of entries) {
1830
2634
  if (!ORPHAN_DIR_RE.test(name)) continue;
1831
2635
  if (activeTicketIds.has(name)) continue;
1832
- const target = join10(root, name);
2636
+ const target = join12(root, name);
1833
2637
  try {
1834
2638
  rmSync(target, { recursive: true, force: true });
1835
2639
  removed.push(target);
@@ -1840,22 +2644,22 @@ function gcOrphanWorkspaces(root, activeTicketIds) {
1840
2644
  }
1841
2645
 
1842
2646
  // src/role-pipeline/install-slash-command.ts
1843
- import { copyFileSync, existsSync as existsSync10, mkdirSync as mkdirSync7, readdirSync as readdirSync6, readFileSync as readFileSync7, statSync as statSync4 } from "fs";
1844
- import { homedir as homedir4 } from "os";
1845
- import { dirname, join as join11 } from "path";
2647
+ import { copyFileSync, existsSync as existsSync11, mkdirSync as mkdirSync9, readdirSync as readdirSync7, readFileSync as readFileSync8, statSync as statSync4 } from "fs";
2648
+ import { homedir as homedir5 } from "os";
2649
+ import { dirname, join as join13 } from "path";
1846
2650
  import { fileURLToPath } from "url";
1847
2651
  var moduleDir = dirname(fileURLToPath(import.meta.url));
1848
- var BUNDLED_PROMPT = join11(moduleDir, "assign-ticket.md");
2652
+ var BUNDLED_PROMPT = join13(moduleDir, "assign-ticket.md");
1849
2653
  function installRolePipelineSlashCommand() {
1850
- if (!existsSync10(BUNDLED_PROMPT)) return;
1851
- const bundled = readFileSync7(BUNDLED_PROMPT);
2654
+ if (!existsSync11(BUNDLED_PROMPT)) return;
2655
+ const bundled = readFileSync8(BUNDLED_PROMPT);
1852
2656
  const targets = resolveTargetDirs();
1853
2657
  for (const dir of targets) {
1854
2658
  try {
1855
- mkdirSync7(dir, { recursive: true });
1856
- const target = join11(dir, "assign-ticket.md");
1857
- if (existsSync10(target)) {
1858
- const current = readFileSync7(target);
2659
+ mkdirSync9(dir, { recursive: true });
2660
+ const target = join13(dir, "assign-ticket.md");
2661
+ if (existsSync11(target)) {
2662
+ const current = readFileSync8(target);
1859
2663
  if (current.equals(bundled)) continue;
1860
2664
  }
1861
2665
  copyFileSync(BUNDLED_PROMPT, target);
@@ -1864,23 +2668,23 @@ function installRolePipelineSlashCommand() {
1864
2668
  }
1865
2669
  }
1866
2670
  function resolveTargetDirs() {
1867
- const home = homedir4();
2671
+ const home = homedir5();
1868
2672
  const dirs = /* @__PURE__ */ new Set();
1869
- dirs.add(join11(home, ".claude", "commands"));
2673
+ dirs.add(join13(home, ".claude", "commands"));
1870
2674
  const configDir2 = process.env.CLAUDE_CONFIG_DIR;
1871
2675
  if (configDir2 && configDir2.length > 0) {
1872
- dirs.add(join11(configDir2, "commands"));
2676
+ dirs.add(join13(configDir2, "commands"));
1873
2677
  }
1874
2678
  try {
1875
- for (const name of readdirSync6(home)) {
2679
+ for (const name of readdirSync7(home)) {
1876
2680
  if (!name.startsWith(".claude-")) continue;
1877
- const candidate = join11(home, name);
2681
+ const candidate = join13(home, name);
1878
2682
  let isDir = false;
1879
2683
  try {
1880
2684
  isDir = statSync4(candidate).isDirectory();
1881
2685
  } catch {
1882
2686
  }
1883
- if (isDir) dirs.add(join11(candidate, "commands"));
2687
+ if (isDir) dirs.add(join13(candidate, "commands"));
1884
2688
  }
1885
2689
  } catch {
1886
2690
  }
@@ -1888,6 +2692,18 @@ function resolveTargetDirs() {
1888
2692
  }
1889
2693
 
1890
2694
  // src/role-pipeline/runner.ts
2695
+ function resolveWorkspaceMode(config, noStamp) {
2696
+ if (noStamp) return "github";
2697
+ if (config.stamp?.enforce) {
2698
+ if (!config.stamp.host || config.stamp.host.length === 0) {
2699
+ throw new Error(
2700
+ "oteam assign: stamp.enforce is on but stamp.host is empty \u2014 run 'oteam config stamp set --host <url>' or 'oteam config stamp set --enforce off'"
2701
+ );
2702
+ }
2703
+ return "stamp";
2704
+ }
2705
+ return "github";
2706
+ }
1891
2707
  async function assignTicket(opts) {
1892
2708
  const config = readConfig();
1893
2709
  let resolvedVault = resolveVault({ flagValue: opts.vault, config });
@@ -1895,7 +2711,7 @@ async function assignTicket(opts) {
1895
2711
  if (isAgtId(opts.ticketPath)) {
1896
2712
  ticketPath = findTicketFileByID(resolvedVault.path, opts.ticketPath);
1897
2713
  } else {
1898
- ticketPath = resolve3(opts.ticketPath);
2714
+ ticketPath = resolve4(opts.ticketPath);
1899
2715
  if (!opts.vault) {
1900
2716
  const detected = findVaultRootForPath(ticketPath, config);
1901
2717
  if (detected) resolvedVault = detected;
@@ -1916,11 +2732,13 @@ async function assignTicket(opts) {
1916
2732
  }
1917
2733
  let workspace = null;
1918
2734
  if (ticket.repo) {
2735
+ const mode = resolveWorkspaceMode(config, opts.noStamp ?? false);
1919
2736
  try {
1920
2737
  workspace = prepareAgentWorkspace({
1921
2738
  ticketId: ticket.id,
1922
2739
  repoSlug: ticket.repo,
1923
- noStamp: opts.noStamp ?? false,
2740
+ mode,
2741
+ stampHost: mode === "stamp" ? config.stamp?.host : void 0,
1924
2742
  activeTicketIds: collectActiveTicketIds(resolvedVault.path)
1925
2743
  });
1926
2744
  } catch (err) {
@@ -1931,7 +2749,7 @@ async function assignTicket(opts) {
1931
2749
  }
1932
2750
  throw err;
1933
2751
  }
1934
- if (opts.noStamp) {
2752
+ if (opts.noStamp && config.stamp?.enforce) {
1935
2753
  process.stderr.write(
1936
2754
  `oteam assign: --no-stamp set; cloned from ${workspace.originUrl}. The stamp gate is bypassed \u2014 verify any push manually.
1937
2755
  `
@@ -1939,9 +2757,25 @@ async function assignTicket(opts) {
1939
2757
  }
1940
2758
  }
1941
2759
  const projectContext = loadProjectContext(resolvedVault.path, ticket.project);
2760
+ const model = resolveRoleModel(ticket.state, config.models);
2761
+ const phase = phaseForState(ticket.state);
2762
+ const telemetry = phase !== null && getTelemetryEnabled() ? {
2763
+ ticketId: ticket.id,
2764
+ phase,
2765
+ sessionId: randomUUID(),
2766
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
2767
+ } : null;
1942
2768
  const kittyPath = !opts.workInline && isMacOS() ? findKittyBinary() : null;
1943
2769
  if (!kittyPath) {
1944
- runInline(claudePath, ticketPath, resolvedVault.path, projectContext, workspace);
2770
+ runInline(
2771
+ claudePath,
2772
+ ticketPath,
2773
+ resolvedVault.path,
2774
+ projectContext,
2775
+ workspace,
2776
+ model,
2777
+ telemetry
2778
+ );
1945
2779
  return;
1946
2780
  }
1947
2781
  const monitored = opts.monitoredOrgs ?? readMonitoredOrgsFromEnv();
@@ -1952,7 +2786,15 @@ async function assignTicket(opts) {
1952
2786
  `oteam assign: no kitty socket reachable (preferring "${preferring}"); falling back to inline run.
1953
2787
  `
1954
2788
  );
1955
- runInline(claudePath, ticketPath, resolvedVault.path, projectContext, workspace);
2789
+ runInline(
2790
+ claudePath,
2791
+ ticketPath,
2792
+ resolvedVault.path,
2793
+ projectContext,
2794
+ workspace,
2795
+ model,
2796
+ telemetry
2797
+ );
1956
2798
  return;
1957
2799
  }
1958
2800
  const cwd = workspace?.path ?? dirname2(ticketPath);
@@ -1967,7 +2809,17 @@ async function assignTicket(opts) {
1967
2809
  const slashPrompt = `/assign-ticket ${escapedTicket}`;
1968
2810
  const escapedPrompt = shellEscape(slashPrompt);
1969
2811
  const projectFlag = projectContext ? ` --append-system-prompt "$(cat '${shellEscape(projectContext.tmpFile)}')"` : "";
1970
- const shellCmd = `${envPrefix}exec '${escapedClaude}' --dangerously-skip-permissions --model ${ROLE_PIPELINE_MODEL}${projectFlag} '${escapedPrompt}'`;
2812
+ const sessionFlag = telemetry ? ` --session-id '${shellEscape(telemetry.sessionId)}'` : "";
2813
+ const claudeCmd = `'${escapedClaude}' --dangerously-skip-permissions --model ${shellEscape(model)}${sessionFlag}${projectFlag} '${escapedPrompt}'`;
2814
+ const telemetryTail = telemetry ? buildTelemetryTail({
2815
+ oteamPath: findToolOnPath("oteam") ?? "oteam",
2816
+ ticketId: telemetry.ticketId,
2817
+ phase: telemetry.phase,
2818
+ model,
2819
+ sessionId: telemetry.sessionId,
2820
+ startedAt: telemetry.startedAt
2821
+ }) : "";
2822
+ const shellCmd = `${envPrefix}${claudeCmd}${telemetryTail}`;
1971
2823
  const result = kittyLaunch({
1972
2824
  socket,
1973
2825
  title,
@@ -1981,16 +2833,32 @@ async function assignTicket(opts) {
1981
2833
  );
1982
2834
  }
1983
2835
  }
1984
- function runInline(claudePath, ticketPath, vaultPath, projectContext, workspace) {
2836
+ function buildTelemetryTail(input) {
2837
+ const oteam = `'${shellEscape(input.oteamPath)}'`;
2838
+ const args = [
2839
+ `--ticket '${shellEscape(input.ticketId)}'`,
2840
+ `--phase '${shellEscape(input.phase)}'`,
2841
+ `--model '${shellEscape(input.model)}'`,
2842
+ `--session '${shellEscape(input.sessionId)}'`,
2843
+ `--started-at '${shellEscape(input.startedAt)}'`,
2844
+ `--exit-code "$EC"`
2845
+ ].join(" ");
2846
+ return `; EC=$?; ${oteam} telemetry record ${args} >/dev/null 2>&1 || true; exit "$EC"`;
2847
+ }
2848
+ function runInline(claudePath, ticketPath, vaultPath, projectContext, workspace, model, telemetry) {
1985
2849
  const args = [
1986
2850
  "--dangerously-skip-permissions",
1987
2851
  "--model",
1988
- ROLE_PIPELINE_MODEL
2852
+ model
1989
2853
  ];
2854
+ if (telemetry) {
2855
+ args.push("--session-id", telemetry.sessionId);
2856
+ }
1990
2857
  if (projectContext) {
1991
2858
  args.push("--append-system-prompt", projectContext.content);
1992
2859
  }
1993
2860
  args.push(`/assign-ticket ${ticketPath}`);
2861
+ const cwd = workspace?.path ?? process.cwd();
1994
2862
  const r = spawnSync4(
1995
2863
  claudePath,
1996
2864
  args,
@@ -2000,6 +2868,17 @@ function runInline(claudePath, ticketPath, vaultPath, projectContext, workspace)
2000
2868
  env: { ...process.env, PRODUCT_VAULT_PATH: vaultPath }
2001
2869
  }
2002
2870
  );
2871
+ if (telemetry) {
2872
+ recordPhase({
2873
+ ticket: telemetry.ticketId,
2874
+ phase: telemetry.phase,
2875
+ model,
2876
+ sessionId: telemetry.sessionId,
2877
+ startedAt: telemetry.startedAt,
2878
+ exitCode: r.status ?? -1,
2879
+ cwd
2880
+ });
2881
+ }
2003
2882
  if (r.status != null && r.status !== 0) process.exit(r.status);
2004
2883
  }
2005
2884
  function collectActiveTicketIds(vaultPath) {
@@ -2024,8 +2903,8 @@ function loadProjectContext(vaultPath, projectId) {
2024
2903
  }
2025
2904
  const content = formatProjectContextForPrompt(project);
2026
2905
  const safeId = projectId.replace(/[^a-zA-Z0-9._-]/g, "_");
2027
- const tmpFile = join12(tmpdir(), `oteam-project-${safeId}.md`);
2028
- writeFileSync6(tmpFile, content, "utf8");
2906
+ const tmpFile = join14(tmpdir(), `oteam-project-${safeId}.md`);
2907
+ writeFileSync7(tmpFile, content, "utf8");
2029
2908
  return { tmpFile, content };
2030
2909
  }
2031
2910
  function findToolOnPath(name) {
@@ -2041,7 +2920,7 @@ function readMonitoredOrgsFromEnv() {
2041
2920
  }
2042
2921
 
2043
2922
  // src/index.ts
2044
- var program = new Command5();
2923
+ var program = new Command6();
2045
2924
  program.name("oteam").description(
2046
2925
  "Source-agnostic vault-driven role pipeline for spawning Claude agents against tickets"
2047
2926
  ).version("0.0.1");
@@ -2074,7 +2953,7 @@ program.command("assign <ticket-or-id>").description(
2074
2953
  "Run the role pipeline in the current terminal instead of spawning kitty"
2075
2954
  ).option("--vault <name-or-path>", "Use a specific registered vault").option(
2076
2955
  "--no-stamp",
2077
- "Skip the stamp-server gate; clone the agent worktree from GitHub instead. Not recommended \u2014 bypasses the safeguard against agents pushing direct to GitHub. Use only when the repo is intentionally not stamp-governed."
2956
+ "Force a github clone for this run, overriding stamp.enforce in oteam config. The durable knob is 'oteam config stamp set --enforce off'."
2078
2957
  ).action(
2079
2958
  async (ticketPath, opts) => {
2080
2959
  await assignTicket({
@@ -2120,6 +2999,7 @@ program.command("archive <ticket-id>").description("Move a done ticket to archiv
2120
2999
  program.addCommand(buildConfigCommand());
2121
3000
  program.addCommand(buildInitCommand());
2122
3001
  program.addCommand(buildProjectCommand());
3002
+ program.addCommand(buildTelemetryCommand());
2123
3003
  program.addCommand(buildTicketCommand());
2124
3004
  program.parseAsync(process.argv).catch((err) => {
2125
3005
  process.stderr.write(`oteam: ${err.message}