@openthink/team 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -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,20 @@ 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
+ const body = JSON.stringify(onDisk, null, 2) + "\n";
452
491
  writeFileSync(configPath(), body);
453
492
  }
493
+ function emptyConfig() {
494
+ return { vaults: {}, default: null, stamp: null, models: {} };
495
+ }
454
496
  function addVault(rawPath, options = {}) {
455
497
  const path = absolutise(rawPath);
456
498
  const config = readConfig();
@@ -533,7 +575,9 @@ function findVaultRootForPath(filePath, config = readConfig()) {
533
575
  return null;
534
576
  }
535
577
  function normalise(parsed) {
536
- if (!parsed || typeof parsed !== "object") return { vaults: {}, default: null };
578
+ if (!parsed || typeof parsed !== "object") {
579
+ return emptyConfig();
580
+ }
537
581
  const obj = parsed;
538
582
  const vaults = {};
539
583
  if (obj.vaults && typeof obj.vaults === "object") {
@@ -542,7 +586,131 @@ function normalise(parsed) {
542
586
  }
543
587
  }
544
588
  const def = typeof obj.default === "string" && obj.default in vaults ? obj.default : null;
545
- return { vaults, default: def };
589
+ return {
590
+ vaults,
591
+ default: def,
592
+ stamp: normaliseStamp(obj.stamp),
593
+ models: normaliseModels(obj.models)
594
+ };
595
+ }
596
+ function normaliseModels(value) {
597
+ if (!value || typeof value !== "object") return {};
598
+ const out = {};
599
+ for (const [phase, modelId] of Object.entries(value)) {
600
+ if (!isPhase(phase)) continue;
601
+ if (typeof modelId !== "string") continue;
602
+ const trimmed = modelId.trim();
603
+ if (trimmed.length === 0) continue;
604
+ out[phase] = trimmed;
605
+ }
606
+ return out;
607
+ }
608
+ function normaliseStamp(value) {
609
+ if (value == null) return null;
610
+ if (typeof value !== "object") return null;
611
+ const s = value;
612
+ if (typeof s.host !== "string") return null;
613
+ const host = s.host.trim();
614
+ if (host.length === 0) return null;
615
+ return { host: stripTrailingSlash(host), enforce: s.enforce === true };
616
+ }
617
+ function stripTrailingSlash(s) {
618
+ return s.replace(/\/+$/, "");
619
+ }
620
+ function getStampConfig() {
621
+ return readConfig().stamp;
622
+ }
623
+ function setStampHost(host) {
624
+ const trimmed = host.trim();
625
+ if (trimmed.length === 0) {
626
+ throw new Error("stamp host cannot be empty \u2014 pass a value like ssh://git@host:port");
627
+ }
628
+ const config = readConfig();
629
+ const next = {
630
+ host: stripTrailingSlash(trimmed),
631
+ enforce: config.stamp?.enforce ?? false
632
+ };
633
+ config.stamp = next;
634
+ writeConfig(config);
635
+ return next;
636
+ }
637
+ function setStampEnforce(enforce) {
638
+ const config = readConfig();
639
+ if (enforce && (!config.stamp || config.stamp.host.length === 0)) {
640
+ throw new Error(
641
+ "cannot set stamp.enforce on with no stamp.host \u2014 run 'oteam config stamp set --host <url>' first"
642
+ );
643
+ }
644
+ const next = {
645
+ host: config.stamp?.host ?? "",
646
+ enforce
647
+ };
648
+ config.stamp = next;
649
+ writeConfig(config);
650
+ return next;
651
+ }
652
+ function setStamp(input) {
653
+ const trimmed = input.host.trim();
654
+ if (trimmed.length === 0) {
655
+ throw new Error("stamp host cannot be empty \u2014 pass a value like ssh://git@host:port");
656
+ }
657
+ const next = {
658
+ host: stripTrailingSlash(trimmed),
659
+ enforce: input.enforce
660
+ };
661
+ const config = readConfig();
662
+ config.stamp = next;
663
+ writeConfig(config);
664
+ return next;
665
+ }
666
+ function clearStamp() {
667
+ const config = readConfig();
668
+ config.stamp = null;
669
+ writeConfig(config);
670
+ }
671
+ function getModels() {
672
+ return readConfig().models;
673
+ }
674
+ function setModel(phase, modelId) {
675
+ const trimmed = modelId.trim();
676
+ if (trimmed.length === 0) {
677
+ throw new Error(
678
+ `model id for phase "${phase}" cannot be empty \u2014 pass a non-empty string`
679
+ );
680
+ }
681
+ if (!isPhase(phase)) {
682
+ throw new Error(
683
+ `unknown phase "${phase}" \u2014 supported: ${PHASES.join(", ")}`
684
+ );
685
+ }
686
+ const config = readConfig();
687
+ config.models = { ...config.models, [phase]: trimmed };
688
+ writeConfig(config);
689
+ return config.models;
690
+ }
691
+ function seedDefaultModelsIfEmpty() {
692
+ const config = readConfig();
693
+ if (Object.keys(config.models).length > 0) {
694
+ return { action: "preserved", models: config.models };
695
+ }
696
+ config.models = { ...DEFAULT_MODELS };
697
+ writeConfig(config);
698
+ return { action: "seeded", models: config.models };
699
+ }
700
+ function clearModel(phase) {
701
+ if (!isPhase(phase)) {
702
+ throw new Error(
703
+ `unknown phase "${phase}" \u2014 supported: ${PHASES.join(", ")}`
704
+ );
705
+ }
706
+ const config = readConfig();
707
+ if (phase in config.models) {
708
+ const next = { ...config.models };
709
+ delete next[phase];
710
+ config.models = next;
711
+ writeConfig(config);
712
+ }
713
+ return config.models;
546
714
  }
547
715
  function findEntry(config, nameOrPath) {
548
716
  if (config.vaults[nameOrPath]) return nameOrPath;
@@ -1033,24 +1201,188 @@ function buildConfigCommand() {
1033
1201
  process.stdout.write(`${def}
1034
1202
  `);
1035
1203
  });
1204
+ const stamp = new Command("stamp").description(
1205
+ "Manage stamp-server integration (host + enforce flag)"
1206
+ );
1207
+ 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) => {
1208
+ if (!opts.host && !opts.enforce) {
1209
+ process.stderr.write(
1210
+ "oteam config stamp set: pass --host <url> and/or --enforce <on|off>\n"
1211
+ );
1212
+ process.exit(2);
1213
+ }
1214
+ if (opts.host) {
1215
+ const next = setStampHost(opts.host);
1216
+ process.stdout.write(
1217
+ `\u2705 stamp.host = ${next.host} (enforce=${next.enforce ? "on" : "off"})
1218
+ `
1219
+ );
1220
+ }
1221
+ if (opts.enforce) {
1222
+ const flag = opts.enforce.toLowerCase();
1223
+ if (flag !== "on" && flag !== "off") {
1224
+ process.stderr.write(
1225
+ `oteam config stamp set: --enforce expects on|off, got "${opts.enforce}"
1226
+ `
1227
+ );
1228
+ process.exit(2);
1229
+ }
1230
+ const next = setStampEnforce(flag === "on");
1231
+ process.stdout.write(
1232
+ `\u2705 stamp.enforce = ${next.enforce ? "on" : "off"} (host=${next.host || "(unset)"})
1233
+ `
1234
+ );
1235
+ }
1236
+ });
1237
+ const stampClear = new Command("clear").description("Remove the stamp block from oteam config").action(() => {
1238
+ clearStamp();
1239
+ process.stdout.write("\u2705 stamp config cleared\n");
1240
+ });
1241
+ const stampShow = new Command("show").description("Print the current stamp config").action(() => {
1242
+ const s = getStampConfig();
1243
+ if (!s) {
1244
+ process.stdout.write("(stamp not configured)\n");
1245
+ return;
1246
+ }
1247
+ process.stdout.write(`host: ${s.host}
1248
+ enforce: ${s.enforce ? "on" : "off"}
1249
+ `);
1250
+ });
1251
+ stamp.addCommand(stampSet);
1252
+ stamp.addCommand(stampClear);
1253
+ stamp.addCommand(stampShow);
1254
+ const models = new Command("models").description(
1255
+ "Per-phase model overrides for the role pipeline (product|spike|implementation|qa)"
1256
+ );
1257
+ models.command("set <phase> <model-id>").description(
1258
+ `Pin a model id for one phase (phase: ${PHASES.join("|")})`
1259
+ ).action((phaseRaw, modelId) => {
1260
+ const phase = expectPhase(phaseRaw);
1261
+ const next = setModel(phase, modelId);
1262
+ process.stdout.write(
1263
+ `\u2705 models.${phase} = ${next[phase]}
1264
+ `
1265
+ );
1266
+ });
1267
+ models.command("clear <phase>").description("Remove the override for one phase (falls back to the role-pipeline default)").action((phaseRaw) => {
1268
+ const phase = expectPhase(phaseRaw);
1269
+ clearModel(phase);
1270
+ process.stdout.write(`\u2705 models.${phase} cleared
1271
+ `);
1272
+ });
1273
+ models.command("show").description("Print the current per-phase model overrides").action(() => {
1274
+ const m = getModels();
1275
+ const lines = PHASES.map((p) => `${p.padEnd(15)} ${m[p] ?? "(unset)"}`);
1276
+ process.stdout.write(lines.join("\n") + "\n");
1277
+ });
1036
1278
  config.addCommand(vault);
1279
+ config.addCommand(stamp);
1280
+ config.addCommand(models);
1037
1281
  return config;
1038
1282
  }
1283
+ function expectPhase(value) {
1284
+ if (!isPhase(value)) {
1285
+ process.stderr.write(
1286
+ `oteam config models: unknown phase "${value}" \u2014 supported: ${PHASES.join(", ")}
1287
+ `
1288
+ );
1289
+ process.exit(2);
1290
+ }
1291
+ return value;
1292
+ }
1039
1293
 
1040
1294
  // src/commands/init.ts
1041
1295
  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";
1296
+ import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
1297
+ import { resolve as resolve3, join as join7 } from "path";
1044
1298
  import readline from "readline";
1299
+
1300
+ // src/lib/workspace-tree.ts
1301
+ import {
1302
+ existsSync as existsSync3,
1303
+ mkdirSync as mkdirSync4,
1304
+ readdirSync as readdirSync3,
1305
+ writeFileSync as writeFileSync3
1306
+ } from "fs";
1307
+ import { homedir as homedir3 } from "os";
1308
+ import { join as join6, resolve as resolve2 } from "path";
1309
+ var SENTINEL_FILENAME = ".oteam-workspace";
1310
+ var WORKSPACE_SUBDIRS = [
1311
+ "tickets/triage",
1312
+ "tickets/refined",
1313
+ "tickets/in-progress",
1314
+ "tickets/qa",
1315
+ "tickets/blocked",
1316
+ "projects",
1317
+ "archive",
1318
+ "00-meta"
1319
+ ];
1320
+ var META_README_BODY = `# 00-meta
1321
+
1322
+ Workspace metadata. Templates, schema notes, and ad-hoc bookkeeping live
1323
+ here. Created by \`oteam init\` \u2014 safe to extend.
1324
+ `;
1325
+ var SENTINEL_BODY = `${JSON.stringify(
1326
+ { version: 1, createdBy: "@openthink/team" },
1327
+ null,
1328
+ 2
1329
+ )}
1330
+ `;
1331
+ function defaultWorkspacePath() {
1332
+ return join6(homedir3(), "openteam");
1333
+ }
1334
+ var WorkspaceConflictError = class extends Error {
1335
+ path;
1336
+ conflictingPaths;
1337
+ constructor(path, conflictingPaths) {
1338
+ const head = `oteam init: refusing to initialise workspace at ${path} \u2014 directory is non-empty and lacks the ${SENTINEL_FILENAME} marker.`;
1339
+ const list = conflictingPaths.slice(0, 10).map((p) => ` - ${p}`).join("\n");
1340
+ const more = conflictingPaths.length > 10 ? `
1341
+ ...and ${conflictingPaths.length - 10} more` : "";
1342
+ super(`${head}
1343
+ Conflicting entries:
1344
+ ${list}${more}`);
1345
+ this.name = "WorkspaceConflictError";
1346
+ this.path = path;
1347
+ this.conflictingPaths = conflictingPaths;
1348
+ }
1349
+ };
1350
+ function expandHome(input) {
1351
+ const home = homedir3();
1352
+ if (input === "~") return home;
1353
+ if (input.startsWith("~/")) return join6(home, input.slice(2));
1354
+ return input;
1355
+ }
1356
+ function bootstrapWorkspace(rawTarget) {
1357
+ const target = resolve2(expandHome(rawTarget));
1358
+ if (existsSync3(join6(target, SENTINEL_FILENAME))) {
1359
+ return { outcome: "already-initialised", path: target };
1360
+ }
1361
+ if (existsSync3(target)) {
1362
+ const visible = readdirSync3(target).filter((n) => !n.startsWith("."));
1363
+ if (visible.length > 0) {
1364
+ throw new WorkspaceConflictError(target, visible);
1365
+ }
1366
+ }
1367
+ mkdirSync4(target, { recursive: true });
1368
+ for (const sub of WORKSPACE_SUBDIRS) {
1369
+ mkdirSync4(join6(target, sub), { recursive: true });
1370
+ }
1371
+ writeFileSync3(join6(target, "00-meta", "README.md"), META_README_BODY);
1372
+ writeFileSync3(join6(target, SENTINEL_FILENAME), SENTINEL_BODY);
1373
+ return { outcome: "created", path: target };
1374
+ }
1375
+
1376
+ // src/commands/init.ts
1045
1377
  var BLOCK_BEGIN = "<!-- oteam:begin (managed by `oteam init` \u2014 do not edit between markers) -->";
1046
1378
  var BLOCK_END = "<!-- oteam:end -->";
1047
- var AGENTS_BODY = `## oteam \u2014 vault-driven role pipeline for Claude agents
1379
+ var AGENTS_BODY = `## oteam \u2014 workspace-driven role pipeline for Claude agents
1048
1380
 
1049
1381
  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\`),
1382
+ tickets**, or mentions a "workspace", an "Obsidian vault", an \`AGT-NNN\` id,
1383
+ a project, "ingesting GitHub issues or PRs", or driving tickets through a
1384
+ "role pipeline" \u2014 \`oteam\` is the right tool. The workspace is a directory
1385
+ of markdown files (typically \`~/openteam/tickets/<state>/AGT-NNN-*.md\`),
1054
1386
  but **do not search it with \`find\` or \`grep\` directly.** The CLI knows the
1055
1387
  ticket schema and has structured + free-text filters; filesystem search
1056
1388
  does not, and you will fight false positives from incidental keyword
@@ -1068,13 +1400,13 @@ Other common verbs: \`oteam ticket new "<title>" [--project X]\` to file a
1068
1400
  ticket, \`oteam pull github owner/repo#NN\` to ingest a GitHub issue or PR,
1069
1401
  \`oteam assign <AGT-NNN>\` to drive a ticket through the role pipeline. Run
1070
1402
  \`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.
1403
+ know whether a workspace is configured, \`oteam config vault list\` tells you.
1072
1404
  `;
1073
1405
  var CLAUDE_BODY = `## oteam
1074
1406
 
1075
1407
  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
1408
+ "workspace", "Obsidian vault", an \`AGT-NNN\` id, or a role pipeline, use the
1409
+ \`oteam\` CLI \u2014 **do not** \`find\`/\`grep\` the workspace directly. Start with
1078
1410
  \`oteam list --grep "<term>"\` or \`oteam list --match "<term>"\`. See
1079
1411
  \`AGENTS.md\` next to this file for the short summary and \`oteam --help\` for
1080
1412
  the full surface.
@@ -1089,8 +1421,8 @@ ${BLOCK_END}
1089
1421
  }
1090
1422
  function upsertBlock(filePath, body) {
1091
1423
  const block = renderBlock(body);
1092
- if (!existsSync3(filePath)) {
1093
- writeFileSync3(filePath, block, "utf8");
1424
+ if (!existsSync4(filePath)) {
1425
+ writeFileSync4(filePath, block, "utf8");
1094
1426
  return "created";
1095
1427
  }
1096
1428
  const existing = readFileSync4(filePath, "utf8");
@@ -1099,17 +1431,17 @@ function upsertBlock(filePath, body) {
1099
1431
  if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
1100
1432
  const before = existing.slice(0, beginIdx);
1101
1433
  const after = existing.slice(endIdx + BLOCK_END.length);
1102
- writeFileSync3(filePath, before + block.trimEnd() + after, "utf8");
1434
+ writeFileSync4(filePath, before + block.trimEnd() + after, "utf8");
1103
1435
  return "updated";
1104
1436
  }
1105
1437
  const separator = existing.endsWith("\n") ? "\n" : "\n\n";
1106
- writeFileSync3(filePath, existing + separator + block, "utf8");
1438
+ writeFileSync4(filePath, existing + separator + block, "utf8");
1107
1439
  return "appended";
1108
1440
  }
1109
- function expandHome(input) {
1441
+ function expandHome2(input) {
1110
1442
  const home = process.env.HOME ?? "";
1111
1443
  if (input === "~") return home;
1112
- if (input.startsWith("~/")) return join6(home, input.slice(2));
1444
+ if (input.startsWith("~/")) return join7(home, input.slice(2));
1113
1445
  return input;
1114
1446
  }
1115
1447
  function prompt(question, fallback) {
@@ -1125,35 +1457,133 @@ function prompt(question, fallback) {
1125
1457
  });
1126
1458
  });
1127
1459
  }
1460
+ function promptRaw(question) {
1461
+ return new Promise((resolvePrompt) => {
1462
+ const rl = readline.createInterface({
1463
+ input: process.stdin,
1464
+ output: process.stdout
1465
+ });
1466
+ rl.question(question, (answer) => {
1467
+ rl.close();
1468
+ resolvePrompt(answer.trim());
1469
+ });
1470
+ });
1471
+ }
1128
1472
  async function runInit(opts) {
1129
1473
  const home = process.env.HOME ?? "";
1130
- const defaultDir = home;
1131
- let targetDir;
1132
- if (opts.dir) {
1133
- targetDir = opts.dir;
1474
+ const defaultWorkspace = defaultWorkspacePath();
1475
+ if (opts.dir && opts.workspace && opts.dir !== opts.workspace) {
1476
+ throw new Error(
1477
+ `oteam init: --dir and --workspace disagree (${opts.dir} vs ${opts.workspace}); pass one`
1478
+ );
1479
+ }
1480
+ const workspaceFlag = opts.workspace ?? opts.dir;
1481
+ let workspaceDir;
1482
+ if (workspaceFlag) {
1483
+ workspaceDir = workspaceFlag;
1134
1484
  } else if (opts.yes) {
1135
- targetDir = defaultDir;
1485
+ workspaceDir = defaultWorkspace;
1136
1486
  } else {
1137
- targetDir = await prompt(
1138
- `Where should AGENTS.md / CLAUDE.md be written? (${defaultDir}) `,
1139
- defaultDir
1487
+ workspaceDir = await prompt(
1488
+ `Where should the oteam workspace live? (${defaultWorkspace}) `,
1489
+ defaultWorkspace
1140
1490
  );
1141
1491
  }
1142
- targetDir = resolve2(expandHome(targetDir));
1143
- if (!existsSync3(targetDir)) {
1144
- process.stderr.write(`oteam init: directory does not exist: ${targetDir}
1492
+ workspaceDir = resolve3(expandHome2(workspaceDir));
1493
+ const bootstrap = bootstrapWorkspace(workspaceDir);
1494
+ const registration = addVault(bootstrap.path);
1495
+ const currentDefault = listVaults().default;
1496
+ const models = seedDefaultModelsIfEmpty();
1497
+ const stamp = await runStampStep(opts);
1498
+ const docsDir = resolve3(expandHome2(opts.docsDir ?? home));
1499
+ if (!existsSync4(docsDir)) {
1500
+ process.stderr.write(`oteam init: docs directory does not exist: ${docsDir}
1145
1501
  `);
1146
1502
  process.exit(1);
1147
1503
  }
1148
- const agentsPath = join6(targetDir, "AGENTS.md");
1149
- const claudePath = join6(targetDir, "CLAUDE.md");
1504
+ const agentsPath = join7(docsDir, "AGENTS.md");
1505
+ const claudePath = join7(docsDir, "CLAUDE.md");
1150
1506
  const agents = upsertBlock(agentsPath, AGENTS_BODY);
1151
1507
  const claude = upsertBlock(claudePath, CLAUDE_BODY);
1152
1508
  return {
1509
+ workspace: {
1510
+ path: bootstrap.path,
1511
+ outcome: bootstrap.outcome,
1512
+ registeredAs: registration.name,
1513
+ promotedToDefault: registration.promotedToDefault,
1514
+ currentDefault
1515
+ },
1153
1516
  agents: { path: agentsPath, result: agents },
1154
- claude: { path: claudePath, result: claude }
1517
+ claude: { path: claudePath, result: claude },
1518
+ stamp,
1519
+ models
1155
1520
  };
1156
1521
  }
1522
+ async function runStampStep(opts) {
1523
+ if (opts.skipStamp || opts.yes) {
1524
+ return { action: "skipped" };
1525
+ }
1526
+ const existing = getStampConfig();
1527
+ let hostInput;
1528
+ if (opts.stampHost !== void 0) {
1529
+ hostInput = opts.stampHost.trim();
1530
+ } else {
1531
+ const hint = existing ? ` (current: ${existing.host}; press enter to keep)` : " (leave blank to skip)";
1532
+ hostInput = await promptRaw(
1533
+ `Configure a stamp server for signed-merge integration?
1534
+ Paste host (e.g. ssh://git@host:port)${hint}: `
1535
+ );
1536
+ }
1537
+ let nextHost;
1538
+ if (hostInput.length === 0) {
1539
+ nextHost = existing?.host ?? null;
1540
+ } else {
1541
+ nextHost = hostInput;
1542
+ }
1543
+ if (nextHost === null) {
1544
+ return { action: "unchanged", stamp: null };
1545
+ }
1546
+ let enforce;
1547
+ if (opts.stampEnforce !== void 0) {
1548
+ enforce = opts.stampEnforce;
1549
+ } else {
1550
+ const enforceHint = existing ? ` [${existing.enforce ? "Y/n" : "y/N"}]` : " [y/N]";
1551
+ const enforceAnswer = await promptRaw(
1552
+ `Refuse to operate on repos not registered on this stamp server?${enforceHint}: `
1553
+ );
1554
+ if (enforceAnswer.length === 0) {
1555
+ enforce = existing?.enforce ?? false;
1556
+ } else {
1557
+ enforce = /^(y|yes)$/i.test(enforceAnswer);
1558
+ }
1559
+ }
1560
+ if (existing && existing.host === nextHost && existing.enforce === enforce) {
1561
+ return { action: "unchanged", stamp: existing };
1562
+ }
1563
+ const result = setStamp({ host: nextHost, enforce });
1564
+ return { action: "set", stamp: result };
1565
+ }
1566
+ function modelsLine(result) {
1567
+ if (result.action === "preserved") {
1568
+ return "\u2139\uFE0F Models: existing customisation preserved";
1569
+ }
1570
+ const pairs = PHASES.map((phase) => `${phase}=${result.models[phase]}`).join(
1571
+ ", "
1572
+ );
1573
+ return `\u2705 Models: defaults seeded (${pairs})`;
1574
+ }
1575
+ function stampLine(outcome) {
1576
+ switch (outcome.action) {
1577
+ case "skipped":
1578
+ return null;
1579
+ case "unchanged":
1580
+ if (!outcome.stamp) return null;
1581
+ return `\u2139\uFE0F Stamp integration: host=${outcome.stamp.host} enforce=${outcome.stamp.enforce ? "on" : "off"} (no changes)`;
1582
+ case "set":
1583
+ if (!outcome.stamp) return "\u2705 Stamp integration: cleared";
1584
+ return `\u2705 Stamp integration: host=${outcome.stamp.host} enforce=${outcome.stamp.enforce ? "on" : "off"}`;
1585
+ }
1586
+ }
1157
1587
  function pastTense(action) {
1158
1588
  switch (action) {
1159
1589
  case "created":
@@ -1164,14 +1594,42 @@ function pastTense(action) {
1164
1594
  return "Appended oteam block to";
1165
1595
  }
1166
1596
  }
1597
+ function workspaceLine(ws) {
1598
+ if (ws.outcome === "already-initialised") {
1599
+ return `\u2139\uFE0F Workspace already initialised at ${ws.path} (registered as "${ws.registeredAs}")`;
1600
+ }
1601
+ 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";
1602
+ return `\u2705 Created workspace at ${ws.path} (registered as "${ws.registeredAs}"; ${trail})`;
1603
+ }
1167
1604
  function buildInitCommand() {
1168
1605
  return new Command2("init").description(
1169
- "Write oteam guidance to AGENTS.md (full) and CLAUDE.md (pointer) so agents discover oteam at session start"
1606
+ "Bootstrap an oteam workspace and write guidance to AGENTS.md / CLAUDE.md"
1170
1607
  ).option(
1171
1608
  "-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);
1609
+ "Workspace location (default: ~/openteam)"
1610
+ ).option(
1611
+ "-w, --workspace <path>",
1612
+ "Workspace location (alias of --dir)"
1613
+ ).option(
1614
+ "--docs-dir <path>",
1615
+ "Where to write AGENTS.md / CLAUDE.md (default: $HOME)"
1616
+ ).option("-y, --yes", "Skip prompts, use defaults").option(
1617
+ "--skip-stamp",
1618
+ "Skip the stamp host/enforce prompts (leaves any existing config alone)"
1619
+ ).action(async (opts) => {
1620
+ let result;
1621
+ try {
1622
+ result = await runInit(opts);
1623
+ } catch (err) {
1624
+ if (err instanceof WorkspaceConflictError) {
1625
+ process.stderr.write(`${err.message}
1626
+ `);
1627
+ process.exit(1);
1628
+ }
1629
+ throw err;
1630
+ }
1631
+ process.stdout.write(`${workspaceLine(result.workspace)}
1632
+ `);
1175
1633
  process.stdout.write(
1176
1634
  `\u2705 ${pastTense(result.agents.result)} ${result.agents.path}
1177
1635
  `
@@ -1180,31 +1638,36 @@ function buildInitCommand() {
1180
1638
  `\u2705 ${pastTense(result.claude.result)} ${result.claude.path}
1181
1639
  `
1182
1640
  );
1641
+ process.stdout.write(`${modelsLine(result.models)}
1642
+ `);
1643
+ const stampMsg = stampLine(result.stamp);
1644
+ if (stampMsg) process.stdout.write(`${stampMsg}
1645
+ `);
1183
1646
  });
1184
1647
  }
1185
1648
 
1186
1649
  // src/commands/project.ts
1187
1650
  import { Command as Command3 } from "commander";
1188
1651
  import { spawnSync } from "child_process";
1189
- import { existsSync as existsSync5, mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
1652
+ import { existsSync as existsSync6, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
1190
1653
  import { basename as basename3 } from "path";
1191
1654
 
1192
1655
  // 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";
1656
+ import { existsSync as existsSync5, readFileSync as readFileSync5, readdirSync as readdirSync4, statSync as statSync3 } from "fs";
1657
+ import { join as join8 } from "path";
1195
1658
  function projectsRoot(vaultPath) {
1196
- return join7(vaultPath, "projects");
1659
+ return join8(vaultPath, "projects");
1197
1660
  }
1198
1661
  function projectDir(vaultPath, id) {
1199
- return join7(projectsRoot(vaultPath), id);
1662
+ return join8(projectsRoot(vaultPath), id);
1200
1663
  }
1201
1664
  function projectReadmePath(vaultPath, id) {
1202
- return join7(projectDir(vaultPath, id), "README.md");
1665
+ return join8(projectDir(vaultPath, id), "README.md");
1203
1666
  }
1204
1667
  function readProject(vaultPath, id) {
1205
1668
  const dir = projectDir(vaultPath, id);
1206
1669
  const readme = projectReadmePath(vaultPath, id);
1207
- if (!existsSync4(readme)) return null;
1670
+ if (!existsSync5(readme)) return null;
1208
1671
  let raw;
1209
1672
  try {
1210
1673
  raw = readFileSync5(readme, "utf8");
@@ -1230,14 +1693,14 @@ function listProjects(vaultPath) {
1230
1693
  const root = projectsRoot(vaultPath);
1231
1694
  let entries = [];
1232
1695
  try {
1233
- entries = readdirSync3(root);
1696
+ entries = readdirSync4(root);
1234
1697
  } catch {
1235
1698
  return [];
1236
1699
  }
1237
1700
  const projects = [];
1238
1701
  for (const name of entries) {
1239
1702
  if (name.startsWith(".")) continue;
1240
- const dir = join7(root, name);
1703
+ const dir = join8(root, name);
1241
1704
  let isDir = false;
1242
1705
  try {
1243
1706
  isDir = statSync3(dir).isDirectory();
@@ -1294,7 +1757,7 @@ function bodyAfterFrontmatter(raw) {
1294
1757
  function listSiblings(dir) {
1295
1758
  let entries = [];
1296
1759
  try {
1297
- entries = readdirSync3(dir);
1760
+ entries = readdirSync4(dir);
1298
1761
  } catch {
1299
1762
  return [];
1300
1763
  }
@@ -1302,7 +1765,7 @@ function listSiblings(dir) {
1302
1765
  for (const name of entries) {
1303
1766
  if (name === "README.md") continue;
1304
1767
  if (name.startsWith(".")) continue;
1305
- const full = join7(dir, name);
1768
+ const full = join8(dir, name);
1306
1769
  let isFile = false;
1307
1770
  try {
1308
1771
  isFile = statSync3(full).isFile();
@@ -1356,15 +1819,15 @@ function runInit2(id, opts) {
1356
1819
  const vaultPath = resolveVaultPath({ flagValue: opts.vault });
1357
1820
  const dir = projectDir(vaultPath, id);
1358
1821
  const readme = projectReadmePath(vaultPath, id);
1359
- if (existsSync5(readme)) {
1822
+ if (existsSync6(readme)) {
1360
1823
  process.stderr.write(
1361
1824
  `oteam project init: ${readme} already exists \u2014 refusing to overwrite
1362
1825
  `
1363
1826
  );
1364
1827
  process.exit(1);
1365
1828
  }
1366
- mkdirSync4(dir, { recursive: true });
1367
- writeFileSync4(readme, projectFrontmatterTemplate(id), "utf8");
1829
+ mkdirSync5(dir, { recursive: true });
1830
+ writeFileSync5(readme, projectFrontmatterTemplate(id), "utf8");
1368
1831
  process.stdout.write(`\u2705 Created project ${id}
1369
1832
  ${readme}
1370
1833
  `);
@@ -1500,16 +1963,16 @@ function openInEditor(path) {
1500
1963
 
1501
1964
  // src/commands/ticket.ts
1502
1965
  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";
1966
+ import { existsSync as existsSync7, mkdirSync as mkdirSync6, writeFileSync as writeFileSync6 } from "fs";
1967
+ import { join as join9 } from "path";
1505
1968
  function runTicketNew(opts) {
1506
1969
  const title = opts.title.trim();
1507
1970
  if (title.length === 0) {
1508
1971
  throw new Error("oteam ticket new: <title> must not be empty");
1509
1972
  }
1510
1973
  const vault = resolveVaultPath({ flagValue: opts.vault });
1511
- const triageDir = join8(vault, "tickets", "triage");
1512
- mkdirSync5(triageDir, { recursive: true });
1974
+ const triageDir = join9(vault, "tickets", "triage");
1975
+ mkdirSync6(triageDir, { recursive: true });
1513
1976
  const id = nextTicketID(vault);
1514
1977
  const slug = slugify(title);
1515
1978
  if (slug.length === 0) {
@@ -1517,8 +1980,8 @@ function runTicketNew(opts) {
1517
1980
  `oteam ticket new: title "${title}" produced an empty slug \u2014 use a title with at least one alphanumeric character`
1518
1981
  );
1519
1982
  }
1520
- const target = join8(triageDir, `${id}-${slug}.md`);
1521
- if (existsSync6(target)) {
1983
+ const target = join9(triageDir, `${id}-${slug}.md`);
1984
+ if (existsSync7(target)) {
1522
1985
  throw new Error(
1523
1986
  `oteam ticket new: target already exists at ${target} \u2014 ID scan collision`
1524
1987
  );
@@ -1533,7 +1996,7 @@ function runTicketNew(opts) {
1533
1996
  priority: opts.priority ?? "medium",
1534
1997
  labels: opts.labels ?? []
1535
1998
  });
1536
- writeFileSync5(target, body, "utf8");
1999
+ writeFileSync6(target, body, "utf8");
1537
2000
  return { ticketID: id, path: target };
1538
2001
  }
1539
2002
  function collectLabel(value, prev = []) {
@@ -1573,13 +2036,13 @@ function buildTicketCommand() {
1573
2036
 
1574
2037
  // src/role-pipeline/runner.ts
1575
2038
  import { spawnSync as spawnSync4 } from "child_process";
1576
- import { writeFileSync as writeFileSync6 } from "fs";
2039
+ import { writeFileSync as writeFileSync7 } from "fs";
1577
2040
  import { tmpdir } from "os";
1578
- import { resolve as resolve3, basename as basename5, dirname as dirname2, join as join12 } from "path";
2041
+ import { resolve as resolve4, basename as basename5, dirname as dirname2, join as join12 } from "path";
1579
2042
 
1580
2043
  // src/lib/kitty.ts
1581
2044
  import { spawnSync as spawnSync2 } from "child_process";
1582
- import { existsSync as existsSync7, readdirSync as readdirSync4 } from "fs";
2045
+ import { existsSync as existsSync8, readdirSync as readdirSync5 } from "fs";
1583
2046
  var SOCKET_BASENAME = "kitty-claudini";
1584
2047
  var KNOWN_INSTANCES = ["personal", "work"];
1585
2048
  function isMacOS() {
@@ -1611,7 +2074,7 @@ function findKittySocket(kittyPath, preferring) {
1611
2074
  let pidSuffixed = [];
1612
2075
  try {
1613
2076
  const prefix = `${SOCKET_BASENAME}-`;
1614
- pidSuffixed = readdirSync4("/tmp").filter((n) => n.startsWith(prefix)).map((n) => `/tmp/${n}`).filter((p) => !candidates.includes(p));
2077
+ pidSuffixed = readdirSync5("/tmp").filter((n) => n.startsWith(prefix)).map((n) => `/tmp/${n}`).filter((p) => !candidates.includes(p));
1615
2078
  } catch {
1616
2079
  }
1617
2080
  if (preferring) {
@@ -1622,7 +2085,7 @@ function findKittySocket(kittyPath, preferring) {
1622
2085
  candidates.push(...pidSuffixed);
1623
2086
  }
1624
2087
  for (const path of candidates) {
1625
- if (!existsSync7(path)) continue;
2088
+ if (!existsSync8(path)) continue;
1626
2089
  const socket = `unix:${path}`;
1627
2090
  const r = spawnSync2(kittyPath, ["@", "--to", socket, "ls"], {
1628
2091
  encoding: "utf8"
@@ -1688,48 +2151,12 @@ function shellEscape(s) {
1688
2151
  }
1689
2152
 
1690
2153
  // src/lib/workspace.ts
1691
- import { existsSync as existsSync9, mkdirSync as mkdirSync6, readdirSync as readdirSync5, rmSync } from "fs";
2154
+ import { existsSync as existsSync9, mkdirSync as mkdirSync7, readdirSync as readdirSync6, rmSync } from "fs";
1692
2155
  import { spawnSync as spawnSync3 } from "child_process";
1693
2156
  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
- }
1728
2157
  function buildGithubUrl(repoSlug) {
1729
2158
  return `git@github.com:${repoSlug}.git`;
1730
2159
  }
1731
-
1732
- // src/lib/workspace.ts
1733
2160
  var WORKSPACE_ROOT = "/tmp/open-team-issues";
1734
2161
  var TICKET_ID_RE = /^AGT-\d+$/;
1735
2162
  var ORPHAN_DIR_RE = /^agt-\d+$/;
@@ -1747,7 +2174,9 @@ var StampGateError = class extends Error {
1747
2174
  ` Reason: ${args.reason}`,
1748
2175
  ` Fix: provision the repo on the stamp server with`,
1749
2176
  ` stamp provision ${basename4(args.repoSlug)}`,
1750
- ` Or pass --no-stamp to bypass this gate (not recommended; see README).`
2177
+ ` Or turn enforcement off:`,
2178
+ ` oteam config stamp set --enforce off`,
2179
+ ` Or pass --no-stamp to bypass this gate for a single run.`
1751
2180
  );
1752
2181
  super(lines.join("\n"));
1753
2182
  this.name = "StampGateError";
@@ -1762,34 +2191,31 @@ function prepareAgentWorkspace(opts) {
1762
2191
  );
1763
2192
  }
1764
2193
  const root = opts.rootDir ?? WORKSPACE_ROOT;
1765
- mkdirSync6(root, { recursive: true });
2194
+ mkdirSync7(root, { recursive: true });
1766
2195
  if (opts.activeTicketIds) gcOrphanWorkspaces(root, opts.activeTicketIds);
1767
2196
  const ticketDir = join10(root, opts.ticketId.toLowerCase());
1768
2197
  const repoDir = join10(ticketDir, "repo");
1769
2198
  rmSync(ticketDir, { recursive: true, force: true });
1770
- mkdirSync6(ticketDir, { recursive: true });
2199
+ mkdirSync7(ticketDir, { recursive: true });
1771
2200
  const repoBasename = basename4(opts.repoSlug);
1772
2201
  const cloneRunner = opts.cloneRunner ?? defaultCloneRunner;
1773
- if (opts.noStamp) {
2202
+ if (opts.mode === "github") {
1774
2203
  const url = buildGithubUrl(opts.repoSlug);
1775
2204
  const r2 = cloneRunner(url, repoDir);
1776
2205
  if (r2.status !== 0) {
1777
2206
  throw new Error(
1778
- `oteam assign: --no-stamp fallback clone failed (git clone ${url}):
2207
+ `oteam assign: github clone failed (git clone ${url}):
1779
2208
  ${r2.stderr.trim() || "(no stderr)"}`
1780
2209
  );
1781
2210
  }
1782
2211
  return { path: repoDir, originUrl: url, source: "github" };
1783
2212
  }
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
- });
2213
+ if (!opts.stampHost || opts.stampHost.trim().length === 0) {
2214
+ throw new Error(
2215
+ "prepareAgentWorkspace: mode='stamp' requires opts.stampHost (run 'oteam config stamp set --host <url>')"
2216
+ );
1791
2217
  }
1792
- const stampUrl = buildStampUrl(stampConfig, repoBasename);
2218
+ const stampUrl = buildStampCloneUrl(opts.stampHost, repoBasename);
1793
2219
  const r = cloneRunner(stampUrl, repoDir);
1794
2220
  if (r.status !== 0) {
1795
2221
  throw new StampGateError({
@@ -1801,6 +2227,9 @@ ${r2.stderr.trim() || "(no stderr)"}`
1801
2227
  }
1802
2228
  return { path: repoDir, originUrl: stampUrl, source: "stamp" };
1803
2229
  }
2230
+ function buildStampCloneUrl(host, repoBasename) {
2231
+ return `${host}/srv/git/${repoBasename}.git`;
2232
+ }
1804
2233
  function stampGateReason(r) {
1805
2234
  const stderr = r.stderr.trim();
1806
2235
  if (!stderr) return `git clone exited ${r.status}`;
@@ -1822,7 +2251,7 @@ function gcOrphanWorkspaces(root, activeTicketIds) {
1822
2251
  const removed = [];
1823
2252
  let entries;
1824
2253
  try {
1825
- entries = readdirSync5(root);
2254
+ entries = readdirSync6(root);
1826
2255
  } catch {
1827
2256
  return [];
1828
2257
  }
@@ -1840,7 +2269,7 @@ function gcOrphanWorkspaces(root, activeTicketIds) {
1840
2269
  }
1841
2270
 
1842
2271
  // 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";
2272
+ import { copyFileSync, existsSync as existsSync10, mkdirSync as mkdirSync8, readdirSync as readdirSync7, readFileSync as readFileSync6, statSync as statSync4 } from "fs";
1844
2273
  import { homedir as homedir4 } from "os";
1845
2274
  import { dirname, join as join11 } from "path";
1846
2275
  import { fileURLToPath } from "url";
@@ -1848,14 +2277,14 @@ var moduleDir = dirname(fileURLToPath(import.meta.url));
1848
2277
  var BUNDLED_PROMPT = join11(moduleDir, "assign-ticket.md");
1849
2278
  function installRolePipelineSlashCommand() {
1850
2279
  if (!existsSync10(BUNDLED_PROMPT)) return;
1851
- const bundled = readFileSync7(BUNDLED_PROMPT);
2280
+ const bundled = readFileSync6(BUNDLED_PROMPT);
1852
2281
  const targets = resolveTargetDirs();
1853
2282
  for (const dir of targets) {
1854
2283
  try {
1855
- mkdirSync7(dir, { recursive: true });
2284
+ mkdirSync8(dir, { recursive: true });
1856
2285
  const target = join11(dir, "assign-ticket.md");
1857
2286
  if (existsSync10(target)) {
1858
- const current = readFileSync7(target);
2287
+ const current = readFileSync6(target);
1859
2288
  if (current.equals(bundled)) continue;
1860
2289
  }
1861
2290
  copyFileSync(BUNDLED_PROMPT, target);
@@ -1872,7 +2301,7 @@ function resolveTargetDirs() {
1872
2301
  dirs.add(join11(configDir2, "commands"));
1873
2302
  }
1874
2303
  try {
1875
- for (const name of readdirSync6(home)) {
2304
+ for (const name of readdirSync7(home)) {
1876
2305
  if (!name.startsWith(".claude-")) continue;
1877
2306
  const candidate = join11(home, name);
1878
2307
  let isDir = false;
@@ -1888,6 +2317,18 @@ function resolveTargetDirs() {
1888
2317
  }
1889
2318
 
1890
2319
  // src/role-pipeline/runner.ts
2320
+ function resolveWorkspaceMode(config, noStamp) {
2321
+ if (noStamp) return "github";
2322
+ if (config.stamp?.enforce) {
2323
+ if (!config.stamp.host || config.stamp.host.length === 0) {
2324
+ throw new Error(
2325
+ "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'"
2326
+ );
2327
+ }
2328
+ return "stamp";
2329
+ }
2330
+ return "github";
2331
+ }
1891
2332
  async function assignTicket(opts) {
1892
2333
  const config = readConfig();
1893
2334
  let resolvedVault = resolveVault({ flagValue: opts.vault, config });
@@ -1895,7 +2336,7 @@ async function assignTicket(opts) {
1895
2336
  if (isAgtId(opts.ticketPath)) {
1896
2337
  ticketPath = findTicketFileByID(resolvedVault.path, opts.ticketPath);
1897
2338
  } else {
1898
- ticketPath = resolve3(opts.ticketPath);
2339
+ ticketPath = resolve4(opts.ticketPath);
1899
2340
  if (!opts.vault) {
1900
2341
  const detected = findVaultRootForPath(ticketPath, config);
1901
2342
  if (detected) resolvedVault = detected;
@@ -1916,11 +2357,13 @@ async function assignTicket(opts) {
1916
2357
  }
1917
2358
  let workspace = null;
1918
2359
  if (ticket.repo) {
2360
+ const mode = resolveWorkspaceMode(config, opts.noStamp ?? false);
1919
2361
  try {
1920
2362
  workspace = prepareAgentWorkspace({
1921
2363
  ticketId: ticket.id,
1922
2364
  repoSlug: ticket.repo,
1923
- noStamp: opts.noStamp ?? false,
2365
+ mode,
2366
+ stampHost: mode === "stamp" ? config.stamp?.host : void 0,
1924
2367
  activeTicketIds: collectActiveTicketIds(resolvedVault.path)
1925
2368
  });
1926
2369
  } catch (err) {
@@ -1931,7 +2374,7 @@ async function assignTicket(opts) {
1931
2374
  }
1932
2375
  throw err;
1933
2376
  }
1934
- if (opts.noStamp) {
2377
+ if (opts.noStamp && config.stamp?.enforce) {
1935
2378
  process.stderr.write(
1936
2379
  `oteam assign: --no-stamp set; cloned from ${workspace.originUrl}. The stamp gate is bypassed \u2014 verify any push manually.
1937
2380
  `
@@ -1939,9 +2382,10 @@ async function assignTicket(opts) {
1939
2382
  }
1940
2383
  }
1941
2384
  const projectContext = loadProjectContext(resolvedVault.path, ticket.project);
2385
+ const model = resolveRoleModel(ticket.state, config.models);
1942
2386
  const kittyPath = !opts.workInline && isMacOS() ? findKittyBinary() : null;
1943
2387
  if (!kittyPath) {
1944
- runInline(claudePath, ticketPath, resolvedVault.path, projectContext, workspace);
2388
+ runInline(claudePath, ticketPath, resolvedVault.path, projectContext, workspace, model);
1945
2389
  return;
1946
2390
  }
1947
2391
  const monitored = opts.monitoredOrgs ?? readMonitoredOrgsFromEnv();
@@ -1952,7 +2396,7 @@ async function assignTicket(opts) {
1952
2396
  `oteam assign: no kitty socket reachable (preferring "${preferring}"); falling back to inline run.
1953
2397
  `
1954
2398
  );
1955
- runInline(claudePath, ticketPath, resolvedVault.path, projectContext, workspace);
2399
+ runInline(claudePath, ticketPath, resolvedVault.path, projectContext, workspace, model);
1956
2400
  return;
1957
2401
  }
1958
2402
  const cwd = workspace?.path ?? dirname2(ticketPath);
@@ -1967,7 +2411,7 @@ async function assignTicket(opts) {
1967
2411
  const slashPrompt = `/assign-ticket ${escapedTicket}`;
1968
2412
  const escapedPrompt = shellEscape(slashPrompt);
1969
2413
  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}'`;
2414
+ const shellCmd = `${envPrefix}exec '${escapedClaude}' --dangerously-skip-permissions --model ${shellEscape(model)}${projectFlag} '${escapedPrompt}'`;
1971
2415
  const result = kittyLaunch({
1972
2416
  socket,
1973
2417
  title,
@@ -1981,11 +2425,11 @@ async function assignTicket(opts) {
1981
2425
  );
1982
2426
  }
1983
2427
  }
1984
- function runInline(claudePath, ticketPath, vaultPath, projectContext, workspace) {
2428
+ function runInline(claudePath, ticketPath, vaultPath, projectContext, workspace, model) {
1985
2429
  const args = [
1986
2430
  "--dangerously-skip-permissions",
1987
2431
  "--model",
1988
- ROLE_PIPELINE_MODEL
2432
+ model
1989
2433
  ];
1990
2434
  if (projectContext) {
1991
2435
  args.push("--append-system-prompt", projectContext.content);
@@ -2025,7 +2469,7 @@ function loadProjectContext(vaultPath, projectId) {
2025
2469
  const content = formatProjectContextForPrompt(project);
2026
2470
  const safeId = projectId.replace(/[^a-zA-Z0-9._-]/g, "_");
2027
2471
  const tmpFile = join12(tmpdir(), `oteam-project-${safeId}.md`);
2028
- writeFileSync6(tmpFile, content, "utf8");
2472
+ writeFileSync7(tmpFile, content, "utf8");
2029
2473
  return { tmpFile, content };
2030
2474
  }
2031
2475
  function findToolOnPath(name) {
@@ -2074,7 +2518,7 @@ program.command("assign <ticket-or-id>").description(
2074
2518
  "Run the role pipeline in the current terminal instead of spawning kitty"
2075
2519
  ).option("--vault <name-or-path>", "Use a specific registered vault").option(
2076
2520
  "--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."
2521
+ "Force a github clone for this run, overriding stamp.enforce in oteam config. The durable knob is 'oteam config stamp set --enforce off'."
2078
2522
  ).action(
2079
2523
  async (ticketPath, opts) => {
2080
2524
  await assignTicket({