@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/README.md +54 -31
- package/dist/assign-ticket.md +25 -21
- package/dist/implement-project.md +160 -0
- package/dist/index.js +901 -368
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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,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
|
|
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
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
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
|
|
2803
|
-
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
|
+
}
|
|
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
|
-
|
|
3177
|
+
if (!uri.startsWith(config.stamp.host)) {
|
|
3178
|
+
throw new StampEnforceError({ slug, uri, stampHost: config.stamp.host });
|
|
3179
|
+
}
|
|
2811
3180
|
}
|
|
2812
|
-
return
|
|
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
|
-
|
|
3231
|
+
let cloneUri;
|
|
2843
3232
|
try {
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
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
|
|
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
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
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
|
|
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 ${
|
|
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
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
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(
|
|
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
|
|
2954
|
-
|
|
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
|
|
2960
|
-
const
|
|
2961
|
-
const
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
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
|
|
2985
|
-
const
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
{
|
|
2989
|
-
|
|
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
|
|
3446
|
+
exitCode,
|
|
3002
3447
|
cwd
|
|
3003
3448
|
});
|
|
3004
3449
|
}
|
|
3005
|
-
if (
|
|
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 =
|
|
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.
|
|
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
|
|
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
|
|
3139
|
-
).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("--
|
|
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("--
|
|
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
|
|
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 <
|
|
3179
|
-
).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(
|
|
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("--
|
|
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
|
`);
|