@tablation/crew 0.67.2 → 0.68.1

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.
Files changed (2) hide show
  1. package/dist/cli.js +133 -2
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2662,6 +2662,14 @@ function resolveApiKey(r) {
2662
2662
  `route "${r.route}": no API key (looked for, in order: apiKey; ${r.apiKeyVar ?? "VAR"} in ${r.apiKeyFile ?? "<unset>"}; CREW_API_KEY in the environment)`
2663
2663
  );
2664
2664
  }
2665
+ function credentialSource(r) {
2666
+ if (r.apiKey) return KEYCHAIN_ELIGIBLE_ROUTES.has(r) ? "keychain" : "inline";
2667
+ if (r.apiKeyFile && r.apiKeyVar && existsSync7(r.apiKeyFile)) {
2668
+ const re = new RegExp(`^${r.apiKeyVar}=`);
2669
+ if (readFileSync6(r.apiKeyFile, "utf8").split("\n").some((l) => re.test(l))) return "apiKeyFile";
2670
+ }
2671
+ return process.env.CREW_API_KEY?.trim() ? "CREW_API_KEY" : "none";
2672
+ }
2665
2673
  var KEYCHAIN_ELIGIBLE_ROUTES = /* @__PURE__ */ new WeakSet();
2666
2674
  async function hydrateApiKeys(routes, getStore = getSessionStore) {
2667
2675
  let store;
@@ -3879,6 +3887,9 @@ function ticketMentions(text) {
3879
3887
  var TicketAttributionTracker = class {
3880
3888
  #tokensByTicket = /* @__PURE__ */ new Map();
3881
3889
  #current;
3890
+ /** Tracker record uuid → ticket key, learned when a write names both (a claim resolves the key from the last mention). */
3891
+ #keyByRecord = /* @__PURE__ */ new Map();
3892
+ #lastBy = {};
3882
3893
  /** The ticket key most recently named in the transcript, or undefined before any. */
3883
3894
  get current() {
3884
3895
  return this.#current;
@@ -3895,6 +3906,37 @@ var TicketAttributionTracker = class {
3895
3906
  if (this.#current === void 0 || tokens <= 0) return;
3896
3907
  this.#tokensByTicket.set(this.#current, (this.#tokensByTicket.get(this.#current) ?? 0) + tokens);
3897
3908
  }
3909
+ /**
3910
+ * Record a tool call if it is a ticket-targeted tracker write (claim,
3911
+ * hand-off or comment). The ticket key comes from a record uuid seen on an
3912
+ * earlier write when known, else from the key last named in the transcript
3913
+ * (a claim or hand-off announces its ticket). A comment on a record never
3914
+ * seen before is ignored: its uuid cannot be resolved to a key.
3915
+ */
3916
+ noteWrite(tool, input) {
3917
+ const w = classifyWrite(tool, input);
3918
+ if (!w) return;
3919
+ let key = w.recordId ? this.#keyByRecord.get(w.recordId) : void 0;
3920
+ if (w.kind !== "comment") key ??= this.#current;
3921
+ if (!key) return;
3922
+ if (w.kind !== "comment" && w.recordId) this.#keyByRecord.set(w.recordId, key);
3923
+ this.#lastBy[w.kind] = key;
3924
+ }
3925
+ /**
3926
+ * How the attribution is decided: a hand-off write beats a claim, a claim
3927
+ * beats a comment (so a follow-up ticket filed or commented on while
3928
+ * working never displaces the claimed one), and any write beats the
3929
+ * mention-token heuristic, which is only the fallback for runs that made
3930
+ * no ticket-targeted write. The pre-run hint is last.
3931
+ */
3932
+ decide(hint) {
3933
+ for (const by of ["handoff", "claim", "comment"]) {
3934
+ const ticket = this.#lastBy[by];
3935
+ if (ticket) return { ticket, by };
3936
+ }
3937
+ if (this.#tokensByTicket.size > 0) return { ticket: this.winner(hint), by: "mentions" };
3938
+ return { ticket: hint, by: "hint" };
3939
+ }
3898
3940
  /**
3899
3941
  * The ticket key the run spent the most tokens on, or `fallback` (the
3900
3942
  * pre-run poll hint) when the transcript never named one — an
@@ -3918,6 +3960,20 @@ function isClaimWrite(tool, input) {
3918
3960
  const body = input?.body;
3919
3961
  return typeof body === "object" && body !== null && body.status === "in_progress";
3920
3962
  }
3963
+ var HANDOFF_STATUSES = /* @__PURE__ */ new Set(["fixed", "verified", "needs_info", "accepted"]);
3964
+ function classifyWrite(tool, input) {
3965
+ if (!tool) return void 0;
3966
+ const body = input?.body;
3967
+ if (typeof body !== "object" || body === null) return void 0;
3968
+ const b = body;
3969
+ if (/records_controller_update$/.test(tool)) {
3970
+ const recordId = typeof input?.recordId === "string" ? input.recordId : void 0;
3971
+ if (b.status === "in_progress" || b.status === "qa") return { kind: "claim", recordId };
3972
+ if (typeof b.status === "string" && HANDOFF_STATUSES.has(b.status)) return { kind: "handoff", recordId };
3973
+ }
3974
+ if (/records_controller_create$/.test(tool) && typeof b.ticket_id === "string") return { kind: "comment", recordId: b.ticket_id };
3975
+ return void 0;
3976
+ }
3921
3977
 
3922
3978
  // src/agent.ts
3923
3979
  var DIGEST_MAX_AGE_SECONDS = 600;
@@ -4208,6 +4264,7 @@ var RunObserver = class {
4208
4264
  let turnText = "";
4209
4265
  for (const ev of events) if ((ev.kind === "thought" || ev.kind === "text") && ev.text) this.attribution.note(ev.text);
4210
4266
  for (const ev of events) {
4267
+ if (ev.kind === "tool") this.attribution.noteWrite(ev.tool, ev.input);
4211
4268
  if (ev.kind === "tool" && isClaimWrite(ev.tool, ev.input)) {
4212
4269
  const claimed = this.attribution.current;
4213
4270
  if (claimed && claimed !== this.liveTicket) {
@@ -4292,12 +4349,13 @@ function writeRunMeta(id, startedAt, liveTicket, extra) {
4292
4349
  }
4293
4350
  }
4294
4351
  async function finishRun(id, obs, emit, startedAt, end, agentLogKey) {
4295
- const attributedTicket = obs.attribution.winner(obs.liveTicket);
4352
+ const { ticket: attributedTicket, by: attributedBy } = obs.attribution.decide(obs.liveTicket);
4296
4353
  const identity = { toolchain: id.toolchain, harness: id.harness, provider: id.provider };
4297
4354
  const { result } = obs;
4298
4355
  try {
4299
4356
  writeRunMeta(id, startedAt, obs.liveTicket, {
4300
4357
  attributedTicket: attributedTicket ?? null,
4358
+ attributedBy,
4301
4359
  running: void 0,
4302
4360
  exitCode: end.code,
4303
4361
  ...end.interrupted ? { interrupted: end.interrupted } : {},
@@ -11674,6 +11732,61 @@ function formatProjectsReport(report) {
11674
11732
  `;
11675
11733
  }
11676
11734
 
11735
+ // src/whoami.ts
11736
+ async function buildWhoami(routes, deps = {}) {
11737
+ const doFetch = deps.fetch ?? fetch;
11738
+ return Promise.all(routes.map(async (route) => {
11739
+ const credential = credentialSource(route);
11740
+ try {
11741
+ const key = resolveApiKey(route);
11742
+ const q = route.resolved ? `?workspaceId=${encodeURIComponent(route.resolved.workspaceId)}` : "";
11743
+ const res = await doFetch(`${route.baseUrl.replace(/\/+$/, "")}/api/auth/me${q}`, {
11744
+ headers: { Authorization: `Bearer ${key}`, "User-Agent": deps.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
11745
+ });
11746
+ if (res.status === 401 || res.status === 403) {
11747
+ return { route: route.route, ok: false, credential, error: `key rejected by ${route.baseUrl} (HTTP ${res.status}): expired or revoked, re-run crew connect` };
11748
+ }
11749
+ if (!res.ok) return { route: route.route, ok: false, credential, error: `/auth/me answered HTTP ${res.status}` };
11750
+ const me = await res.json();
11751
+ const operatorId = route.resolved?.operator ?? null;
11752
+ let operatorName = null;
11753
+ if (operatorId && deps.operatorName) {
11754
+ try {
11755
+ operatorName = await deps.operatorName(route, operatorId) ?? null;
11756
+ } catch {
11757
+ }
11758
+ }
11759
+ return {
11760
+ route: route.route,
11761
+ ok: true,
11762
+ credential,
11763
+ identity: { id: me.id ?? null, email: me.email ?? null, name: me.name ?? null },
11764
+ role: typeof me.role === "string" ? me.role : null,
11765
+ operator: { id: operatorId, name: operatorName }
11766
+ };
11767
+ } catch (e) {
11768
+ return { route: route.route, ok: false, credential, error: e.message };
11769
+ }
11770
+ }));
11771
+ }
11772
+ function formatWhoami(report) {
11773
+ const lines = [];
11774
+ for (const r of report) {
11775
+ lines.push(`route ${r.route}`);
11776
+ if (!r.ok) {
11777
+ lines.push(` error: ${r.error}`, ` credential: ${r.credential}`);
11778
+ continue;
11779
+ }
11780
+ const who2 = [r.identity.name, r.identity.email ? `<${r.identity.email}>` : null].filter(Boolean).join(" ");
11781
+ lines.push(` signed in as: ${who2 || r.identity.id || "(unknown)"}${r.identity.id ? ` (${r.identity.id})` : ""}`);
11782
+ lines.push(` role: ${r.role ?? "(unknown)"}`);
11783
+ lines.push(` operator: ${r.operator.id ? `${r.operator.name ?? "(unnamed)"} (${r.operator.id})` : "(not linked)"}`);
11784
+ lines.push(` credential: ${r.credential}`);
11785
+ }
11786
+ return `${lines.join("\n")}
11787
+ `;
11788
+ }
11789
+
11677
11790
  // src/cli.ts
11678
11791
  import { StaleWriteError as StaleWriteError9 } from "@tablation/client";
11679
11792
 
@@ -15477,6 +15590,7 @@ function usage() {
15477
15590
  crew watch [route] live view of what the crew is doing
15478
15591
  crew status [route] [--json] paused/running state; --json for a machine reader
15479
15592
  crew projects [--route R] [--json] the Projects rows each route can read (id, name, prefix, work mode)
15593
+ crew whoami [route] [--json] the signed-in identity, key role, operator Crew row and credential source per route
15480
15594
  crew reload make the running daemon re-read crew.yaml (--now: restart it immediately)
15481
15595
  crew doctor [route] [--fix] [--offline] [--json] preflight; --fix enables clean routes and offers crew install;
15482
15596
  --offline skips push probes; --json prints push-auth and tool checks only
@@ -15698,7 +15812,7 @@ if (command === "passenger-sync-daemon") {
15698
15812
  const DAEMON_SUBCOMMANDS = /* @__PURE__ */ new Set(["start", "stop", "status", "restart"]);
15699
15813
  const worktreeArgs = command === "worktree" ? splitWorktreeArgs(positional, cfg.routes.map((r) => r.route)) : void 0;
15700
15814
  const named = worktreeArgs ? worktreeArgs.route : command === "daemon" && DAEMON_SUBCOMMANDS.has(positional[1] ?? "") ? positional[2] : positional[1];
15701
- const fleetWide = command === "inbox" || command === "connect" || command === "disconnect" || command === "agents" || command === "skills" || command === "logbook" || command === "repos" || command === "projects" || releaseFleetWide || command === "pause" || command === "resume" || command === "reload" || FLEET_CAPABLE.has(command) && !named && cfg.routes.length > 1 || worktreeArgs !== void 0 && !named && cfg.routes.length > 1;
15815
+ const fleetWide = command === "inbox" || command === "connect" || command === "disconnect" || command === "agents" || command === "skills" || command === "logbook" || command === "repos" || command === "projects" || command === "whoami" || releaseFleetWide || command === "pause" || command === "resume" || command === "reload" || FLEET_CAPABLE.has(command) && !named && cfg.routes.length > 1 || worktreeArgs !== void 0 && !named && cfg.routes.length > 1;
15702
15816
  let route;
15703
15817
  try {
15704
15818
  route = fleetWide ? cfg.routes[0] : findRoute(cfg, named);
@@ -18902,6 +19016,23 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
18902
19016
  if (results.some((r) => !r.ok)) process.exitCode = 1;
18903
19017
  break;
18904
19018
  }
19019
+ case "whoami": {
19020
+ const only = value("route") ?? positional[1];
19021
+ const wanted = only ? cfg.routes.filter((r) => r.route === only) : cfg.routes;
19022
+ if (only && wanted.length === 0) {
19023
+ process.stderr.write(`crew whoami: no route "${only}" (have: ${cfg.routes.map((r) => r.route).join(", ")})
19024
+ `);
19025
+ process.exit(2);
19026
+ }
19027
+ const report = await buildWhoami(wanted, {
19028
+ userAgent: cfg.ship.userAgent,
19029
+ operatorName: async (r, id) => (await new Tracker(r, cfg.ship).crewRows()).find((c) => c.id === id)?.name
19030
+ });
19031
+ process.stdout.write(flag("json") ? `${JSON.stringify(report.length === 1 && only ? report[0] : report, null, 2)}
19032
+ ` : formatWhoami(report));
19033
+ if (report.some((r) => !r.ok)) process.exitCode = 1;
19034
+ break;
19035
+ }
18905
19036
  case "projects": {
18906
19037
  const only = value("route") ?? positional[1];
18907
19038
  const wanted = only ? cfg.routes.filter((r) => r.route === only) : cfg.routes;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.67.2",
3
+ "version": "0.68.1",
4
4
  "description": "A standing team of headless agents that picks work off a Tablation board, does it on a machine you control, and reports back.",
5
5
  "type": "module",
6
6
  "license": "MIT",