@nanobpm/nano-workforce 0.74.0 → 0.75.0

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/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [0.75.0](https://github.com/nanobpm/nano-workforce/compare/v0.74.0...v0.75.0) (2026-08-17)
2
+
3
+
4
+ ### Features
5
+
6
+ * surface issue/PR titles on dispatch surfaces ([#248](https://github.com/nanobpm/nano-workforce/issues/248)) ([#249](https://github.com/nanobpm/nano-workforce/issues/249)) ([a63f6dd](https://github.com/nanobpm/nano-workforce/commit/a63f6dd9bab3040cdc61c807bdc999ce9a591299))
7
+
1
8
  # [0.74.0](https://github.com/nanobpm/nano-workforce/compare/v0.73.1...v0.74.0) (2026-08-16)
2
9
 
3
10
 
@@ -5,10 +5,27 @@
5
5
  // drive it against an in-memory data layer + a stub engine and assert the row shape, the
6
6
  // short-circuit on an already-running run, the in-place restart of a settled run, and the seeded
7
7
  // process variables (the single `task` slice + the base-branch brief).
8
- import { test } from "node:test";
8
+ import { after, test } from "node:test";
9
9
  import { assertEquals } from "#test-assert";
10
10
  import { FEATURE_PROCESS_ID, FEATURE_TERMINAL_STATUSES, featureTaskId, startFeature } from "./feature.ts";
11
11
 
12
+ // `startFeature` now fetches the issue title (issue #248) via the GitHub transport. Force the token
13
+ // transport with no token so the fetch is a hermetic no-op (returns null) — no `gh` subprocess, no
14
+ // network — and the row `title` deterministically coalesces to the `owner/repo#N` key. A dedicated
15
+ // test below stubs a successful fetch to cover the real-title path. Capture the prior values and
16
+ // restore them after this file's tests so the module-scope mutation never leaks into other test
17
+ // files under concurrent `node --test`.
18
+ const PRIOR_TRANSPORT = process.env["NANO_PR_GITHUB_TRANSPORT"];
19
+ const PRIOR_TOKEN = process.env["GITHUB_TOKEN"];
20
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
21
+ delete process.env["GITHUB_TOKEN"];
22
+ after(() => {
23
+ if (PRIOR_TRANSPORT === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
24
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = PRIOR_TRANSPORT;
25
+ if (PRIOR_TOKEN === undefined) delete process.env["GITHUB_TOKEN"];
26
+ else process.env["GITHUB_TOKEN"] = PRIOR_TOKEN;
27
+ });
28
+
12
29
  function memTable(rows: any[], key: string) {
13
30
  return {
14
31
  get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
@@ -221,3 +238,48 @@ test("startFeature: a settled run is restarted in place (status reset, pr/outcom
221
238
  assertEquals(row.auto_merge, 1);
222
239
  assertEquals(row.process_key, "PI-2");
223
240
  });
241
+
242
+ // Issue #248: the human-readable identity for the feature grids. Every start persists a non-blank
243
+ // `title` — the fetched issue title when available, else the `owner/repo#N` key — on BOTH the insert
244
+ // (new run) and update (in-place restart) paths, so the title-led grid never renders a blank cell.
245
+ test("startFeature: coalesces title to the key when the fetch yields nothing (insert path)", async () => {
246
+ const stores = { feature_runs: { rows: [] as any[], key: "feature_key" } };
247
+ const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-T1" }) } as any;
248
+ await startFeature(memData(stores), engine, PARSED, "main", false, false);
249
+ assertEquals(stores.feature_runs.rows[0].title, "owner/repo#42");
250
+ });
251
+
252
+ test("startFeature: repopulates a non-blank title on the in-place restart (update path)", async () => {
253
+ const stores = {
254
+ feature_runs: {
255
+ rows: [{ feature_key: "owner/repo#42", status: "opened", title: null, process_key: "PI-OLD" }],
256
+ key: "feature_key",
257
+ },
258
+ };
259
+ const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-T2" }) } as any;
260
+ await startFeature(memData(stores), engine, PARSED, "main", false, false);
261
+ assertEquals(stores.feature_runs.rows[0].title, "owner/repo#42");
262
+ });
263
+
264
+ test("startFeature: persists the real issue title when the fetch succeeds", async () => {
265
+ const prevTok = process.env["GITHUB_TOKEN"];
266
+ const prevFetch = globalThis.fetch;
267
+ process.env["GITHUB_TOKEN"] = "t0ken";
268
+ globalThis.fetch = ((url: string | URL | Request) => {
269
+ const u = String(url);
270
+ if (u.endsWith("/repos/owner/repo/issues/42")) {
271
+ return Promise.resolve(new Response(JSON.stringify({ title: "Add the widget" }), { status: 200 }));
272
+ }
273
+ throw new Error(`unexpected fetch: ${u}`);
274
+ }) as typeof fetch;
275
+ try {
276
+ const stores = { feature_runs: { rows: [] as any[], key: "feature_key" } };
277
+ const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-T3" }) } as any;
278
+ await startFeature(memData(stores), engine, PARSED, "main", false, false);
279
+ assertEquals(stores.feature_runs.rows[0].title, "Add the widget");
280
+ } finally {
281
+ globalThis.fetch = prevFetch;
282
+ if (prevTok === undefined) delete process.env["GITHUB_TOKEN"];
283
+ else process.env["GITHUB_TOKEN"] = prevTok;
284
+ }
285
+ });
package/app/feature.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  // Data access goes through the record gateway (`data.table`), never hand-written
16
16
  // SQL — matching app/plan.ts and app/service.ts.
17
17
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
18
+ import { coalesceTitle, fetchIssueTitle } from "./github.ts";
18
19
  import { ESCALATION_SLA_TIMEOUT, normalizeBaseBranch, type ParsedIssue, renderBaseBranchBrief } from "./plan.ts";
19
20
 
20
21
  /** The BPMN process this module drives (resources/processes/feature.bpmn). */
@@ -32,6 +33,11 @@ export interface FeatureRun {
32
33
  repo: string;
33
34
  issue_number: number;
34
35
  issue_url: string;
36
+ /** Human-readable identity (the GitHub issue title) for the feature grids (issue #248). Fetched
37
+ * best-effort at `startFeature` and coalesced to the `owner/repo#N` key at write time, so it is
38
+ * ALWAYS non-blank — the grid's `{{title}}` template needs no fallback and a failed/absent title
39
+ * fetch still shows a usable identity (the key) rather than an empty cell. */
40
+ title: string | null;
35
41
  base_branch: string;
36
42
  status: FeatureRunStatus;
37
43
  process_key: string | null;
@@ -282,11 +288,20 @@ export async function startFeature(
282
288
  }
283
289
  const base = normalizeBaseBranch(baseBranch);
284
290
  const ts = now();
291
+ // Human-readable identity for the feature grids (issue #248): fetch the issue title best-effort
292
+ // and coalesce to the `owner/repo#N` key so `feature_runs.title` is ALWAYS non-blank (see the
293
+ // interface note); a blank/whitespace fetch counts as missing. A fetch failure never blocks the
294
+ // start (`fetchIssueTitle` returns null on any error).
295
+ const title = coalesceTitle(
296
+ await fetchIssueTitle(parsed.repo, parsed.number, process.env.GITHUB_TOKEN ?? ""),
297
+ parsed.planKey,
298
+ );
285
299
  if (existing) {
286
300
  await table.update(parsed.planKey, {
287
301
  status: "running",
288
302
  base_branch: base,
289
303
  issue_url: parsed.url,
304
+ title,
290
305
  pr_key: null,
291
306
  converge: converge ? 1 : 0,
292
307
  auto_merge: autoMerge ? 1 : 0,
@@ -303,6 +318,7 @@ export async function startFeature(
303
318
  repo: parsed.repo,
304
319
  issue_number: parsed.number,
305
320
  issue_url: parsed.url,
321
+ title,
306
322
  base_branch: base,
307
323
  status: "running",
308
324
  process_key: null,
@@ -3,7 +3,7 @@
3
3
  // the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
4
4
  import { test } from "node:test";
5
5
  import { assertEquals, assertRejects } from "#test-assert";
6
- import { BaseBranchMustExistError, ensureBaseBranch, fetchPrFiles, isNotAPullRequestError } from "./github.ts";
6
+ import { BaseBranchMustExistError, coalesceTitle, ensureBaseBranch, fetchIssueTitle, fetchPrFiles, isNotAPullRequestError } from "./github.ts";
7
7
 
8
8
  // A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
9
9
  // files (named `f{index}`), setting a `Link: rel="next"` header whenever a later page exists.
@@ -259,3 +259,79 @@ test("isNotAPullRequestError: transient failures stay blocking (false)", () => {
259
259
  assertEquals(isNotAPullRequestError(new Error("fetch failed")), false);
260
260
  assertEquals(isNotAPullRequestError(null), false);
261
261
  });
262
+
263
+ // Issue #248: `fetchIssueTitle` labels the epics/feature rows with the real GitHub issue title. It
264
+ // is best-effort and MUST be tolerant of failure (returns null, never throws) so a title fetch can
265
+ // never block an epic/feature start — the caller falls back to the `owner/repo#N` key.
266
+ function withTitleFetch<T>(
267
+ fetchImpl: typeof globalThis.fetch,
268
+ token: string | undefined,
269
+ fn: () => Promise<T>,
270
+ ): Promise<T> {
271
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
272
+ const prevTok = process.env["GITHUB_TOKEN"];
273
+ const prevFetch = globalThis.fetch;
274
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
275
+ if (token === undefined) delete process.env["GITHUB_TOKEN"];
276
+ else process.env["GITHUB_TOKEN"] = token;
277
+ globalThis.fetch = fetchImpl;
278
+ const restore = () => {
279
+ globalThis.fetch = prevFetch;
280
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
281
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
282
+ if (prevTok === undefined) delete process.env["GITHUB_TOKEN"];
283
+ else process.env["GITHUB_TOKEN"] = prevTok;
284
+ };
285
+ return fn().finally(restore);
286
+ }
287
+
288
+ test("fetchIssueTitle: returns the title on a successful token-transport read", async () => {
289
+ const stub = ((url: string | URL | Request) => {
290
+ assertEquals(String(url).endsWith("/repos/owner/repo/issues/248"), true);
291
+ return Promise.resolve(new Response(JSON.stringify({ title: "Surface titles" }), { status: 200 }));
292
+ }) as typeof fetch;
293
+ const title = await withTitleFetch(stub, "t0ken", () => fetchIssueTitle("owner/repo", 248, "t0ken"));
294
+ assertEquals(title, "Surface titles");
295
+ });
296
+
297
+ test("fetchIssueTitle: a non-2xx response yields null (best-effort, never throws)", async () => {
298
+ const stub = (() => Promise.resolve(new Response("nope", { status: 404 }))) as typeof fetch;
299
+ const title = await withTitleFetch(stub, "t0ken", () => fetchIssueTitle("owner/repo", 999, "t0ken"));
300
+ assertEquals(title, null);
301
+ });
302
+
303
+ test("fetchIssueTitle: a thrown transport error is swallowed to null", async () => {
304
+ const stub = (() => Promise.reject(new Error("network down"))) as typeof fetch;
305
+ const title = await withTitleFetch(stub, "t0ken", () => fetchIssueTitle("owner/repo", 7, "t0ken"));
306
+ assertEquals(title, null);
307
+ });
308
+
309
+ test("fetchIssueTitle: token mode with no token is a no-op (null)", async () => {
310
+ const stub = (() => {
311
+ throw new Error("fetch must not be called without a token");
312
+ }) as typeof fetch;
313
+ const title = await withTitleFetch(stub, undefined, () => fetchIssueTitle("owner/repo", 7, ""));
314
+ assertEquals(title, null);
315
+ });
316
+
317
+ // Issue #248: `coalesceTitle` guarantees a non-blank identity for the title-led grids. A best-effort
318
+ // fetch can legitimately return "" (or whitespace) — `??` would persist that blank; `coalesceTitle`
319
+ // treats it as missing (matching the 036 backfill's `trim(title) = ''`) and falls back to the key.
320
+ test("coalesceTitle: a non-blank first candidate wins", () => {
321
+ assertEquals(coalesceTitle("Real title", "owner/repo#1"), "Real title");
322
+ });
323
+
324
+ test("coalesceTitle: null/undefined candidates fall through to the key", () => {
325
+ assertEquals(coalesceTitle(null, "owner/repo#1"), "owner/repo#1");
326
+ assertEquals(coalesceTitle(undefined, "owner/repo#1"), "owner/repo#1");
327
+ });
328
+
329
+ test("coalesceTitle: a blank/whitespace title counts as missing", () => {
330
+ assertEquals(coalesceTitle("", "owner/repo#1"), "owner/repo#1");
331
+ assertEquals(coalesceTitle(" ", "owner/repo#1"), "owner/repo#1");
332
+ });
333
+
334
+ test("coalesceTitle: skips a blank middle candidate to the next non-blank one", () => {
335
+ assertEquals(coalesceTitle("", "Prior title", "owner/repo#1"), "Prior title");
336
+ assertEquals(coalesceTitle(null, " ", "owner/repo#1"), "owner/repo#1");
337
+ });
package/app/github.ts CHANGED
@@ -429,6 +429,50 @@ export async function fetchPrMeta(
429
429
  return { title: j.title ?? null, body: j.body ?? "", headRef: j.head?.ref ?? null };
430
430
  }
431
431
 
432
+ /** Fetch an issue's title via the configured transport, mirroring `fetchPrMeta` (both `gh` and
433
+ * token transports). Best-effort and tolerant of failure: returns `null` when no transport is
434
+ * usable OR when the fetch fails/returns no title, so a caller can label a row with the real issue
435
+ * title on success and fall back to the `owner/repo#N` key otherwise — a title fetch must never
436
+ * block an epic/feature start. Unlike the merge-stage reads it does NOT throw on a transport
437
+ * failure; the identity it feeds is cosmetic, not a correctness gate. */
438
+ export async function fetchIssueTitle(
439
+ repo: string,
440
+ number: number | string,
441
+ token: string,
442
+ ): Promise<string | null> {
443
+ try {
444
+ if (await useGh()) {
445
+ const out = await runGh(["issue", "view", String(number), "--repo", repo, "--json", "title"]);
446
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
447
+ const j = JSON.parse(out) as { title?: string };
448
+ return j.title ?? null;
449
+ }
450
+ if (!token) return null; // token mode with no token → no identity to fetch (caller falls back to the key)
451
+ const r = await fetch(`https://api.github.com/repos/${repo}/issues/${number}`, {
452
+ headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
453
+ });
454
+ if (!r.ok) return null;
455
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
456
+ const j = (await r.json()) as { title?: string };
457
+ return j.title ?? null;
458
+ } catch {
459
+ return null;
460
+ }
461
+ }
462
+
463
+ /** Coalesce best-effort title candidates to a non-blank identity for the title-led grids (issue
464
+ * #248). A candidate that is null/undefined OR blank/whitespace-only is treated as missing —
465
+ * external data (`fetchIssueTitle`/`fetchPrMeta`) can legitimately return `""`, which `??` would
466
+ * wrongly persist as a blank identity cell. Returns the first non-blank candidate, else the last
467
+ * one (the caller's key fallback, which is always non-blank). Mirrors the 036 backfill's
468
+ * `trim(title) = ''` test so write-time and backfill agree. */
469
+ export function coalesceTitle(...candidates: (string | null | undefined)[]): string {
470
+ for (const c of candidates) {
471
+ if (c != null && c.trim() !== "") return c;
472
+ }
473
+ return candidates[candidates.length - 1] ?? "";
474
+ }
475
+
432
476
  /** A PR's merge state, narrowed to what the merge poller needs to classify landability.
433
477
  * `mergeStateStatus` uses GitHub's vocabulary (CLEAN | BLOCKED | BEHIND | DIRTY | UNSTABLE |
434
478
  * DRAFT | HAS_HOOKS | UNKNOWN). `failingChecks` is `-1` when the transport can't enumerate
package/app/plan.test.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // `NaN`/`0` (e.g. unset, "", "abc"), the cap check `round + 1 >= cap` would never fire and the
5
5
  // planner could revise forever. `positiveIntEnv` must fall back to the default on any value that
6
6
  // is not a positive integer, so the loop is always bounded.
7
- import { test } from "node:test";
7
+ import { after, test } from "node:test";
8
8
  import { assertEquals, assertRejects, assertThrows } from "#test-assert";
9
9
  import { positiveIntEnv } from "./plan.ts";
10
10
 
@@ -59,6 +59,23 @@ test("valid positive integer → honoured", () => {
59
59
  // asserts the `plan_reviews` rows for the plan key are gone after a re-plan.
60
60
  import { startPlan } from "./plan.ts";
61
61
 
62
+ // `startPlan` now fetches the epic issue title (issue #248) via the GitHub transport. Force the
63
+ // token transport with no token so the fetch is a hermetic no-op (returns null) and the row `title`
64
+ // deterministically coalesces to the `owner/repo#N` key — no `gh` subprocess, no network. A
65
+ // dedicated test below stubs a successful fetch to cover the real-title path. Capture the prior
66
+ // values and restore them after this file's tests so the module-scope mutation never leaks into
67
+ // other test files under concurrent `node --test`.
68
+ const PRIOR_TRANSPORT = process.env["NANO_PR_GITHUB_TRANSPORT"];
69
+ const PRIOR_TOKEN = process.env["GITHUB_TOKEN"];
70
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
71
+ delete process.env["GITHUB_TOKEN"];
72
+ after(() => {
73
+ if (PRIOR_TRANSPORT === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
74
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = PRIOR_TRANSPORT;
75
+ if (PRIOR_TOKEN === undefined) delete process.env["GITHUB_TOKEN"];
76
+ else process.env["GITHUB_TOKEN"] = PRIOR_TOKEN;
77
+ });
78
+
62
79
  function memTable(rows: any[], key: string) {
63
80
  return {
64
81
  get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
@@ -499,3 +516,75 @@ test("findActivePlansByBase returns only non-terminal plans on the matching repo
499
516
  assertEquals(active.length, 1);
500
517
  assertEquals(active[0].plan_key, "o/x#1");
501
518
  });
519
+
520
+ // Issue #248: the human-readable identity for the epics grids. `startPlan` persists a non-blank
521
+ // `plans.title` on BOTH the insert (new epic) and update (re-plan) paths — the fetched issue title
522
+ // when available, else the `owner/repo#N` key — so the title-led grid never renders a blank cell.
523
+ test("startPlan coalesces title to the key when the fetch yields nothing (insert path)", async () => {
524
+ const PLAN_KEY = "owner/repo#248";
525
+ const stores: Record<string, { rows: any[]; key: string }> = {
526
+ plans: { rows: [], key: "plan_key" },
527
+ plan_tasks: { rows: [], key: "id" },
528
+ plan_reviews: { rows: [], key: "plan_key" },
529
+ plan_task_deps: { rows: [], key: "plan_key" },
530
+ };
531
+ const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-T1" }) } as any;
532
+ await startPlan(memData(stores), engine, {
533
+ repo: "owner/repo",
534
+ number: 248,
535
+ url: "https://github.com/owner/repo/issues/248",
536
+ planKey: PLAN_KEY,
537
+ }, "main");
538
+ assertEquals((stores.plans.rows[0] as any).title, PLAN_KEY);
539
+ });
540
+
541
+ test("startPlan repopulates a non-blank title on re-plan (update path)", async () => {
542
+ const PLAN_KEY = "owner/repo#249";
543
+ const stores: Record<string, { rows: any[]; key: string }> = {
544
+ plans: { rows: [{ plan_key: PLAN_KEY, status: "done", task_count: 0, title: null }], key: "plan_key" },
545
+ plan_tasks: { rows: [], key: "id" },
546
+ plan_reviews: { rows: [], key: "plan_key" },
547
+ plan_task_deps: { rows: [], key: "plan_key" },
548
+ };
549
+ const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-T2" }) } as any;
550
+ await startPlan(memData(stores), engine, {
551
+ repo: "owner/repo",
552
+ number: 249,
553
+ url: "https://github.com/owner/repo/issues/249",
554
+ planKey: PLAN_KEY,
555
+ }, "main");
556
+ assertEquals((stores.plans.rows[0] as any).title, PLAN_KEY);
557
+ });
558
+
559
+ test("startPlan persists the real epic issue title when the fetch succeeds", async () => {
560
+ const prevTok = process.env["GITHUB_TOKEN"];
561
+ const prevFetch = globalThis.fetch;
562
+ process.env["GITHUB_TOKEN"] = "t0ken";
563
+ globalThis.fetch = ((url: string | URL | Request) => {
564
+ const u = String(url);
565
+ if (u.endsWith("/repos/owner/repo/issues/250")) {
566
+ return Promise.resolve(new Response(JSON.stringify({ title: "Ship the epic" }), { status: 200 }));
567
+ }
568
+ throw new Error(`unexpected fetch: ${u}`);
569
+ }) as typeof fetch;
570
+ try {
571
+ const stores: Record<string, { rows: any[]; key: string }> = {
572
+ plans: { rows: [], key: "plan_key" },
573
+ plan_tasks: { rows: [], key: "id" },
574
+ plan_reviews: { rows: [], key: "plan_key" },
575
+ plan_task_deps: { rows: [], key: "plan_key" },
576
+ };
577
+ const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-T3" }) } as any;
578
+ await startPlan(memData(stores), engine, {
579
+ repo: "owner/repo",
580
+ number: 250,
581
+ url: "https://github.com/owner/repo/issues/250",
582
+ planKey: "owner/repo#250",
583
+ }, "main");
584
+ assertEquals((stores.plans.rows[0] as any).title, "Ship the epic");
585
+ } finally {
586
+ globalThis.fetch = prevFetch;
587
+ if (prevTok === undefined) delete process.env["GITHUB_TOKEN"];
588
+ else process.env["GITHUB_TOKEN"] = prevTok;
589
+ }
590
+ });
package/app/plan.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
13
13
  import { blackboardUrl, mintBlackboardToken, renderCoordinationBrief } from "./blackboard.ts";
14
14
  import { DEFAULT_ESCALATION_SLA_TIMEOUT, escalationSlaTimeout } from "./escalationSla.ts";
15
- import { ensureBaseBranch, fetchDefaultBranch } from "./github.ts";
15
+ import { coalesceTitle, ensureBaseBranch, fetchDefaultBranch, fetchIssueTitle } from "./github.ts";
16
16
  import { clearExclusions } from "./mergeExclusion.ts";
17
17
  import { clearTaskDeltas } from "./taskDelta.ts";
18
18
 
@@ -400,6 +400,15 @@ export async function startPlan(
400
400
  }
401
401
  const base = normalizeBaseBranch(baseBranch);
402
402
  const ts = now();
403
+ // Human-readable identity for the epics grids (issue #248): fetch the epic issue's title,
404
+ // best-effort. Coalesce to the `owner/repo#N` key at write time so `plans.title` is ALWAYS
405
+ // non-blank — the grid's `{{title}}` template then needs no fallback, and a failed/absent/blank
406
+ // fetch still shows a usable identity (the key) rather than an empty cell. A fetch failure never
407
+ // blocks the start (`fetchIssueTitle` returns null on any error).
408
+ const title = coalesceTitle(
409
+ await fetchIssueTitle(parsed.repo, parsed.number, process.env.GITHUB_TOKEN ?? ""),
410
+ parsed.planKey,
411
+ );
403
412
  // Mint (or reuse, on a re-plan) this plan's blackboard capability token, and render the
404
413
  // coordination brief that carries its concrete URL. The token is the credential; agents reach
405
414
  // the blackboard directly with the URL we seed into `appendPrompt` below (#51).
@@ -425,6 +434,7 @@ export async function startPlan(
425
434
  status: "planning",
426
435
  task_count: 0,
427
436
  issue_url: parsed.url,
437
+ title,
428
438
  outcome: null,
429
439
  blackboard_token: token,
430
440
  base_branch: base,
@@ -436,6 +446,7 @@ export async function startPlan(
436
446
  repo: parsed.repo,
437
447
  issue_number: parsed.number,
438
448
  issue_url: parsed.url,
449
+ title,
439
450
  status: "planning",
440
451
  task_count: 0,
441
452
  blackboard_token: token,
package/app/service.ts CHANGED
@@ -14,6 +14,7 @@ import { agentSlaTimeout } from "./agentSla.ts";
14
14
  import { deriveFeatureBlockedPatch, deriveFeatureDelivery, deriveFeatureEscalationPatch, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRun, type FeatureRunStatus, featureRuns } from "./feature.ts";
15
15
  import {
16
16
  classifyMergeability,
17
+ coalesceTitle,
17
18
  ensureFreshHeadRun,
18
19
  fetchPrHead,
19
20
  fetchPrMeta,
@@ -460,7 +461,11 @@ export async function submitPr(
460
461
  status: "converging",
461
462
  current_round: 1,
462
463
  url: parsed.url,
463
- title: title ?? existing.title,
464
+ // Coalesce to the key so `pull_requests.title` stays non-blank for the title-led grids
465
+ // (issue #248): a fresh fetch wins, else the prior title, else the `owner/repo#N` key.
466
+ // A blank/whitespace title counts as missing (matches the 036 backfill), so an empty
467
+ // external title never lands as an unlabeled row.
468
+ title: coalesceTitle(title, existing.title, parsed.prKey),
464
469
  waiting_since: null,
465
470
  last_review_id: null,
466
471
  last_nudge_at: null,
@@ -476,7 +481,9 @@ export async function submitPr(
476
481
  repo: parsed.repo,
477
482
  number: parsed.number,
478
483
  url: parsed.url,
479
- title,
484
+ // Coalesce to the key so the title-led grids never render a blank identity (issue #248);
485
+ // a blank/whitespace external title counts as missing (matches the 036 backfill).
486
+ title: coalesceTitle(title, parsed.prKey),
480
487
  status: "converging",
481
488
  current_round: 1,
482
489
  abandon_token: abandonToken,
@@ -0,0 +1,17 @@
1
+ -- Surface the GitHub issue title as the primary, human-readable identity on the
2
+ -- feature grids (issue #248, L0). Every dispatch row was keyed only by
3
+ -- `owner/repo#N` — meaningful while you remember the number, opaque hours later.
4
+ --
5
+ -- `plans.title` (004_planning.sql) and `pull_requests.title` already existed;
6
+ -- `feature_runs` had no title column at all. Add one so `startFeature` can persist
7
+ -- the fetched issue title (best-effort), coalesced to the `owner/repo#N` key at
8
+ -- write time so the column is ALWAYS non-blank — the grid's `{{title}}` template
9
+ -- then needs no fallback and a failed/absent fetch still shows a usable identity.
10
+ --
11
+ -- Forward-only, additive (expand): nullable with no default, so pre-#248 rows
12
+ -- grandfather in as NULL. Write-time coalescing keeps new rows non-blank; the
13
+ -- follow-up 036_backfill_titles.sql then backfills these legacy NULL/blank titles
14
+ -- to the row key, so the title-led grids never render a blank identity cell.
15
+ -- Numbered after the current highest prefix on origin/main (034); the runner wraps
16
+ -- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
17
+ ALTER TABLE feature_runs ADD COLUMN title TEXT;
@@ -0,0 +1,18 @@
1
+ -- Backfill legacy NULL/blank titles so the title-led grids (issue #248) never
2
+ -- render a blank identity cell for pre-#248 rows.
3
+ --
4
+ -- 035_feature_runs_title.sql added `feature_runs.title` nullable, and both
5
+ -- `plans.title` (004_planning.sql) and `pull_requests.title` (001_init.sql) have
6
+ -- long allowed NULL. The dispatch/overview grids now render `template: "{{title}}"`
7
+ -- directly off these tables with NO key fallback, so any historical row whose title
8
+ -- is NULL or blank shows as an unlabeled row. `startPlan`/`startFeature`/the PR
9
+ -- upsert already coalesce title to the `owner/repo#N` key at write time; this
10
+ -- migration applies the same coalesce once to the rows that predate that behaviour.
11
+ --
12
+ -- Forward-only, additive (data-only): sets title to the row's key where it is
13
+ -- currently NULL or blank. Idempotent — re-running is a no-op once titles are set.
14
+ -- Numbered after the current highest prefix on origin/main (035); the runner wraps
15
+ -- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
16
+ UPDATE plans SET title = plan_key WHERE title IS NULL OR trim(title) = '';
17
+ UPDATE feature_runs SET title = feature_key WHERE title IS NULL OR trim(title) = '';
18
+ UPDATE pull_requests SET title = pr_key WHERE title IS NULL OR trim(title) = '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.74.0",
3
+ "version": "0.75.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -55,7 +55,7 @@
55
55
  "filter": [{ "field": "plan_key", "eqParam": true }]
56
56
  },
57
57
  "columns": [
58
- { "field": "plan_key", "header": "Issue", "linkField": "issue_url" },
58
+ { "field": "title", "template": "{{title}}", "header": "Item", "linkField": "issue_url" },
59
59
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
60
60
  { "field": "delivery", "header": "Delivery" },
61
61
  { "field": "wave_label", "header": "Wave" },
@@ -73,7 +73,7 @@
73
73
  { "label": "All", "filter": [] }
74
74
  ],
75
75
  "columns": [
76
- { "field": "plan_key", "header": "Epic", "link": { "kind": "page", "page": "epic-detail", "keyField": "plan_key" } },
76
+ { "field": "title", "template": "{{title}}", "header": "Item", "link": { "kind": "page", "page": "epic-detail", "keyField": "plan_key" } },
77
77
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
78
78
  { "field": "delivery", "header": "Delivery" },
79
79
  { "field": "base_branch", "header": "Base branch" },
@@ -70,7 +70,7 @@
70
70
  { "label": "All", "filter": [] }
71
71
  ],
72
72
  "columns": [
73
- { "field": "feature_key", "header": "Feature", "linkField": "issue_url" },
73
+ { "field": "title", "template": "{{title}}", "header": "Item", "linkField": "issue_url" },
74
74
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
75
75
  { "field": "escalation_question", "header": "Escalation" },
76
76
  { "field": "base_branch", "header": "Base branch" },
@@ -163,8 +163,9 @@
163
163
  ],
164
164
  "columns": [
165
165
  {
166
- "field": "pr_key",
167
- "header": "PR",
166
+ "field": "title",
167
+ "template": "{{title}}",
168
+ "header": "Item",
168
169
  "linkField": "url"
169
170
  },
170
171
  {
@@ -63,15 +63,9 @@
63
63
  ]
64
64
  },
65
65
  "columns": [
66
- { "field": "pr_key", "header": "PR", "link": { "kind": "page", "page": "home", "keyField": "pr_key" } },
66
+ { "field": "title", "template": "{{title}}", "header": "Item", "link": { "kind": "page", "page": "home", "keyField": "pr_key" } },
67
67
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
68
- {
69
- "field": "incident_message",
70
- "header": "Incident",
71
- "badge": { "tone": "danger", "label": "1" }
72
- },
73
- { "field": "current_round", "header": "Round" },
74
- { "field": "active_worker", "header": "Agent" },
68
+ { "field": "current_round", "template": "{{current_round}} · {{active_worker}}", "header": "Round · Agent" },
75
69
  { "field": "updated_at", "header": "Updated" }
76
70
  ]
77
71
  }
@@ -94,13 +88,9 @@
94
88
  "filter": [{ "field": "status", "in": ["planning", "dispatched"] }]
95
89
  },
96
90
  "columns": [
97
- { "field": "plan_key", "header": "Epic", "link": { "kind": "page", "page": "epic-detail", "keyField": "plan_key" } },
91
+ { "field": "title", "template": "{{title}}", "header": "Item", "link": { "kind": "page", "page": "epic-detail", "keyField": "plan_key" } },
98
92
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
99
- { "field": "delivery", "header": "Delivery" },
100
- { "field": "base_branch", "header": "Base branch" },
101
93
  { "field": "wave_label", "header": "Wave" },
102
- { "field": "task_count", "header": "Tasks" },
103
- { "field": "issue_number", "header": "Issue", "linkField": "issue_url" },
104
94
  { "field": "updated_at", "header": "Updated" }
105
95
  ]
106
96
  }
@@ -123,14 +113,9 @@
123
113
  "filter": [{ "field": "status", "in": ["running", "escalated", "awaiting_operator"] }]
124
114
  },
125
115
  "columns": [
126
- { "field": "feature_key", "header": "Feature", "link": { "kind": "page", "page": "feature", "keyField": "feature_key" } },
116
+ { "field": "title", "template": "{{title}}", "header": "Item", "link": { "kind": "page", "page": "feature", "keyField": "feature_key" } },
127
117
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
128
- { "field": "escalation_question", "header": "Escalation" },
129
- { "field": "base_branch", "header": "Base branch" },
130
- { "field": "pr_key", "header": "PR", "link": { "kind": "page", "page": "home", "keyField": "pr_key" } },
131
- { "field": "converge", "header": "Converge" },
132
- { "field": "auto_merge", "header": "Auto-merge" },
133
- { "field": "outcome", "header": "Outcome" },
118
+ { "field": "delivery_label", "header": "Delivery" },
134
119
  { "field": "updated_at", "header": "Updated" }
135
120
  ],
136
121
  "rowActions": [