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