@openthink/team 0.0.11 → 0.0.13

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 Command6 } from "commander";
4
+ import { Command as Command6, Option as Option3 } from "commander";
5
5
 
6
6
  // src/commands/pull.ts
7
7
  import { existsSync as existsSync2, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
@@ -333,7 +333,7 @@ function resolveModelForTicket(args) {
333
333
  }
334
334
 
335
335
  // src/lib/normalise.ts
336
- var SYSTEM_PROMPT = `You normalise unstructured work-item payloads (GitHub issues, Linear tickets, etc.) into well-formed product-vault tickets.
336
+ var SYSTEM_PROMPT = `You normalise unstructured work-item payloads (GitHub issues, Linear tickets, etc.) into well-formed workspace tickets.
337
337
 
338
338
  Output contract \u2014 return EXACTLY this JSON, nothing else:
339
339
 
@@ -364,7 +364,7 @@ ${payload.pr.files.slice(0, 25).map(
364
364
 
365
365
  For a PR, the Problem Statement should describe the proposed change and its rationale (1-2 sentences). The Acceptance Criteria should describe what "we are willing to take this PR through stamp" means \u2014 e.g., CI passes, no unrelated changes, scope matches title.
366
366
  ` : "";
367
- const userMessage = `Normalise this ${kindLabel} item into a vault ticket.
367
+ const userMessage = `Normalise this ${kindLabel} item into a workspace ticket.
368
368
 
369
369
  <source>
370
370
  Title: ${payload.title}
@@ -436,9 +436,9 @@ function nextTicketID(vaultPath) {
436
436
  let highest = 0;
437
437
  for (const sub of ["tickets", "archive"]) {
438
438
  const dir = join(vaultPath, sub);
439
- walk(dir, (basename6) => {
440
- if (!basename6.startsWith("AGT-") || !basename6.endsWith(".md")) return;
441
- const trimmed = basename6.slice("AGT-".length);
439
+ walk(dir, (basename5) => {
440
+ if (!basename5.startsWith("AGT-") || !basename5.endsWith(".md")) return;
441
+ const trimmed = basename5.slice("AGT-".length);
442
442
  const digits = trimmed.match(/^\d+/)?.[0];
443
443
  if (!digits) return;
444
444
  const n = parseInt(digits, 10);
@@ -509,6 +509,7 @@ import {
509
509
  import { homedir } from "os";
510
510
  import { basename, isAbsolute, resolve, join as join2 } from "path";
511
511
  var DEFAULT_PRODUCT_DOWNSHIFT = true;
512
+ var DEFAULT_PUSH = "on";
512
513
  function configDir() {
513
514
  return join2(homedir(), ".open-team");
514
515
  }
@@ -535,6 +536,9 @@ function writeConfig(config) {
535
536
  default: config.default,
536
537
  stamp: config.stamp
537
538
  };
539
+ if (Object.keys(config.repos).length > 0) {
540
+ onDisk.repos = config.repos;
541
+ }
538
542
  const onDiskModels = { ...config.models };
539
543
  if (config.productDownshift !== DEFAULT_PRODUCT_DOWNSHIFT) {
540
544
  onDiskModels.productDownshift = config.productDownshift;
@@ -545,6 +549,12 @@ function writeConfig(config) {
545
549
  if (!config.telemetry.enabled) {
546
550
  onDisk.telemetry = config.telemetry;
547
551
  }
552
+ if (config.botIdentity.length > 0) {
553
+ onDisk.botIdentity = config.botIdentity;
554
+ }
555
+ if (config.push !== DEFAULT_PUSH) {
556
+ onDisk.push = config.push;
557
+ }
548
558
  const body = JSON.stringify(onDisk, null, 2) + "\n";
549
559
  writeFileSync(configPath(), body);
550
560
  }
@@ -553,9 +563,12 @@ function emptyConfig() {
553
563
  vaults: {},
554
564
  default: null,
555
565
  stamp: null,
566
+ repos: {},
556
567
  models: {},
557
568
  productDownshift: DEFAULT_PRODUCT_DOWNSHIFT,
558
- telemetry: { enabled: true }
569
+ telemetry: { enabled: true },
570
+ botIdentity: "",
571
+ push: DEFAULT_PUSH
559
572
  };
560
573
  }
561
574
  function addVault(rawPath, options = {}) {
@@ -589,7 +602,7 @@ function removeVault(nameOrPath) {
589
602
  const config = readConfig();
590
603
  const name = findEntry(config, nameOrPath);
591
604
  if (!name) {
592
- throw new Error(`no vault registered as "${nameOrPath}"`);
605
+ throw new Error(`no workspace registered as "${nameOrPath}"`);
593
606
  }
594
607
  delete config.vaults[name];
595
608
  let cleared = false;
@@ -604,7 +617,7 @@ function setDefault(nameOrPath) {
604
617
  const config = readConfig();
605
618
  const name = findEntry(config, nameOrPath);
606
619
  if (!name) {
607
- throw new Error(`no vault registered as "${nameOrPath}"`);
620
+ throw new Error(`no workspace registered as "${nameOrPath}"`);
608
621
  }
609
622
  config.default = name;
610
623
  writeConfig(config);
@@ -655,11 +668,17 @@ function normalise(parsed) {
655
668
  vaults,
656
669
  default: def,
657
670
  stamp: normaliseStamp(obj.stamp),
671
+ repos: normaliseRepos(obj.repos),
658
672
  models: normaliseModels(obj.models),
659
673
  productDownshift: normaliseProductDownshift(obj.models),
660
- telemetry: normaliseTelemetry(obj.telemetry)
674
+ telemetry: normaliseTelemetry(obj.telemetry),
675
+ botIdentity: typeof obj.botIdentity === "string" ? obj.botIdentity.trim() : "",
676
+ push: normalisePush(obj.push)
661
677
  };
662
678
  }
679
+ function normalisePush(value) {
680
+ return value === "off" ? "off" : DEFAULT_PUSH;
681
+ }
663
682
  function normaliseProductDownshift(value) {
664
683
  if (!value || typeof value !== "object") return DEFAULT_PRODUCT_DOWNSHIFT;
665
684
  const v = value;
@@ -694,6 +713,27 @@ function normaliseStamp(value) {
694
713
  function stripTrailingSlash(s) {
695
714
  return s.replace(/\/+$/, "");
696
715
  }
716
+ var REPO_SLUG_RE = /^[^/]+\/[^/]+$/;
717
+ function validateSlug(slug) {
718
+ if (!REPO_SLUG_RE.test(slug.trim())) {
719
+ throw new Error(
720
+ `invalid repo slug "${slug}" \u2014 expected <owner>/<name> (e.g. OpenThinkAi/open-team)`
721
+ );
722
+ }
723
+ }
724
+ function normaliseRepos(value) {
725
+ if (!value || typeof value !== "object") return {};
726
+ const out = {};
727
+ for (const [slug, entry] of Object.entries(value)) {
728
+ if (!REPO_SLUG_RE.test(slug)) continue;
729
+ if (!entry || typeof entry !== "object") continue;
730
+ const e = entry;
731
+ if (typeof e["clone-uri"] !== "string" || e["clone-uri"].trim().length === 0) continue;
732
+ if (typeof e.added !== "string") continue;
733
+ out[slug] = { "clone-uri": e["clone-uri"].trim(), added: e.added };
734
+ }
735
+ return out;
736
+ }
697
737
  function getStampConfig() {
698
738
  return readConfig().stamp;
699
739
  }
@@ -792,6 +832,82 @@ function setTelemetryEnabled(enabled) {
792
832
  writeConfig(config);
793
833
  return config.telemetry;
794
834
  }
835
+ function getPush() {
836
+ return readConfig().push;
837
+ }
838
+ function setPush(flag) {
839
+ const config = readConfig();
840
+ config.push = flag;
841
+ writeConfig(config);
842
+ return config.push;
843
+ }
844
+ function getBotIdentity() {
845
+ return readConfig().botIdentity;
846
+ }
847
+ function setBotIdentity(login) {
848
+ const trimmed = login.trim();
849
+ if (trimmed.length === 0) {
850
+ throw new Error(
851
+ "bot identity cannot be empty \u2014 pass a GitHub login (use `oteam config bot-identity clear` to remove)"
852
+ );
853
+ }
854
+ const config = readConfig();
855
+ config.botIdentity = trimmed;
856
+ writeConfig(config);
857
+ return trimmed;
858
+ }
859
+ function clearBotIdentity() {
860
+ const config = readConfig();
861
+ config.botIdentity = "";
862
+ writeConfig(config);
863
+ }
864
+ function getRepoEntry(slug, config = readConfig()) {
865
+ const key = findRepoKey(config, slug);
866
+ return key ? config.repos[key] ?? null : null;
867
+ }
868
+ function setRepoCloneUri(slug, cloneUri) {
869
+ validateSlug(slug);
870
+ const trimmedUri = cloneUri.trim();
871
+ if (trimmedUri.length === 0) {
872
+ throw new Error(
873
+ `clone URI for "${slug}" cannot be empty \u2014 pass a valid git URL`
874
+ );
875
+ }
876
+ const config = readConfig();
877
+ const existingKey = findRepoKey(config, slug);
878
+ const key = existingKey ?? slug;
879
+ const existing = existingKey ? config.repos[existingKey] : void 0;
880
+ config.repos[key] = {
881
+ "clone-uri": trimmedUri,
882
+ added: existing?.added ?? (/* @__PURE__ */ new Date()).toISOString()
883
+ };
884
+ writeConfig(config);
885
+ return config.repos[key];
886
+ }
887
+ function listRepoEntries() {
888
+ const config = readConfig();
889
+ return Object.entries(config.repos).map(([slug, entry]) => ({ slug, entry })).sort((a, b) => a.slug.localeCompare(b.slug));
890
+ }
891
+ function removeRepoEntry(slug) {
892
+ const config = readConfig();
893
+ const key = findRepoKey(config, slug);
894
+ if (!key) return false;
895
+ delete config.repos[key];
896
+ writeConfig(config);
897
+ return true;
898
+ }
899
+ function findRepoKey(config, slug) {
900
+ const lower = slug.toLowerCase();
901
+ for (const key of Object.keys(config.repos)) {
902
+ if (key.toLowerCase() === lower) return key;
903
+ }
904
+ return null;
905
+ }
906
+ function resolveBotIdentity(config = readConfig()) {
907
+ const env = process.env.OTEAM_BOT_IDENTITY;
908
+ if (typeof env === "string" && env.trim().length > 0) return env.trim();
909
+ return config.botIdentity;
910
+ }
795
911
  function clearModel(phase) {
796
912
  if (!isPhase(phase)) {
797
913
  throw new Error(
@@ -934,7 +1050,7 @@ function resolveVault(opts = {}) {
934
1050
  const fromFlag = resolveByNameOrPath(opts.flagValue, config);
935
1051
  if (!fromFlag) {
936
1052
  throw new Error(
937
- `--vault: "${opts.flagValue}" is not a registered name and not a path`
1053
+ `--workspace: "${opts.flagValue}" is not a registered workspace name or path`
938
1054
  );
939
1055
  }
940
1056
  return fromFlag;
@@ -979,7 +1095,7 @@ function findTicketFileByID(vaultPath, ticketID) {
979
1095
  });
980
1096
  } catch {
981
1097
  throw new Error(
982
- `vault has no tickets/ directory at ${ticketsRoot}`
1098
+ `workspace has no tickets/ directory at ${ticketsRoot}`
983
1099
  );
984
1100
  }
985
1101
  for (const state of stateDirs) {
@@ -1101,17 +1217,81 @@ function parseTicket(path) {
1101
1217
  };
1102
1218
  }
1103
1219
 
1220
+ // src/lib/prompt-clone-uri.ts
1221
+ import { createInterface } from "readline";
1222
+ async function promptCloneUri(slug, defaultUri, opts, onNoTTY) {
1223
+ if (!opts.isTTY) {
1224
+ if (onNoTTY === "refuse") {
1225
+ throw new NoTTYError(slug);
1226
+ }
1227
+ return { uri: defaultUri, recorded: true };
1228
+ }
1229
+ const rl = opts.readLine ?? defaultReadLine;
1230
+ const raw = await rl(
1231
+ `Clone URI for ${slug}? (default: ${defaultUri})
1232
+ > `
1233
+ );
1234
+ const trimmed = raw.trim();
1235
+ const uri = trimmed.length > 0 ? trimmed : defaultUri;
1236
+ return { uri, recorded: true };
1237
+ }
1238
+ var NoTTYError = class extends Error {
1239
+ slug;
1240
+ constructor(slug) {
1241
+ super(
1242
+ [
1243
+ `oteam assign: no clone URI recorded for "${slug}" and stdin is not a TTY.`,
1244
+ ` Fix: run oteam config repo add ${slug} <git-url>`,
1245
+ ` Or assign interactively (with a TTY) to be prompted once.`
1246
+ ].join("\n")
1247
+ );
1248
+ this.name = "NoTTYError";
1249
+ this.slug = slug;
1250
+ }
1251
+ };
1252
+ async function defaultReadLine(prompt2) {
1253
+ const rl = createInterface({
1254
+ input: process.stdin,
1255
+ output: process.stdout,
1256
+ terminal: true
1257
+ });
1258
+ return new Promise((resolve5) => {
1259
+ rl.question(prompt2, (answer) => {
1260
+ rl.close();
1261
+ resolve5(answer);
1262
+ });
1263
+ });
1264
+ }
1265
+
1104
1266
  // src/commands/pull.ts
1105
1267
  async function runPull(opts) {
1106
1268
  const vault = resolveVaultPath({ flagValue: opts.vault });
1107
1269
  const triageDir = join4(vault, "tickets", "triage");
1108
1270
  if (!existsSync2(triageDir)) {
1109
1271
  throw new Error(
1110
- `vault triage dir missing at ${triageDir} \u2014 create it or set PRODUCT_VAULT_PATH`
1272
+ `workspace triage dir missing at ${triageDir} \u2014 create it or set PRODUCT_VAULT_PATH`
1111
1273
  );
1112
1274
  }
1113
1275
  const ingestor = getIngestor(opts.source);
1114
1276
  const payload = await ingestor.fetch(opts.ref);
1277
+ if (payload.repo) {
1278
+ const config = readConfig();
1279
+ const existing2 = getRepoEntry(payload.repo, config);
1280
+ if (!existing2) {
1281
+ if (opts.cloneUri) {
1282
+ setRepoCloneUri(payload.repo, opts.cloneUri);
1283
+ } else {
1284
+ const defaultUri = `https://github.com/${payload.repo}.git`;
1285
+ const result = await promptCloneUri(
1286
+ payload.repo,
1287
+ defaultUri,
1288
+ { isTTY: process.stdin.isTTY === true },
1289
+ "default"
1290
+ );
1291
+ setRepoCloneUri(payload.repo, result.uri);
1292
+ }
1293
+ }
1294
+ }
1115
1295
  const existing = readAllTickets(vault).find(
1116
1296
  (t) => t.source.id === payload.id
1117
1297
  );
@@ -1217,8 +1397,72 @@ function formatTicket(t) {
1217
1397
  }
1218
1398
 
1219
1399
  // src/commands/archive.ts
1220
- import { mkdirSync as mkdirSync3, renameSync } from "fs";
1221
- import { basename as basename2, join as join5 } from "path";
1400
+ import { mkdirSync as mkdirSync4, renameSync, rmSync as rmSync2 } from "fs";
1401
+ import { basename as basename2, join as join6 } from "path";
1402
+
1403
+ // src/lib/workspace.ts
1404
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readdirSync as readdirSync3, rmSync } from "fs";
1405
+ import { spawnSync } from "child_process";
1406
+ import { join as join5 } from "path";
1407
+ var WORKSPACE_ROOT = "/tmp/open-team-issues";
1408
+ var TICKET_ID_RE = /^AGT-\d+$/;
1409
+ var ORPHAN_DIR_RE = /^agt-\d+$/;
1410
+ function prepareAgentWorkspace(opts) {
1411
+ if (!TICKET_ID_RE.test(opts.ticketId)) {
1412
+ throw new Error(
1413
+ `prepareAgentWorkspace: refusing to operate on non-AGT ticket id "${opts.ticketId}" (expected AGT-NNN)`
1414
+ );
1415
+ }
1416
+ const root = opts.rootDir ?? WORKSPACE_ROOT;
1417
+ mkdirSync3(root, { recursive: true });
1418
+ if (opts.activeTicketIds) gcOrphanWorkspaces(root, opts.activeTicketIds);
1419
+ const ticketDir = join5(root, opts.ticketId.toLowerCase());
1420
+ const repoDir = join5(ticketDir, "repo");
1421
+ rmSync(ticketDir, { recursive: true, force: true });
1422
+ mkdirSync3(ticketDir, { recursive: true });
1423
+ const cloneRunner = opts.cloneRunner ?? defaultCloneRunner;
1424
+ const r = cloneRunner(opts.cloneUri, repoDir);
1425
+ if (r.status !== 0) {
1426
+ throw new Error(
1427
+ `oteam assign: clone failed (git clone ${opts.cloneUri}):
1428
+ ${r.stderr.trim() || "(no stderr)"}`
1429
+ );
1430
+ }
1431
+ return { path: repoDir, originUrl: opts.cloneUri };
1432
+ }
1433
+ var defaultCloneRunner = (url, dest) => {
1434
+ const r = spawnSync("git", ["clone", "--quiet", "--", url, dest], {
1435
+ encoding: "utf8",
1436
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
1437
+ });
1438
+ return {
1439
+ status: r.status ?? -1,
1440
+ stderr: r.stderr ?? ""
1441
+ };
1442
+ };
1443
+ function gcOrphanWorkspaces(root, activeTicketIds) {
1444
+ if (!existsSync3(root)) return [];
1445
+ const removed = [];
1446
+ let entries;
1447
+ try {
1448
+ entries = readdirSync3(root);
1449
+ } catch {
1450
+ return [];
1451
+ }
1452
+ for (const name of entries) {
1453
+ if (!ORPHAN_DIR_RE.test(name)) continue;
1454
+ if (activeTicketIds.has(name)) continue;
1455
+ const target = join5(root, name);
1456
+ try {
1457
+ rmSync(target, { recursive: true, force: true });
1458
+ removed.push(target);
1459
+ } catch {
1460
+ }
1461
+ }
1462
+ return removed;
1463
+ }
1464
+
1465
+ // src/commands/archive.ts
1222
1466
  function runArchive(opts) {
1223
1467
  const vault = resolveVaultPath({ flagValue: opts.vault });
1224
1468
  const tickets = readAllTickets(vault);
@@ -1232,10 +1476,13 @@ function runArchive(opts) {
1232
1476
  );
1233
1477
  }
1234
1478
  const yearMonth = (/* @__PURE__ */ new Date()).toISOString().slice(0, 7);
1235
- const archiveDir = join5(vault, "archive", yearMonth);
1236
- mkdirSync3(archiveDir, { recursive: true });
1237
- const target = join5(archiveDir, basename2(match.filePath));
1479
+ const archiveDir = join6(vault, "archive", yearMonth);
1480
+ mkdirSync4(archiveDir, { recursive: true });
1481
+ const target = join6(archiveDir, basename2(match.filePath));
1238
1482
  renameSync(match.filePath, target);
1483
+ if (isAgtId(match.id)) {
1484
+ rmSync2(join6(WORKSPACE_ROOT, match.id.toLowerCase()), { recursive: true, force: true });
1485
+ }
1239
1486
  return target;
1240
1487
  }
1241
1488
 
@@ -1245,57 +1492,65 @@ function buildConfigCommand() {
1245
1492
  const config = new Command("config").description(
1246
1493
  "Manage oteam config (~/.open-team/config.json)"
1247
1494
  );
1248
- const vault = new Command("vault").description(
1249
- "Manage registered vault paths and the default vault"
1250
- );
1251
- vault.command("add <path>").description("Register a vault path under a name").option("--name <name>", "Override the auto-derived name").action((rawPath, opts) => {
1252
- const result = addVault(rawPath, { name: opts.name });
1253
- const promoted = result.promotedToDefault ? "\n (set as default \u2014 first vault registered)" : "";
1254
- process.stdout.write(
1255
- `\u2705 Registered "${result.name}" \u2192 ${result.path}${promoted}
1256
- `
1257
- );
1258
- });
1259
- vault.command("list").description("List registered vaults").action(() => {
1260
- const { vaults, default: def } = listVaults();
1261
- if (vaults.length === 0) {
1495
+ function attachWorkspaceSubcommands(parent) {
1496
+ parent.command("add <path>").description("Register a workspace path under a name").option("--name <name>", "Override the auto-derived name").action((rawPath, opts) => {
1497
+ const result = addVault(rawPath, { name: opts.name });
1498
+ const promoted = result.promotedToDefault ? "\n (set as default \u2014 first workspace registered)" : "";
1262
1499
  process.stdout.write(
1263
- `(no vaults registered)
1264
- config: ${configPath()}
1500
+ `\u2705 Registered "${result.name}" \u2192 ${result.path}${promoted}
1265
1501
  `
1266
1502
  );
1267
- return;
1268
- }
1269
- const width = Math.max(...vaults.map((v) => v.name.length));
1270
- const lines = vaults.map((v) => {
1271
- const tag = v.name === def ? " (default)" : "";
1272
- return `${v.name.padEnd(width)} ${v.path}${tag}`;
1273
1503
  });
1274
- process.stdout.write(lines.join("\n") + "\n");
1275
- });
1276
- vault.command("remove <name-or-path>").description("Remove a vault registration").action((nameOrPath) => {
1277
- const result = removeVault(nameOrPath);
1278
- const note = result.clearedDefault ? '\n default cleared \u2014 pass --vault until you set a new one with "oteam config vault default --set <name>"' : "";
1279
- process.stdout.write(`\u2705 Removed "${result.name}"${note}
1504
+ parent.command("list").description("List registered workspaces").action(() => {
1505
+ const { vaults, default: def } = listVaults();
1506
+ if (vaults.length === 0) {
1507
+ process.stdout.write(
1508
+ `(no workspaces registered)
1509
+ config: ${configPath()}
1510
+ `
1511
+ );
1512
+ return;
1513
+ }
1514
+ const width = Math.max(...vaults.map((v) => v.name.length));
1515
+ const lines = vaults.map((v) => {
1516
+ const tag = v.name === def ? " (default)" : "";
1517
+ return `${v.name.padEnd(width)} ${v.path}${tag}`;
1518
+ });
1519
+ process.stdout.write(lines.join("\n") + "\n");
1520
+ });
1521
+ parent.command("remove <name-or-path>").description("Remove a workspace registration").action((nameOrPath) => {
1522
+ const result = removeVault(nameOrPath);
1523
+ const note = result.clearedDefault ? '\n default cleared \u2014 pass --workspace until you set a new one with "oteam config workspace default --set <name>"' : "";
1524
+ process.stdout.write(`\u2705 Removed "${result.name}"${note}
1280
1525
  `);
1281
- });
1282
- vault.command("default").description("Print or set the default vault").option("--set <name-or-path>", "Set the default to this name or path").action((opts) => {
1283
- if (opts.set) {
1284
- const name = setDefault(opts.set);
1285
- process.stdout.write(`\u2705 Default is now "${name}"
1526
+ });
1527
+ parent.command("default").description("Print or set the default workspace").option("--set <name-or-path>", "Set the default to this name or path").action((opts) => {
1528
+ if (opts.set) {
1529
+ const name = setDefault(opts.set);
1530
+ process.stdout.write(`\u2705 Default is now "${name}"
1286
1531
  `);
1287
- return;
1288
- }
1289
- const { default: def } = listVaults();
1290
- if (!def) {
1291
- process.stdout.write(
1292
- "(no default \u2014 pass --vault on every command, or set one with --set)\n"
1293
- );
1294
- return;
1295
- }
1296
- process.stdout.write(`${def}
1532
+ return;
1533
+ }
1534
+ const { default: def } = listVaults();
1535
+ if (!def) {
1536
+ process.stdout.write(
1537
+ "(no default \u2014 pass --workspace on every command, or set one with --set)\n"
1538
+ );
1539
+ return;
1540
+ }
1541
+ process.stdout.write(`${def}
1297
1542
  `);
1298
- });
1543
+ });
1544
+ return parent;
1545
+ }
1546
+ const workspace = attachWorkspaceSubcommands(
1547
+ new Command("workspace").description(
1548
+ "Manage registered workspace paths and the default workspace"
1549
+ )
1550
+ );
1551
+ const vaultAlias = attachWorkspaceSubcommands(
1552
+ new Command("vault").description("Alias for workspace (back-compat)")
1553
+ );
1299
1554
  const stamp = new Command("stamp").description(
1300
1555
  "Manage stamp-server integration (host + enforce flag)"
1301
1556
  );
@@ -1414,10 +1669,107 @@ enforce: ${s.enforce ? "on" : "off"}
1414
1669
  process.stdout.write(`${getTelemetryEnabled() ? "on" : "off"}
1415
1670
  `);
1416
1671
  });
1417
- config.addCommand(vault);
1672
+ const botIdentity = new Command("bot-identity").description(
1673
+ "Manage the GitHub login `oteam assign` claims issues under (default: empty \u2014 no claim attempted)"
1674
+ );
1675
+ botIdentity.command("set <login>").description("Set the bot identity (GitHub login)").action((login) => {
1676
+ const next = setBotIdentity(login);
1677
+ process.stdout.write(`\u2705 botIdentity = ${next}
1678
+ `);
1679
+ });
1680
+ botIdentity.command("clear").description("Remove the bot identity (disables claim-on-assign)").action(() => {
1681
+ clearBotIdentity();
1682
+ process.stdout.write("\u2705 botIdentity cleared\n");
1683
+ });
1684
+ botIdentity.command("show").description("Print the current bot identity").action(() => {
1685
+ const id = getBotIdentity();
1686
+ process.stdout.write(id.length > 0 ? `${id}
1687
+ ` : "(unset)\n");
1688
+ });
1689
+ const repo = new Command("repo").description(
1690
+ "Manage per-repo clone URIs (<owner>/<name> \u2192 git-url)"
1691
+ );
1692
+ repo.command("add <slug> <git-url>").description("Record a clone URI for a repo (e.g. OpenThinkAi/open-team git@github.com:OpenThinkAi/open-team.git)").action((slug, gitUrl) => {
1693
+ const entry = setRepoCloneUri(slug, gitUrl);
1694
+ process.stdout.write(`\u2705 repos.${slug} clone-uri = ${entry["clone-uri"]}
1695
+ `);
1696
+ });
1697
+ repo.command("set <slug>").description("Update fields for an existing repo entry").option("--clone-uri <url>", "New clone URI").action((slug, opts) => {
1698
+ if (!opts.cloneUri) {
1699
+ process.stderr.write("oteam config repo set: pass --clone-uri <url>\n");
1700
+ process.exit(2);
1701
+ }
1702
+ const entry = setRepoCloneUri(slug, opts.cloneUri);
1703
+ process.stdout.write(`\u2705 repos.${slug} clone-uri = ${entry["clone-uri"]}
1704
+ `);
1705
+ });
1706
+ repo.command("show <slug>").description("Print the recorded entry for a repo").action((slug) => {
1707
+ const entry = getRepoEntry(slug);
1708
+ if (!entry) {
1709
+ process.stdout.write(`(no entry for "${slug}")
1710
+ `);
1711
+ return;
1712
+ }
1713
+ process.stdout.write(`clone-uri: ${entry["clone-uri"]}
1714
+ added: ${entry.added}
1715
+ `);
1716
+ });
1717
+ repo.command("list").description("List all recorded repo entries").action(() => {
1718
+ const entries = listRepoEntries();
1719
+ if (entries.length === 0) {
1720
+ process.stdout.write(`(no repos registered)
1721
+ config: ${configPath()}
1722
+ `);
1723
+ return;
1724
+ }
1725
+ const slugWidth = Math.max(...entries.map((e) => e.slug.length));
1726
+ for (const { slug, entry } of entries) {
1727
+ process.stdout.write(`${slug.padEnd(slugWidth)} ${entry["clone-uri"]}
1728
+ `);
1729
+ }
1730
+ });
1731
+ repo.command("remove <slug>").description("Remove the clone URI entry for a repo (idempotent)").action((slug) => {
1732
+ const removed = removeRepoEntry(slug);
1733
+ if (removed) {
1734
+ process.stdout.write(`\u2705 Removed "${slug}"
1735
+ `);
1736
+ } else {
1737
+ process.stdout.write(`\u2139\uFE0F No entry for "${slug}" \u2014 nothing to remove
1738
+ `);
1739
+ }
1740
+ });
1741
+ const push = new Command("push").description(
1742
+ "Manage the global no-push toggle for the assign-side push step (default: on)"
1743
+ );
1744
+ push.command("set <on|off>").description(
1745
+ "Turn the assign-side push step on or off (idempotent; persists to ~/.open-team/config.json)"
1746
+ ).action((flag) => {
1747
+ const lower = flag.toLowerCase();
1748
+ if (lower !== "on" && lower !== "off") {
1749
+ process.stderr.write(
1750
+ `oteam config push set: expected on|off, got "${flag}"
1751
+ `
1752
+ );
1753
+ process.exit(2);
1754
+ }
1755
+ const next = setPush(lower);
1756
+ process.stdout.write(`\u2705 push ${next}
1757
+ `);
1758
+ });
1759
+ push.command("show").description("Print the current push toggle with a one-line description").action(() => {
1760
+ const flag = getPush();
1761
+ const description = flag === "on" ? "push: on (default) \u2014 assigns push to origin after merge" : "push: off \u2014 assigns finish at local commit; user pushes manually";
1762
+ process.stdout.write(`${description}
1763
+ `);
1764
+ });
1765
+ config.addCommand(workspace);
1766
+ config.addCommand(vaultAlias, { hidden: true });
1418
1767
  config.addCommand(stamp);
1768
+ config.addCommand(repo);
1419
1769
  config.addCommand(models);
1420
1770
  config.addCommand(telemetry);
1771
+ config.addCommand(botIdentity);
1772
+ config.addCommand(push);
1421
1773
  return config;
1422
1774
  }
1423
1775
  function expectPhase(value) {
@@ -1433,19 +1785,19 @@ function expectPhase(value) {
1433
1785
 
1434
1786
  // src/commands/init.ts
1435
1787
  import { Command as Command2 } from "commander";
1436
- import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
1437
- import { resolve as resolve3, join as join7 } from "path";
1788
+ import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
1789
+ import { resolve as resolve3, join as join8 } from "path";
1438
1790
  import readline from "readline";
1439
1791
 
1440
1792
  // src/lib/workspace-tree.ts
1441
1793
  import {
1442
- existsSync as existsSync3,
1443
- mkdirSync as mkdirSync4,
1444
- readdirSync as readdirSync3,
1794
+ existsSync as existsSync4,
1795
+ mkdirSync as mkdirSync5,
1796
+ readdirSync as readdirSync4,
1445
1797
  writeFileSync as writeFileSync3
1446
1798
  } from "fs";
1447
1799
  import { homedir as homedir3 } from "os";
1448
- import { join as join6, resolve as resolve2 } from "path";
1800
+ import { join as join7, resolve as resolve2 } from "path";
1449
1801
  var SENTINEL_FILENAME = ".oteam-workspace";
1450
1802
  var WORKSPACE_SUBDIRS = [
1451
1803
  "tickets/triage",
@@ -1469,7 +1821,7 @@ var SENTINEL_BODY = `${JSON.stringify(
1469
1821
  )}
1470
1822
  `;
1471
1823
  function defaultWorkspacePath() {
1472
- return join6(homedir3(), "openteam");
1824
+ return join7(homedir3(), "openteam");
1473
1825
  }
1474
1826
  var WorkspaceConflictError = class extends Error {
1475
1827
  path;
@@ -1490,26 +1842,26 @@ ${list}${more}`);
1490
1842
  function expandHome(input) {
1491
1843
  const home = homedir3();
1492
1844
  if (input === "~") return home;
1493
- if (input.startsWith("~/")) return join6(home, input.slice(2));
1845
+ if (input.startsWith("~/")) return join7(home, input.slice(2));
1494
1846
  return input;
1495
1847
  }
1496
1848
  function bootstrapWorkspace(rawTarget) {
1497
1849
  const target = resolve2(expandHome(rawTarget));
1498
- if (existsSync3(join6(target, SENTINEL_FILENAME))) {
1850
+ if (existsSync4(join7(target, SENTINEL_FILENAME))) {
1499
1851
  return { outcome: "already-initialised", path: target };
1500
1852
  }
1501
- if (existsSync3(target)) {
1502
- const visible = readdirSync3(target).filter((n) => !n.startsWith("."));
1853
+ if (existsSync4(target)) {
1854
+ const visible = readdirSync4(target).filter((n) => !n.startsWith("."));
1503
1855
  if (visible.length > 0) {
1504
1856
  throw new WorkspaceConflictError(target, visible);
1505
1857
  }
1506
1858
  }
1507
- mkdirSync4(target, { recursive: true });
1859
+ mkdirSync5(target, { recursive: true });
1508
1860
  for (const sub of WORKSPACE_SUBDIRS) {
1509
- mkdirSync4(join6(target, sub), { recursive: true });
1861
+ mkdirSync5(join7(target, sub), { recursive: true });
1510
1862
  }
1511
- writeFileSync3(join6(target, "00-meta", "README.md"), META_README_BODY);
1512
- writeFileSync3(join6(target, SENTINEL_FILENAME), SENTINEL_BODY);
1863
+ writeFileSync3(join7(target, "00-meta", "README.md"), META_README_BODY);
1864
+ writeFileSync3(join7(target, SENTINEL_FILENAME), SENTINEL_BODY);
1513
1865
  return { outcome: "created", path: target };
1514
1866
  }
1515
1867
 
@@ -1519,10 +1871,10 @@ var BLOCK_END = "<!-- oteam:end -->";
1519
1871
  var AGENTS_BODY = `## oteam \u2014 workspace-driven role pipeline for Claude agents
1520
1872
 
1521
1873
  If the user asks you to **search, find, list, filter, count, or file
1522
- tickets**, or mentions a "workspace", an "Obsidian vault", an \`AGT-NNN\` id,
1523
- a project, "ingesting GitHub issues or PRs", or driving tickets through a
1524
- "role pipeline" \u2014 \`oteam\` is the right tool. The workspace is a directory
1525
- of markdown files (typically \`~/openteam/tickets/<state>/AGT-NNN-*.md\`),
1874
+ tickets**, or mentions a "workspace", an \`AGT-NNN\` id, a project,
1875
+ "ingesting GitHub issues or PRs", or driving tickets through a "role
1876
+ pipeline" \u2014 \`oteam\` is the right tool. The workspace is a directory of
1877
+ markdown files (typically \`~/openteam/tickets/<state>/AGT-NNN-*.md\`),
1526
1878
  but **do not search it with \`find\` or \`grep\` directly.** The CLI knows the
1527
1879
  ticket schema and has structured + free-text filters; filesystem search
1528
1880
  does not, and you will fight false positives from incidental keyword
@@ -1540,13 +1892,13 @@ Other common verbs: \`oteam ticket new "<title>" [--project X]\` to file a
1540
1892
  ticket, \`oteam pull github owner/repo#NN\` to ingest a GitHub issue or PR,
1541
1893
  \`oteam assign <AGT-NNN>\` to drive a ticket through the role pipeline. Run
1542
1894
  \`oteam --help\` or \`oteam <command> --help\` for full details. If you don't
1543
- know whether a workspace is configured, \`oteam config vault list\` tells you.
1895
+ know whether a workspace is configured, \`oteam config workspace list\` tells you.
1544
1896
  `;
1545
1897
  var CLAUDE_BODY = `## oteam
1546
1898
 
1547
1899
  If the user asks to search, find, list, or file tickets, or mentions a
1548
- "workspace", "Obsidian vault", an \`AGT-NNN\` id, or a role pipeline, use the
1549
- \`oteam\` CLI \u2014 **do not** \`find\`/\`grep\` the workspace directly. Start with
1900
+ "workspace", an \`AGT-NNN\` id, or a role pipeline, use the \`oteam\` CLI \u2014
1901
+ **do not** \`find\`/\`grep\` the workspace directly. Start with
1550
1902
  \`oteam list --grep "<term>"\` or \`oteam list --match "<term>"\`. See
1551
1903
  \`AGENTS.md\` next to this file for the short summary and \`oteam --help\` for
1552
1904
  the full surface.
@@ -1561,7 +1913,7 @@ ${BLOCK_END}
1561
1913
  }
1562
1914
  function upsertBlock(filePath, body) {
1563
1915
  const block = renderBlock(body);
1564
- if (!existsSync4(filePath)) {
1916
+ if (!existsSync5(filePath)) {
1565
1917
  writeFileSync4(filePath, block, "utf8");
1566
1918
  return "created";
1567
1919
  }
@@ -1581,7 +1933,7 @@ function upsertBlock(filePath, body) {
1581
1933
  function expandHome2(input) {
1582
1934
  const home = process.env.HOME ?? "";
1583
1935
  if (input === "~") return home;
1584
- if (input.startsWith("~/")) return join7(home, input.slice(2));
1936
+ if (input.startsWith("~/")) return join8(home, input.slice(2));
1585
1937
  return input;
1586
1938
  }
1587
1939
  function prompt(question, fallback) {
@@ -1636,13 +1988,13 @@ async function runInit(opts) {
1636
1988
  const models = seedDefaultModelsIfEmpty();
1637
1989
  const stamp = await runStampStep(opts);
1638
1990
  const docsDir = resolve3(expandHome2(opts.docsDir ?? home));
1639
- if (!existsSync4(docsDir)) {
1991
+ if (!existsSync5(docsDir)) {
1640
1992
  process.stderr.write(`oteam init: docs directory does not exist: ${docsDir}
1641
1993
  `);
1642
1994
  process.exit(1);
1643
1995
  }
1644
- const agentsPath = join7(docsDir, "AGENTS.md");
1645
- const claudePath = join7(docsDir, "CLAUDE.md");
1996
+ const agentsPath = join8(docsDir, "AGENTS.md");
1997
+ const claudePath = join8(docsDir, "CLAUDE.md");
1646
1998
  const agents = upsertBlock(agentsPath, AGENTS_BODY);
1647
1999
  const claude = upsertBlock(claudePath, CLAUDE_BODY);
1648
2000
  return {
@@ -1738,7 +2090,7 @@ function workspaceLine(ws) {
1738
2090
  if (ws.outcome === "already-initialised") {
1739
2091
  return `\u2139\uFE0F Workspace already initialised at ${ws.path} (registered as "${ws.registeredAs}")`;
1740
2092
  }
1741
- 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";
2093
+ const trail = ws.promotedToDefault ? "set as default" : ws.currentDefault && ws.currentDefault !== ws.registeredAs ? `current default is "${ws.currentDefault}" \u2014 pass \`oteam config workspace default --set ${ws.registeredAs}\` to switch` : "registered";
1742
2094
  return `\u2705 Created workspace at ${ws.path} (registered as "${ws.registeredAs}"; ${trail})`;
1743
2095
  }
1744
2096
  function buildInitCommand() {
@@ -1787,27 +2139,27 @@ function buildInitCommand() {
1787
2139
  }
1788
2140
 
1789
2141
  // src/commands/project.ts
1790
- import { Command as Command3 } from "commander";
1791
- import { spawnSync } from "child_process";
1792
- import { existsSync as existsSync6, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
2142
+ import { Command as Command3, Option } from "commander";
2143
+ import { spawnSync as spawnSync2 } from "child_process";
2144
+ import { existsSync as existsSync7, mkdirSync as mkdirSync6, writeFileSync as writeFileSync5 } from "fs";
1793
2145
  import { basename as basename3 } from "path";
1794
2146
 
1795
2147
  // src/lib/projects.ts
1796
- import { existsSync as existsSync5, readFileSync as readFileSync5, readdirSync as readdirSync4, statSync as statSync3 } from "fs";
1797
- import { join as join8 } from "path";
2148
+ import { existsSync as existsSync6, readFileSync as readFileSync5, readdirSync as readdirSync5, statSync as statSync3 } from "fs";
2149
+ import { join as join9 } from "path";
1798
2150
  function projectsRoot(vaultPath) {
1799
- return join8(vaultPath, "projects");
2151
+ return join9(vaultPath, "projects");
1800
2152
  }
1801
2153
  function projectDir(vaultPath, id) {
1802
- return join8(projectsRoot(vaultPath), id);
2154
+ return join9(projectsRoot(vaultPath), id);
1803
2155
  }
1804
2156
  function projectReadmePath(vaultPath, id) {
1805
- return join8(projectDir(vaultPath, id), "README.md");
2157
+ return join9(projectDir(vaultPath, id), "README.md");
1806
2158
  }
1807
2159
  function readProject(vaultPath, id) {
1808
2160
  const dir = projectDir(vaultPath, id);
1809
2161
  const readme = projectReadmePath(vaultPath, id);
1810
- if (!existsSync5(readme)) return null;
2162
+ if (!existsSync6(readme)) return null;
1811
2163
  let raw;
1812
2164
  try {
1813
2165
  raw = readFileSync5(readme, "utf8");
@@ -1833,14 +2185,14 @@ function listProjects(vaultPath) {
1833
2185
  const root = projectsRoot(vaultPath);
1834
2186
  let entries = [];
1835
2187
  try {
1836
- entries = readdirSync4(root);
2188
+ entries = readdirSync5(root);
1837
2189
  } catch {
1838
2190
  return [];
1839
2191
  }
1840
2192
  const projects = [];
1841
2193
  for (const name of entries) {
1842
2194
  if (name.startsWith(".")) continue;
1843
- const dir = join8(root, name);
2195
+ const dir = join9(root, name);
1844
2196
  let isDir = false;
1845
2197
  try {
1846
2198
  isDir = statSync3(dir).isDirectory();
@@ -1897,7 +2249,7 @@ function bodyAfterFrontmatter(raw) {
1897
2249
  function listSiblings(dir) {
1898
2250
  let entries = [];
1899
2251
  try {
1900
- entries = readdirSync4(dir);
2252
+ entries = readdirSync5(dir);
1901
2253
  } catch {
1902
2254
  return [];
1903
2255
  }
@@ -1905,7 +2257,7 @@ function listSiblings(dir) {
1905
2257
  for (const name of entries) {
1906
2258
  if (name === "README.md") continue;
1907
2259
  if (name.startsWith(".")) continue;
1908
- const full = join8(dir, name);
2260
+ const full = join9(dir, name);
1909
2261
  let isFile = false;
1910
2262
  try {
1911
2263
  isFile = statSync3(full).isFile();
@@ -1930,6 +2282,16 @@ repos: []
1930
2282
 
1931
2283
  <!-- Canonical design doc for this project. Tickets reference this project via \`project: ${id}\` in their frontmatter. The role-pipeline auto-loads this README into the spawned agent's context, so anything authoritative about the project's architecture, scope, naming, or defaults belongs here. -->
1932
2284
 
2285
+ <!-- Conventions when editing this README:
2286
+ - Design-doc only. Anything that would drift over time \u2014 ticket lists, status
2287
+ tables, audit logs, per-row records \u2014 belongs to a CLI command, not inline
2288
+ text. Before adding a new section that resembles a list of records, ask
2289
+ whether \`oteam\` (or another CLI) already owns the content, or should, and
2290
+ link to the command instead of hand-maintaining the list here.
2291
+ - Architecture, scope, naming, defaults, and prior decisions are exactly the
2292
+ things that DO belong here \u2014 those are stable design-doc content the
2293
+ pipeline-spawned agent needs in its context. -->
2294
+
1933
2295
  ## Tickets
1934
2296
 
1935
2297
  For the live ticket list, run:
@@ -1938,7 +2300,7 @@ For the live ticket list, run:
1938
2300
  oteam project show ${id} --tickets
1939
2301
  \`\`\`
1940
2302
 
1941
- Tickets are not stored inside this folder \u2014 they live in \`<vault>/tickets/<state>/\` and reference this project via frontmatter \`project: ${id}\`.
2303
+ Tickets are not stored inside this folder \u2014 they live in \`<workspace>/tickets/<state>/\` and reference this project via frontmatter \`project: ${id}\`.
1942
2304
 
1943
2305
  ### Notable shipped milestones (drift expected)
1944
2306
 
@@ -1949,16 +2311,16 @@ Tickets are not stored inside this folder \u2014 they live in \`<vault>/tickets/
1949
2311
  // src/commands/project.ts
1950
2312
  function buildProjectCommand() {
1951
2313
  const project = new Command3("project").description(
1952
- "Manage vault projects (folders under <vault>/projects/<id>/)"
2314
+ "Manage workspace projects (folders under <workspace>/projects/<id>/)"
1953
2315
  );
1954
- project.command("init <id>").description("Scaffold <vault>/projects/<id>/README.md and open in $EDITOR").option("--vault <name-or-path>", "Use a specific registered vault").option("--no-edit", "Skip opening the README in $EDITOR after scaffolding").action((id, opts) => {
1955
- runInit2(id, opts);
2316
+ project.command("init <id>").description("Scaffold <workspace>/projects/<id>/README.md and open in $EDITOR").option("-w, --workspace <name-or-path>", "Use a specific registered workspace").addOption(new Option("--vault <name-or-path>").hideHelp()).option("--no-edit", "Skip opening the README in $EDITOR after scaffolding").action((id, opts) => {
2317
+ runInit2(id, { vault: opts.workspace ?? opts.vault, edit: opts.edit });
1956
2318
  });
1957
- project.command("list").description("List projects in the vault with derived ticket counts").option("--vault <name-or-path>", "Use a specific registered vault").action((opts) => {
1958
- runList2(opts);
2319
+ project.command("list").description("List projects in the workspace with derived ticket counts").option("-w, --workspace <name-or-path>", "Use a specific registered workspace").addOption(new Option("--vault <name-or-path>").hideHelp()).action((opts) => {
2320
+ runList2({ vault: opts.workspace ?? opts.vault });
1959
2321
  });
1960
- project.command("show <id>").description("Print a project's frontmatter, body, siblings, and ticket counts").option("--vault <name-or-path>", "Use a specific registered vault").option("--tickets", "Also list every ticket tagged with this project").action((id, opts) => {
1961
- runShow(id, opts);
2322
+ project.command("show <id>").description("Print a project's frontmatter, body, siblings, and ticket counts").option("-w, --workspace <name-or-path>", "Use a specific registered workspace").addOption(new Option("--vault <name-or-path>").hideHelp()).option("--tickets", "Also list every ticket tagged with this project").action((id, opts) => {
2323
+ runShow(id, { vault: opts.workspace ?? opts.vault, tickets: opts.tickets });
1962
2324
  });
1963
2325
  return project;
1964
2326
  }
@@ -1973,14 +2335,14 @@ function runInit2(id, opts) {
1973
2335
  const vaultPath = resolveVaultPath({ flagValue: opts.vault });
1974
2336
  const dir = projectDir(vaultPath, id);
1975
2337
  const readme = projectReadmePath(vaultPath, id);
1976
- if (existsSync6(readme)) {
2338
+ if (existsSync7(readme)) {
1977
2339
  process.stderr.write(
1978
2340
  `oteam project init: ${readme} already exists \u2014 refusing to overwrite
1979
2341
  `
1980
2342
  );
1981
2343
  process.exit(1);
1982
2344
  }
1983
- mkdirSync5(dir, { recursive: true });
2345
+ mkdirSync6(dir, { recursive: true });
1984
2346
  writeFileSync5(readme, projectFrontmatterTemplate(id), "utf8");
1985
2347
  process.stdout.write(`\u2705 Created project ${id}
1986
2348
  ${readme}
@@ -1995,7 +2357,7 @@ function runList2(opts) {
1995
2357
  if (projects.length === 0) {
1996
2358
  process.stdout.write(
1997
2359
  `(no projects)
1998
- <vault>/projects/<id>/README.md is the convention; create one with: oteam project init <id>
2360
+ <workspace>/projects/<id>/README.md is the convention; create one with: oteam project init <id>
1999
2361
  `
2000
2362
  );
2001
2363
  return;
@@ -2106,7 +2468,7 @@ function isValidProjectId(id) {
2106
2468
  function openInEditor(path) {
2107
2469
  const editor = process.env.EDITOR || process.env.VISUAL;
2108
2470
  if (!editor) return;
2109
- const r = spawnSync(editor, [path], { stdio: "inherit", shell: true });
2471
+ const r = spawnSync2(editor, [path], { stdio: "inherit", shell: true });
2110
2472
  if (r.status !== 0 && r.status !== null) {
2111
2473
  process.stderr.write(
2112
2474
  `oteam project init: $EDITOR exited ${r.status} (file is created at ${path}; edit it manually)
@@ -2121,21 +2483,21 @@ import { Command as Command4 } from "commander";
2121
2483
  // src/lib/telemetry.ts
2122
2484
  import {
2123
2485
  appendFileSync,
2124
- existsSync as existsSync7,
2125
- mkdirSync as mkdirSync6,
2486
+ existsSync as existsSync8,
2487
+ mkdirSync as mkdirSync7,
2126
2488
  readFileSync as readFileSync7
2127
2489
  } from "fs";
2128
2490
  import { homedir as homedir4 } from "os";
2129
- import { join as join10 } from "path";
2491
+ import { join as join11 } from "path";
2130
2492
 
2131
2493
  // src/lib/claude-session.ts
2132
2494
  import { readFileSync as readFileSync6 } from "fs";
2133
- import { join as join9 } from "path";
2495
+ import { join as join10 } from "path";
2134
2496
  function encodeProjectDir(cwd) {
2135
2497
  return cwd.replace(/\//g, "-");
2136
2498
  }
2137
2499
  function findSessionFile(claudeConfigDir, cwd, sessionId) {
2138
- return join9(
2500
+ return join10(
2139
2501
  claudeConfigDir,
2140
2502
  "projects",
2141
2503
  encodeProjectDir(cwd),
@@ -2151,9 +2513,34 @@ function parseSessionFile(path) {
2151
2513
  }
2152
2514
  return parseSessionJsonl(raw);
2153
2515
  }
2516
+ function lastAssistantText(path) {
2517
+ let raw;
2518
+ try {
2519
+ raw = readFileSync6(path, "utf8");
2520
+ } catch {
2521
+ return null;
2522
+ }
2523
+ let last = "";
2524
+ for (const line of raw.split("\n")) {
2525
+ if (line.length === 0) continue;
2526
+ let entry;
2527
+ try {
2528
+ entry = JSON.parse(line);
2529
+ } catch {
2530
+ continue;
2531
+ }
2532
+ if (!entry || typeof entry !== "object") continue;
2533
+ const e = entry;
2534
+ if (e.type !== "assistant") continue;
2535
+ const m = e.message ?? {};
2536
+ const text = extractAssistantText(m.content);
2537
+ if (text.length > 0) last = text;
2538
+ }
2539
+ return last.length > 0 ? last : null;
2540
+ }
2154
2541
  function parseSessionJsonl(raw) {
2155
2542
  const tokens = {};
2156
- let lastAssistantText = "";
2543
+ let lastAssistantText2 = "";
2157
2544
  for (const line of raw.split("\n")) {
2158
2545
  if (line.length === 0) continue;
2159
2546
  let entry;
@@ -2176,9 +2563,9 @@ function parseSessionJsonl(raw) {
2176
2563
  addIfFinite(tokens, "cache-read", u.cache_read_input_tokens);
2177
2564
  }
2178
2565
  const text = extractAssistantText(m.content);
2179
- if (text.length > 0) lastAssistantText = text;
2566
+ if (text.length > 0) lastAssistantText2 = text;
2180
2567
  }
2181
- return { tokens, outcome: detectOutcome(lastAssistantText) };
2568
+ return { tokens, outcome: detectOutcome(lastAssistantText2) };
2182
2569
  }
2183
2570
  function extractAssistantText(content) {
2184
2571
  if (typeof content === "string") return content;
@@ -2208,10 +2595,10 @@ function addIfFinite(target, key, value) {
2208
2595
  function telemetryDir() {
2209
2596
  const override = process.env.OTEAM_TELEMETRY_DIR;
2210
2597
  if (override && override.length > 0) return override;
2211
- return join10(homedir4(), ".open-team", "telemetry");
2598
+ return join11(homedir4(), ".open-team", "telemetry");
2212
2599
  }
2213
2600
  function runsPath(dir = telemetryDir()) {
2214
- return join10(dir, "runs.jsonl");
2601
+ return join11(dir, "runs.jsonl");
2215
2602
  }
2216
2603
  function recordPhase(input) {
2217
2604
  try {
@@ -2225,10 +2612,21 @@ function recordPhase(input) {
2225
2612
  );
2226
2613
  let tokens = {};
2227
2614
  let markerOutcome = null;
2228
- if (existsSync7(sessionFile)) {
2615
+ if (existsSync8(sessionFile)) {
2229
2616
  const parsed = parseSessionFile(sessionFile);
2230
2617
  tokens = parsed.tokens;
2231
2618
  markerOutcome = parsed.outcome;
2619
+ if (Object.keys(tokens).length === 0) {
2620
+ process.stderr.write(
2621
+ `oteam: telemetry: session file found but no token data parsed \u2014 ${sessionFile}
2622
+ `
2623
+ );
2624
+ }
2625
+ } else {
2626
+ process.stderr.write(
2627
+ `oteam: telemetry: session file not found \u2014 ${sessionFile}
2628
+ `
2629
+ );
2232
2630
  }
2233
2631
  const outcome = input.exitCode !== 0 ? "failed" : markerOutcome ?? "unknown";
2234
2632
  const line = {
@@ -2242,7 +2640,7 @@ function recordPhase(input) {
2242
2640
  outcome
2243
2641
  };
2244
2642
  const dir = telemetryDir();
2245
- mkdirSync6(dir, { recursive: true });
2643
+ mkdirSync7(dir, { recursive: true });
2246
2644
  appendFileSync(runsPath(dir), JSON.stringify(line) + "\n");
2247
2645
  } catch (err) {
2248
2646
  const msg = err instanceof Error ? err.message : String(err);
@@ -2253,7 +2651,7 @@ function recordPhase(input) {
2253
2651
  function resolveClaudeConfigDir() {
2254
2652
  const env = process.env.CLAUDE_CONFIG_DIR;
2255
2653
  if (env && env.length > 0) return env;
2256
- return join10(homedir4(), ".claude");
2654
+ return join11(homedir4(), ".claude");
2257
2655
  }
2258
2656
  function computeWallClockMs(startedAt, endedAt) {
2259
2657
  const startMs = Date.parse(startedAt);
@@ -2263,7 +2661,7 @@ function computeWallClockMs(startedAt, endedAt) {
2263
2661
  }
2264
2662
  function readRuns(dir = telemetryDir()) {
2265
2663
  const path = runsPath(dir);
2266
- if (!existsSync7(path)) return [];
2664
+ if (!existsSync8(path)) return [];
2267
2665
  const raw = readFileSync7(path, "utf8");
2268
2666
  const out = [];
2269
2667
  for (const line of raw.split("\n")) {
@@ -2443,17 +2841,17 @@ function formatTokenField(tokens, key) {
2443
2841
  }
2444
2842
 
2445
2843
  // src/commands/ticket.ts
2446
- import { Command as Command5 } from "commander";
2447
- import { existsSync as existsSync8, mkdirSync as mkdirSync7, writeFileSync as writeFileSync6 } from "fs";
2448
- import { join as join11 } from "path";
2844
+ import { Command as Command5, Option as Option2 } from "commander";
2845
+ import { existsSync as existsSync9, mkdirSync as mkdirSync8, writeFileSync as writeFileSync6 } from "fs";
2846
+ import { join as join12 } from "path";
2449
2847
  function runTicketNew(opts) {
2450
2848
  const title = opts.title.trim();
2451
2849
  if (title.length === 0) {
2452
2850
  throw new Error("oteam ticket new: <title> must not be empty");
2453
2851
  }
2454
- const vault = resolveVaultPath({ flagValue: opts.vault });
2455
- const triageDir = join11(vault, "tickets", "triage");
2456
- mkdirSync7(triageDir, { recursive: true });
2852
+ const vault = resolveVaultPath({ flagValue: opts.workspace ?? opts.vault });
2853
+ const triageDir = join12(vault, "tickets", "triage");
2854
+ mkdirSync8(triageDir, { recursive: true });
2457
2855
  const id = nextTicketID(vault);
2458
2856
  const slug = slugify(title);
2459
2857
  if (slug.length === 0) {
@@ -2461,8 +2859,8 @@ function runTicketNew(opts) {
2461
2859
  `oteam ticket new: title "${title}" produced an empty slug \u2014 use a title with at least one alphanumeric character`
2462
2860
  );
2463
2861
  }
2464
- const target = join11(triageDir, `${id}-${slug}.md`);
2465
- if (existsSync8(target)) {
2862
+ const target = join12(triageDir, `${id}-${slug}.md`);
2863
+ if (existsSync9(target)) {
2466
2864
  throw new Error(
2467
2865
  `oteam ticket new: target already exists at ${target} \u2014 ID scan collision`
2468
2866
  );
@@ -2485,10 +2883,10 @@ function collectLabel(value, prev = []) {
2485
2883
  }
2486
2884
  function buildTicketCommand() {
2487
2885
  const ticket = new Command5("ticket").description(
2488
- "Create vault tickets directly (without an external source)"
2886
+ "Create workspace tickets directly (without an external source)"
2489
2887
  );
2490
2888
  ticket.command("new <title>").description(
2491
- "File a new ticket in <vault>/tickets/triage/ \u2014 works with or without a project"
2889
+ "File a new ticket in <workspace>/tickets/triage/ \u2014 works with or without a project"
2492
2890
  ).option(
2493
2891
  "--project <id>",
2494
2892
  "Tag the ticket with a project (omit for no project)"
@@ -2497,7 +2895,7 @@ function buildTicketCommand() {
2497
2895
  "Add a label (repeatable: --label foo --label bar)",
2498
2896
  collectLabel,
2499
2897
  []
2500
- ).option("--vault <name-or-path>", "Use a specific registered vault").action(
2898
+ ).option("-w, --workspace <name-or-path>", "Use a specific registered workspace").addOption(new Option2("--vault <name-or-path>").hideHelp()).action(
2501
2899
  (title, opts) => {
2502
2900
  const result = runTicketNew({
2503
2901
  title,
@@ -2505,7 +2903,7 @@ function buildTicketCommand() {
2505
2903
  team: opts.team,
2506
2904
  priority: opts.priority,
2507
2905
  labels: opts.label,
2508
- vault: opts.vault
2906
+ vault: opts.workspace ?? opts.vault
2509
2907
  });
2510
2908
  process.stdout.write(`\u2705 Filed ${result.ticketID}
2511
2909
  ${result.path}
@@ -2516,22 +2914,89 @@ function buildTicketCommand() {
2516
2914
  }
2517
2915
 
2518
2916
  // src/role-pipeline/runner.ts
2519
- import { spawnSync as spawnSync4 } from "child_process";
2917
+ import { spawn, spawnSync as spawnSync5 } from "child_process";
2520
2918
  import { randomUUID } from "crypto";
2521
- import { readFileSync as readFileSync9, writeFileSync as writeFileSync7 } from "fs";
2522
- import { tmpdir } from "os";
2523
- import { resolve as resolve4, basename as basename5, dirname as dirname2, join as join14 } from "path";
2919
+ import { readFileSync as readFileSync9, realpathSync, unlinkSync, writeFileSync as writeFileSync7 } from "fs";
2920
+ import { homedir as homedir6, tmpdir } from "os";
2921
+ import { resolve as resolve4, basename as basename4, dirname as dirname2, join as join14 } from "path";
2922
+
2923
+ // src/lib/github.ts
2924
+ import { spawnSync as spawnSync3 } from "child_process";
2925
+ function parseIssueRef(ref) {
2926
+ const url = ref.match(
2927
+ /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/
2928
+ );
2929
+ if (url) {
2930
+ return { slug: `${url[1]}/${url[2]}`, number: parseInt(url[3], 10) };
2931
+ }
2932
+ const slug = ref.match(/^([^/]+\/[^/#]+)#(\d+)$/);
2933
+ if (slug) {
2934
+ return { slug: slug[1], number: parseInt(slug[2], 10) };
2935
+ }
2936
+ return null;
2937
+ }
2938
+ function claimGitHubIssue(slug, issueNumber, identity) {
2939
+ const getR = ghJSON(["api", `repos/${slug}/issues/${issueNumber}`]);
2940
+ if (!getR.ok) return { ok: false, reason: "api-error", error: getR.error };
2941
+ const issue = getR.value;
2942
+ if (issue.state === "closed") return { ok: false, reason: "issue-closed" };
2943
+ const existing = collectAssignees(issue);
2944
+ if (existing.length > 0 && !existing.includes(identity)) {
2945
+ return { ok: false, reason: "already-claimed", assignees: existing };
2946
+ }
2947
+ const body = JSON.stringify({ assignees: [identity] });
2948
+ const patchR = ghJSON(
2949
+ [
2950
+ "api",
2951
+ `repos/${slug}/issues/${issueNumber}`,
2952
+ "-X",
2953
+ "PATCH",
2954
+ "--input",
2955
+ "-"
2956
+ ],
2957
+ body
2958
+ );
2959
+ if (!patchR.ok) return { ok: false, reason: "api-error", error: patchR.error };
2960
+ const updated = patchR.value;
2961
+ if (updated.state === "closed") return { ok: false, reason: "issue-closed" };
2962
+ const after = collectAssignees(updated);
2963
+ if (after.length === 0) return { ok: false, reason: "no-write-access" };
2964
+ if (after.length !== 1 || after[0] !== identity) {
2965
+ return { ok: false, reason: "already-claimed", assignees: after };
2966
+ }
2967
+ return { ok: true, assignees: after };
2968
+ }
2969
+ function collectAssignees(issue) {
2970
+ if (!issue.assignees) return [];
2971
+ const out = [];
2972
+ for (const a of issue.assignees) {
2973
+ if (typeof a?.login === "string" && a.login.length > 0) out.push(a.login);
2974
+ }
2975
+ return out;
2976
+ }
2977
+ function ghJSON(args, input) {
2978
+ const r = spawnSync3("gh", args, { encoding: "utf8", input });
2979
+ if (r.error) return { ok: false, error: r.error.message };
2980
+ if (r.status !== 0) {
2981
+ return { ok: false, error: r.stderr || `gh exited ${r.status}` };
2982
+ }
2983
+ try {
2984
+ return { ok: true, value: JSON.parse(r.stdout) };
2985
+ } catch (e) {
2986
+ return { ok: false, error: `gh returned non-JSON: ${e.message}` };
2987
+ }
2988
+ }
2524
2989
 
2525
2990
  // src/lib/kitty.ts
2526
- import { spawnSync as spawnSync2 } from "child_process";
2527
- import { existsSync as existsSync9, readdirSync as readdirSync5 } from "fs";
2991
+ import { spawnSync as spawnSync4 } from "child_process";
2992
+ import { existsSync as existsSync10, readdirSync as readdirSync6 } from "fs";
2528
2993
  var SOCKET_BASENAME = "kitty-claudini";
2529
2994
  var KNOWN_INSTANCES = ["personal", "work"];
2530
2995
  function isMacOS() {
2531
2996
  return process.platform === "darwin";
2532
2997
  }
2533
2998
  function findKittyBinary() {
2534
- const r = spawnSync2("/usr/bin/env", ["which", "kitty"], { encoding: "utf8" });
2999
+ const r = spawnSync4("/usr/bin/env", ["which", "kitty"], { encoding: "utf8" });
2535
3000
  if (r.status !== 0) return null;
2536
3001
  const path = r.stdout.trim();
2537
3002
  return path.length > 0 ? path : null;
@@ -2556,7 +3021,7 @@ function findKittySocket(kittyPath, preferring) {
2556
3021
  let pidSuffixed = [];
2557
3022
  try {
2558
3023
  const prefix = `${SOCKET_BASENAME}-`;
2559
- pidSuffixed = readdirSync5("/tmp").filter((n) => n.startsWith(prefix)).map((n) => `/tmp/${n}`).filter((p) => !candidates.includes(p));
3024
+ pidSuffixed = readdirSync6("/tmp").filter((n) => n.startsWith(prefix)).map((n) => `/tmp/${n}`).filter((p) => !candidates.includes(p));
2560
3025
  } catch {
2561
3026
  }
2562
3027
  if (preferring) {
@@ -2567,9 +3032,9 @@ function findKittySocket(kittyPath, preferring) {
2567
3032
  candidates.push(...pidSuffixed);
2568
3033
  }
2569
3034
  for (const path of candidates) {
2570
- if (!existsSync9(path)) continue;
3035
+ if (!existsSync10(path)) continue;
2571
3036
  const socket = `unix:${path}`;
2572
- const r = spawnSync2(kittyPath, ["@", "--to", socket, "ls"], {
3037
+ const r = spawnSync4(kittyPath, ["@", "--to", socket, "ls"], {
2573
3038
  encoding: "utf8"
2574
3039
  });
2575
3040
  if (r.status === 0) return socket;
@@ -2614,7 +3079,7 @@ function kittyLaunch(opts) {
2614
3079
  "-c",
2615
3080
  opts.shellCmd
2616
3081
  ];
2617
- const r = spawnSync2(opts.kittyPath, args, { encoding: "utf8" });
3082
+ const r = spawnSync4(opts.kittyPath, args, { encoding: "utf8" });
2618
3083
  return { exitCode: r.status ?? -1, stderr: r.stderr ?? "" };
2619
3084
  }
2620
3085
  function augmentedPATH() {
@@ -2632,145 +3097,32 @@ function shellEscape(s) {
2632
3097
  return s.replace(/'/g, "'\\''");
2633
3098
  }
2634
3099
 
2635
- // src/lib/workspace.ts
2636
- import { existsSync as existsSync10, mkdirSync as mkdirSync8, readdirSync as readdirSync6, rmSync } from "fs";
2637
- import { spawnSync as spawnSync3 } from "child_process";
2638
- import { basename as basename4, join as join12 } from "path";
2639
- function buildGithubUrl(repoSlug) {
2640
- return `git@github.com:${repoSlug}.git`;
2641
- }
2642
- var WORKSPACE_ROOT = "/tmp/open-team-issues";
2643
- var TICKET_ID_RE = /^AGT-\d+$/;
2644
- var ORPHAN_DIR_RE = /^agt-\d+$/;
2645
- var StampGateError = class extends Error {
2646
- stampUrl;
2647
- cloneStderr;
2648
- constructor(args) {
2649
- const lines = [
2650
- `oteam assign: ${args.repoSlug} is not stamp-governed.`
2651
- ];
2652
- if (args.stampUrl) {
2653
- lines.push(` Tried: git clone ${args.stampUrl}`);
2654
- }
2655
- lines.push(
2656
- ` Reason: ${args.reason}`,
2657
- ` Fix: provision the repo on the stamp server with`,
2658
- ` stamp provision ${basename4(args.repoSlug)}`,
2659
- ` Or turn enforcement off:`,
2660
- ` oteam config stamp set --enforce off`,
2661
- ` Or pass --no-stamp to bypass this gate for a single run.`
2662
- );
2663
- super(lines.join("\n"));
2664
- this.name = "StampGateError";
2665
- this.stampUrl = args.stampUrl;
2666
- this.cloneStderr = args.cloneStderr ?? "";
2667
- }
2668
- };
2669
- function prepareAgentWorkspace(opts) {
2670
- if (!TICKET_ID_RE.test(opts.ticketId)) {
2671
- throw new Error(
2672
- `prepareAgentWorkspace: refusing to operate on non-AGT ticket id "${opts.ticketId}" (expected AGT-NNN)`
2673
- );
2674
- }
2675
- const root = opts.rootDir ?? WORKSPACE_ROOT;
2676
- mkdirSync8(root, { recursive: true });
2677
- if (opts.activeTicketIds) gcOrphanWorkspaces(root, opts.activeTicketIds);
2678
- const ticketDir = join12(root, opts.ticketId.toLowerCase());
2679
- const repoDir = join12(ticketDir, "repo");
2680
- rmSync(ticketDir, { recursive: true, force: true });
2681
- mkdirSync8(ticketDir, { recursive: true });
2682
- const repoBasename = basename4(opts.repoSlug);
2683
- const cloneRunner = opts.cloneRunner ?? defaultCloneRunner;
2684
- if (opts.mode === "github") {
2685
- const url = buildGithubUrl(opts.repoSlug);
2686
- const r2 = cloneRunner(url, repoDir);
2687
- if (r2.status !== 0) {
2688
- throw new Error(
2689
- `oteam assign: github clone failed (git clone ${url}):
2690
- ${r2.stderr.trim() || "(no stderr)"}`
2691
- );
2692
- }
2693
- return { path: repoDir, originUrl: url, source: "github" };
2694
- }
2695
- if (!opts.stampHost || opts.stampHost.trim().length === 0) {
2696
- throw new Error(
2697
- "prepareAgentWorkspace: mode='stamp' requires opts.stampHost (run 'oteam config stamp set --host <url>')"
2698
- );
2699
- }
2700
- const stampUrl = buildStampCloneUrl(opts.stampHost, repoBasename);
2701
- const r = cloneRunner(stampUrl, repoDir);
2702
- if (r.status !== 0) {
2703
- throw new StampGateError({
2704
- repoSlug: opts.repoSlug,
2705
- stampUrl,
2706
- reason: stampGateReason(r),
2707
- cloneStderr: r.stderr
2708
- });
2709
- }
2710
- return { path: repoDir, originUrl: stampUrl, source: "stamp" };
2711
- }
2712
- function buildStampCloneUrl(host, repoBasename) {
2713
- return `${host}/srv/git/${repoBasename}.git`;
2714
- }
2715
- function stampGateReason(r) {
2716
- const stderr = r.stderr.trim();
2717
- if (!stderr) return `git clone exited ${r.status}`;
2718
- const firstLine = stderr.split(/\r?\n/).find((l) => l.trim().length > 0);
2719
- return `git clone exited ${r.status}: ${firstLine ?? "(no stderr)"}`;
2720
- }
2721
- var defaultCloneRunner = (url, dest) => {
2722
- const r = spawnSync3("git", ["clone", "--quiet", url, dest], {
2723
- encoding: "utf8",
2724
- env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
2725
- });
2726
- return {
2727
- status: r.status ?? -1,
2728
- stderr: r.stderr ?? ""
2729
- };
2730
- };
2731
- function gcOrphanWorkspaces(root, activeTicketIds) {
2732
- if (!existsSync10(root)) return [];
2733
- const removed = [];
2734
- let entries;
2735
- try {
2736
- entries = readdirSync6(root);
2737
- } catch {
2738
- return [];
2739
- }
2740
- for (const name of entries) {
2741
- if (!ORPHAN_DIR_RE.test(name)) continue;
2742
- if (activeTicketIds.has(name)) continue;
2743
- const target = join12(root, name);
2744
- try {
2745
- rmSync(target, { recursive: true, force: true });
2746
- removed.push(target);
2747
- } catch {
2748
- }
2749
- }
2750
- return removed;
2751
- }
2752
-
2753
3100
  // src/role-pipeline/install-slash-command.ts
2754
3101
  import { copyFileSync, existsSync as existsSync11, mkdirSync as mkdirSync9, readdirSync as readdirSync7, readFileSync as readFileSync8, statSync as statSync4 } from "fs";
2755
3102
  import { homedir as homedir5 } from "os";
2756
3103
  import { dirname, join as join13 } from "path";
2757
3104
  import { fileURLToPath } from "url";
2758
3105
  var moduleDir = dirname(fileURLToPath(import.meta.url));
2759
- var BUNDLED_PROMPT = join13(moduleDir, "assign-ticket.md");
3106
+ var BUNDLED_COMMANDS = [
3107
+ { src: join13(moduleDir, "assign-ticket.md"), dest: "assign-ticket.md" },
3108
+ { src: join13(moduleDir, "implement-project.md"), dest: "implement-project.md" }
3109
+ ];
2760
3110
  function installRolePipelineSlashCommand() {
2761
- if (!existsSync11(BUNDLED_PROMPT)) return;
2762
- const bundled = readFileSync8(BUNDLED_PROMPT);
2763
3111
  const targets = resolveTargetDirs();
2764
- for (const dir of targets) {
2765
- try {
2766
- mkdirSync9(dir, { recursive: true });
2767
- const target = join13(dir, "assign-ticket.md");
2768
- if (existsSync11(target)) {
2769
- const current = readFileSync8(target);
2770
- if (current.equals(bundled)) continue;
3112
+ for (const { src, dest } of BUNDLED_COMMANDS) {
3113
+ if (!existsSync11(src)) continue;
3114
+ const bundled = readFileSync8(src);
3115
+ for (const dir of targets) {
3116
+ try {
3117
+ mkdirSync9(dir, { recursive: true });
3118
+ const target = join13(dir, dest);
3119
+ if (existsSync11(target)) {
3120
+ const current = readFileSync8(target);
3121
+ if (current.equals(bundled)) continue;
3122
+ }
3123
+ copyFileSync(src, target);
3124
+ } catch {
2771
3125
  }
2772
- copyFileSync(BUNDLED_PROMPT, target);
2773
- } catch {
2774
3126
  }
2775
3127
  }
2776
3128
  }
@@ -2799,18 +3151,54 @@ function resolveTargetDirs() {
2799
3151
  }
2800
3152
 
2801
3153
  // src/role-pipeline/runner.ts
2802
- function resolveWorkspaceMode(config, noStamp) {
2803
- if (noStamp) return "github";
3154
+ async function resolveCloneUriForAssign(config, slug, resolver) {
3155
+ if (resolver) return resolver(slug);
3156
+ const existing = getRepoEntry(slug, config);
3157
+ let uri;
3158
+ if (existing) {
3159
+ uri = existing["clone-uri"];
3160
+ } else {
3161
+ const defaultUri = `https://github.com/${slug}.git`;
3162
+ const result = await promptCloneUri(
3163
+ slug,
3164
+ defaultUri,
3165
+ { isTTY: process.stdin.isTTY === true },
3166
+ "refuse"
3167
+ );
3168
+ uri = result.uri;
3169
+ setRepoCloneUri(slug, uri);
3170
+ }
2804
3171
  if (config.stamp?.enforce) {
2805
3172
  if (!config.stamp.host || config.stamp.host.length === 0) {
2806
3173
  throw new Error(
2807
3174
  "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'"
2808
3175
  );
2809
3176
  }
2810
- return "stamp";
3177
+ if (!uri.startsWith(config.stamp.host)) {
3178
+ throw new StampEnforceError({ slug, uri, stampHost: config.stamp.host });
3179
+ }
2811
3180
  }
2812
- return "github";
3181
+ return uri;
2813
3182
  }
3183
+ var StampEnforceError = class extends Error {
3184
+ slug;
3185
+ uri;
3186
+ constructor(args) {
3187
+ const lines = [
3188
+ `oteam assign: ${args.slug} clone URI is not stamp-governed.`,
3189
+ ` Recorded URI: ${args.uri}`,
3190
+ ` Expected URI starting with: ${args.stampHost}`,
3191
+ ` Fix: update the recorded URI:`,
3192
+ ` oteam config repo set ${args.slug} --clone-uri <stamp-url>`,
3193
+ ` Or turn enforcement off:`,
3194
+ ` oteam config stamp set --enforce off`
3195
+ ];
3196
+ super(lines.join("\n"));
3197
+ this.name = "StampEnforceError";
3198
+ this.slug = args.slug;
3199
+ this.uri = args.uri;
3200
+ }
3201
+ };
2814
3202
  async function assignTicket(opts) {
2815
3203
  const config = readConfig();
2816
3204
  let resolvedVault = resolveVault({ flagValue: opts.vault, config });
@@ -2830,6 +3218,7 @@ async function assignTicket(opts) {
2830
3218
  `assign: could not parse ticket at ${ticketPath} (frontmatter unreadable)`
2831
3219
  );
2832
3220
  }
3221
+ enforceClaimOrExit(ticket.source, config);
2833
3222
  installRolePipelineSlashCommand();
2834
3223
  const claudePath = findToolOnPath("claude");
2835
3224
  if (!claudePath) {
@@ -2839,28 +3228,32 @@ async function assignTicket(opts) {
2839
3228
  }
2840
3229
  let workspace = null;
2841
3230
  if (ticket.repo) {
2842
- const mode = resolveWorkspaceMode(config, opts.noStamp ?? false);
3231
+ let cloneUri;
2843
3232
  try {
2844
- workspace = prepareAgentWorkspace({
2845
- ticketId: ticket.id,
2846
- repoSlug: ticket.repo,
2847
- mode,
2848
- stampHost: mode === "stamp" ? config.stamp?.host : void 0,
2849
- activeTicketIds: collectActiveTicketIds(resolvedVault.path)
2850
- });
3233
+ cloneUri = await resolveCloneUriForAssign(
3234
+ config,
3235
+ ticket.repo,
3236
+ opts.cloneUriResolver
3237
+ );
2851
3238
  } catch (err) {
2852
- if (err instanceof StampGateError) {
3239
+ if (err instanceof NoTTYError || err instanceof StampEnforceError) {
2853
3240
  process.stderr.write(`${err.message}
2854
3241
  `);
2855
3242
  process.exit(1);
2856
3243
  }
2857
3244
  throw err;
2858
3245
  }
2859
- if (opts.noStamp && config.stamp?.enforce) {
2860
- process.stderr.write(
2861
- `oteam assign: --no-stamp set; cloned from ${workspace.originUrl}. The stamp gate is bypassed \u2014 verify any push manually.
2862
- `
2863
- );
3246
+ try {
3247
+ workspace = prepareAgentWorkspace({
3248
+ ticketId: ticket.id,
3249
+ repoSlug: ticket.repo,
3250
+ cloneUri,
3251
+ activeTicketIds: collectActiveTicketIds(resolvedVault.path)
3252
+ });
3253
+ } catch (err) {
3254
+ process.stderr.write(`${err.message}
3255
+ `);
3256
+ process.exit(1);
2864
3257
  }
2865
3258
  }
2866
3259
  const projectContext = loadProjectContext(resolvedVault.path, ticket.project);
@@ -2873,7 +3266,13 @@ async function assignTicket(opts) {
2873
3266
  models: config.models
2874
3267
  });
2875
3268
  const haikuDownshift = model === HAIKU_PRODUCT_MODEL && ticket.state === "triage";
2876
- const systemPrompt = composeSystemPrompt(ticket.id, projectContext, haikuDownshift);
3269
+ const pushDisabled = config.push === "off";
3270
+ const systemPrompt = composeSystemPrompt(
3271
+ ticket.id,
3272
+ projectContext,
3273
+ haikuDownshift,
3274
+ pushDisabled
3275
+ );
2877
3276
  const phase = phaseForState(ticket.state);
2878
3277
  const telemetry = phase !== null && getTelemetryEnabled() ? {
2879
3278
  ticketId: ticket.id,
@@ -2884,7 +3283,7 @@ async function assignTicket(opts) {
2884
3283
  const wantsKitty = !opts.workInline && isMacOS();
2885
3284
  if (!wantsKitty) {
2886
3285
  process.stdout.write(inlineStartLine(ticket.id) + "\n");
2887
- runInline(
3286
+ await runInline(
2888
3287
  claudePath,
2889
3288
  ticketPath,
2890
3289
  resolvedVault.path,
@@ -2913,7 +3312,7 @@ async function assignTicket(opts) {
2913
3312
  process.exit(1);
2914
3313
  }
2915
3314
  const cwd = workspace?.path ?? dirname2(ticketPath);
2916
- const title = `Vault \xB7 ${basename5(ticketPath)}`;
3315
+ const title = `Vault \xB7 ${basename4(ticketPath)}`;
2917
3316
  const repoBasename = ticket.repo?.split("/").pop() ?? null;
2918
3317
  const repoSlug = ticket.repo ? ticket.repo.replace(/\//g, "-").toLowerCase() : null;
2919
3318
  const envPrefix = envSourcingPrefix(preferring, repoBasename, repoSlug, {
@@ -2926,15 +3325,23 @@ async function assignTicket(opts) {
2926
3325
  const projectFlag = systemPrompt ? ` --append-system-prompt "$(cat '${shellEscape(systemPrompt.tmpFile)}')"` : "";
2927
3326
  const sessionFlag = telemetry ? ` --session-id '${shellEscape(telemetry.sessionId)}'` : "";
2928
3327
  const claudeCmd = `'${escapedClaude}' --dangerously-skip-permissions --model ${shellEscape(model)}${sessionFlag}${projectFlag} '${escapedPrompt}'`;
2929
- const telemetryTail = telemetry ? buildTelemetryTail({
2930
- oteamPath: findToolOnPath("oteam") ?? "oteam",
2931
- ticketId: telemetry.ticketId,
2932
- phase: telemetry.phase,
2933
- model,
2934
- sessionId: telemetry.sessionId,
2935
- startedAt: telemetry.startedAt
2936
- }) : "";
2937
- const shellCmd = `${envPrefix}${claudeCmd}${telemetryTail}`;
3328
+ const sentinelPath = sentinelPathForTicket(ticket.id);
3329
+ try {
3330
+ unlinkSync(sentinelPath);
3331
+ } catch {
3332
+ }
3333
+ const tail2 = buildKittySpawnTail({
3334
+ sentinelPath,
3335
+ telemetry: telemetry ? {
3336
+ oteamPath: findToolOnPath("oteam") ?? "oteam",
3337
+ ticketId: telemetry.ticketId,
3338
+ phase: telemetry.phase,
3339
+ model,
3340
+ sessionId: telemetry.sessionId,
3341
+ startedAt: telemetry.startedAt
3342
+ } : null
3343
+ });
3344
+ const shellCmd = `${envPrefix}${claudeCmd}${tail2}`;
2938
3345
  const result = kittyLaunch({
2939
3346
  socket,
2940
3347
  title,
@@ -2947,50 +3354,88 @@ async function assignTicket(opts) {
2947
3354
  `kitty @ launch exited ${result.exitCode}: ${result.stderr || "(no stderr)"}`
2948
3355
  );
2949
3356
  }
2950
- process.stdout.write(kittySpawnLine(ticket.id, workspace?.path ?? null) + "\n");
3357
+ process.stdout.write(
3358
+ kittySpawnLine(ticket.id, workspace?.path ?? null, sentinelPath) + "\n"
3359
+ );
3360
+ }
3361
+ function sentinelPathForTicket(ticketId) {
3362
+ return `/tmp/oteam-sentinel-${ticketId.toLowerCase()}.exit`;
2951
3363
  }
2952
- function kittySpawnLine(ticketId, workspacePath) {
2953
- const suffix = workspacePath ? ` (worktree at ${workspacePath})` : "";
2954
- return `oteam assign: spawned kitty window for ${ticketId}${suffix}`;
3364
+ function kittySpawnLine(ticketId, workspacePath, sentinelPath = null) {
3365
+ const worktree = workspacePath ? ` (worktree at ${workspacePath})` : "";
3366
+ const sentinel = sentinelPath ? ` (sentinel ${sentinelPath})` : "";
3367
+ return `oteam assign: spawned kitty window for ${ticketId}${worktree}${sentinel}`;
2955
3368
  }
2956
3369
  function inlineStartLine(ticketId) {
2957
3370
  return `oteam assign: running inline for ${ticketId}; agent starting\u2026`;
2958
3371
  }
2959
- function buildTelemetryTail(input) {
2960
- const oteam = `'${shellEscape(input.oteamPath)}'`;
2961
- const args = [
2962
- `--ticket '${shellEscape(input.ticketId)}'`,
2963
- `--phase '${shellEscape(input.phase)}'`,
2964
- `--model '${shellEscape(input.model)}'`,
2965
- `--session '${shellEscape(input.sessionId)}'`,
2966
- `--started-at '${shellEscape(input.startedAt)}'`,
2967
- `--exit-code "$EC"`
2968
- ].join(" ");
2969
- return `; EC=$?; ${oteam} telemetry record ${args} >/dev/null 2>&1 || true; exit "$EC"`;
2970
- }
2971
- function runInline(claudePath, ticketPath, vaultPath, systemPrompt, workspace, model, telemetry) {
3372
+ function buildKittySpawnTail(input) {
3373
+ const sentinelWrite = `printf '%s\\n' "$EC" > '${shellEscape(input.sentinelPath)}' || true`;
3374
+ const parts = [`EC=$?`, sentinelWrite];
3375
+ if (input.telemetry) {
3376
+ const oteam = `'${shellEscape(input.telemetry.oteamPath)}'`;
3377
+ const args = [
3378
+ `--ticket '${shellEscape(input.telemetry.ticketId)}'`,
3379
+ `--phase '${shellEscape(input.telemetry.phase)}'`,
3380
+ `--model '${shellEscape(input.telemetry.model)}'`,
3381
+ `--session '${shellEscape(input.telemetry.sessionId)}'`,
3382
+ `--started-at '${shellEscape(input.telemetry.startedAt)}'`,
3383
+ `--exit-code "$EC"`
3384
+ ].join(" ");
3385
+ parts.push(`${oteam} telemetry record ${args} >/dev/null 2>&1 || true`);
3386
+ }
3387
+ parts.push(`exit "$EC"`);
3388
+ return `; ${parts.join("; ")}`;
3389
+ }
3390
+ async function runInline(claudePath, ticketPath, vaultPath, systemPrompt, workspace, model, telemetry) {
3391
+ const sessionId = telemetry?.sessionId ?? randomUUID();
2972
3392
  const args = [
2973
3393
  "--dangerously-skip-permissions",
2974
3394
  "--model",
2975
- model
3395
+ model,
3396
+ "--session-id",
3397
+ sessionId
2976
3398
  ];
2977
- if (telemetry) {
2978
- args.push("--session-id", telemetry.sessionId);
2979
- }
2980
3399
  if (systemPrompt) {
2981
3400
  args.push("--append-system-prompt", systemPrompt.content);
2982
3401
  }
2983
3402
  args.push(`/assign-ticket ${ticketPath}`);
2984
- const cwd = workspace?.path ?? process.cwd();
2985
- const r = spawnSync4(
2986
- claudePath,
2987
- args,
2988
- {
2989
- stdio: "inherit",
2990
- cwd: workspace?.path,
2991
- env: { ...process.env, PRODUCT_VAULT_PATH: vaultPath }
3403
+ const rawCwd = workspace?.path ?? process.cwd();
3404
+ const cwd = (() => {
3405
+ try {
3406
+ return realpathSync(rawCwd);
3407
+ } catch {
3408
+ return rawCwd;
2992
3409
  }
2993
- );
3410
+ })();
3411
+ const child = spawn(claudePath, args, {
3412
+ stdio: "inherit",
3413
+ cwd: workspace?.path,
3414
+ env: { ...process.env, PRODUCT_VAULT_PATH: vaultPath },
3415
+ detached: true
3416
+ });
3417
+ if (child.pid == null) {
3418
+ throw new Error(`oteam assign: failed to spawn claude \u2014 pid is null`);
3419
+ }
3420
+ const pgid = child.pid;
3421
+ const exitCode = await new Promise((resolve5) => {
3422
+ child.on("exit", (code) => resolve5(code ?? 0));
3423
+ });
3424
+ const killed = await killGroupAfterGrace(pgid, 3e4);
3425
+ if (killed.length > 0) {
3426
+ process.stderr.write(
3427
+ `oteam assign: killed ${killed.length} subprocess(es) that outlived the agent turn: PIDs ${killed.join(", ")}
3428
+ `
3429
+ );
3430
+ const claudeConfigDir = (process.env["CLAUDE_CONFIG_DIR"] ?? "").length > 0 ? process.env["CLAUDE_CONFIG_DIR"] : join14(homedir6(), ".claude");
3431
+ const sessionPath = findSessionFile(claudeConfigDir, cwd, sessionId);
3432
+ const summary = lastAssistantText(sessionPath);
3433
+ if (summary) {
3434
+ process.stdout.write(
3435
+ "\n--- Last agent output (recovered from session JSONL) ---\n" + summary + "\n--- end recovered output ---\n"
3436
+ );
3437
+ }
3438
+ }
2994
3439
  if (telemetry) {
2995
3440
  recordPhase({
2996
3441
  ticket: telemetry.ticketId,
@@ -2998,17 +3443,45 @@ function runInline(claudePath, ticketPath, vaultPath, systemPrompt, workspace, m
2998
3443
  model,
2999
3444
  sessionId: telemetry.sessionId,
3000
3445
  startedAt: telemetry.startedAt,
3001
- exitCode: r.status ?? -1,
3446
+ exitCode,
3002
3447
  cwd
3003
3448
  });
3004
3449
  }
3005
- if (r.status != null && r.status !== 0) process.exit(r.status);
3450
+ if (exitCode !== 0) process.exit(exitCode);
3006
3451
  }
3452
+ async function killGroupAfterGrace(pgid, graceMs, opts = {}) {
3453
+ const listFn = opts.listFn ?? listProcessGroup;
3454
+ const killFn = opts.killFn ?? ((pid) => {
3455
+ try {
3456
+ process.kill(pid, "SIGKILL");
3457
+ } catch {
3458
+ }
3459
+ });
3460
+ const pollMs = opts.pollMs ?? 500;
3461
+ const deadline = Date.now() + graceMs;
3462
+ while (Date.now() < deadline) {
3463
+ const pids = listFn(pgid);
3464
+ if (pids.length === 0) return [];
3465
+ await new Promise((r) => setTimeout(r, pollMs));
3466
+ }
3467
+ const survivors = listFn(pgid);
3468
+ for (const pid of survivors) killFn(pid);
3469
+ return survivors;
3470
+ }
3471
+ function listProcessGroup(pgid) {
3472
+ let r = spawnSync5("pgrep", ["-g", String(pgid)], { encoding: "utf8" });
3473
+ if (r.status !== 0 || !r.stdout?.trim()) {
3474
+ r = spawnSync5("ps", ["-o", "pid=", "-g", String(pgid)], { encoding: "utf8" });
3475
+ }
3476
+ if (r.status !== 0 || !r.stdout) return [];
3477
+ return r.stdout.trim().split("\n").map((s) => parseInt(s.trim(), 10)).filter((n) => Number.isFinite(n) && n > 0);
3478
+ }
3479
+ var TERMINAL_STATES = /* @__PURE__ */ new Set(["done", "blocked"]);
3007
3480
  function collectActiveTicketIds(vaultPath) {
3008
3481
  const ids = /* @__PURE__ */ new Set();
3009
3482
  try {
3010
3483
  for (const t of readAllTickets(vaultPath)) {
3011
- ids.add(t.id.toLowerCase());
3484
+ if (!TERMINAL_STATES.has(t.state)) ids.add(t.id.toLowerCase());
3012
3485
  }
3013
3486
  } catch {
3014
3487
  }
@@ -3026,10 +3499,11 @@ function loadProjectContext(vaultPath, projectId) {
3026
3499
  }
3027
3500
  return formatProjectContextForPrompt(project);
3028
3501
  }
3029
- function composeSystemPrompt(ticketId, projectContext, haikuDownshift) {
3502
+ function composeSystemPrompt(ticketId, projectContext, haikuDownshift, pushDisabled) {
3030
3503
  const parts = [];
3031
3504
  if (projectContext) parts.push(projectContext);
3032
3505
  if (haikuDownshift) parts.push(haikuDownshiftPromptHint());
3506
+ if (pushDisabled) parts.push(pushDisabledPromptHint());
3033
3507
  if (parts.length === 0) return null;
3034
3508
  const content = parts.join("\n\n");
3035
3509
  const safeId = ticketId.replace(/[^a-zA-Z0-9._-]/g, "_");
@@ -3050,6 +3524,21 @@ function haikuDownshiftPromptHint() {
3050
3524
  "instead of the standard `### YYYY-MM-DD \u2014 Product agent`. That makes the heuristic visible in the ticket's audit trail."
3051
3525
  ].join("\n");
3052
3526
  }
3527
+ function pushDisabledPromptHint() {
3528
+ return [
3529
+ "# Push step: disabled by oteam config",
3530
+ "",
3531
+ "AGT-099: the operator has set `push: off` in `~/.open-team/config.json`. When you reach Phase 4b's outbound push (Step 5a `stamp push`, Step 5b `git push -u origin <feature>`, or Step 5c `git push -u origin <feature>` after the local stamp-merge), do NOT run it. Run every step before the push as normal \u2014 review, status gate, stamp-merge \u2014 but stop short of the push command itself.",
3532
+ "",
3533
+ "Instead of pushing, print this status line verbatim (substituting `<sha>` with the SHA of the most recent commit on the branch about to be pushed \u2014 `git rev-parse HEAD` after the merge in 5a/5c, or after the last feature commit in 5b):",
3534
+ "",
3535
+ " push disabled by oteam config; merge commit is local at <sha>; run 'git push origin' manually when ready",
3536
+ "",
3537
+ "Then continue with the rest of Phase 4b (PR creation in 5b/5c is also skipped, since there is nothing pushed for `gh pr create` to reference; record `linked-pr:` as empty and note in the wrap-up comment that the push was held). Step 6 (stamp retro routing) still runs because it does not depend on any push.",
3538
+ "",
3539
+ "This gate covers only the assign-side push step. Ingest commands (`oteam pull github`) are unaffected."
3540
+ ].join("\n");
3541
+ }
3053
3542
  function readTicketBody(path) {
3054
3543
  try {
3055
3544
  return readFileSync9(path, "utf8");
@@ -3058,7 +3547,7 @@ function readTicketBody(path) {
3058
3547
  }
3059
3548
  }
3060
3549
  function findToolOnPath(name) {
3061
- const r = spawnSync4("/usr/bin/env", ["which", name], { encoding: "utf8" });
3550
+ const r = spawnSync5("/usr/bin/env", ["which", name], { encoding: "utf8" });
3062
3551
  if (r.status !== 0) return null;
3063
3552
  const path = (r.stdout || "").trim();
3064
3553
  return path.length > 0 ? path : null;
@@ -3068,11 +3557,52 @@ function readMonitoredOrgsFromEnv() {
3068
3557
  if (!raw) return [];
3069
3558
  return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
3070
3559
  }
3560
+ function enforceClaimOrExit(source, config) {
3561
+ if (source.type !== "github" || !source.url) return;
3562
+ const identity = resolveBotIdentity(config);
3563
+ if (identity.length === 0) return;
3564
+ const ref = parseIssueRef(source.url);
3565
+ if (!ref) {
3566
+ process.stderr.write(
3567
+ `oteam assign: ticket source.url "${source.url}" is not a parseable github issue ref \u2014 skipping claim
3568
+ `
3569
+ );
3570
+ return;
3571
+ }
3572
+ const claim = claimGitHubIssue(ref.slug, ref.number, identity);
3573
+ if (claim.ok) return;
3574
+ switch (claim.reason) {
3575
+ case "issue-closed":
3576
+ process.stderr.write(
3577
+ `oteam assign: refusing to drive role pipeline \u2014 ${ref.slug}#${ref.number} is closed
3578
+ `
3579
+ );
3580
+ process.exit(1);
3581
+ case "already-claimed":
3582
+ process.stderr.write(
3583
+ `oteam assign: refusing to drive role pipeline \u2014 ${ref.slug}#${ref.number} is assigned to ${claim.assignees.join(", ")} (not "${identity}")
3584
+ `
3585
+ );
3586
+ process.exit(1);
3587
+ case "no-write-access":
3588
+ process.stderr.write(
3589
+ `oteam assign: cannot claim ${ref.slug}#${ref.number} as "${identity}" \u2014 gh token has no push access on the repo (assignee changes are silently dropped). Add the operator as a collaborator, or unset botIdentity if claims aren't wanted on this repo.
3590
+ `
3591
+ );
3592
+ process.exit(1);
3593
+ case "api-error":
3594
+ process.stderr.write(
3595
+ `oteam assign: claim failed for ${ref.slug}#${ref.number} \u2014 ${claim.error}
3596
+ `
3597
+ );
3598
+ process.exit(1);
3599
+ }
3600
+ }
3071
3601
 
3072
3602
  // package.json
3073
3603
  var package_default = {
3074
3604
  name: "@openthink/team",
3075
- version: "0.0.11",
3605
+ version: "0.0.13",
3076
3606
  type: "module",
3077
3607
  description: "Source-agnostic vault-driven role pipeline for spawning Claude agents against tickets",
3078
3608
  bin: {
@@ -3082,7 +3612,7 @@ var package_default = {
3082
3612
  "dist"
3083
3613
  ],
3084
3614
  scripts: {
3085
- build: "tsup && cp src/role-pipeline/assign-ticket.md dist/assign-ticket.md",
3615
+ build: "tsup && cp src/role-pipeline/assign-ticket.md dist/assign-ticket.md && cp src/role-pipeline/implement-project.md dist/implement-project.md",
3086
3616
  dev: "tsx src/index.ts",
3087
3617
  typecheck: "tsc --noEmit",
3088
3618
  test: "node --test --import tsx 'tests/**/*.test.ts'",
@@ -3120,14 +3650,15 @@ var package_default = {
3120
3650
  // src/index.ts
3121
3651
  var program = new Command6();
3122
3652
  program.name("oteam").description(
3123
- "Source-agnostic vault-driven role pipeline for spawning Claude agents against tickets"
3653
+ "Source-agnostic workspace-driven role pipeline for spawning Claude agents against tickets"
3124
3654
  ).version(package_default.version);
3125
3655
  async function handlePull(source, ref, opts) {
3126
3656
  const result = await runPull({
3127
3657
  source,
3128
3658
  ref,
3129
- vault: opts.vault,
3130
- project: opts.project
3659
+ vault: opts.workspace ?? opts.vault,
3660
+ project: opts.project,
3661
+ cloneUri: opts.cloneUri
3131
3662
  });
3132
3663
  const verb = result.reused ? "Reused existing" : "Filed";
3133
3664
  process.stdout.write(`\u2705 ${verb} ${result.ticketID}
@@ -3135,34 +3666,36 @@ async function handlePull(source, ref, opts) {
3135
3666
  `);
3136
3667
  }
3137
3668
  program.command("pull <source> <ref>").description(
3138
- "Ingest an external item into the vault as a triage ticket (sources: github)"
3139
- ).option("--vault <name-or-path>", "Use a specific registered vault").option(
3669
+ "Ingest an external item into the workspace as a triage ticket (sources: github)"
3670
+ ).option("-w, --workspace <name-or-path>", "Use a specific registered workspace").addOption(new Option3("--vault <name-or-path>").hideHelp()).option(
3140
3671
  "--project <name>",
3141
3672
  "Tag the ticket with a project name (defaults to the source repo's bare name)"
3673
+ ).option(
3674
+ "--clone-uri <url>",
3675
+ "Record this clone URI for the repo instead of prompting (daemon-friendly)"
3142
3676
  ).action(handlePull);
3143
- program.command("ingest <source> <ref>", { hidden: true }).description("Hidden alias for `pull`.").option("--vault <name-or-path>", "Use a specific registered vault").option(
3677
+ program.command("ingest <source> <ref>", { hidden: true }).description("Hidden alias for `pull`.").option("-w, --workspace <name-or-path>", "Use a specific registered workspace").addOption(new Option3("--vault <name-or-path>").hideHelp()).option(
3144
3678
  "--project <name>",
3145
3679
  "Tag the ticket with a project name (defaults to the source repo's bare name)"
3680
+ ).option(
3681
+ "--clone-uri <url>",
3682
+ "Record this clone URI for the repo instead of prompting (daemon-friendly)"
3146
3683
  ).action(handlePull);
3147
3684
  program.command("assign <ticket-or-id>").description(
3148
3685
  "Drive the role pipeline against a ticket (full path or AGT-NNN id)"
3149
3686
  ).option(
3150
3687
  "--inline",
3151
3688
  "Run the role pipeline in the current terminal instead of spawning kitty"
3152
- ).option("--vault <name-or-path>", "Use a specific registered vault").option(
3153
- "--no-stamp",
3154
- "Force a github clone for this run, overriding stamp.enforce in oteam config. The durable knob is 'oteam config stamp set --enforce off'."
3155
- ).action(
3689
+ ).option("-w, --workspace <name-or-path>", "Use a specific registered workspace").addOption(new Option3("--vault <name-or-path>").hideHelp()).action(
3156
3690
  async (ticketPath, opts) => {
3157
3691
  await assignTicket({
3158
3692
  ticketPath,
3159
3693
  workInline: opts.inline,
3160
- vault: opts.vault,
3161
- noStamp: opts.stamp === false
3694
+ vault: opts.workspace ?? opts.vault
3162
3695
  });
3163
3696
  }
3164
3697
  );
3165
- program.command("list").description("List tickets in the vault (filter by structured frontmatter or grep)").option("--state <state>", "Filter by ticket state (triage|refined|...)").option("--project <name>", "Filter by project name (case-insensitive)").option("--repo <slug>", "Filter by repo frontmatter (case-insensitive)").option("--team <team>", "Filter by team (case-insensitive)").option("--priority <priority>", "Filter by priority (case-insensitive)").option("--source <type>", "Filter by source.type (github|manual|...)").option(
3698
+ program.command("list").description("List tickets in the workspace (filter by structured frontmatter or grep)").option("--state <state>", "Filter by ticket state (triage|refined|...)").option("--project <name>", "Filter by project name (case-insensitive)").option("--repo <slug>", "Filter by repo frontmatter (case-insensitive)").option("--team <team>", "Filter by team (case-insensitive)").option("--priority <priority>", "Filter by priority (case-insensitive)").option("--source <type>", "Filter by source.type (github|manual|...)").option(
3166
3699
  "--label <label>",
3167
3700
  "Filter by label (case-insensitive; repeatable, all must match)",
3168
3701
  (value, prev = []) => [...prev, value],
@@ -3175,8 +3708,8 @@ program.command("list").description("List tickets in the vault (filter by struct
3175
3708
  "Case-insensitive substring match against the ticket body (reads files)"
3176
3709
  ).option(
3177
3710
  "--include-archived",
3178
- "Also search <vault>/archive/ (excluded by default)"
3179
- ).option("--vault <name-or-path>", "Use a specific registered vault").action(
3711
+ "Also search <workspace>/archive/ (excluded by default)"
3712
+ ).option("-w, --workspace <name-or-path>", "Use a specific registered workspace").addOption(new Option3("--vault <name-or-path>").hideHelp()).action(
3180
3713
  (opts) => {
3181
3714
  if (opts.state && !TICKET_STATES.includes(opts.state)) {
3182
3715
  process.stderr.write(
@@ -3185,11 +3718,11 @@ program.command("list").description("List tickets in the vault (filter by struct
3185
3718
  );
3186
3719
  process.exit(2);
3187
3720
  }
3188
- process.stdout.write(runList(opts) + "\n");
3721
+ process.stdout.write(runList({ ...opts, vault: opts.workspace ?? opts.vault }) + "\n");
3189
3722
  }
3190
3723
  );
3191
- program.command("archive <ticket-id>").description("Move a done ticket to archive/YYYY-MM/").option("--vault <name-or-path>", "Use a specific registered vault").action((ticketID, opts) => {
3192
- const path = runArchive({ ticketID, vault: opts.vault });
3724
+ program.command("archive <ticket-id>").description("Move a done ticket to archive/YYYY-MM/").option("-w, --workspace <name-or-path>", "Use a specific registered workspace").addOption(new Option3("--vault <name-or-path>").hideHelp()).action((ticketID, opts) => {
3725
+ const path = runArchive({ ticketID, vault: opts.workspace ?? opts.vault });
3193
3726
  process.stdout.write(`\u2705 Archived
3194
3727
  ${path}
3195
3728
  `);