@openthink/team 0.0.12 → 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/README.md +54 -31
- package/dist/assign-ticket.md +25 -21
- package/dist/index.js +883 -355
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
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, (
|
|
440
|
-
if (!
|
|
441
|
-
const trimmed =
|
|
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
|
|
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
|
|
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
|
-
`--
|
|
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
|
-
`
|
|
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
|
-
`
|
|
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
|
|
1221
|
-
import { basename as basename2, join as
|
|
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 =
|
|
1236
|
-
|
|
1237
|
-
const target =
|
|
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
|
-
|
|
1249
|
-
"
|
|
1250
|
-
|
|
1251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
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
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
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
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
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
|
-
|
|
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
|
|
1437
|
-
import { resolve as resolve3, join as
|
|
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
|
|
1443
|
-
mkdirSync as
|
|
1444
|
-
readdirSync as
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
1850
|
+
if (existsSync4(join7(target, SENTINEL_FILENAME))) {
|
|
1499
1851
|
return { outcome: "already-initialised", path: target };
|
|
1500
1852
|
}
|
|
1501
|
-
if (
|
|
1502
|
-
const visible =
|
|
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
|
-
|
|
1859
|
+
mkdirSync5(target, { recursive: true });
|
|
1508
1860
|
for (const sub of WORKSPACE_SUBDIRS) {
|
|
1509
|
-
|
|
1861
|
+
mkdirSync5(join7(target, sub), { recursive: true });
|
|
1510
1862
|
}
|
|
1511
|
-
writeFileSync3(
|
|
1512
|
-
writeFileSync3(
|
|
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
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
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
|
|
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",
|
|
1549
|
-
|
|
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 (!
|
|
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
|
|
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 (!
|
|
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 =
|
|
1645
|
-
const claudePath =
|
|
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
|
|
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
|
|
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
|
|
1797
|
-
import { join as
|
|
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
|
|
2151
|
+
return join9(vaultPath, "projects");
|
|
1800
2152
|
}
|
|
1801
2153
|
function projectDir(vaultPath, id) {
|
|
1802
|
-
return
|
|
2154
|
+
return join9(projectsRoot(vaultPath), id);
|
|
1803
2155
|
}
|
|
1804
2156
|
function projectReadmePath(vaultPath, id) {
|
|
1805
|
-
return
|
|
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 (!
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 \`<
|
|
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
|
|
2314
|
+
"Manage workspace projects (folders under <workspace>/projects/<id>/)"
|
|
1953
2315
|
);
|
|
1954
|
-
project.command("init <id>").description("Scaffold <
|
|
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
|
|
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("--
|
|
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 (
|
|
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
|
-
|
|
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
|
-
<
|
|
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 =
|
|
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
|
|
2125
|
-
mkdirSync as
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
2566
|
+
if (text.length > 0) lastAssistantText2 = text;
|
|
2180
2567
|
}
|
|
2181
|
-
return { tokens, outcome: detectOutcome(
|
|
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
|
|
2598
|
+
return join11(homedir4(), ".open-team", "telemetry");
|
|
2212
2599
|
}
|
|
2213
2600
|
function runsPath(dir = telemetryDir()) {
|
|
2214
|
-
return
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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 (!
|
|
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
|
|
2448
|
-
import { join as
|
|
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 =
|
|
2456
|
-
|
|
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 =
|
|
2465
|
-
if (
|
|
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
|
|
2886
|
+
"Create workspace tickets directly (without an external source)"
|
|
2489
2887
|
);
|
|
2490
2888
|
ticket.command("new <title>").description(
|
|
2491
|
-
"File a new ticket in <
|
|
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("--
|
|
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
|
|
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
|
|
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
|
|
2527
|
-
import { existsSync as
|
|
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 =
|
|
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 =
|
|
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 (!
|
|
3035
|
+
if (!existsSync10(path)) continue;
|
|
2571
3036
|
const socket = `unix:${path}`;
|
|
2572
|
-
const r =
|
|
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 =
|
|
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,124 +3097,6 @@ 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";
|
|
@@ -2804,18 +3151,54 @@ function resolveTargetDirs() {
|
|
|
2804
3151
|
}
|
|
2805
3152
|
|
|
2806
3153
|
// src/role-pipeline/runner.ts
|
|
2807
|
-
function
|
|
2808
|
-
if (
|
|
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
|
+
}
|
|
2809
3171
|
if (config.stamp?.enforce) {
|
|
2810
3172
|
if (!config.stamp.host || config.stamp.host.length === 0) {
|
|
2811
3173
|
throw new Error(
|
|
2812
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'"
|
|
2813
3175
|
);
|
|
2814
3176
|
}
|
|
2815
|
-
|
|
3177
|
+
if (!uri.startsWith(config.stamp.host)) {
|
|
3178
|
+
throw new StampEnforceError({ slug, uri, stampHost: config.stamp.host });
|
|
3179
|
+
}
|
|
2816
3180
|
}
|
|
2817
|
-
return
|
|
3181
|
+
return uri;
|
|
2818
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
|
+
};
|
|
2819
3202
|
async function assignTicket(opts) {
|
|
2820
3203
|
const config = readConfig();
|
|
2821
3204
|
let resolvedVault = resolveVault({ flagValue: opts.vault, config });
|
|
@@ -2835,6 +3218,7 @@ async function assignTicket(opts) {
|
|
|
2835
3218
|
`assign: could not parse ticket at ${ticketPath} (frontmatter unreadable)`
|
|
2836
3219
|
);
|
|
2837
3220
|
}
|
|
3221
|
+
enforceClaimOrExit(ticket.source, config);
|
|
2838
3222
|
installRolePipelineSlashCommand();
|
|
2839
3223
|
const claudePath = findToolOnPath("claude");
|
|
2840
3224
|
if (!claudePath) {
|
|
@@ -2844,28 +3228,32 @@ async function assignTicket(opts) {
|
|
|
2844
3228
|
}
|
|
2845
3229
|
let workspace = null;
|
|
2846
3230
|
if (ticket.repo) {
|
|
2847
|
-
|
|
3231
|
+
let cloneUri;
|
|
2848
3232
|
try {
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
activeTicketIds: collectActiveTicketIds(resolvedVault.path)
|
|
2855
|
-
});
|
|
3233
|
+
cloneUri = await resolveCloneUriForAssign(
|
|
3234
|
+
config,
|
|
3235
|
+
ticket.repo,
|
|
3236
|
+
opts.cloneUriResolver
|
|
3237
|
+
);
|
|
2856
3238
|
} catch (err) {
|
|
2857
|
-
if (err instanceof
|
|
3239
|
+
if (err instanceof NoTTYError || err instanceof StampEnforceError) {
|
|
2858
3240
|
process.stderr.write(`${err.message}
|
|
2859
3241
|
`);
|
|
2860
3242
|
process.exit(1);
|
|
2861
3243
|
}
|
|
2862
3244
|
throw err;
|
|
2863
3245
|
}
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
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);
|
|
2869
3257
|
}
|
|
2870
3258
|
}
|
|
2871
3259
|
const projectContext = loadProjectContext(resolvedVault.path, ticket.project);
|
|
@@ -2878,7 +3266,13 @@ async function assignTicket(opts) {
|
|
|
2878
3266
|
models: config.models
|
|
2879
3267
|
});
|
|
2880
3268
|
const haikuDownshift = model === HAIKU_PRODUCT_MODEL && ticket.state === "triage";
|
|
2881
|
-
const
|
|
3269
|
+
const pushDisabled = config.push === "off";
|
|
3270
|
+
const systemPrompt = composeSystemPrompt(
|
|
3271
|
+
ticket.id,
|
|
3272
|
+
projectContext,
|
|
3273
|
+
haikuDownshift,
|
|
3274
|
+
pushDisabled
|
|
3275
|
+
);
|
|
2882
3276
|
const phase = phaseForState(ticket.state);
|
|
2883
3277
|
const telemetry = phase !== null && getTelemetryEnabled() ? {
|
|
2884
3278
|
ticketId: ticket.id,
|
|
@@ -2889,7 +3283,7 @@ async function assignTicket(opts) {
|
|
|
2889
3283
|
const wantsKitty = !opts.workInline && isMacOS();
|
|
2890
3284
|
if (!wantsKitty) {
|
|
2891
3285
|
process.stdout.write(inlineStartLine(ticket.id) + "\n");
|
|
2892
|
-
runInline(
|
|
3286
|
+
await runInline(
|
|
2893
3287
|
claudePath,
|
|
2894
3288
|
ticketPath,
|
|
2895
3289
|
resolvedVault.path,
|
|
@@ -2918,7 +3312,7 @@ async function assignTicket(opts) {
|
|
|
2918
3312
|
process.exit(1);
|
|
2919
3313
|
}
|
|
2920
3314
|
const cwd = workspace?.path ?? dirname2(ticketPath);
|
|
2921
|
-
const title = `Vault \xB7 ${
|
|
3315
|
+
const title = `Vault \xB7 ${basename4(ticketPath)}`;
|
|
2922
3316
|
const repoBasename = ticket.repo?.split("/").pop() ?? null;
|
|
2923
3317
|
const repoSlug = ticket.repo ? ticket.repo.replace(/\//g, "-").toLowerCase() : null;
|
|
2924
3318
|
const envPrefix = envSourcingPrefix(preferring, repoBasename, repoSlug, {
|
|
@@ -2931,15 +3325,23 @@ async function assignTicket(opts) {
|
|
|
2931
3325
|
const projectFlag = systemPrompt ? ` --append-system-prompt "$(cat '${shellEscape(systemPrompt.tmpFile)}')"` : "";
|
|
2932
3326
|
const sessionFlag = telemetry ? ` --session-id '${shellEscape(telemetry.sessionId)}'` : "";
|
|
2933
3327
|
const claudeCmd = `'${escapedClaude}' --dangerously-skip-permissions --model ${shellEscape(model)}${sessionFlag}${projectFlag} '${escapedPrompt}'`;
|
|
2934
|
-
const
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
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}`;
|
|
2943
3345
|
const result = kittyLaunch({
|
|
2944
3346
|
socket,
|
|
2945
3347
|
title,
|
|
@@ -2952,50 +3354,88 @@ async function assignTicket(opts) {
|
|
|
2952
3354
|
`kitty @ launch exited ${result.exitCode}: ${result.stderr || "(no stderr)"}`
|
|
2953
3355
|
);
|
|
2954
3356
|
}
|
|
2955
|
-
process.stdout.write(
|
|
3357
|
+
process.stdout.write(
|
|
3358
|
+
kittySpawnLine(ticket.id, workspace?.path ?? null, sentinelPath) + "\n"
|
|
3359
|
+
);
|
|
2956
3360
|
}
|
|
2957
|
-
function
|
|
2958
|
-
|
|
2959
|
-
|
|
3361
|
+
function sentinelPathForTicket(ticketId) {
|
|
3362
|
+
return `/tmp/oteam-sentinel-${ticketId.toLowerCase()}.exit`;
|
|
3363
|
+
}
|
|
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}`;
|
|
2960
3368
|
}
|
|
2961
3369
|
function inlineStartLine(ticketId) {
|
|
2962
3370
|
return `oteam assign: running inline for ${ticketId}; agent starting\u2026`;
|
|
2963
3371
|
}
|
|
2964
|
-
function
|
|
2965
|
-
const
|
|
2966
|
-
const
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
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();
|
|
2977
3392
|
const args = [
|
|
2978
3393
|
"--dangerously-skip-permissions",
|
|
2979
3394
|
"--model",
|
|
2980
|
-
model
|
|
3395
|
+
model,
|
|
3396
|
+
"--session-id",
|
|
3397
|
+
sessionId
|
|
2981
3398
|
];
|
|
2982
|
-
if (telemetry) {
|
|
2983
|
-
args.push("--session-id", telemetry.sessionId);
|
|
2984
|
-
}
|
|
2985
3399
|
if (systemPrompt) {
|
|
2986
3400
|
args.push("--append-system-prompt", systemPrompt.content);
|
|
2987
3401
|
}
|
|
2988
3402
|
args.push(`/assign-ticket ${ticketPath}`);
|
|
2989
|
-
const
|
|
2990
|
-
const
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
{
|
|
2994
|
-
|
|
2995
|
-
cwd: workspace?.path,
|
|
2996
|
-
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;
|
|
2997
3409
|
}
|
|
2998
|
-
);
|
|
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
|
+
}
|
|
2999
3439
|
if (telemetry) {
|
|
3000
3440
|
recordPhase({
|
|
3001
3441
|
ticket: telemetry.ticketId,
|
|
@@ -3003,17 +3443,45 @@ function runInline(claudePath, ticketPath, vaultPath, systemPrompt, workspace, m
|
|
|
3003
3443
|
model,
|
|
3004
3444
|
sessionId: telemetry.sessionId,
|
|
3005
3445
|
startedAt: telemetry.startedAt,
|
|
3006
|
-
exitCode
|
|
3446
|
+
exitCode,
|
|
3007
3447
|
cwd
|
|
3008
3448
|
});
|
|
3009
3449
|
}
|
|
3010
|
-
if (
|
|
3450
|
+
if (exitCode !== 0) process.exit(exitCode);
|
|
3011
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"]);
|
|
3012
3480
|
function collectActiveTicketIds(vaultPath) {
|
|
3013
3481
|
const ids = /* @__PURE__ */ new Set();
|
|
3014
3482
|
try {
|
|
3015
3483
|
for (const t of readAllTickets(vaultPath)) {
|
|
3016
|
-
ids.add(t.id.toLowerCase());
|
|
3484
|
+
if (!TERMINAL_STATES.has(t.state)) ids.add(t.id.toLowerCase());
|
|
3017
3485
|
}
|
|
3018
3486
|
} catch {
|
|
3019
3487
|
}
|
|
@@ -3031,10 +3499,11 @@ function loadProjectContext(vaultPath, projectId) {
|
|
|
3031
3499
|
}
|
|
3032
3500
|
return formatProjectContextForPrompt(project);
|
|
3033
3501
|
}
|
|
3034
|
-
function composeSystemPrompt(ticketId, projectContext, haikuDownshift) {
|
|
3502
|
+
function composeSystemPrompt(ticketId, projectContext, haikuDownshift, pushDisabled) {
|
|
3035
3503
|
const parts = [];
|
|
3036
3504
|
if (projectContext) parts.push(projectContext);
|
|
3037
3505
|
if (haikuDownshift) parts.push(haikuDownshiftPromptHint());
|
|
3506
|
+
if (pushDisabled) parts.push(pushDisabledPromptHint());
|
|
3038
3507
|
if (parts.length === 0) return null;
|
|
3039
3508
|
const content = parts.join("\n\n");
|
|
3040
3509
|
const safeId = ticketId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
@@ -3055,6 +3524,21 @@ function haikuDownshiftPromptHint() {
|
|
|
3055
3524
|
"instead of the standard `### YYYY-MM-DD \u2014 Product agent`. That makes the heuristic visible in the ticket's audit trail."
|
|
3056
3525
|
].join("\n");
|
|
3057
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
|
+
}
|
|
3058
3542
|
function readTicketBody(path) {
|
|
3059
3543
|
try {
|
|
3060
3544
|
return readFileSync9(path, "utf8");
|
|
@@ -3063,7 +3547,7 @@ function readTicketBody(path) {
|
|
|
3063
3547
|
}
|
|
3064
3548
|
}
|
|
3065
3549
|
function findToolOnPath(name) {
|
|
3066
|
-
const r =
|
|
3550
|
+
const r = spawnSync5("/usr/bin/env", ["which", name], { encoding: "utf8" });
|
|
3067
3551
|
if (r.status !== 0) return null;
|
|
3068
3552
|
const path = (r.stdout || "").trim();
|
|
3069
3553
|
return path.length > 0 ? path : null;
|
|
@@ -3073,11 +3557,52 @@ function readMonitoredOrgsFromEnv() {
|
|
|
3073
3557
|
if (!raw) return [];
|
|
3074
3558
|
return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
3075
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
|
+
}
|
|
3076
3601
|
|
|
3077
3602
|
// package.json
|
|
3078
3603
|
var package_default = {
|
|
3079
3604
|
name: "@openthink/team",
|
|
3080
|
-
version: "0.0.
|
|
3605
|
+
version: "0.0.13",
|
|
3081
3606
|
type: "module",
|
|
3082
3607
|
description: "Source-agnostic vault-driven role pipeline for spawning Claude agents against tickets",
|
|
3083
3608
|
bin: {
|
|
@@ -3125,14 +3650,15 @@ var package_default = {
|
|
|
3125
3650
|
// src/index.ts
|
|
3126
3651
|
var program = new Command6();
|
|
3127
3652
|
program.name("oteam").description(
|
|
3128
|
-
"Source-agnostic
|
|
3653
|
+
"Source-agnostic workspace-driven role pipeline for spawning Claude agents against tickets"
|
|
3129
3654
|
).version(package_default.version);
|
|
3130
3655
|
async function handlePull(source, ref, opts) {
|
|
3131
3656
|
const result = await runPull({
|
|
3132
3657
|
source,
|
|
3133
3658
|
ref,
|
|
3134
|
-
vault: opts.vault,
|
|
3135
|
-
project: opts.project
|
|
3659
|
+
vault: opts.workspace ?? opts.vault,
|
|
3660
|
+
project: opts.project,
|
|
3661
|
+
cloneUri: opts.cloneUri
|
|
3136
3662
|
});
|
|
3137
3663
|
const verb = result.reused ? "Reused existing" : "Filed";
|
|
3138
3664
|
process.stdout.write(`\u2705 ${verb} ${result.ticketID}
|
|
@@ -3140,34 +3666,36 @@ async function handlePull(source, ref, opts) {
|
|
|
3140
3666
|
`);
|
|
3141
3667
|
}
|
|
3142
3668
|
program.command("pull <source> <ref>").description(
|
|
3143
|
-
"Ingest an external item into the
|
|
3144
|
-
).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(
|
|
3145
3671
|
"--project <name>",
|
|
3146
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)"
|
|
3147
3676
|
).action(handlePull);
|
|
3148
|
-
program.command("ingest <source> <ref>", { hidden: true }).description("Hidden alias for `pull`.").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(
|
|
3149
3678
|
"--project <name>",
|
|
3150
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)"
|
|
3151
3683
|
).action(handlePull);
|
|
3152
3684
|
program.command("assign <ticket-or-id>").description(
|
|
3153
3685
|
"Drive the role pipeline against a ticket (full path or AGT-NNN id)"
|
|
3154
3686
|
).option(
|
|
3155
3687
|
"--inline",
|
|
3156
3688
|
"Run the role pipeline in the current terminal instead of spawning kitty"
|
|
3157
|
-
).option("--
|
|
3158
|
-
"--no-stamp",
|
|
3159
|
-
"Force a github clone for this run, overriding stamp.enforce in oteam config. The durable knob is 'oteam config stamp set --enforce off'."
|
|
3160
|
-
).action(
|
|
3689
|
+
).option("-w, --workspace <name-or-path>", "Use a specific registered workspace").addOption(new Option3("--vault <name-or-path>").hideHelp()).action(
|
|
3161
3690
|
async (ticketPath, opts) => {
|
|
3162
3691
|
await assignTicket({
|
|
3163
3692
|
ticketPath,
|
|
3164
3693
|
workInline: opts.inline,
|
|
3165
|
-
vault: opts.vault
|
|
3166
|
-
noStamp: opts.stamp === false
|
|
3694
|
+
vault: opts.workspace ?? opts.vault
|
|
3167
3695
|
});
|
|
3168
3696
|
}
|
|
3169
3697
|
);
|
|
3170
|
-
program.command("list").description("List tickets in the
|
|
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(
|
|
3171
3699
|
"--label <label>",
|
|
3172
3700
|
"Filter by label (case-insensitive; repeatable, all must match)",
|
|
3173
3701
|
(value, prev = []) => [...prev, value],
|
|
@@ -3180,8 +3708,8 @@ program.command("list").description("List tickets in the vault (filter by struct
|
|
|
3180
3708
|
"Case-insensitive substring match against the ticket body (reads files)"
|
|
3181
3709
|
).option(
|
|
3182
3710
|
"--include-archived",
|
|
3183
|
-
"Also search <
|
|
3184
|
-
).option("--
|
|
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(
|
|
3185
3713
|
(opts) => {
|
|
3186
3714
|
if (opts.state && !TICKET_STATES.includes(opts.state)) {
|
|
3187
3715
|
process.stderr.write(
|
|
@@ -3190,11 +3718,11 @@ program.command("list").description("List tickets in the vault (filter by struct
|
|
|
3190
3718
|
);
|
|
3191
3719
|
process.exit(2);
|
|
3192
3720
|
}
|
|
3193
|
-
process.stdout.write(runList(opts) + "\n");
|
|
3721
|
+
process.stdout.write(runList({ ...opts, vault: opts.workspace ?? opts.vault }) + "\n");
|
|
3194
3722
|
}
|
|
3195
3723
|
);
|
|
3196
|
-
program.command("archive <ticket-id>").description("Move a done ticket to archive/YYYY-MM/").option("--
|
|
3197
|
-
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 });
|
|
3198
3726
|
process.stdout.write(`\u2705 Archived
|
|
3199
3727
|
${path}
|
|
3200
3728
|
`);
|