@tablation/crew 0.75.1 → 0.75.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.
Files changed (2) hide show
  1. package/dist/cli.js +73 -21
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2571,8 +2571,8 @@ function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome, t
2571
2571
  }
2572
2572
  const hasRepos = Object.keys(repoDirs).length > 0;
2573
2573
  const reposBasePath = expand(String(c?.reposBasePath ?? raw.ship?.reposBasePath ?? DEFAULT_REPOS_BASE_PATH), base);
2574
- const dirIsCloneBase = !c?.dir && !hasRepos && c?.repos && typeof c.repos === "object" && !Array.isArray(c.repos);
2575
- const dir = c?.dir ? expand(String(c.dir), base) : hasRepos ? Object.values(repoDirs)[0] : dirIsCloneBase ? join9(reposBasePath, splitRoute(route)[0]) : missing.req(void 0, `${where}.dir (or ${where}.repos)`) ?? "";
2574
+ const dirIsCloneBase = !c?.dir && !hasRepos && !Array.isArray(c?.repos);
2575
+ const dir = c?.dir ? expand(String(c.dir), base) : hasRepos ? Object.values(repoDirs)[0] : dirIsCloneBase ? join9(reposBasePath, splitRoute(route)[0]) : "";
2576
2576
  const weight = c?.weight === void 0 ? void 0 : Number(c.weight);
2577
2577
  if (weight !== void 0 && !(Number.isFinite(weight) && weight > 0)) {
2578
2578
  missing.add(`${where}.weight must be a positive number (got "${c?.weight}")`);
@@ -5168,6 +5168,7 @@ var DEFAULT_CONTRACT = {
5168
5168
  resolutionNote: "resolution_note",
5169
5169
  verifiedSha: "verified_sha",
5170
5170
  prRef: "pr_ref",
5171
+ externalId: "external_id",
5171
5172
  position: "position"
5172
5173
  },
5173
5174
  comments: {
@@ -5652,6 +5653,29 @@ var State = class _State {
5652
5653
  }
5653
5654
  };
5654
5655
  }
5656
+ /**
5657
+ * The previous cycle's unroutable ticket ids for one route (CREW-2548): open
5658
+ * tickets with no repository that are not exempt. `poll.ts` diffs against it
5659
+ * so each ticket warns once when it becomes unroutable, not every cycle; the
5660
+ * caller persists the new set beside `attention(route).persist(...)`. A
5661
+ * missing or corrupt file reads as empty, so a fresh install warns once.
5662
+ */
5663
+ unroutable(route) {
5664
+ const file = `.unroutable-${_State.safe(route)}.json`;
5665
+ return {
5666
+ previous: () => {
5667
+ try {
5668
+ const parsed = JSON.parse(readFileSync12(this.path(file), "utf8"));
5669
+ return Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string") : [];
5670
+ } catch {
5671
+ return [];
5672
+ }
5673
+ },
5674
+ persist: (next) => {
5675
+ writeFileSync8(this.path(file), JSON.stringify(next));
5676
+ }
5677
+ };
5678
+ }
5655
5679
  /**
5656
5680
  * Open ship-level attention items (CREW-1373), ship-wide rather than
5657
5681
  * per-route: a parked role or a missing hook command is about this
@@ -9349,6 +9373,14 @@ function filingErrors(tickets, opts = {}) {
9349
9373
  return !exempt.has(t[reportTypeColumn]);
9350
9374
  });
9351
9375
  }
9376
+ function unroutableChanges(unroutable, previous, tickets, externalIdColumn) {
9377
+ const prev = new Set(previous);
9378
+ const now = new Set(unroutable.map((t) => t.id));
9379
+ return {
9380
+ added: unroutable.filter((t) => !prev.has(t.id)).map((t) => ({ ticket: t, synced: !!t[externalIdColumn] })),
9381
+ gained: tickets.filter((t) => prev.has(t.id) && !now.has(t.id) && !!t.repo_id)
9382
+ };
9383
+ }
9352
9384
  function sweepComment(step) {
9353
9385
  return step.action === "park" ? `Parked as **Blocked** by the crew \u2014 waiting on: ${step.blockers}.
9354
9386
 
@@ -10131,15 +10163,30 @@ async function decideCycle(o) {
10131
10163
  emit.emit(`every child is done \u2014 this coordinating ticket is too`, { ticket: r.parent.issue_id });
10132
10164
  }
10133
10165
  }
10134
- for (const t of filingErrors(tickets, {
10166
+ const unroutableTickets = filingErrors(tickets, {
10135
10167
  ...coordinating,
10136
10168
  repoExemptValues: tracker.contract.repoExemptReportTypes
10137
- })) {
10169
+ });
10170
+ const unroutable = unroutableTickets.map((t) => t.id);
10171
+ const { added, gained } = unroutableChanges(
10172
+ unroutableTickets,
10173
+ state.unroutable(route.route).previous(),
10174
+ tickets,
10175
+ tracker.contract.columns.externalId
10176
+ );
10177
+ for (const { ticket: t, synced } of added) {
10138
10178
  emit.warn(
10139
- "no repo and not marked coordinating/question/investigation \u2014 a filing error, not an epic, and unroutable as written",
10179
+ synced ? "no repository \u2014 not crew work until one is set" : "no repo and not marked coordinating/question/investigation \u2014 a filing error, not an epic, and unroutable as written",
10140
10180
  { ticket: t.issue_id }
10141
10181
  );
10142
10182
  }
10183
+ for (const t of gained) emit.emit("now has a repository \u2014 no longer unroutable", { ticket: t.issue_id });
10184
+ if (unroutableTickets.length) {
10185
+ emit.emit(
10186
+ `${unroutableTickets.length} open ticket(s) have no repository and are not routable (${added.length} new since last cycle)`,
10187
+ { step: "sweep", data: { unroutable: unroutableTickets.map((t) => t.issue_id), newUnroutable: added.map((a) => a.ticket.issue_id) } }
10188
+ );
10189
+ }
10143
10190
  const attentionCurrent = {};
10144
10191
  for (const t of tickets) {
10145
10192
  const reasons = attentionReasons(t, tracker.contract);
@@ -10212,7 +10259,8 @@ async function decideCycle(o) {
10212
10259
  watermark,
10213
10260
  directionEvents,
10214
10261
  selectionInput,
10215
- attention: attentionCurrent
10262
+ attention: attentionCurrent,
10263
+ unroutable
10216
10264
  };
10217
10265
  const advanced = state.advanceWatermark([...comments, ...tickets]);
10218
10266
  if (advanced !== watermark) {
@@ -10798,7 +10846,7 @@ function renderShipBlock(a) {
10798
10846
  agent: { bin: a.agentBin },
10799
10847
  ...a.extraPath ? { extraPath: a.extraPath } : {}
10800
10848
  }
10801
- });
10849
+ }, { lineWidth: 0 });
10802
10850
  }
10803
10851
  function renderFullConfig(a, routeBlock) {
10804
10852
  return `${renderShipBlock(a)}
@@ -13128,7 +13176,10 @@ async function decideFleet(o) {
13128
13176
  if (e.error || !e.decision) continue;
13129
13177
  const won = best?.route.route === e.route.route;
13130
13178
  o.state.fairness(e.route.route).record(e.decision.actionable.count, e.decision.actionable.top?.issue_id, won);
13131
- if (!o.dryRun) o.state.attention(e.route.route).persist(e.decision.attention);
13179
+ if (!o.dryRun) {
13180
+ o.state.attention(e.route.route).persist(e.decision.attention);
13181
+ o.state.unroutable(e.route.route).persist(e.decision.unroutable);
13182
+ }
13132
13183
  }
13133
13184
  return {
13134
13185
  entries,
@@ -13916,23 +13967,18 @@ async function createOperatorRow(o, found) {
13916
13967
  }
13917
13968
  }
13918
13969
  function renderConnection(d, route, dir, opts = {}) {
13919
- const apiKeyFileLine = opts.keychainBacked ? ` # apiKey: resolved automatically from the OS keychain (crew connect) \u2014 no apiKeyFile needed` : opts.apiKeyFile ? ` apiKeyFile: "${opts.apiKeyFile}"` : ` apiKeyFile: "REPLACE \u2014 a file holding this workspace's key"`;
13970
+ const apiKeyFileLine = opts.keychainBacked ? "" : opts.apiKeyFile ? ` apiKeyFile: "${opts.apiKeyFile}"` : ` apiKeyFile: "REPLACE \u2014 a file holding this workspace's key"`;
13920
13971
  const apiKeyVarLine = opts.keychainBacked ? "" : opts.apiKeyVar ? `
13921
13972
  apiKeyVar: ${opts.apiKeyVar}` : `
13922
13973
  apiKeyVar: REPLACE_KEY_VAR`;
13974
+ const baseUrlLine = opts.baseUrl ? opts.baseUrl.replace(/\/+$/, "") === DEFAULT_BASE_URL ? "" : ` baseUrl: "${opts.baseUrl}"
13975
+ ` : ' baseUrl: "REPLACE \u2014 the same baseUrl as your other routes"\n';
13976
+ const keyLines = [apiKeyFileLine, apiKeyVarLine.replace(/^\n/, "")].filter(Boolean).join("\n");
13923
13977
  return ` - route: ${route}
13924
- enabled: false # arm it deliberately, once doctor is green
13978
+ enabled: false # set to true once \`crew doctor\` is green
13925
13979
  ${opts.area ? ` area: "${opts.area}"
13926
- ` : ""} ${dir === null ? "repos: {} # `crew repos clone` / `crew repos add` fill this in" : `dir: "${dir}"`}
13927
- # worktreePrefix: omitted \u2014 each repo derives its own from its checkout's
13928
- # directory name (ISSUE-400). Only set this if every repo this route
13929
- # serves genuinely wants the SAME prefix, which is rare once an area has
13930
- # more than one repo.
13931
- # What a host must be to build this \u2014 unix, macos, linux, or windows \u2014
13932
- # belongs in each repo's own .crew.yaml, not here (see docs/REPO_SPEC.md).
13933
- baseUrl: ${opts.baseUrl ? `"${opts.baseUrl}"` : '"REPLACE \u2014 the same baseUrl as your other routes"'}
13934
- ${opts.hostPassengers ? " hostPassengers: true\n" : ""}${apiKeyFileLine}${apiKeyVarLine}
13935
- `;
13980
+ ` : ""}${dir === null ? "" : ` dir: "${dir}"
13981
+ `}${baseUrlLine}${opts.hostPassengers ? " hostPassengers: true\n" : ""}${keyLines ? keyLines + "\n" : ""}`;
13936
13982
  }
13937
13983
 
13938
13984
  // src/cli.ts
@@ -16276,6 +16322,7 @@ async function runOnePassUnguarded(o) {
16276
16322
  }
16277
16323
  const decision = await decide(ctx);
16278
16324
  o.state.attention(o.route.route).persist(decision.attention);
16325
+ o.state.unroutable(o.route.route).persist(decision.unroutable);
16279
16326
  if (decision.sweep.length) {
16280
16327
  const tracker = new Tracker(o.route, o.ship);
16281
16328
  const seat = o.route.resolved?.seats.qa ?? o.route.resolved?.seats.dev ?? "";
@@ -18034,7 +18081,10 @@ ${changed2.map((c) => ` - ${c}
18034
18081
  break;
18035
18082
  }
18036
18083
  const decision = await decideCycle({ route, ship: cfg.ship, state, emit });
18037
- if (!dryRun) state.attention(route.route).persist(decision.attention);
18084
+ if (!dryRun) {
18085
+ state.attention(route.route).persist(decision.attention);
18086
+ state.unroutable(route.route).persist(decision.unroutable);
18087
+ }
18038
18088
  if (decision.sweep.length) {
18039
18089
  if (dryRun) {
18040
18090
  for (const s of decision.sweep) {
@@ -20567,6 +20617,8 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
20567
20617
  // Additive (CREW-1496): each project and its work mode. `version` stays 1.
20568
20618
  projects: projects.map((p) => ({ id: p.id, name: p.name, workMode: p.mode })),
20569
20619
  dir: route.dir,
20620
+ // Additive (CREW-2548): open tickets with no repository, as of the last cycle.
20621
+ unroutable: state.unroutable(route.route).previous().length,
20570
20622
  enabled: route.enabled,
20571
20623
  area: route.area ?? null,
20572
20624
  // Additive (CREW-1511): the toolchain this route's sessions run on.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.75.1",
3
+ "version": "0.75.3",
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",