@openthink/team 0.0.1 → 0.0.3
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 +130 -19
- package/dist/assign-ticket.md +2 -2
- package/dist/index.js +1031 -151
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { Command as
|
|
4
|
+
import { Command as Command6 } from "commander";
|
|
5
5
|
|
|
6
6
|
// src/commands/pull.ts
|
|
7
7
|
import { existsSync as existsSync2, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
@@ -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,29 @@ 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
|
+
if (!config.telemetry.enabled) {
|
|
491
|
+
onDisk.telemetry = config.telemetry;
|
|
492
|
+
}
|
|
493
|
+
const body = JSON.stringify(onDisk, null, 2) + "\n";
|
|
452
494
|
writeFileSync(configPath(), body);
|
|
453
495
|
}
|
|
496
|
+
function emptyConfig() {
|
|
497
|
+
return {
|
|
498
|
+
vaults: {},
|
|
499
|
+
default: null,
|
|
500
|
+
stamp: null,
|
|
501
|
+
models: {},
|
|
502
|
+
telemetry: { enabled: true }
|
|
503
|
+
};
|
|
504
|
+
}
|
|
454
505
|
function addVault(rawPath, options = {}) {
|
|
455
506
|
const path = absolutise(rawPath);
|
|
456
507
|
const config = readConfig();
|
|
@@ -533,7 +584,9 @@ function findVaultRootForPath(filePath, config = readConfig()) {
|
|
|
533
584
|
return null;
|
|
534
585
|
}
|
|
535
586
|
function normalise(parsed) {
|
|
536
|
-
if (!parsed || typeof parsed !== "object")
|
|
587
|
+
if (!parsed || typeof parsed !== "object") {
|
|
588
|
+
return emptyConfig();
|
|
589
|
+
}
|
|
537
590
|
const obj = parsed;
|
|
538
591
|
const vaults = {};
|
|
539
592
|
if (obj.vaults && typeof obj.vaults === "object") {
|
|
@@ -542,7 +595,146 @@ function normalise(parsed) {
|
|
|
542
595
|
}
|
|
543
596
|
}
|
|
544
597
|
const def = typeof obj.default === "string" && obj.default in vaults ? obj.default : null;
|
|
545
|
-
return {
|
|
598
|
+
return {
|
|
599
|
+
vaults,
|
|
600
|
+
default: def,
|
|
601
|
+
stamp: normaliseStamp(obj.stamp),
|
|
602
|
+
models: normaliseModels(obj.models),
|
|
603
|
+
telemetry: normaliseTelemetry(obj.telemetry)
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
function normaliseTelemetry(value) {
|
|
607
|
+
if (!value || typeof value !== "object") return { enabled: true };
|
|
608
|
+
const v = value;
|
|
609
|
+
return { enabled: v.enabled !== false };
|
|
610
|
+
}
|
|
611
|
+
function normaliseModels(value) {
|
|
612
|
+
if (!value || typeof value !== "object") return {};
|
|
613
|
+
const out = {};
|
|
614
|
+
for (const [phase, modelId] of Object.entries(value)) {
|
|
615
|
+
if (!isPhase(phase)) continue;
|
|
616
|
+
if (typeof modelId !== "string") continue;
|
|
617
|
+
const trimmed = modelId.trim();
|
|
618
|
+
if (trimmed.length === 0) continue;
|
|
619
|
+
out[phase] = trimmed;
|
|
620
|
+
}
|
|
621
|
+
return out;
|
|
622
|
+
}
|
|
623
|
+
function normaliseStamp(value) {
|
|
624
|
+
if (value == null) return null;
|
|
625
|
+
if (typeof value !== "object") return null;
|
|
626
|
+
const s = value;
|
|
627
|
+
if (typeof s.host !== "string") return null;
|
|
628
|
+
const host = s.host.trim();
|
|
629
|
+
if (host.length === 0) return null;
|
|
630
|
+
return { host: stripTrailingSlash(host), enforce: s.enforce === true };
|
|
631
|
+
}
|
|
632
|
+
function stripTrailingSlash(s) {
|
|
633
|
+
return s.replace(/\/+$/, "");
|
|
634
|
+
}
|
|
635
|
+
function getStampConfig() {
|
|
636
|
+
return readConfig().stamp;
|
|
637
|
+
}
|
|
638
|
+
function setStampHost(host) {
|
|
639
|
+
const trimmed = host.trim();
|
|
640
|
+
if (trimmed.length === 0) {
|
|
641
|
+
throw new Error("stamp host cannot be empty \u2014 pass a value like ssh://git@host:port");
|
|
642
|
+
}
|
|
643
|
+
const config = readConfig();
|
|
644
|
+
const next = {
|
|
645
|
+
host: stripTrailingSlash(trimmed),
|
|
646
|
+
enforce: config.stamp?.enforce ?? false
|
|
647
|
+
};
|
|
648
|
+
config.stamp = next;
|
|
649
|
+
writeConfig(config);
|
|
650
|
+
return next;
|
|
651
|
+
}
|
|
652
|
+
function setStampEnforce(enforce) {
|
|
653
|
+
const config = readConfig();
|
|
654
|
+
if (enforce && (!config.stamp || config.stamp.host.length === 0)) {
|
|
655
|
+
throw new Error(
|
|
656
|
+
"cannot set stamp.enforce on with no stamp.host \u2014 run 'oteam config stamp set --host <url>' first"
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
const next = {
|
|
660
|
+
host: config.stamp?.host ?? "",
|
|
661
|
+
enforce
|
|
662
|
+
};
|
|
663
|
+
config.stamp = next;
|
|
664
|
+
writeConfig(config);
|
|
665
|
+
return next;
|
|
666
|
+
}
|
|
667
|
+
function setStamp(input) {
|
|
668
|
+
const trimmed = input.host.trim();
|
|
669
|
+
if (trimmed.length === 0) {
|
|
670
|
+
throw new Error("stamp host cannot be empty \u2014 pass a value like ssh://git@host:port");
|
|
671
|
+
}
|
|
672
|
+
const next = {
|
|
673
|
+
host: stripTrailingSlash(trimmed),
|
|
674
|
+
enforce: input.enforce
|
|
675
|
+
};
|
|
676
|
+
const config = readConfig();
|
|
677
|
+
config.stamp = next;
|
|
678
|
+
writeConfig(config);
|
|
679
|
+
return next;
|
|
680
|
+
}
|
|
681
|
+
function clearStamp() {
|
|
682
|
+
const config = readConfig();
|
|
683
|
+
config.stamp = null;
|
|
684
|
+
writeConfig(config);
|
|
685
|
+
}
|
|
686
|
+
function getModels() {
|
|
687
|
+
return readConfig().models;
|
|
688
|
+
}
|
|
689
|
+
function setModel(phase, modelId) {
|
|
690
|
+
const trimmed = modelId.trim();
|
|
691
|
+
if (trimmed.length === 0) {
|
|
692
|
+
throw new Error(
|
|
693
|
+
`model id for phase "${phase}" cannot be empty \u2014 pass a non-empty string`
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
if (!isPhase(phase)) {
|
|
697
|
+
throw new Error(
|
|
698
|
+
`unknown phase "${phase}" \u2014 supported: ${PHASES.join(", ")}`
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
const config = readConfig();
|
|
702
|
+
config.models = { ...config.models, [phase]: trimmed };
|
|
703
|
+
writeConfig(config);
|
|
704
|
+
return config.models;
|
|
705
|
+
}
|
|
706
|
+
function seedDefaultModelsIfEmpty() {
|
|
707
|
+
const config = readConfig();
|
|
708
|
+
if (Object.keys(config.models).length > 0) {
|
|
709
|
+
return { action: "preserved", models: config.models };
|
|
710
|
+
}
|
|
711
|
+
config.models = { ...DEFAULT_MODELS };
|
|
712
|
+
writeConfig(config);
|
|
713
|
+
return { action: "seeded", models: config.models };
|
|
714
|
+
}
|
|
715
|
+
function getTelemetryEnabled() {
|
|
716
|
+
return readConfig().telemetry.enabled;
|
|
717
|
+
}
|
|
718
|
+
function setTelemetryEnabled(enabled) {
|
|
719
|
+
const config = readConfig();
|
|
720
|
+
config.telemetry = { enabled };
|
|
721
|
+
writeConfig(config);
|
|
722
|
+
return config.telemetry;
|
|
723
|
+
}
|
|
724
|
+
function clearModel(phase) {
|
|
725
|
+
if (!isPhase(phase)) {
|
|
726
|
+
throw new Error(
|
|
727
|
+
`unknown phase "${phase}" \u2014 supported: ${PHASES.join(", ")}`
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
const config = readConfig();
|
|
731
|
+
if (phase in config.models) {
|
|
732
|
+
const next = { ...config.models };
|
|
733
|
+
delete next[phase];
|
|
734
|
+
config.models = next;
|
|
735
|
+
writeConfig(config);
|
|
736
|
+
}
|
|
737
|
+
return config.models;
|
|
546
738
|
}
|
|
547
739
|
function findEntry(config, nameOrPath) {
|
|
548
740
|
if (config.vaults[nameOrPath]) return nameOrPath;
|
|
@@ -1031,26 +1223,213 @@ function buildConfigCommand() {
|
|
|
1031
1223
|
return;
|
|
1032
1224
|
}
|
|
1033
1225
|
process.stdout.write(`${def}
|
|
1226
|
+
`);
|
|
1227
|
+
});
|
|
1228
|
+
const stamp = new Command("stamp").description(
|
|
1229
|
+
"Manage stamp-server integration (host + enforce flag)"
|
|
1230
|
+
);
|
|
1231
|
+
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) => {
|
|
1232
|
+
if (!opts.host && !opts.enforce) {
|
|
1233
|
+
process.stderr.write(
|
|
1234
|
+
"oteam config stamp set: pass --host <url> and/or --enforce <on|off>\n"
|
|
1235
|
+
);
|
|
1236
|
+
process.exit(2);
|
|
1237
|
+
}
|
|
1238
|
+
if (opts.host) {
|
|
1239
|
+
const next = setStampHost(opts.host);
|
|
1240
|
+
process.stdout.write(
|
|
1241
|
+
`\u2705 stamp.host = ${next.host} (enforce=${next.enforce ? "on" : "off"})
|
|
1242
|
+
`
|
|
1243
|
+
);
|
|
1244
|
+
}
|
|
1245
|
+
if (opts.enforce) {
|
|
1246
|
+
const flag = opts.enforce.toLowerCase();
|
|
1247
|
+
if (flag !== "on" && flag !== "off") {
|
|
1248
|
+
process.stderr.write(
|
|
1249
|
+
`oteam config stamp set: --enforce expects on|off, got "${opts.enforce}"
|
|
1250
|
+
`
|
|
1251
|
+
);
|
|
1252
|
+
process.exit(2);
|
|
1253
|
+
}
|
|
1254
|
+
const next = setStampEnforce(flag === "on");
|
|
1255
|
+
process.stdout.write(
|
|
1256
|
+
`\u2705 stamp.enforce = ${next.enforce ? "on" : "off"} (host=${next.host || "(unset)"})
|
|
1257
|
+
`
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
});
|
|
1261
|
+
const stampClear = new Command("clear").description("Remove the stamp block from oteam config").action(() => {
|
|
1262
|
+
clearStamp();
|
|
1263
|
+
process.stdout.write("\u2705 stamp config cleared\n");
|
|
1264
|
+
});
|
|
1265
|
+
const stampShow = new Command("show").description("Print the current stamp config").action(() => {
|
|
1266
|
+
const s = getStampConfig();
|
|
1267
|
+
if (!s) {
|
|
1268
|
+
process.stdout.write("(stamp not configured)\n");
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
process.stdout.write(`host: ${s.host}
|
|
1272
|
+
enforce: ${s.enforce ? "on" : "off"}
|
|
1273
|
+
`);
|
|
1274
|
+
});
|
|
1275
|
+
stamp.addCommand(stampSet);
|
|
1276
|
+
stamp.addCommand(stampClear);
|
|
1277
|
+
stamp.addCommand(stampShow);
|
|
1278
|
+
const models = new Command("models").description(
|
|
1279
|
+
"Per-phase model overrides for the role pipeline (product|spike|implementation|qa)"
|
|
1280
|
+
);
|
|
1281
|
+
models.command("set <phase> <model-id>").description(
|
|
1282
|
+
`Pin a model id for one phase (phase: ${PHASES.join("|")})`
|
|
1283
|
+
).action((phaseRaw, modelId) => {
|
|
1284
|
+
const phase = expectPhase(phaseRaw);
|
|
1285
|
+
const next = setModel(phase, modelId);
|
|
1286
|
+
process.stdout.write(
|
|
1287
|
+
`\u2705 models.${phase} = ${next[phase]}
|
|
1288
|
+
`
|
|
1289
|
+
);
|
|
1290
|
+
});
|
|
1291
|
+
models.command("clear <phase>").description("Remove the override for one phase (falls back to the role-pipeline default)").action((phaseRaw) => {
|
|
1292
|
+
const phase = expectPhase(phaseRaw);
|
|
1293
|
+
clearModel(phase);
|
|
1294
|
+
process.stdout.write(`\u2705 models.${phase} cleared
|
|
1295
|
+
`);
|
|
1296
|
+
});
|
|
1297
|
+
models.command("show").description("Print the current per-phase model overrides").action(() => {
|
|
1298
|
+
const m = getModels();
|
|
1299
|
+
const lines = PHASES.map((p) => `${p.padEnd(15)} ${m[p] ?? "(unset)"}`);
|
|
1300
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
1301
|
+
});
|
|
1302
|
+
const telemetry = new Command("telemetry").description(
|
|
1303
|
+
"Manage per-phase telemetry recording (default: on)"
|
|
1304
|
+
);
|
|
1305
|
+
telemetry.command("set <on|off>").description("Turn per-phase telemetry recording on or off").action((flag) => {
|
|
1306
|
+
const lower = flag.toLowerCase();
|
|
1307
|
+
if (lower !== "on" && lower !== "off") {
|
|
1308
|
+
process.stderr.write(
|
|
1309
|
+
`oteam config telemetry set: expected on|off, got "${flag}"
|
|
1310
|
+
`
|
|
1311
|
+
);
|
|
1312
|
+
process.exit(2);
|
|
1313
|
+
}
|
|
1314
|
+
const next = setTelemetryEnabled(lower === "on");
|
|
1315
|
+
process.stdout.write(
|
|
1316
|
+
`\u2705 telemetry ${next.enabled ? "on" : "off"}
|
|
1317
|
+
`
|
|
1318
|
+
);
|
|
1319
|
+
});
|
|
1320
|
+
telemetry.command("show").description("Print whether telemetry recording is on").action(() => {
|
|
1321
|
+
process.stdout.write(`${getTelemetryEnabled() ? "on" : "off"}
|
|
1034
1322
|
`);
|
|
1035
1323
|
});
|
|
1036
1324
|
config.addCommand(vault);
|
|
1325
|
+
config.addCommand(stamp);
|
|
1326
|
+
config.addCommand(models);
|
|
1327
|
+
config.addCommand(telemetry);
|
|
1037
1328
|
return config;
|
|
1038
1329
|
}
|
|
1330
|
+
function expectPhase(value) {
|
|
1331
|
+
if (!isPhase(value)) {
|
|
1332
|
+
process.stderr.write(
|
|
1333
|
+
`oteam config models: unknown phase "${value}" \u2014 supported: ${PHASES.join(", ")}
|
|
1334
|
+
`
|
|
1335
|
+
);
|
|
1336
|
+
process.exit(2);
|
|
1337
|
+
}
|
|
1338
|
+
return value;
|
|
1339
|
+
}
|
|
1039
1340
|
|
|
1040
1341
|
// src/commands/init.ts
|
|
1041
1342
|
import { Command as Command2 } from "commander";
|
|
1042
|
-
import { existsSync as
|
|
1043
|
-
import { resolve as
|
|
1343
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
|
|
1344
|
+
import { resolve as resolve3, join as join7 } from "path";
|
|
1044
1345
|
import readline from "readline";
|
|
1346
|
+
|
|
1347
|
+
// src/lib/workspace-tree.ts
|
|
1348
|
+
import {
|
|
1349
|
+
existsSync as existsSync3,
|
|
1350
|
+
mkdirSync as mkdirSync4,
|
|
1351
|
+
readdirSync as readdirSync3,
|
|
1352
|
+
writeFileSync as writeFileSync3
|
|
1353
|
+
} from "fs";
|
|
1354
|
+
import { homedir as homedir3 } from "os";
|
|
1355
|
+
import { join as join6, resolve as resolve2 } from "path";
|
|
1356
|
+
var SENTINEL_FILENAME = ".oteam-workspace";
|
|
1357
|
+
var WORKSPACE_SUBDIRS = [
|
|
1358
|
+
"tickets/triage",
|
|
1359
|
+
"tickets/refined",
|
|
1360
|
+
"tickets/in-progress",
|
|
1361
|
+
"tickets/qa",
|
|
1362
|
+
"tickets/blocked",
|
|
1363
|
+
"projects",
|
|
1364
|
+
"archive",
|
|
1365
|
+
"00-meta"
|
|
1366
|
+
];
|
|
1367
|
+
var META_README_BODY = `# 00-meta
|
|
1368
|
+
|
|
1369
|
+
Workspace metadata. Templates, schema notes, and ad-hoc bookkeeping live
|
|
1370
|
+
here. Created by \`oteam init\` \u2014 safe to extend.
|
|
1371
|
+
`;
|
|
1372
|
+
var SENTINEL_BODY = `${JSON.stringify(
|
|
1373
|
+
{ version: 1, createdBy: "@openthink/team" },
|
|
1374
|
+
null,
|
|
1375
|
+
2
|
|
1376
|
+
)}
|
|
1377
|
+
`;
|
|
1378
|
+
function defaultWorkspacePath() {
|
|
1379
|
+
return join6(homedir3(), "openteam");
|
|
1380
|
+
}
|
|
1381
|
+
var WorkspaceConflictError = class extends Error {
|
|
1382
|
+
path;
|
|
1383
|
+
conflictingPaths;
|
|
1384
|
+
constructor(path, conflictingPaths) {
|
|
1385
|
+
const head = `oteam init: refusing to initialise workspace at ${path} \u2014 directory is non-empty and lacks the ${SENTINEL_FILENAME} marker.`;
|
|
1386
|
+
const list = conflictingPaths.slice(0, 10).map((p) => ` - ${p}`).join("\n");
|
|
1387
|
+
const more = conflictingPaths.length > 10 ? `
|
|
1388
|
+
...and ${conflictingPaths.length - 10} more` : "";
|
|
1389
|
+
super(`${head}
|
|
1390
|
+
Conflicting entries:
|
|
1391
|
+
${list}${more}`);
|
|
1392
|
+
this.name = "WorkspaceConflictError";
|
|
1393
|
+
this.path = path;
|
|
1394
|
+
this.conflictingPaths = conflictingPaths;
|
|
1395
|
+
}
|
|
1396
|
+
};
|
|
1397
|
+
function expandHome(input) {
|
|
1398
|
+
const home = homedir3();
|
|
1399
|
+
if (input === "~") return home;
|
|
1400
|
+
if (input.startsWith("~/")) return join6(home, input.slice(2));
|
|
1401
|
+
return input;
|
|
1402
|
+
}
|
|
1403
|
+
function bootstrapWorkspace(rawTarget) {
|
|
1404
|
+
const target = resolve2(expandHome(rawTarget));
|
|
1405
|
+
if (existsSync3(join6(target, SENTINEL_FILENAME))) {
|
|
1406
|
+
return { outcome: "already-initialised", path: target };
|
|
1407
|
+
}
|
|
1408
|
+
if (existsSync3(target)) {
|
|
1409
|
+
const visible = readdirSync3(target).filter((n) => !n.startsWith("."));
|
|
1410
|
+
if (visible.length > 0) {
|
|
1411
|
+
throw new WorkspaceConflictError(target, visible);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
mkdirSync4(target, { recursive: true });
|
|
1415
|
+
for (const sub of WORKSPACE_SUBDIRS) {
|
|
1416
|
+
mkdirSync4(join6(target, sub), { recursive: true });
|
|
1417
|
+
}
|
|
1418
|
+
writeFileSync3(join6(target, "00-meta", "README.md"), META_README_BODY);
|
|
1419
|
+
writeFileSync3(join6(target, SENTINEL_FILENAME), SENTINEL_BODY);
|
|
1420
|
+
return { outcome: "created", path: target };
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// src/commands/init.ts
|
|
1045
1424
|
var BLOCK_BEGIN = "<!-- oteam:begin (managed by `oteam init` \u2014 do not edit between markers) -->";
|
|
1046
1425
|
var BLOCK_END = "<!-- oteam:end -->";
|
|
1047
|
-
var AGENTS_BODY = `## oteam \u2014
|
|
1426
|
+
var AGENTS_BODY = `## oteam \u2014 workspace-driven role pipeline for Claude agents
|
|
1048
1427
|
|
|
1049
1428
|
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 \`~/
|
|
1429
|
+
tickets**, or mentions a "workspace", an "Obsidian vault", an \`AGT-NNN\` id,
|
|
1430
|
+
a project, "ingesting GitHub issues or PRs", or driving tickets through a
|
|
1431
|
+
"role pipeline" \u2014 \`oteam\` is the right tool. The workspace is a directory
|
|
1432
|
+
of markdown files (typically \`~/openteam/tickets/<state>/AGT-NNN-*.md\`),
|
|
1054
1433
|
but **do not search it with \`find\` or \`grep\` directly.** The CLI knows the
|
|
1055
1434
|
ticket schema and has structured + free-text filters; filesystem search
|
|
1056
1435
|
does not, and you will fight false positives from incidental keyword
|
|
@@ -1068,13 +1447,13 @@ Other common verbs: \`oteam ticket new "<title>" [--project X]\` to file a
|
|
|
1068
1447
|
ticket, \`oteam pull github owner/repo#NN\` to ingest a GitHub issue or PR,
|
|
1069
1448
|
\`oteam assign <AGT-NNN>\` to drive a ticket through the role pipeline. Run
|
|
1070
1449
|
\`oteam --help\` or \`oteam <command> --help\` for full details. If you don't
|
|
1071
|
-
know whether a
|
|
1450
|
+
know whether a workspace is configured, \`oteam config vault list\` tells you.
|
|
1072
1451
|
`;
|
|
1073
1452
|
var CLAUDE_BODY = `## oteam
|
|
1074
1453
|
|
|
1075
1454
|
If the user asks to search, find, list, or file tickets, or mentions a
|
|
1076
|
-
"
|
|
1077
|
-
\`oteam\` CLI \u2014 **do not** \`find\`/\`grep\` the
|
|
1455
|
+
"workspace", "Obsidian vault", an \`AGT-NNN\` id, or a role pipeline, use the
|
|
1456
|
+
\`oteam\` CLI \u2014 **do not** \`find\`/\`grep\` the workspace directly. Start with
|
|
1078
1457
|
\`oteam list --grep "<term>"\` or \`oteam list --match "<term>"\`. See
|
|
1079
1458
|
\`AGENTS.md\` next to this file for the short summary and \`oteam --help\` for
|
|
1080
1459
|
the full surface.
|
|
@@ -1089,8 +1468,8 @@ ${BLOCK_END}
|
|
|
1089
1468
|
}
|
|
1090
1469
|
function upsertBlock(filePath, body) {
|
|
1091
1470
|
const block = renderBlock(body);
|
|
1092
|
-
if (!
|
|
1093
|
-
|
|
1471
|
+
if (!existsSync4(filePath)) {
|
|
1472
|
+
writeFileSync4(filePath, block, "utf8");
|
|
1094
1473
|
return "created";
|
|
1095
1474
|
}
|
|
1096
1475
|
const existing = readFileSync4(filePath, "utf8");
|
|
@@ -1099,17 +1478,17 @@ function upsertBlock(filePath, body) {
|
|
|
1099
1478
|
if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
|
|
1100
1479
|
const before = existing.slice(0, beginIdx);
|
|
1101
1480
|
const after = existing.slice(endIdx + BLOCK_END.length);
|
|
1102
|
-
|
|
1481
|
+
writeFileSync4(filePath, before + block.trimEnd() + after, "utf8");
|
|
1103
1482
|
return "updated";
|
|
1104
1483
|
}
|
|
1105
1484
|
const separator = existing.endsWith("\n") ? "\n" : "\n\n";
|
|
1106
|
-
|
|
1485
|
+
writeFileSync4(filePath, existing + separator + block, "utf8");
|
|
1107
1486
|
return "appended";
|
|
1108
1487
|
}
|
|
1109
|
-
function
|
|
1488
|
+
function expandHome2(input) {
|
|
1110
1489
|
const home = process.env.HOME ?? "";
|
|
1111
1490
|
if (input === "~") return home;
|
|
1112
|
-
if (input.startsWith("~/")) return
|
|
1491
|
+
if (input.startsWith("~/")) return join7(home, input.slice(2));
|
|
1113
1492
|
return input;
|
|
1114
1493
|
}
|
|
1115
1494
|
function prompt(question, fallback) {
|
|
@@ -1125,35 +1504,133 @@ function prompt(question, fallback) {
|
|
|
1125
1504
|
});
|
|
1126
1505
|
});
|
|
1127
1506
|
}
|
|
1507
|
+
function promptRaw(question) {
|
|
1508
|
+
return new Promise((resolvePrompt) => {
|
|
1509
|
+
const rl = readline.createInterface({
|
|
1510
|
+
input: process.stdin,
|
|
1511
|
+
output: process.stdout
|
|
1512
|
+
});
|
|
1513
|
+
rl.question(question, (answer) => {
|
|
1514
|
+
rl.close();
|
|
1515
|
+
resolvePrompt(answer.trim());
|
|
1516
|
+
});
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1128
1519
|
async function runInit(opts) {
|
|
1129
1520
|
const home = process.env.HOME ?? "";
|
|
1130
|
-
const
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1521
|
+
const defaultWorkspace = defaultWorkspacePath();
|
|
1522
|
+
if (opts.dir && opts.workspace && opts.dir !== opts.workspace) {
|
|
1523
|
+
throw new Error(
|
|
1524
|
+
`oteam init: --dir and --workspace disagree (${opts.dir} vs ${opts.workspace}); pass one`
|
|
1525
|
+
);
|
|
1526
|
+
}
|
|
1527
|
+
const workspaceFlag = opts.workspace ?? opts.dir;
|
|
1528
|
+
let workspaceDir;
|
|
1529
|
+
if (workspaceFlag) {
|
|
1530
|
+
workspaceDir = workspaceFlag;
|
|
1134
1531
|
} else if (opts.yes) {
|
|
1135
|
-
|
|
1532
|
+
workspaceDir = defaultWorkspace;
|
|
1136
1533
|
} else {
|
|
1137
|
-
|
|
1138
|
-
`Where should
|
|
1139
|
-
|
|
1534
|
+
workspaceDir = await prompt(
|
|
1535
|
+
`Where should the oteam workspace live? (${defaultWorkspace}) `,
|
|
1536
|
+
defaultWorkspace
|
|
1140
1537
|
);
|
|
1141
1538
|
}
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1539
|
+
workspaceDir = resolve3(expandHome2(workspaceDir));
|
|
1540
|
+
const bootstrap = bootstrapWorkspace(workspaceDir);
|
|
1541
|
+
const registration = addVault(bootstrap.path);
|
|
1542
|
+
const currentDefault = listVaults().default;
|
|
1543
|
+
const models = seedDefaultModelsIfEmpty();
|
|
1544
|
+
const stamp = await runStampStep(opts);
|
|
1545
|
+
const docsDir = resolve3(expandHome2(opts.docsDir ?? home));
|
|
1546
|
+
if (!existsSync4(docsDir)) {
|
|
1547
|
+
process.stderr.write(`oteam init: docs directory does not exist: ${docsDir}
|
|
1145
1548
|
`);
|
|
1146
1549
|
process.exit(1);
|
|
1147
1550
|
}
|
|
1148
|
-
const agentsPath =
|
|
1149
|
-
const claudePath =
|
|
1551
|
+
const agentsPath = join7(docsDir, "AGENTS.md");
|
|
1552
|
+
const claudePath = join7(docsDir, "CLAUDE.md");
|
|
1150
1553
|
const agents = upsertBlock(agentsPath, AGENTS_BODY);
|
|
1151
1554
|
const claude = upsertBlock(claudePath, CLAUDE_BODY);
|
|
1152
1555
|
return {
|
|
1556
|
+
workspace: {
|
|
1557
|
+
path: bootstrap.path,
|
|
1558
|
+
outcome: bootstrap.outcome,
|
|
1559
|
+
registeredAs: registration.name,
|
|
1560
|
+
promotedToDefault: registration.promotedToDefault,
|
|
1561
|
+
currentDefault
|
|
1562
|
+
},
|
|
1153
1563
|
agents: { path: agentsPath, result: agents },
|
|
1154
|
-
claude: { path: claudePath, result: claude }
|
|
1564
|
+
claude: { path: claudePath, result: claude },
|
|
1565
|
+
stamp,
|
|
1566
|
+
models
|
|
1155
1567
|
};
|
|
1156
1568
|
}
|
|
1569
|
+
async function runStampStep(opts) {
|
|
1570
|
+
if (opts.skipStamp || opts.yes) {
|
|
1571
|
+
return { action: "skipped" };
|
|
1572
|
+
}
|
|
1573
|
+
const existing = getStampConfig();
|
|
1574
|
+
let hostInput;
|
|
1575
|
+
if (opts.stampHost !== void 0) {
|
|
1576
|
+
hostInput = opts.stampHost.trim();
|
|
1577
|
+
} else {
|
|
1578
|
+
const hint = existing ? ` (current: ${existing.host}; press enter to keep)` : " (leave blank to skip)";
|
|
1579
|
+
hostInput = await promptRaw(
|
|
1580
|
+
`Configure a stamp server for signed-merge integration?
|
|
1581
|
+
Paste host (e.g. ssh://git@host:port)${hint}: `
|
|
1582
|
+
);
|
|
1583
|
+
}
|
|
1584
|
+
let nextHost;
|
|
1585
|
+
if (hostInput.length === 0) {
|
|
1586
|
+
nextHost = existing?.host ?? null;
|
|
1587
|
+
} else {
|
|
1588
|
+
nextHost = hostInput;
|
|
1589
|
+
}
|
|
1590
|
+
if (nextHost === null) {
|
|
1591
|
+
return { action: "unchanged", stamp: null };
|
|
1592
|
+
}
|
|
1593
|
+
let enforce;
|
|
1594
|
+
if (opts.stampEnforce !== void 0) {
|
|
1595
|
+
enforce = opts.stampEnforce;
|
|
1596
|
+
} else {
|
|
1597
|
+
const enforceHint = existing ? ` [${existing.enforce ? "Y/n" : "y/N"}]` : " [y/N]";
|
|
1598
|
+
const enforceAnswer = await promptRaw(
|
|
1599
|
+
`Refuse to operate on repos not registered on this stamp server?${enforceHint}: `
|
|
1600
|
+
);
|
|
1601
|
+
if (enforceAnswer.length === 0) {
|
|
1602
|
+
enforce = existing?.enforce ?? false;
|
|
1603
|
+
} else {
|
|
1604
|
+
enforce = /^(y|yes)$/i.test(enforceAnswer);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
if (existing && existing.host === nextHost && existing.enforce === enforce) {
|
|
1608
|
+
return { action: "unchanged", stamp: existing };
|
|
1609
|
+
}
|
|
1610
|
+
const result = setStamp({ host: nextHost, enforce });
|
|
1611
|
+
return { action: "set", stamp: result };
|
|
1612
|
+
}
|
|
1613
|
+
function modelsLine(result) {
|
|
1614
|
+
if (result.action === "preserved") {
|
|
1615
|
+
return "\u2139\uFE0F Models: existing customisation preserved";
|
|
1616
|
+
}
|
|
1617
|
+
const pairs = PHASES.map((phase) => `${phase}=${result.models[phase]}`).join(
|
|
1618
|
+
", "
|
|
1619
|
+
);
|
|
1620
|
+
return `\u2705 Models: defaults seeded (${pairs})`;
|
|
1621
|
+
}
|
|
1622
|
+
function stampLine(outcome) {
|
|
1623
|
+
switch (outcome.action) {
|
|
1624
|
+
case "skipped":
|
|
1625
|
+
return null;
|
|
1626
|
+
case "unchanged":
|
|
1627
|
+
if (!outcome.stamp) return null;
|
|
1628
|
+
return `\u2139\uFE0F Stamp integration: host=${outcome.stamp.host} enforce=${outcome.stamp.enforce ? "on" : "off"} (no changes)`;
|
|
1629
|
+
case "set":
|
|
1630
|
+
if (!outcome.stamp) return "\u2705 Stamp integration: cleared";
|
|
1631
|
+
return `\u2705 Stamp integration: host=${outcome.stamp.host} enforce=${outcome.stamp.enforce ? "on" : "off"}`;
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1157
1634
|
function pastTense(action) {
|
|
1158
1635
|
switch (action) {
|
|
1159
1636
|
case "created":
|
|
@@ -1164,14 +1641,42 @@ function pastTense(action) {
|
|
|
1164
1641
|
return "Appended oteam block to";
|
|
1165
1642
|
}
|
|
1166
1643
|
}
|
|
1644
|
+
function workspaceLine(ws) {
|
|
1645
|
+
if (ws.outcome === "already-initialised") {
|
|
1646
|
+
return `\u2139\uFE0F Workspace already initialised at ${ws.path} (registered as "${ws.registeredAs}")`;
|
|
1647
|
+
}
|
|
1648
|
+
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";
|
|
1649
|
+
return `\u2705 Created workspace at ${ws.path} (registered as "${ws.registeredAs}"; ${trail})`;
|
|
1650
|
+
}
|
|
1167
1651
|
function buildInitCommand() {
|
|
1168
1652
|
return new Command2("init").description(
|
|
1169
|
-
"
|
|
1653
|
+
"Bootstrap an oteam workspace and write guidance to AGENTS.md / CLAUDE.md"
|
|
1170
1654
|
).option(
|
|
1171
1655
|
"-d, --dir <path>",
|
|
1172
|
-
"
|
|
1173
|
-
).option(
|
|
1174
|
-
|
|
1656
|
+
"Workspace location (default: ~/openteam)"
|
|
1657
|
+
).option(
|
|
1658
|
+
"-w, --workspace <path>",
|
|
1659
|
+
"Workspace location (alias of --dir)"
|
|
1660
|
+
).option(
|
|
1661
|
+
"--docs-dir <path>",
|
|
1662
|
+
"Where to write AGENTS.md / CLAUDE.md (default: $HOME)"
|
|
1663
|
+
).option("-y, --yes", "Skip prompts, use defaults").option(
|
|
1664
|
+
"--skip-stamp",
|
|
1665
|
+
"Skip the stamp host/enforce prompts (leaves any existing config alone)"
|
|
1666
|
+
).action(async (opts) => {
|
|
1667
|
+
let result;
|
|
1668
|
+
try {
|
|
1669
|
+
result = await runInit(opts);
|
|
1670
|
+
} catch (err) {
|
|
1671
|
+
if (err instanceof WorkspaceConflictError) {
|
|
1672
|
+
process.stderr.write(`${err.message}
|
|
1673
|
+
`);
|
|
1674
|
+
process.exit(1);
|
|
1675
|
+
}
|
|
1676
|
+
throw err;
|
|
1677
|
+
}
|
|
1678
|
+
process.stdout.write(`${workspaceLine(result.workspace)}
|
|
1679
|
+
`);
|
|
1175
1680
|
process.stdout.write(
|
|
1176
1681
|
`\u2705 ${pastTense(result.agents.result)} ${result.agents.path}
|
|
1177
1682
|
`
|
|
@@ -1180,31 +1685,36 @@ function buildInitCommand() {
|
|
|
1180
1685
|
`\u2705 ${pastTense(result.claude.result)} ${result.claude.path}
|
|
1181
1686
|
`
|
|
1182
1687
|
);
|
|
1688
|
+
process.stdout.write(`${modelsLine(result.models)}
|
|
1689
|
+
`);
|
|
1690
|
+
const stampMsg = stampLine(result.stamp);
|
|
1691
|
+
if (stampMsg) process.stdout.write(`${stampMsg}
|
|
1692
|
+
`);
|
|
1183
1693
|
});
|
|
1184
1694
|
}
|
|
1185
1695
|
|
|
1186
1696
|
// src/commands/project.ts
|
|
1187
1697
|
import { Command as Command3 } from "commander";
|
|
1188
1698
|
import { spawnSync } from "child_process";
|
|
1189
|
-
import { existsSync as
|
|
1699
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
|
|
1190
1700
|
import { basename as basename3 } from "path";
|
|
1191
1701
|
|
|
1192
1702
|
// src/lib/projects.ts
|
|
1193
|
-
import { existsSync as
|
|
1194
|
-
import { join as
|
|
1703
|
+
import { existsSync as existsSync5, readFileSync as readFileSync5, readdirSync as readdirSync4, statSync as statSync3 } from "fs";
|
|
1704
|
+
import { join as join8 } from "path";
|
|
1195
1705
|
function projectsRoot(vaultPath) {
|
|
1196
|
-
return
|
|
1706
|
+
return join8(vaultPath, "projects");
|
|
1197
1707
|
}
|
|
1198
1708
|
function projectDir(vaultPath, id) {
|
|
1199
|
-
return
|
|
1709
|
+
return join8(projectsRoot(vaultPath), id);
|
|
1200
1710
|
}
|
|
1201
1711
|
function projectReadmePath(vaultPath, id) {
|
|
1202
|
-
return
|
|
1712
|
+
return join8(projectDir(vaultPath, id), "README.md");
|
|
1203
1713
|
}
|
|
1204
1714
|
function readProject(vaultPath, id) {
|
|
1205
1715
|
const dir = projectDir(vaultPath, id);
|
|
1206
1716
|
const readme = projectReadmePath(vaultPath, id);
|
|
1207
|
-
if (!
|
|
1717
|
+
if (!existsSync5(readme)) return null;
|
|
1208
1718
|
let raw;
|
|
1209
1719
|
try {
|
|
1210
1720
|
raw = readFileSync5(readme, "utf8");
|
|
@@ -1230,14 +1740,14 @@ function listProjects(vaultPath) {
|
|
|
1230
1740
|
const root = projectsRoot(vaultPath);
|
|
1231
1741
|
let entries = [];
|
|
1232
1742
|
try {
|
|
1233
|
-
entries =
|
|
1743
|
+
entries = readdirSync4(root);
|
|
1234
1744
|
} catch {
|
|
1235
1745
|
return [];
|
|
1236
1746
|
}
|
|
1237
1747
|
const projects = [];
|
|
1238
1748
|
for (const name of entries) {
|
|
1239
1749
|
if (name.startsWith(".")) continue;
|
|
1240
|
-
const dir =
|
|
1750
|
+
const dir = join8(root, name);
|
|
1241
1751
|
let isDir = false;
|
|
1242
1752
|
try {
|
|
1243
1753
|
isDir = statSync3(dir).isDirectory();
|
|
@@ -1294,7 +1804,7 @@ function bodyAfterFrontmatter(raw) {
|
|
|
1294
1804
|
function listSiblings(dir) {
|
|
1295
1805
|
let entries = [];
|
|
1296
1806
|
try {
|
|
1297
|
-
entries =
|
|
1807
|
+
entries = readdirSync4(dir);
|
|
1298
1808
|
} catch {
|
|
1299
1809
|
return [];
|
|
1300
1810
|
}
|
|
@@ -1302,7 +1812,7 @@ function listSiblings(dir) {
|
|
|
1302
1812
|
for (const name of entries) {
|
|
1303
1813
|
if (name === "README.md") continue;
|
|
1304
1814
|
if (name.startsWith(".")) continue;
|
|
1305
|
-
const full =
|
|
1815
|
+
const full = join8(dir, name);
|
|
1306
1816
|
let isFile = false;
|
|
1307
1817
|
try {
|
|
1308
1818
|
isFile = statSync3(full).isFile();
|
|
@@ -1356,15 +1866,15 @@ function runInit2(id, opts) {
|
|
|
1356
1866
|
const vaultPath = resolveVaultPath({ flagValue: opts.vault });
|
|
1357
1867
|
const dir = projectDir(vaultPath, id);
|
|
1358
1868
|
const readme = projectReadmePath(vaultPath, id);
|
|
1359
|
-
if (
|
|
1869
|
+
if (existsSync6(readme)) {
|
|
1360
1870
|
process.stderr.write(
|
|
1361
1871
|
`oteam project init: ${readme} already exists \u2014 refusing to overwrite
|
|
1362
1872
|
`
|
|
1363
1873
|
);
|
|
1364
1874
|
process.exit(1);
|
|
1365
1875
|
}
|
|
1366
|
-
|
|
1367
|
-
|
|
1876
|
+
mkdirSync5(dir, { recursive: true });
|
|
1877
|
+
writeFileSync5(readme, projectFrontmatterTemplate(id), "utf8");
|
|
1368
1878
|
process.stdout.write(`\u2705 Created project ${id}
|
|
1369
1879
|
${readme}
|
|
1370
1880
|
`);
|
|
@@ -1498,18 +2008,345 @@ function openInEditor(path) {
|
|
|
1498
2008
|
}
|
|
1499
2009
|
}
|
|
1500
2010
|
|
|
1501
|
-
// src/commands/
|
|
2011
|
+
// src/commands/telemetry.ts
|
|
1502
2012
|
import { Command as Command4 } from "commander";
|
|
1503
|
-
|
|
1504
|
-
|
|
2013
|
+
|
|
2014
|
+
// src/lib/telemetry.ts
|
|
2015
|
+
import {
|
|
2016
|
+
appendFileSync,
|
|
2017
|
+
existsSync as existsSync7,
|
|
2018
|
+
mkdirSync as mkdirSync6,
|
|
2019
|
+
readFileSync as readFileSync7
|
|
2020
|
+
} from "fs";
|
|
2021
|
+
import { homedir as homedir4 } from "os";
|
|
2022
|
+
import { join as join10 } from "path";
|
|
2023
|
+
|
|
2024
|
+
// src/lib/claude-session.ts
|
|
2025
|
+
import { readFileSync as readFileSync6 } from "fs";
|
|
2026
|
+
import { join as join9 } from "path";
|
|
2027
|
+
function encodeProjectDir(cwd) {
|
|
2028
|
+
return cwd.replace(/\//g, "-");
|
|
2029
|
+
}
|
|
2030
|
+
function findSessionFile(claudeConfigDir, cwd, sessionId) {
|
|
2031
|
+
return join9(
|
|
2032
|
+
claudeConfigDir,
|
|
2033
|
+
"projects",
|
|
2034
|
+
encodeProjectDir(cwd),
|
|
2035
|
+
`${sessionId}.jsonl`
|
|
2036
|
+
);
|
|
2037
|
+
}
|
|
2038
|
+
function parseSessionFile(path) {
|
|
2039
|
+
let raw;
|
|
2040
|
+
try {
|
|
2041
|
+
raw = readFileSync6(path, "utf8");
|
|
2042
|
+
} catch {
|
|
2043
|
+
return { tokens: {}, outcome: null };
|
|
2044
|
+
}
|
|
2045
|
+
return parseSessionJsonl(raw);
|
|
2046
|
+
}
|
|
2047
|
+
function parseSessionJsonl(raw) {
|
|
2048
|
+
const tokens = {};
|
|
2049
|
+
let lastAssistantText = "";
|
|
2050
|
+
for (const line of raw.split("\n")) {
|
|
2051
|
+
if (line.length === 0) continue;
|
|
2052
|
+
let entry;
|
|
2053
|
+
try {
|
|
2054
|
+
entry = JSON.parse(line);
|
|
2055
|
+
} catch {
|
|
2056
|
+
continue;
|
|
2057
|
+
}
|
|
2058
|
+
if (!entry || typeof entry !== "object") continue;
|
|
2059
|
+
const e = entry;
|
|
2060
|
+
if (e.type !== "assistant") continue;
|
|
2061
|
+
const message = e.message;
|
|
2062
|
+
if (!message || typeof message !== "object") continue;
|
|
2063
|
+
const m = message;
|
|
2064
|
+
if (m.usage && typeof m.usage === "object") {
|
|
2065
|
+
const u = m.usage;
|
|
2066
|
+
addIfFinite(tokens, "input", u.input_tokens);
|
|
2067
|
+
addIfFinite(tokens, "output", u.output_tokens);
|
|
2068
|
+
addIfFinite(tokens, "cache-write", u.cache_creation_input_tokens);
|
|
2069
|
+
addIfFinite(tokens, "cache-read", u.cache_read_input_tokens);
|
|
2070
|
+
}
|
|
2071
|
+
const text = extractAssistantText(m.content);
|
|
2072
|
+
if (text.length > 0) lastAssistantText = text;
|
|
2073
|
+
}
|
|
2074
|
+
return { tokens, outcome: detectOutcome(lastAssistantText) };
|
|
2075
|
+
}
|
|
2076
|
+
function extractAssistantText(content) {
|
|
2077
|
+
if (typeof content === "string") return content;
|
|
2078
|
+
if (!Array.isArray(content)) return "";
|
|
2079
|
+
let out = "";
|
|
2080
|
+
for (const part of content) {
|
|
2081
|
+
if (!part || typeof part !== "object") continue;
|
|
2082
|
+
const p = part;
|
|
2083
|
+
if (p.type === "text" && typeof p.text === "string") {
|
|
2084
|
+
out += (out.length > 0 ? "\n" : "") + p.text;
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
return out;
|
|
2088
|
+
}
|
|
2089
|
+
function detectOutcome(text) {
|
|
2090
|
+
if (text.includes("\u2705 DONE")) return "done";
|
|
2091
|
+
if (text.includes("\u23F8\uFE0F PAUSED")) return "paused";
|
|
2092
|
+
if (text.includes("\u{1F6D1} BLOCKED")) return "failed";
|
|
2093
|
+
return null;
|
|
2094
|
+
}
|
|
2095
|
+
function addIfFinite(target, key, value) {
|
|
2096
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return;
|
|
2097
|
+
target[key] = (target[key] ?? 0) + value;
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
// src/lib/telemetry.ts
|
|
2101
|
+
function telemetryDir() {
|
|
2102
|
+
const override = process.env.OTEAM_TELEMETRY_DIR;
|
|
2103
|
+
if (override && override.length > 0) return override;
|
|
2104
|
+
return join10(homedir4(), ".open-team", "telemetry");
|
|
2105
|
+
}
|
|
2106
|
+
function runsPath(dir = telemetryDir()) {
|
|
2107
|
+
return join10(dir, "runs.jsonl");
|
|
2108
|
+
}
|
|
2109
|
+
function recordPhase(input) {
|
|
2110
|
+
try {
|
|
2111
|
+
if (!getTelemetryEnabled()) return;
|
|
2112
|
+
const endedAt = input.endedAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
2113
|
+
const wallClockMs = computeWallClockMs(input.startedAt, endedAt);
|
|
2114
|
+
const sessionFile = findSessionFile(
|
|
2115
|
+
resolveClaudeConfigDir(),
|
|
2116
|
+
input.cwd,
|
|
2117
|
+
input.sessionId
|
|
2118
|
+
);
|
|
2119
|
+
let tokens = {};
|
|
2120
|
+
let markerOutcome = null;
|
|
2121
|
+
if (existsSync7(sessionFile)) {
|
|
2122
|
+
const parsed = parseSessionFile(sessionFile);
|
|
2123
|
+
tokens = parsed.tokens;
|
|
2124
|
+
markerOutcome = parsed.outcome;
|
|
2125
|
+
}
|
|
2126
|
+
const outcome = input.exitCode !== 0 ? "failed" : markerOutcome ?? "unknown";
|
|
2127
|
+
const line = {
|
|
2128
|
+
ticket: input.ticket,
|
|
2129
|
+
phase: input.phase,
|
|
2130
|
+
model: input.model,
|
|
2131
|
+
"started-at": input.startedAt,
|
|
2132
|
+
"ended-at": endedAt,
|
|
2133
|
+
"wall-clock-ms": wallClockMs,
|
|
2134
|
+
tokens,
|
|
2135
|
+
outcome
|
|
2136
|
+
};
|
|
2137
|
+
const dir = telemetryDir();
|
|
2138
|
+
mkdirSync6(dir, { recursive: true });
|
|
2139
|
+
appendFileSync(runsPath(dir), JSON.stringify(line) + "\n");
|
|
2140
|
+
} catch (err) {
|
|
2141
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2142
|
+
process.stderr.write(`oteam: telemetry record failed: ${msg}
|
|
2143
|
+
`);
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
function resolveClaudeConfigDir() {
|
|
2147
|
+
const env = process.env.CLAUDE_CONFIG_DIR;
|
|
2148
|
+
if (env && env.length > 0) return env;
|
|
2149
|
+
return join10(homedir4(), ".claude");
|
|
2150
|
+
}
|
|
2151
|
+
function computeWallClockMs(startedAt, endedAt) {
|
|
2152
|
+
const startMs = Date.parse(startedAt);
|
|
2153
|
+
const endMs = Date.parse(endedAt);
|
|
2154
|
+
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return 0;
|
|
2155
|
+
return Math.max(0, endMs - startMs);
|
|
2156
|
+
}
|
|
2157
|
+
function readRuns(dir = telemetryDir()) {
|
|
2158
|
+
const path = runsPath(dir);
|
|
2159
|
+
if (!existsSync7(path)) return [];
|
|
2160
|
+
const raw = readFileSync7(path, "utf8");
|
|
2161
|
+
const out = [];
|
|
2162
|
+
for (const line of raw.split("\n")) {
|
|
2163
|
+
if (line.length === 0) continue;
|
|
2164
|
+
try {
|
|
2165
|
+
const parsed = JSON.parse(line);
|
|
2166
|
+
if (parsed && typeof parsed === "object") out.push(parsed);
|
|
2167
|
+
} catch {
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
return out;
|
|
2171
|
+
}
|
|
2172
|
+
function summarize(runs, filter = {}) {
|
|
2173
|
+
const filtered = applyFilter(runs, filter);
|
|
2174
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
2175
|
+
for (const r of filtered) {
|
|
2176
|
+
const key = `${r.phase} ${r.model}`;
|
|
2177
|
+
const bucket = buckets.get(key) ?? { phase: r.phase, model: r.model, rows: [] };
|
|
2178
|
+
bucket.rows.push(r);
|
|
2179
|
+
buckets.set(key, bucket);
|
|
2180
|
+
}
|
|
2181
|
+
const rows = [];
|
|
2182
|
+
for (const bucket of buckets.values()) {
|
|
2183
|
+
rows.push({
|
|
2184
|
+
phase: bucket.phase,
|
|
2185
|
+
model: bucket.model,
|
|
2186
|
+
count: bucket.rows.length,
|
|
2187
|
+
meanWallClockMs: meanWallClock(bucket.rows),
|
|
2188
|
+
meanTokens: meanTokens(bucket.rows)
|
|
2189
|
+
});
|
|
2190
|
+
}
|
|
2191
|
+
rows.sort(
|
|
2192
|
+
(a, b) => a.phase.localeCompare(b.phase) || a.model.localeCompare(b.model)
|
|
2193
|
+
);
|
|
2194
|
+
return rows;
|
|
2195
|
+
}
|
|
2196
|
+
function applyFilter(runs, filter) {
|
|
2197
|
+
let cutoffMs = null;
|
|
2198
|
+
if (typeof filter.days === "number" && filter.days > 0) {
|
|
2199
|
+
cutoffMs = Date.now() - filter.days * 24 * 60 * 60 * 1e3;
|
|
2200
|
+
}
|
|
2201
|
+
return runs.filter((r) => {
|
|
2202
|
+
if (filter.phase && r.phase !== filter.phase) return false;
|
|
2203
|
+
if (filter.model && r.model !== filter.model) return false;
|
|
2204
|
+
if (cutoffMs !== null) {
|
|
2205
|
+
const t = Date.parse(r["started-at"]);
|
|
2206
|
+
if (!Number.isFinite(t) || t < cutoffMs) return false;
|
|
2207
|
+
}
|
|
2208
|
+
return true;
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
function meanWallClock(rows) {
|
|
2212
|
+
if (rows.length === 0) return 0;
|
|
2213
|
+
const sum = rows.reduce((acc, r) => acc + (r["wall-clock-ms"] ?? 0), 0);
|
|
2214
|
+
return Math.round(sum / rows.length);
|
|
2215
|
+
}
|
|
2216
|
+
function meanTokens(rows) {
|
|
2217
|
+
const keys = [
|
|
2218
|
+
"input",
|
|
2219
|
+
"output",
|
|
2220
|
+
"cache-read",
|
|
2221
|
+
"cache-write"
|
|
2222
|
+
];
|
|
2223
|
+
const out = {};
|
|
2224
|
+
for (const k of keys) {
|
|
2225
|
+
let sum = 0;
|
|
2226
|
+
let n = 0;
|
|
2227
|
+
for (const r of rows) {
|
|
2228
|
+
const v = r.tokens?.[k];
|
|
2229
|
+
if (typeof v === "number" && Number.isFinite(v)) {
|
|
2230
|
+
sum += v;
|
|
2231
|
+
n += 1;
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
if (n > 0) out[k] = Math.round(sum / n);
|
|
2235
|
+
}
|
|
2236
|
+
return out;
|
|
2237
|
+
}
|
|
2238
|
+
function tail(n, dir = telemetryDir()) {
|
|
2239
|
+
const all = readRuns(dir);
|
|
2240
|
+
if (n <= 0) return [];
|
|
2241
|
+
return all.slice(-n);
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
// src/commands/telemetry.ts
|
|
2245
|
+
function buildTelemetryCommand() {
|
|
2246
|
+
const telemetry = new Command4("telemetry").description(
|
|
2247
|
+
"Per-phase wall-clock + token telemetry for role-pipeline spawns"
|
|
2248
|
+
);
|
|
2249
|
+
telemetry.command("summary").description(
|
|
2250
|
+
"Aggregate runs.jsonl by phase \xD7 model (count, mean wall-clock, mean tokens)"
|
|
2251
|
+
).option("--days <n>", "Only include runs from the last N days", parsePositiveInt).option("--phase <name>", "Filter by phase (product|spike|implementation|qa)").option("--model <id>", "Filter by resolved model id").action(
|
|
2252
|
+
(opts) => {
|
|
2253
|
+
const runs = readRuns();
|
|
2254
|
+
if (runs.length === 0) {
|
|
2255
|
+
process.stdout.write(
|
|
2256
|
+
`(no telemetry yet \u2014 ${telemetryDir()}/runs.jsonl is empty or missing)
|
|
2257
|
+
`
|
|
2258
|
+
);
|
|
2259
|
+
return;
|
|
2260
|
+
}
|
|
2261
|
+
const rows = summarize(runs, opts);
|
|
2262
|
+
if (rows.length === 0) {
|
|
2263
|
+
process.stdout.write("(no rows match filters)\n");
|
|
2264
|
+
return;
|
|
2265
|
+
}
|
|
2266
|
+
process.stdout.write(formatSummary(rows) + "\n");
|
|
2267
|
+
}
|
|
2268
|
+
);
|
|
2269
|
+
telemetry.command("tail").description("Print the last N telemetry lines (default 20)").option("-n, --count <n>", "How many lines to print", parsePositiveInt, 20).action((opts) => {
|
|
2270
|
+
const lines = tail(opts.count);
|
|
2271
|
+
if (lines.length === 0) {
|
|
2272
|
+
process.stdout.write(
|
|
2273
|
+
`(no telemetry yet \u2014 ${telemetryDir()}/runs.jsonl is empty or missing)
|
|
2274
|
+
`
|
|
2275
|
+
);
|
|
2276
|
+
return;
|
|
2277
|
+
}
|
|
2278
|
+
for (const line of lines) {
|
|
2279
|
+
process.stdout.write(JSON.stringify(line) + "\n");
|
|
2280
|
+
}
|
|
2281
|
+
});
|
|
2282
|
+
telemetry.command("record", { hidden: true }).description("(internal) Record one phase's telemetry line").requiredOption("--ticket <id>", "Ticket id (e.g. AGT-108)").requiredOption("--phase <name>", "Phase name (product|spike|implementation|qa)").requiredOption("--model <id>", "Resolved model id passed to claude").requiredOption("--session <uuid>", "Session UUID passed to `claude --session-id`").requiredOption("--started-at <iso>", "ISO timestamp captured before spawning claude").requiredOption("--exit-code <n>", "claude's exit code", parseSignedInt).option("--cwd <path>", "Working directory the spawn ran in (defaults to $PWD)").action(
|
|
2283
|
+
(opts) => {
|
|
2284
|
+
recordPhase({
|
|
2285
|
+
ticket: opts.ticket,
|
|
2286
|
+
phase: opts.phase,
|
|
2287
|
+
model: opts.model,
|
|
2288
|
+
sessionId: opts.session,
|
|
2289
|
+
startedAt: opts.startedAt,
|
|
2290
|
+
exitCode: opts.exitCode,
|
|
2291
|
+
cwd: opts.cwd ?? process.cwd()
|
|
2292
|
+
});
|
|
2293
|
+
}
|
|
2294
|
+
);
|
|
2295
|
+
return telemetry;
|
|
2296
|
+
}
|
|
2297
|
+
function parsePositiveInt(raw) {
|
|
2298
|
+
const n = Number.parseInt(raw, 10);
|
|
2299
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
2300
|
+
process.stderr.write(`oteam telemetry: expected a positive integer, got "${raw}"
|
|
2301
|
+
`);
|
|
2302
|
+
process.exit(2);
|
|
2303
|
+
}
|
|
2304
|
+
return n;
|
|
2305
|
+
}
|
|
2306
|
+
function parseSignedInt(raw) {
|
|
2307
|
+
const n = Number.parseInt(raw, 10);
|
|
2308
|
+
if (!Number.isFinite(n)) {
|
|
2309
|
+
process.stderr.write(`oteam telemetry: expected an integer, got "${raw}"
|
|
2310
|
+
`);
|
|
2311
|
+
process.exit(2);
|
|
2312
|
+
}
|
|
2313
|
+
return n;
|
|
2314
|
+
}
|
|
2315
|
+
function formatSummary(rows) {
|
|
2316
|
+
const headers = ["phase", "model", "count", "mean_ms", "mean_in", "mean_out", "mean_cache_r", "mean_cache_w"];
|
|
2317
|
+
const data = rows.map((r) => [
|
|
2318
|
+
r.phase,
|
|
2319
|
+
r.model,
|
|
2320
|
+
String(r.count),
|
|
2321
|
+
String(r.meanWallClockMs),
|
|
2322
|
+
formatTokenField(r.meanTokens, "input"),
|
|
2323
|
+
formatTokenField(r.meanTokens, "output"),
|
|
2324
|
+
formatTokenField(r.meanTokens, "cache-read"),
|
|
2325
|
+
formatTokenField(r.meanTokens, "cache-write")
|
|
2326
|
+
]);
|
|
2327
|
+
const widths = headers.map(
|
|
2328
|
+
(h, i) => Math.max(h.length, ...data.map((row) => (row[i] ?? "").length))
|
|
2329
|
+
);
|
|
2330
|
+
const fmt = (cells) => cells.map((c, i) => c.padEnd(widths[i] ?? 0)).join(" ").trimEnd();
|
|
2331
|
+
return [fmt(headers), ...data.map(fmt)].join("\n");
|
|
2332
|
+
}
|
|
2333
|
+
function formatTokenField(tokens, key) {
|
|
2334
|
+
const v = tokens[key];
|
|
2335
|
+
return typeof v === "number" ? String(v) : "-";
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
// src/commands/ticket.ts
|
|
2339
|
+
import { Command as Command5 } from "commander";
|
|
2340
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync7, writeFileSync as writeFileSync6 } from "fs";
|
|
2341
|
+
import { join as join11 } from "path";
|
|
1505
2342
|
function runTicketNew(opts) {
|
|
1506
2343
|
const title = opts.title.trim();
|
|
1507
2344
|
if (title.length === 0) {
|
|
1508
2345
|
throw new Error("oteam ticket new: <title> must not be empty");
|
|
1509
2346
|
}
|
|
1510
2347
|
const vault = resolveVaultPath({ flagValue: opts.vault });
|
|
1511
|
-
const triageDir =
|
|
1512
|
-
|
|
2348
|
+
const triageDir = join11(vault, "tickets", "triage");
|
|
2349
|
+
mkdirSync7(triageDir, { recursive: true });
|
|
1513
2350
|
const id = nextTicketID(vault);
|
|
1514
2351
|
const slug = slugify(title);
|
|
1515
2352
|
if (slug.length === 0) {
|
|
@@ -1517,8 +2354,8 @@ function runTicketNew(opts) {
|
|
|
1517
2354
|
`oteam ticket new: title "${title}" produced an empty slug \u2014 use a title with at least one alphanumeric character`
|
|
1518
2355
|
);
|
|
1519
2356
|
}
|
|
1520
|
-
const target =
|
|
1521
|
-
if (
|
|
2357
|
+
const target = join11(triageDir, `${id}-${slug}.md`);
|
|
2358
|
+
if (existsSync8(target)) {
|
|
1522
2359
|
throw new Error(
|
|
1523
2360
|
`oteam ticket new: target already exists at ${target} \u2014 ID scan collision`
|
|
1524
2361
|
);
|
|
@@ -1533,14 +2370,14 @@ function runTicketNew(opts) {
|
|
|
1533
2370
|
priority: opts.priority ?? "medium",
|
|
1534
2371
|
labels: opts.labels ?? []
|
|
1535
2372
|
});
|
|
1536
|
-
|
|
2373
|
+
writeFileSync6(target, body, "utf8");
|
|
1537
2374
|
return { ticketID: id, path: target };
|
|
1538
2375
|
}
|
|
1539
2376
|
function collectLabel(value, prev = []) {
|
|
1540
2377
|
return [...prev, value];
|
|
1541
2378
|
}
|
|
1542
2379
|
function buildTicketCommand() {
|
|
1543
|
-
const ticket = new
|
|
2380
|
+
const ticket = new Command5("ticket").description(
|
|
1544
2381
|
"Create vault tickets directly (without an external source)"
|
|
1545
2382
|
);
|
|
1546
2383
|
ticket.command("new <title>").description(
|
|
@@ -1573,13 +2410,14 @@ function buildTicketCommand() {
|
|
|
1573
2410
|
|
|
1574
2411
|
// src/role-pipeline/runner.ts
|
|
1575
2412
|
import { spawnSync as spawnSync4 } from "child_process";
|
|
1576
|
-
import {
|
|
2413
|
+
import { randomUUID } from "crypto";
|
|
2414
|
+
import { writeFileSync as writeFileSync7 } from "fs";
|
|
1577
2415
|
import { tmpdir } from "os";
|
|
1578
|
-
import { resolve as
|
|
2416
|
+
import { resolve as resolve4, basename as basename5, dirname as dirname2, join as join14 } from "path";
|
|
1579
2417
|
|
|
1580
2418
|
// src/lib/kitty.ts
|
|
1581
2419
|
import { spawnSync as spawnSync2 } from "child_process";
|
|
1582
|
-
import { existsSync as
|
|
2420
|
+
import { existsSync as existsSync9, readdirSync as readdirSync5 } from "fs";
|
|
1583
2421
|
var SOCKET_BASENAME = "kitty-claudini";
|
|
1584
2422
|
var KNOWN_INSTANCES = ["personal", "work"];
|
|
1585
2423
|
function isMacOS() {
|
|
@@ -1611,7 +2449,7 @@ function findKittySocket(kittyPath, preferring) {
|
|
|
1611
2449
|
let pidSuffixed = [];
|
|
1612
2450
|
try {
|
|
1613
2451
|
const prefix = `${SOCKET_BASENAME}-`;
|
|
1614
|
-
pidSuffixed =
|
|
2452
|
+
pidSuffixed = readdirSync5("/tmp").filter((n) => n.startsWith(prefix)).map((n) => `/tmp/${n}`).filter((p) => !candidates.includes(p));
|
|
1615
2453
|
} catch {
|
|
1616
2454
|
}
|
|
1617
2455
|
if (preferring) {
|
|
@@ -1622,7 +2460,7 @@ function findKittySocket(kittyPath, preferring) {
|
|
|
1622
2460
|
candidates.push(...pidSuffixed);
|
|
1623
2461
|
}
|
|
1624
2462
|
for (const path of candidates) {
|
|
1625
|
-
if (!
|
|
2463
|
+
if (!existsSync9(path)) continue;
|
|
1626
2464
|
const socket = `unix:${path}`;
|
|
1627
2465
|
const r = spawnSync2(kittyPath, ["@", "--to", socket, "ls"], {
|
|
1628
2466
|
encoding: "utf8"
|
|
@@ -1688,48 +2526,12 @@ function shellEscape(s) {
|
|
|
1688
2526
|
}
|
|
1689
2527
|
|
|
1690
2528
|
// src/lib/workspace.ts
|
|
1691
|
-
import { existsSync as
|
|
2529
|
+
import { existsSync as existsSync10, mkdirSync as mkdirSync8, readdirSync as readdirSync6, rmSync } from "fs";
|
|
1692
2530
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
1693
|
-
import { basename as basename4, join as
|
|
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
|
-
}
|
|
2531
|
+
import { basename as basename4, join as join12 } from "path";
|
|
1728
2532
|
function buildGithubUrl(repoSlug) {
|
|
1729
2533
|
return `git@github.com:${repoSlug}.git`;
|
|
1730
2534
|
}
|
|
1731
|
-
|
|
1732
|
-
// src/lib/workspace.ts
|
|
1733
2535
|
var WORKSPACE_ROOT = "/tmp/open-team-issues";
|
|
1734
2536
|
var TICKET_ID_RE = /^AGT-\d+$/;
|
|
1735
2537
|
var ORPHAN_DIR_RE = /^agt-\d+$/;
|
|
@@ -1747,7 +2549,9 @@ var StampGateError = class extends Error {
|
|
|
1747
2549
|
` Reason: ${args.reason}`,
|
|
1748
2550
|
` Fix: provision the repo on the stamp server with`,
|
|
1749
2551
|
` stamp provision ${basename4(args.repoSlug)}`,
|
|
1750
|
-
` Or
|
|
2552
|
+
` Or turn enforcement off:`,
|
|
2553
|
+
` oteam config stamp set --enforce off`,
|
|
2554
|
+
` Or pass --no-stamp to bypass this gate for a single run.`
|
|
1751
2555
|
);
|
|
1752
2556
|
super(lines.join("\n"));
|
|
1753
2557
|
this.name = "StampGateError";
|
|
@@ -1762,34 +2566,31 @@ function prepareAgentWorkspace(opts) {
|
|
|
1762
2566
|
);
|
|
1763
2567
|
}
|
|
1764
2568
|
const root = opts.rootDir ?? WORKSPACE_ROOT;
|
|
1765
|
-
|
|
2569
|
+
mkdirSync8(root, { recursive: true });
|
|
1766
2570
|
if (opts.activeTicketIds) gcOrphanWorkspaces(root, opts.activeTicketIds);
|
|
1767
|
-
const ticketDir =
|
|
1768
|
-
const repoDir =
|
|
2571
|
+
const ticketDir = join12(root, opts.ticketId.toLowerCase());
|
|
2572
|
+
const repoDir = join12(ticketDir, "repo");
|
|
1769
2573
|
rmSync(ticketDir, { recursive: true, force: true });
|
|
1770
|
-
|
|
2574
|
+
mkdirSync8(ticketDir, { recursive: true });
|
|
1771
2575
|
const repoBasename = basename4(opts.repoSlug);
|
|
1772
2576
|
const cloneRunner = opts.cloneRunner ?? defaultCloneRunner;
|
|
1773
|
-
if (opts.
|
|
2577
|
+
if (opts.mode === "github") {
|
|
1774
2578
|
const url = buildGithubUrl(opts.repoSlug);
|
|
1775
2579
|
const r2 = cloneRunner(url, repoDir);
|
|
1776
2580
|
if (r2.status !== 0) {
|
|
1777
2581
|
throw new Error(
|
|
1778
|
-
`oteam assign:
|
|
2582
|
+
`oteam assign: github clone failed (git clone ${url}):
|
|
1779
2583
|
${r2.stderr.trim() || "(no stderr)"}`
|
|
1780
2584
|
);
|
|
1781
2585
|
}
|
|
1782
2586
|
return { path: repoDir, originUrl: url, source: "github" };
|
|
1783
2587
|
}
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
stampUrl: null,
|
|
1789
|
-
reason: `${stampServerConfigPath()} not found \u2014 no stamp server is configured`
|
|
1790
|
-
});
|
|
2588
|
+
if (!opts.stampHost || opts.stampHost.trim().length === 0) {
|
|
2589
|
+
throw new Error(
|
|
2590
|
+
"prepareAgentWorkspace: mode='stamp' requires opts.stampHost (run 'oteam config stamp set --host <url>')"
|
|
2591
|
+
);
|
|
1791
2592
|
}
|
|
1792
|
-
const stampUrl =
|
|
2593
|
+
const stampUrl = buildStampCloneUrl(opts.stampHost, repoBasename);
|
|
1793
2594
|
const r = cloneRunner(stampUrl, repoDir);
|
|
1794
2595
|
if (r.status !== 0) {
|
|
1795
2596
|
throw new StampGateError({
|
|
@@ -1801,6 +2602,9 @@ ${r2.stderr.trim() || "(no stderr)"}`
|
|
|
1801
2602
|
}
|
|
1802
2603
|
return { path: repoDir, originUrl: stampUrl, source: "stamp" };
|
|
1803
2604
|
}
|
|
2605
|
+
function buildStampCloneUrl(host, repoBasename) {
|
|
2606
|
+
return `${host}/srv/git/${repoBasename}.git`;
|
|
2607
|
+
}
|
|
1804
2608
|
function stampGateReason(r) {
|
|
1805
2609
|
const stderr = r.stderr.trim();
|
|
1806
2610
|
if (!stderr) return `git clone exited ${r.status}`;
|
|
@@ -1818,18 +2622,18 @@ var defaultCloneRunner = (url, dest) => {
|
|
|
1818
2622
|
};
|
|
1819
2623
|
};
|
|
1820
2624
|
function gcOrphanWorkspaces(root, activeTicketIds) {
|
|
1821
|
-
if (!
|
|
2625
|
+
if (!existsSync10(root)) return [];
|
|
1822
2626
|
const removed = [];
|
|
1823
2627
|
let entries;
|
|
1824
2628
|
try {
|
|
1825
|
-
entries =
|
|
2629
|
+
entries = readdirSync6(root);
|
|
1826
2630
|
} catch {
|
|
1827
2631
|
return [];
|
|
1828
2632
|
}
|
|
1829
2633
|
for (const name of entries) {
|
|
1830
2634
|
if (!ORPHAN_DIR_RE.test(name)) continue;
|
|
1831
2635
|
if (activeTicketIds.has(name)) continue;
|
|
1832
|
-
const target =
|
|
2636
|
+
const target = join12(root, name);
|
|
1833
2637
|
try {
|
|
1834
2638
|
rmSync(target, { recursive: true, force: true });
|
|
1835
2639
|
removed.push(target);
|
|
@@ -1840,22 +2644,22 @@ function gcOrphanWorkspaces(root, activeTicketIds) {
|
|
|
1840
2644
|
}
|
|
1841
2645
|
|
|
1842
2646
|
// src/role-pipeline/install-slash-command.ts
|
|
1843
|
-
import { copyFileSync, existsSync as
|
|
1844
|
-
import { homedir as
|
|
1845
|
-
import { dirname, join as
|
|
2647
|
+
import { copyFileSync, existsSync as existsSync11, mkdirSync as mkdirSync9, readdirSync as readdirSync7, readFileSync as readFileSync8, statSync as statSync4 } from "fs";
|
|
2648
|
+
import { homedir as homedir5 } from "os";
|
|
2649
|
+
import { dirname, join as join13 } from "path";
|
|
1846
2650
|
import { fileURLToPath } from "url";
|
|
1847
2651
|
var moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
1848
|
-
var BUNDLED_PROMPT =
|
|
2652
|
+
var BUNDLED_PROMPT = join13(moduleDir, "assign-ticket.md");
|
|
1849
2653
|
function installRolePipelineSlashCommand() {
|
|
1850
|
-
if (!
|
|
1851
|
-
const bundled =
|
|
2654
|
+
if (!existsSync11(BUNDLED_PROMPT)) return;
|
|
2655
|
+
const bundled = readFileSync8(BUNDLED_PROMPT);
|
|
1852
2656
|
const targets = resolveTargetDirs();
|
|
1853
2657
|
for (const dir of targets) {
|
|
1854
2658
|
try {
|
|
1855
|
-
|
|
1856
|
-
const target =
|
|
1857
|
-
if (
|
|
1858
|
-
const current =
|
|
2659
|
+
mkdirSync9(dir, { recursive: true });
|
|
2660
|
+
const target = join13(dir, "assign-ticket.md");
|
|
2661
|
+
if (existsSync11(target)) {
|
|
2662
|
+
const current = readFileSync8(target);
|
|
1859
2663
|
if (current.equals(bundled)) continue;
|
|
1860
2664
|
}
|
|
1861
2665
|
copyFileSync(BUNDLED_PROMPT, target);
|
|
@@ -1864,23 +2668,23 @@ function installRolePipelineSlashCommand() {
|
|
|
1864
2668
|
}
|
|
1865
2669
|
}
|
|
1866
2670
|
function resolveTargetDirs() {
|
|
1867
|
-
const home =
|
|
2671
|
+
const home = homedir5();
|
|
1868
2672
|
const dirs = /* @__PURE__ */ new Set();
|
|
1869
|
-
dirs.add(
|
|
2673
|
+
dirs.add(join13(home, ".claude", "commands"));
|
|
1870
2674
|
const configDir2 = process.env.CLAUDE_CONFIG_DIR;
|
|
1871
2675
|
if (configDir2 && configDir2.length > 0) {
|
|
1872
|
-
dirs.add(
|
|
2676
|
+
dirs.add(join13(configDir2, "commands"));
|
|
1873
2677
|
}
|
|
1874
2678
|
try {
|
|
1875
|
-
for (const name of
|
|
2679
|
+
for (const name of readdirSync7(home)) {
|
|
1876
2680
|
if (!name.startsWith(".claude-")) continue;
|
|
1877
|
-
const candidate =
|
|
2681
|
+
const candidate = join13(home, name);
|
|
1878
2682
|
let isDir = false;
|
|
1879
2683
|
try {
|
|
1880
2684
|
isDir = statSync4(candidate).isDirectory();
|
|
1881
2685
|
} catch {
|
|
1882
2686
|
}
|
|
1883
|
-
if (isDir) dirs.add(
|
|
2687
|
+
if (isDir) dirs.add(join13(candidate, "commands"));
|
|
1884
2688
|
}
|
|
1885
2689
|
} catch {
|
|
1886
2690
|
}
|
|
@@ -1888,6 +2692,18 @@ function resolveTargetDirs() {
|
|
|
1888
2692
|
}
|
|
1889
2693
|
|
|
1890
2694
|
// src/role-pipeline/runner.ts
|
|
2695
|
+
function resolveWorkspaceMode(config, noStamp) {
|
|
2696
|
+
if (noStamp) return "github";
|
|
2697
|
+
if (config.stamp?.enforce) {
|
|
2698
|
+
if (!config.stamp.host || config.stamp.host.length === 0) {
|
|
2699
|
+
throw new Error(
|
|
2700
|
+
"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'"
|
|
2701
|
+
);
|
|
2702
|
+
}
|
|
2703
|
+
return "stamp";
|
|
2704
|
+
}
|
|
2705
|
+
return "github";
|
|
2706
|
+
}
|
|
1891
2707
|
async function assignTicket(opts) {
|
|
1892
2708
|
const config = readConfig();
|
|
1893
2709
|
let resolvedVault = resolveVault({ flagValue: opts.vault, config });
|
|
@@ -1895,7 +2711,7 @@ async function assignTicket(opts) {
|
|
|
1895
2711
|
if (isAgtId(opts.ticketPath)) {
|
|
1896
2712
|
ticketPath = findTicketFileByID(resolvedVault.path, opts.ticketPath);
|
|
1897
2713
|
} else {
|
|
1898
|
-
ticketPath =
|
|
2714
|
+
ticketPath = resolve4(opts.ticketPath);
|
|
1899
2715
|
if (!opts.vault) {
|
|
1900
2716
|
const detected = findVaultRootForPath(ticketPath, config);
|
|
1901
2717
|
if (detected) resolvedVault = detected;
|
|
@@ -1916,11 +2732,13 @@ async function assignTicket(opts) {
|
|
|
1916
2732
|
}
|
|
1917
2733
|
let workspace = null;
|
|
1918
2734
|
if (ticket.repo) {
|
|
2735
|
+
const mode = resolveWorkspaceMode(config, opts.noStamp ?? false);
|
|
1919
2736
|
try {
|
|
1920
2737
|
workspace = prepareAgentWorkspace({
|
|
1921
2738
|
ticketId: ticket.id,
|
|
1922
2739
|
repoSlug: ticket.repo,
|
|
1923
|
-
|
|
2740
|
+
mode,
|
|
2741
|
+
stampHost: mode === "stamp" ? config.stamp?.host : void 0,
|
|
1924
2742
|
activeTicketIds: collectActiveTicketIds(resolvedVault.path)
|
|
1925
2743
|
});
|
|
1926
2744
|
} catch (err) {
|
|
@@ -1931,7 +2749,7 @@ async function assignTicket(opts) {
|
|
|
1931
2749
|
}
|
|
1932
2750
|
throw err;
|
|
1933
2751
|
}
|
|
1934
|
-
if (opts.noStamp) {
|
|
2752
|
+
if (opts.noStamp && config.stamp?.enforce) {
|
|
1935
2753
|
process.stderr.write(
|
|
1936
2754
|
`oteam assign: --no-stamp set; cloned from ${workspace.originUrl}. The stamp gate is bypassed \u2014 verify any push manually.
|
|
1937
2755
|
`
|
|
@@ -1939,9 +2757,25 @@ async function assignTicket(opts) {
|
|
|
1939
2757
|
}
|
|
1940
2758
|
}
|
|
1941
2759
|
const projectContext = loadProjectContext(resolvedVault.path, ticket.project);
|
|
2760
|
+
const model = resolveRoleModel(ticket.state, config.models);
|
|
2761
|
+
const phase = phaseForState(ticket.state);
|
|
2762
|
+
const telemetry = phase !== null && getTelemetryEnabled() ? {
|
|
2763
|
+
ticketId: ticket.id,
|
|
2764
|
+
phase,
|
|
2765
|
+
sessionId: randomUUID(),
|
|
2766
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2767
|
+
} : null;
|
|
1942
2768
|
const kittyPath = !opts.workInline && isMacOS() ? findKittyBinary() : null;
|
|
1943
2769
|
if (!kittyPath) {
|
|
1944
|
-
runInline(
|
|
2770
|
+
runInline(
|
|
2771
|
+
claudePath,
|
|
2772
|
+
ticketPath,
|
|
2773
|
+
resolvedVault.path,
|
|
2774
|
+
projectContext,
|
|
2775
|
+
workspace,
|
|
2776
|
+
model,
|
|
2777
|
+
telemetry
|
|
2778
|
+
);
|
|
1945
2779
|
return;
|
|
1946
2780
|
}
|
|
1947
2781
|
const monitored = opts.monitoredOrgs ?? readMonitoredOrgsFromEnv();
|
|
@@ -1952,7 +2786,15 @@ async function assignTicket(opts) {
|
|
|
1952
2786
|
`oteam assign: no kitty socket reachable (preferring "${preferring}"); falling back to inline run.
|
|
1953
2787
|
`
|
|
1954
2788
|
);
|
|
1955
|
-
runInline(
|
|
2789
|
+
runInline(
|
|
2790
|
+
claudePath,
|
|
2791
|
+
ticketPath,
|
|
2792
|
+
resolvedVault.path,
|
|
2793
|
+
projectContext,
|
|
2794
|
+
workspace,
|
|
2795
|
+
model,
|
|
2796
|
+
telemetry
|
|
2797
|
+
);
|
|
1956
2798
|
return;
|
|
1957
2799
|
}
|
|
1958
2800
|
const cwd = workspace?.path ?? dirname2(ticketPath);
|
|
@@ -1967,7 +2809,17 @@ async function assignTicket(opts) {
|
|
|
1967
2809
|
const slashPrompt = `/assign-ticket ${escapedTicket}`;
|
|
1968
2810
|
const escapedPrompt = shellEscape(slashPrompt);
|
|
1969
2811
|
const projectFlag = projectContext ? ` --append-system-prompt "$(cat '${shellEscape(projectContext.tmpFile)}')"` : "";
|
|
1970
|
-
const
|
|
2812
|
+
const sessionFlag = telemetry ? ` --session-id '${shellEscape(telemetry.sessionId)}'` : "";
|
|
2813
|
+
const claudeCmd = `'${escapedClaude}' --dangerously-skip-permissions --model ${shellEscape(model)}${sessionFlag}${projectFlag} '${escapedPrompt}'`;
|
|
2814
|
+
const telemetryTail = telemetry ? buildTelemetryTail({
|
|
2815
|
+
oteamPath: findToolOnPath("oteam") ?? "oteam",
|
|
2816
|
+
ticketId: telemetry.ticketId,
|
|
2817
|
+
phase: telemetry.phase,
|
|
2818
|
+
model,
|
|
2819
|
+
sessionId: telemetry.sessionId,
|
|
2820
|
+
startedAt: telemetry.startedAt
|
|
2821
|
+
}) : "";
|
|
2822
|
+
const shellCmd = `${envPrefix}${claudeCmd}${telemetryTail}`;
|
|
1971
2823
|
const result = kittyLaunch({
|
|
1972
2824
|
socket,
|
|
1973
2825
|
title,
|
|
@@ -1981,16 +2833,32 @@ async function assignTicket(opts) {
|
|
|
1981
2833
|
);
|
|
1982
2834
|
}
|
|
1983
2835
|
}
|
|
1984
|
-
function
|
|
2836
|
+
function buildTelemetryTail(input) {
|
|
2837
|
+
const oteam = `'${shellEscape(input.oteamPath)}'`;
|
|
2838
|
+
const args = [
|
|
2839
|
+
`--ticket '${shellEscape(input.ticketId)}'`,
|
|
2840
|
+
`--phase '${shellEscape(input.phase)}'`,
|
|
2841
|
+
`--model '${shellEscape(input.model)}'`,
|
|
2842
|
+
`--session '${shellEscape(input.sessionId)}'`,
|
|
2843
|
+
`--started-at '${shellEscape(input.startedAt)}'`,
|
|
2844
|
+
`--exit-code "$EC"`
|
|
2845
|
+
].join(" ");
|
|
2846
|
+
return `; EC=$?; ${oteam} telemetry record ${args} >/dev/null 2>&1 || true; exit "$EC"`;
|
|
2847
|
+
}
|
|
2848
|
+
function runInline(claudePath, ticketPath, vaultPath, projectContext, workspace, model, telemetry) {
|
|
1985
2849
|
const args = [
|
|
1986
2850
|
"--dangerously-skip-permissions",
|
|
1987
2851
|
"--model",
|
|
1988
|
-
|
|
2852
|
+
model
|
|
1989
2853
|
];
|
|
2854
|
+
if (telemetry) {
|
|
2855
|
+
args.push("--session-id", telemetry.sessionId);
|
|
2856
|
+
}
|
|
1990
2857
|
if (projectContext) {
|
|
1991
2858
|
args.push("--append-system-prompt", projectContext.content);
|
|
1992
2859
|
}
|
|
1993
2860
|
args.push(`/assign-ticket ${ticketPath}`);
|
|
2861
|
+
const cwd = workspace?.path ?? process.cwd();
|
|
1994
2862
|
const r = spawnSync4(
|
|
1995
2863
|
claudePath,
|
|
1996
2864
|
args,
|
|
@@ -2000,6 +2868,17 @@ function runInline(claudePath, ticketPath, vaultPath, projectContext, workspace)
|
|
|
2000
2868
|
env: { ...process.env, PRODUCT_VAULT_PATH: vaultPath }
|
|
2001
2869
|
}
|
|
2002
2870
|
);
|
|
2871
|
+
if (telemetry) {
|
|
2872
|
+
recordPhase({
|
|
2873
|
+
ticket: telemetry.ticketId,
|
|
2874
|
+
phase: telemetry.phase,
|
|
2875
|
+
model,
|
|
2876
|
+
sessionId: telemetry.sessionId,
|
|
2877
|
+
startedAt: telemetry.startedAt,
|
|
2878
|
+
exitCode: r.status ?? -1,
|
|
2879
|
+
cwd
|
|
2880
|
+
});
|
|
2881
|
+
}
|
|
2003
2882
|
if (r.status != null && r.status !== 0) process.exit(r.status);
|
|
2004
2883
|
}
|
|
2005
2884
|
function collectActiveTicketIds(vaultPath) {
|
|
@@ -2024,8 +2903,8 @@ function loadProjectContext(vaultPath, projectId) {
|
|
|
2024
2903
|
}
|
|
2025
2904
|
const content = formatProjectContextForPrompt(project);
|
|
2026
2905
|
const safeId = projectId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
2027
|
-
const tmpFile =
|
|
2028
|
-
|
|
2906
|
+
const tmpFile = join14(tmpdir(), `oteam-project-${safeId}.md`);
|
|
2907
|
+
writeFileSync7(tmpFile, content, "utf8");
|
|
2029
2908
|
return { tmpFile, content };
|
|
2030
2909
|
}
|
|
2031
2910
|
function findToolOnPath(name) {
|
|
@@ -2041,7 +2920,7 @@ function readMonitoredOrgsFromEnv() {
|
|
|
2041
2920
|
}
|
|
2042
2921
|
|
|
2043
2922
|
// src/index.ts
|
|
2044
|
-
var program = new
|
|
2923
|
+
var program = new Command6();
|
|
2045
2924
|
program.name("oteam").description(
|
|
2046
2925
|
"Source-agnostic vault-driven role pipeline for spawning Claude agents against tickets"
|
|
2047
2926
|
).version("0.0.1");
|
|
@@ -2074,7 +2953,7 @@ program.command("assign <ticket-or-id>").description(
|
|
|
2074
2953
|
"Run the role pipeline in the current terminal instead of spawning kitty"
|
|
2075
2954
|
).option("--vault <name-or-path>", "Use a specific registered vault").option(
|
|
2076
2955
|
"--no-stamp",
|
|
2077
|
-
"
|
|
2956
|
+
"Force a github clone for this run, overriding stamp.enforce in oteam config. The durable knob is 'oteam config stamp set --enforce off'."
|
|
2078
2957
|
).action(
|
|
2079
2958
|
async (ticketPath, opts) => {
|
|
2080
2959
|
await assignTicket({
|
|
@@ -2120,6 +2999,7 @@ program.command("archive <ticket-id>").description("Move a done ticket to archiv
|
|
|
2120
2999
|
program.addCommand(buildConfigCommand());
|
|
2121
3000
|
program.addCommand(buildInitCommand());
|
|
2122
3001
|
program.addCommand(buildProjectCommand());
|
|
3002
|
+
program.addCommand(buildTelemetryCommand());
|
|
2123
3003
|
program.addCommand(buildTicketCommand());
|
|
2124
3004
|
program.parseAsync(process.argv).catch((err) => {
|
|
2125
3005
|
process.stderr.write(`oteam: ${err.message}
|