@nanobpm/nano-workforce 0.89.0 → 0.91.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,17 @@
1
+ # [0.91.0](https://github.com/nanobpm/nano-workforce/compare/v0.90.0...v0.91.0) (2026-08-19)
2
+
3
+
4
+ ### Features
5
+
6
+ * **tasks:** show subject title with repo/issue# as subtitle ([#308](https://github.com/nanobpm/nano-workforce/issues/308)) ([#311](https://github.com/nanobpm/nano-workforce/issues/311)) ([5771b38](https://github.com/nanobpm/nano-workforce/commit/5771b38abc33f4f88329d69c808214dfd42f5113))
7
+
8
+ # [0.90.0](https://github.com/nanobpm/nano-workforce/compare/v0.89.0...v0.90.0) (2026-08-19)
9
+
10
+
11
+ ### Features
12
+
13
+ * **convergence:** surface epic / cross-slice lineage on the PR-row detail ([#307](https://github.com/nanobpm/nano-workforce/issues/307)) ([7010b88](https://github.com/nanobpm/nano-workforce/commit/7010b88889529aec173c552bab2a1e7b881ba9da)), closes [nanobpm/nano-workforce#304](https://github.com/nanobpm/nano-workforce/issues/304)
14
+
1
15
  # [0.89.0](https://github.com/nanobpm/nano-workforce/compare/v0.88.1...v0.89.0) (2026-08-19)
2
16
 
3
17
 
@@ -75,7 +75,7 @@ test("feature: an escalated run surfaces the escalation stage", () => {
75
75
 
76
76
  test("epic: rolls up N slice PRs and stays active while any is in flight", () => {
77
77
  const t = deriveLineage(
78
- { kind: "epic", key: "o/r#9", title: "Big epic", issueUrl: "u", status: "done", processKey: "e1" },
78
+ { kind: "epic", key: "o/r#9", title: "Big epic", issueUrl: "u", status: "done", processKey: "e1", epicPhase: "Implementing (wave 2/3)" },
79
79
  [
80
80
  pr({ prKey: "o/r#10", status: "merged" }),
81
81
  pr({ prKey: "o/r#11", status: "converging", processKey: "c11" }),
@@ -85,6 +85,7 @@ test("epic: rolls up N slice PRs and stays active while any is in flight", () =>
85
85
  assertEquals(t.kind, "epic");
86
86
  assertEquals(t.stage, "converging");
87
87
  assertEquals(t.stageLabel, "1/3 slices merged, 1 converging");
88
+ assertEquals(t.epicPhaseLabel, "Implementing (wave 2/3)", "an epic thread carries its stamped epic_phase down to member PRs");
88
89
  assertEquals(t.processKey, "c11");
89
90
  assertEquals(t.prCount, 3);
90
91
  assert(t.active);
@@ -92,17 +93,18 @@ test("epic: rolls up N slice PRs and stays active while any is in flight", () =>
92
93
 
93
94
  test("epic: all slices merged settles as merged", () => {
94
95
  const t = deriveLineage(
95
- { kind: "epic", key: "o/r#9", title: "Big epic", issueUrl: "u", status: "done", processKey: "e1" },
96
+ { kind: "epic", key: "o/r#9", title: "Big epic", issueUrl: "u", status: "done", processKey: "e1", epicPhase: null },
96
97
  [pr({ prKey: "o/r#10", status: "merged" }), pr({ prKey: "o/r#11", status: "merged" })],
97
98
  );
98
99
  assertEquals(t.stage, "merged");
99
100
  assertEquals(t.stageLabel, "2/2 slices merged");
101
+ assertEquals(t.epicPhaseLabel, "2/2 slices merged", "a grandfathered epic (no epic_phase) falls back to its delivery-rollup stage label");
100
102
  assert(!t.active);
101
103
  });
102
104
 
103
105
  test("epic: mixed terminal (none in flight, not all merged) is resolved, not landed", () => {
104
106
  const t = deriveLineage(
105
- { kind: "epic", key: "o/r#9", title: "E", issueUrl: "u", status: "done", processKey: "e1" },
107
+ { kind: "epic", key: "o/r#9", title: "E", issueUrl: "u", status: "done", processKey: "e1", epicPhase: "Finalizing" },
106
108
  [pr({ prKey: "o/r#10", status: "merged" }), pr({ prKey: "o/r#11", status: "abandoned" })],
107
109
  );
108
110
  assertEquals(t.stage, "resolved");
@@ -111,13 +113,23 @@ test("epic: mixed terminal (none in flight, not all merged) is resolved, not lan
111
113
 
112
114
  test("epic: planning with no PRs yet", () => {
113
115
  const t = deriveLineage(
114
- { kind: "epic", key: "o/r#9", title: "E", issueUrl: "u", status: "planning", processKey: "e1" },
116
+ { kind: "epic", key: "o/r#9", title: "E", issueUrl: "u", status: "planning", processKey: "e1", epicPhase: "Planning" },
115
117
  [],
116
118
  );
117
119
  assertEquals(t.stage, "planning");
118
120
  assert(t.active);
119
121
  });
120
122
 
123
+ test("feature/self-rooted threads carry no epic phase label", () => {
124
+ const feat = deriveLineage(
125
+ { kind: "feature", key: "o/r#1", title: "X", issueUrl: "u", status: "converging", processKey: "f1" },
126
+ [pr({ prKey: "o/r#2", status: "converging" })],
127
+ );
128
+ assertEquals(feat.epicPhaseLabel, null, "a feature PR is not an epic slice");
129
+ const self = deriveLineage({ kind: "pr", key: "o/r#5" }, [pr({ prKey: "o/r#5", status: "converging" })]);
130
+ assertEquals(self.epicPhaseLabel, null, "a self-rooted PR is not an epic slice");
131
+ });
132
+
121
133
  // ── self-rooted (human/webhook) PR ───────────────────────────────────────────────────────────
122
134
 
123
135
  test("pr: a human/webhook PR with no origin is its own root", () => {
@@ -174,7 +186,7 @@ test("pollLineage: projects feature, epic, and self-rooted threads onto lineage_
174
186
  { feature_key: "o/r#1", title: "Feature", issue_url: "u1", status: "converging", process_key: "f1", pr_key: "o/r#100" },
175
187
  ];
176
188
  stores.plans = [
177
- { plan_key: "o/r#2", title: "Epic", issue_url: "u2", status: "done", process_key: "e1" },
189
+ { plan_key: "o/r#2", title: "Epic", issue_url: "u2", status: "done", process_key: "e1", epic_phase: "Implementing (wave 1/2)" },
178
190
  ];
179
191
  stores.plan_tasks = [
180
192
  { id: 1, plan_key: "o/r#2", pr_key: "o/r#200" },
@@ -212,6 +224,14 @@ test("pollLineage: projects feature, epic, and self-rooted threads onto lineage_
212
224
  assertEquals(human?.stage, "merged");
213
225
  assertEquals(human?.active, 0);
214
226
 
227
+ // Epic-phase projection (#304): each epic slice PR gets its parent epic's phase label; the feature
228
+ // and self-rooted PRs (not epic slices) are left NULL, so their PR-row detail shows no epic panel.
229
+ const prById = (k: string) => stores.pull_requests.find((r: any) => r.pr_key === k);
230
+ assertEquals(prById("o/r#200").epic_phase_label, "Implementing (wave 1/2)", "epic slice S1 carries the epic phase");
231
+ assertEquals(prById("o/r#201").epic_phase_label, "Implementing (wave 1/2)", "epic slice S2 carries the epic phase");
232
+ assertEquals(prById("o/r#100").epic_phase_label ?? null, null, "a feature PR is not an epic slice");
233
+ assertEquals(prById("o/r#300").epic_phase_label ?? null, null, "a self-rooted PR is not an epic slice");
234
+
215
235
  // Idempotent: a second pass with no state change writes nothing new (same row count, same ts).
216
236
  const before = stores.lineage_threads.map((r: LineageThreadRow) => r.updated_at);
217
237
  await pollLineage(data);
package/app/lineage.ts CHANGED
@@ -56,6 +56,10 @@ export type LineageOrigin =
56
56
  issueUrl: string | null;
57
57
  status: string;
58
58
  processKey: string | null;
59
+ // The epic's derived domain phase (`plans.epic_phase`, 038_plan_epic_phase.sql) — e.g.
60
+ // "Implementing (wave 3/5)". NULL for pre-#261 epics that never stamped a phase; the thread
61
+ // then falls back to its delivery-rollup `stageLabel` for the projected `epicPhaseLabel`.
62
+ epicPhase: string | null;
59
63
  }
60
64
  | {
61
65
  // A human/webhook PR with no originating request: its own root.
@@ -73,6 +77,12 @@ export interface LineageThread {
73
77
  stage: LineageStage;
74
78
  /** Human narrative rollup for the timeline (e.g. "Converging (round 2)", "3/5 slices merged, …"). */
75
79
  stageLabel: string;
80
+ /** The epic-phase/stage label projected onto each member PR's `pull_requests.epic_phase_label`, so
81
+ * the Convergence PR-row detail can show an epic slice its parent epic's phase (issue #304). For an
82
+ * epic thread it is the epic's `epic_phase` (e.g. "Implementing (wave 3/5)"), falling back to the
83
+ * delivery-rollup `stageLabel` when the epic never stamped a phase. NULL for feature/self-rooted
84
+ * threads — their member PRs are not epic slices, so the epic panel stays empty for them. */
85
+ epicPhaseLabel: string | null;
76
86
  /** The active-frontier process instance (for the processExplorer link), best-effort. */
77
87
  processKey: string | null;
78
88
  prKeys: string[];
@@ -238,6 +248,12 @@ export function deriveLineage(origin: LineageOrigin, prsIn: readonly LineagePr[]
238
248
  }
239
249
 
240
250
  const active = !TERMINAL_STAGES.includes(stage);
251
+ // Epic slices carry their parent epic's phase down to the PR-row detail (issue #304): prefer the
252
+ // epic's own stamped `epic_phase`, falling back to the delivery-rollup `stageLabel` for a
253
+ // grandfathered epic that never stamped one. Feature/self-rooted threads are not epics, so their
254
+ // member PRs get no epic label.
255
+ const epicPhaseLabel =
256
+ origin.kind === "epic" ? (origin.epicPhase ?? stageLabel) : null;
241
257
  return {
242
258
  rootRequestKey: origin.key,
243
259
  kind: origin.kind,
@@ -245,6 +261,7 @@ export function deriveLineage(origin: LineageOrigin, prsIn: readonly LineagePr[]
245
261
  issueUrl: origin.kind === "pr" ? null : origin.issueUrl,
246
262
  stage,
247
263
  stageLabel,
264
+ epicPhaseLabel,
248
265
  processKey,
249
266
  prKeys,
250
267
  prCount: prKeys.length,
@@ -287,6 +304,9 @@ interface PrRow {
287
304
  process_key: string | null;
288
305
  outcome: string | null;
289
306
  root_request_key: string | null;
307
+ // Epic-phase projection this module maintains (issue #304, migration 043): the parent epic's phase
308
+ // label for an epic slice PR, NULL otherwise. Read here only to keep the write idempotent.
309
+ epic_phase_label: string | null;
290
310
  }
291
311
 
292
312
  const prRows = (data: DataLayer) => data.table<PrRow>("pull_requests", "pr_key");
@@ -324,7 +344,9 @@ function toLineagePr(row: PrRow): LineagePr {
324
344
 
325
345
  /** Assemble the origin + PR set for every root from the live gateway rows, then derive each thread.
326
346
  * Reused by both `getLineage` (single root, on demand) and `pollLineage` (all roots, projected). */
327
- async function collectThreads(data: DataLayer): Promise<Map<string, LineageThread>> {
347
+ async function collectThreads(
348
+ data: DataLayer,
349
+ ): Promise<{ threads: Map<string, LineageThread>; allPrs: PrRow[] }> {
328
350
  const allPrs = await prRows(data).all();
329
351
  const prByKey = new Map<string, PrRow>();
330
352
  for (const pr of allPrs) prByKey.set(pr.pr_key, pr);
@@ -388,7 +410,7 @@ async function collectThreads(data: DataLayer): Promise<Map<string, LineageThrea
388
410
  threads.set(rootKey, deriveLineage({ kind: "pr", key: rootKey }, prs.map(toLineagePr)));
389
411
  }
390
412
 
391
- return threads;
413
+ return { threads, allPrs };
392
414
  }
393
415
 
394
416
  /** Union the PRs a feature root owns: those threaded to it + its own denormalised `pr_key`. */
@@ -446,6 +468,7 @@ function epicOrigin(plan: Plan): LineageOrigin {
446
468
  issueUrl: plan.issue_url,
447
469
  status: plan.status,
448
470
  processKey: plan.process_key,
471
+ epicPhase: plan.epic_phase,
449
472
  };
450
473
  }
451
474
 
@@ -455,7 +478,7 @@ export async function getLineage(
455
478
  data: DataLayer,
456
479
  rootRequestKey: string,
457
480
  ): Promise<LineageThread | null> {
458
- const threads = await collectThreads(data);
481
+ const { threads } = await collectThreads(data);
459
482
  return threads.get(rootRequestKey) ?? null;
460
483
  }
461
484
 
@@ -463,7 +486,7 @@ export async function getLineage(
463
486
  * deterministic order (the projection has no per-thread timestamp to sort on, and equal-`active`
464
487
  * ties would otherwise be nondeterministic across passes). */
465
488
  export async function listLineage(data: DataLayer): Promise<LineageThread[]> {
466
- const threads = await collectThreads(data);
489
+ const { threads } = await collectThreads(data);
467
490
  return [...threads.values()].sort(
468
491
  (a, b) => Number(b.active) - Number(a.active) || a.rootRequestKey.localeCompare(b.rootRequestKey),
469
492
  );
@@ -474,8 +497,9 @@ export async function listLineage(data: DataLayer): Promise<LineageThread[]> {
474
497
  * when the projection actually changes. Best-effort; per-root failures are isolated. */
475
498
  export async function pollLineage(data: DataLayer): Promise<void> {
476
499
  let threads: Map<string, LineageThread>;
500
+ let allPrs: PrRow[];
477
501
  try {
478
- threads = await collectThreads(data);
502
+ ({ threads, allPrs } = await collectThreads(data));
479
503
  } catch (err) {
480
504
  console.error(`[poller] lineage collect: ${err}`);
481
505
  return;
@@ -534,4 +558,35 @@ export async function pollLineage(data: DataLayer): Promise<void> {
534
558
  console.error(`[poller] lineage ${thread.rootRequestKey}: ${err}`);
535
559
  }
536
560
  }
561
+ await projectEpicPhaseLabels(data, threads, allPrs);
562
+ }
563
+
564
+ /** Denormalise each epic thread's phase label down onto its member PRs' `pull_requests.epic_phase_label`
565
+ * (issue #304), so the Convergence PR-row detail can show an escalated slice its parent epic's phase
566
+ * without a cross-join to `plans` / `lineage_threads`. Every PR belongs to exactly one thread, so a
567
+ * PR whose thread is a feature/self-root is cleared to NULL — no stale epic label survives if a PR is
568
+ * re-rooted. Idempotent: writes only the rows whose label actually changed. Mirrors the write-time
569
+ * projection convention (`delivery_label`, `epic_phase`). Best-effort; failures are isolated. */
570
+ async function projectEpicPhaseLabels(
571
+ data: DataLayer,
572
+ threads: Map<string, LineageThread>,
573
+ allPrs: PrRow[],
574
+ ): Promise<void> {
575
+ // Desired label per member PR: an epic thread stamps its `epicPhaseLabel`, every other thread NULL.
576
+ const desired = new Map<string, string | null>();
577
+ for (const thread of threads.values()) {
578
+ for (const key of thread.prKeys) desired.set(key, thread.epicPhaseLabel);
579
+ }
580
+ // Reuse the PR rows `collectThreads` already read this pass rather than re-scanning the whole
581
+ // `pull_requests` table — the projection only writes the rows whose label actually changed.
582
+ const table = prRows(data);
583
+ for (const pr of allPrs) {
584
+ const want = desired.get(pr.pr_key) ?? null;
585
+ if ((pr.epic_phase_label ?? null) === want) continue; // steady state — no write
586
+ try {
587
+ await table.update(pr.pr_key, { epic_phase_label: want });
588
+ } catch (err) {
589
+ console.error(`[poller] lineage epic-phase ${pr.pr_key}: ${err}`);
590
+ }
591
+ }
537
592
  }
@@ -0,0 +1,62 @@
1
+ // Regression guard for migration 043's epic_phase_label backfill (issue #304). The Convergence
2
+ // PR-row detail surfaces an epic slice's parent-epic phase via `pull_requests.epic_phase_label`. The
3
+ // column is maintained going forward by `pollLineage` (`projectEpicPhaseLabels`), but pre-existing
4
+ // rows must be backfilled at deploy time so the epic panel is populated before the first poll pass:
5
+ // an epic slice PR (its `root_request_key` is an epic `plans.plan_key`) takes that epic's
6
+ // `plans.epic_phase`; a feature/self-rooted PR (no matching plan) stays NULL — no empty epic panel.
7
+ import { readFileSync } from "node:fs";
8
+ import { DatabaseSync } from "node:sqlite";
9
+ import test from "node:test";
10
+ import { fileURLToPath } from "node:url";
11
+ import { assertEquals } from "#test-assert";
12
+
13
+ function migratedDb(): DatabaseSync {
14
+ const db = new DatabaseSync(":memory:");
15
+ // Minimal pre-043 shape: pull_requests WITH root_request_key (037) but WITHOUT epic_phase_label
16
+ // (043 ADD COLUMNs it), plus the plans table the backfill joins on for the epic phase.
17
+ db.exec(`
18
+ CREATE TABLE pull_requests (pr_key TEXT PRIMARY KEY, repo TEXT, number INTEGER, url TEXT,
19
+ status TEXT, root_request_key TEXT, created_at TEXT, updated_at TEXT);
20
+ CREATE TABLE plans (plan_key TEXT PRIMARY KEY, epic_phase TEXT);
21
+ `);
22
+ const ins = (k: string, root: string) =>
23
+ db
24
+ .prepare(
25
+ `INSERT INTO pull_requests (pr_key, repo, number, url, status, root_request_key, created_at, updated_at)
26
+ VALUES (?, 'o/r', 1, 'u', 'converging', ?, 't', 't')`,
27
+ )
28
+ .run(k, root);
29
+ ins("o/r#20", "o/r#2"); // epic slice of a phased epic
30
+ ins("o/r#21", "o/r#3"); // epic slice of a grandfathered epic (epic_phase NULL)
31
+ ins("o/r#30", "o/r#30"); // self-rooted (human/webhook) PR — root is its own key, no plan
32
+ ins("o/r#40", "o/r#1"); // feature-rooted PR — root is a feature key, no matching plan
33
+ db.prepare("INSERT INTO plans (plan_key, epic_phase) VALUES ('o/r#2', 'Implementing (wave 3/5)')").run();
34
+ db.prepare("INSERT INTO plans (plan_key, epic_phase) VALUES ('o/r#3', NULL)").run();
35
+
36
+ const sql = readFileSync(
37
+ fileURLToPath(new URL("../db/migrations/043_pr_epic_phase.sql", import.meta.url)),
38
+ "utf8",
39
+ );
40
+ db.exec(sql);
41
+ return db;
42
+ }
43
+
44
+ test("migration 043 backfills epic slice PRs with their epic's phase and leaves non-epic PRs NULL", () => {
45
+ const db = migratedDb();
46
+ const labelOf = (k: string) =>
47
+ (
48
+ db.prepare("SELECT epic_phase_label AS l FROM pull_requests WHERE pr_key = ?").get(k) as {
49
+ l: string | null;
50
+ }
51
+ ).l;
52
+
53
+ // An epic slice roots on its epic's plan_key, so it inherits that epic's stamped phase.
54
+ assertEquals(labelOf("o/r#20"), "Implementing (wave 3/5)");
55
+ // A slice of a grandfathered epic (epic_phase NULL) stays NULL — the poller reconciles the
56
+ // delivery-rollup fallback on its next pass; the migration only seeds the stamped phase.
57
+ assertEquals(labelOf("o/r#21"), null);
58
+ // A self-rooted PR has no matching plan → no epic panel.
59
+ assertEquals(labelOf("o/r#30"), null);
60
+ // A feature-rooted PR has no matching plan → no epic panel.
61
+ assertEquals(labelOf("o/r#40"), null);
62
+ });
@@ -78,6 +78,7 @@ test("pollUserTasks: projects feature / plan-review / trial-merge / PR-wait esca
78
78
  status: "escalated",
79
79
  process_key: "fp-10",
80
80
  issue_url: "https://github.com/o/r/issues/10",
81
+ title: "Add the framework selector",
81
82
  escalation_user_task_key: "ut-feat",
82
83
  escalation_question: "which framework?",
83
84
  blocked_user_task_key: null,
@@ -85,7 +86,7 @@ test("pollUserTasks: projects feature / plan-review / trial-merge / PR-wait esca
85
86
  },
86
87
  ],
87
88
  plans: [
88
- { plan_key: "o/r#20", status: "dispatched", process_key: "pp-20", issue_url: "https://github.com/o/r/issues/20" },
89
+ { plan_key: "o/r#20", status: "dispatched", process_key: "pp-20", issue_url: "https://github.com/o/r/issues/20", title: "Broaden the epic scope" },
89
90
  { plan_key: "o/r#21", status: "done", process_key: "pp-21", issue_url: "https://github.com/o/r/issues/21" },
90
91
  ],
91
92
  plan_reviews: [
@@ -96,7 +97,7 @@ test("pollUserTasks: projects feature / plan-review / trial-merge / PR-wait esca
96
97
  { id: 1, plan_key: "o/r#20", wave: 0, result: "suite-failed", summary: "wave 0 red", resolved: 0 },
97
98
  ],
98
99
  pull_requests: [
99
- { pr_key: "o/r#30", status: "escalated", process_key: "rp-30", url: "https://github.com/o/r/pull/30" },
100
+ { pr_key: "o/r#30", status: "escalated", process_key: "rp-30", url: "https://github.com/o/r/pull/30", title: "Resolve the reviews" },
100
101
  ],
101
102
  escalations: [{ id: 1, pr_key: "o/r#30", status: "open", question: "conflicting reviews" }],
102
103
  });
@@ -115,13 +116,17 @@ test("pollUserTasks: projects feature / plan-review / trial-merge / PR-wait esca
115
116
  assertEquals(Object.keys(byKey).sort(), ["ut-feat", "ut-plan", "ut-pr", "ut-trial"]);
116
117
  assertEquals(byKey["ut-feat"].kind_label, "Feature escalation");
117
118
  assertEquals(byKey["ut-feat"].question, "which framework?");
119
+ assertEquals(byKey["ut-feat"].subject_title, "Add the framework selector");
118
120
  assertEquals(byKey["ut-plan"].kind_label, "Plan review");
119
121
  assertEquals(byKey["ut-plan"].question, "scope too broad");
122
+ assertEquals(byKey["ut-plan"].subject_title, "Broaden the epic scope");
120
123
  assertEquals(byKey["ut-trial"].kind_label, "Trial merge");
121
124
  assertEquals(byKey["ut-trial"].question, "wave 0 red");
125
+ assertEquals(byKey["ut-trial"].subject_title, "Broaden the epic scope");
122
126
  assertEquals(byKey["ut-pr"].kind_label, "PR review");
123
127
  assertEquals(byKey["ut-pr"].subject_type, "pr");
124
128
  assertEquals(byKey["ut-pr"].question, "conflicting reviews");
129
+ assertEquals(byKey["ut-pr"].subject_title, "Resolve the reviews");
125
130
  });
126
131
 
127
132
  test("pollUserTasks: projects a merge-loop wait-merge-answer escalation into user_tasks as \"PR merge\"", async () => {
package/app/service.ts CHANGED
@@ -1626,6 +1626,7 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
1626
1626
  elementId: FEATURE_ESCALATION_ELEMENT,
1627
1627
  subjectType: "feature",
1628
1628
  subjectKey: run.feature_key,
1629
+ subjectTitle: run.title,
1629
1630
  subjectUrl: run.issue_url,
1630
1631
  question: run.escalation_question,
1631
1632
  processKey: run.process_key,
@@ -1642,6 +1643,7 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
1642
1643
  elementId: FEATURE_BLOCKED_ELEMENT,
1643
1644
  subjectType: "feature",
1644
1645
  subjectKey: run.feature_key,
1646
+ subjectTitle: run.title,
1645
1647
  subjectUrl: run.issue_url,
1646
1648
  question: run.delivery_label,
1647
1649
  processKey: run.process_key,
@@ -1680,6 +1682,7 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
1680
1682
  elementId: PLAN_REVIEW_ELEMENT,
1681
1683
  subjectType: "plan",
1682
1684
  subjectKey: plan.plan_key,
1685
+ subjectTitle: plan.title,
1683
1686
  subjectUrl: plan.issue_url,
1684
1687
  question,
1685
1688
  processKey: plan.process_key,
@@ -1696,6 +1699,7 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
1696
1699
  elementId: TRIAL_MERGE_ELEMENT,
1697
1700
  subjectType: "plan",
1698
1701
  subjectKey: plan.plan_key,
1702
+ subjectTitle: plan.title,
1699
1703
  subjectUrl: plan.issue_url,
1700
1704
  question,
1701
1705
  processKey: plan.process_key,
@@ -1735,6 +1739,7 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
1735
1739
  elementId: t.elementId,
1736
1740
  subjectType: "pr",
1737
1741
  subjectKey: pr.pr_key,
1742
+ subjectTitle: pr.title,
1738
1743
  subjectUrl: pr.url,
1739
1744
  question,
1740
1745
  processKey: pr.process_key,
@@ -41,6 +41,7 @@ test("buildUserTaskRow: a plan-review task becomes a labelled row with its findi
41
41
  kind_label: "Plan review",
42
42
  subject_type: "plan",
43
43
  subject_key: "o/r#1",
44
+ subject_title: "o/r#1",
44
45
  subject_url: "https://github.com/o/r/issues/1",
45
46
  question: "cap reached: revise scope",
46
47
  process_key: "pk-1",
@@ -80,6 +81,28 @@ test("buildUserTaskRow: a blank userTaskKey or subjectKey yields null", () => {
80
81
  );
81
82
  });
82
83
 
84
+ test("buildUserTaskRow: subject_title carries the subject title, trimmed, and coalesces to subject_key when absent/blank (issue #308)", () => {
85
+ const titled = buildUserTaskRow(
86
+ {
87
+ userTaskKey: "ut-t1",
88
+ elementId: PLAN_REVIEW_ELEMENT,
89
+ subjectType: "plan",
90
+ subjectKey: "o/r#1",
91
+ subjectTitle: " Add the widget ",
92
+ },
93
+ AT,
94
+ );
95
+ assertEquals(titled?.subject_title, "Add the widget");
96
+
97
+ for (const subjectTitle of [undefined, null, " "]) {
98
+ const row = buildUserTaskRow(
99
+ { userTaskKey: "ut-t2", elementId: PLAN_REVIEW_ELEMENT, subjectType: "plan", subjectKey: "o/r#2", subjectTitle },
100
+ AT,
101
+ );
102
+ assertEquals(row?.subject_title, "o/r#2");
103
+ }
104
+ });
105
+
83
106
  function row(key: string, extra: Partial<UserTaskRow> = {}): UserTaskRow {
84
107
  return {
85
108
  user_task_key: key,
@@ -87,6 +110,7 @@ function row(key: string, extra: Partial<UserTaskRow> = {}): UserTaskRow {
87
110
  kind_label: "Plan review",
88
111
  subject_type: "plan",
89
112
  subject_key: "o/r#1",
113
+ subject_title: "o/r#1",
90
114
  subject_url: null,
91
115
  question: null,
92
116
  process_key: null,
package/app/userTasks.ts CHANGED
@@ -51,6 +51,10 @@ export interface UserTaskRow {
51
51
  kind_label: string;
52
52
  subject_type: string;
53
53
  subject_key: string;
54
+ /** The subject's human-readable title (`feature_runs`/`plans`/`pull_requests`.`title`), derived by
55
+ * `pollUserTasks` from the subject row keyed on `subject_key` and coalesced to `subject_key` at
56
+ * build time so the title-led grids never render a blank primary line (issue #308). */
57
+ subject_title: string;
54
58
  subject_url: string | null;
55
59
  question: string | null;
56
60
  process_key: string | null;
@@ -78,6 +82,10 @@ export interface UserTaskContext {
78
82
  elementId: string;
79
83
  subjectType: "feature" | "plan" | "pr";
80
84
  subjectKey: string;
85
+ /** The subject's human-readable title from its own row (`feature_runs`/`plans`/`pull_requests`.
86
+ * `title`). Optional/blank tolerated — `buildUserTaskRow` coalesces it to `subjectKey` so the
87
+ * projected `subject_title` is never blank. */
88
+ subjectTitle?: string | null;
81
89
  subjectUrl?: string | null;
82
90
  question?: string | null;
83
91
  processKey?: string | null;
@@ -93,12 +101,14 @@ export function buildUserTaskRow(ctx: UserTaskContext, at: string = now()): User
93
101
  const kindLabel = USER_TASK_KIND_LABELS[ctx.elementId];
94
102
  if (!userTaskKey || !subjectKey || !kindLabel) return null;
95
103
  const question = typeof ctx.question === "string" && ctx.question.trim() ? ctx.question.trim() : null;
104
+ const subjectTitle = typeof ctx.subjectTitle === "string" && ctx.subjectTitle.trim() ? ctx.subjectTitle.trim() : subjectKey;
96
105
  return {
97
106
  user_task_key: userTaskKey,
98
107
  element_id: ctx.elementId,
99
108
  kind_label: kindLabel,
100
109
  subject_type: ctx.subjectType,
101
110
  subject_key: subjectKey,
111
+ subject_title: subjectTitle,
102
112
  subject_url: ctx.subjectUrl ?? null,
103
113
  question,
104
114
  process_key: ctx.processKey ?? null,
@@ -124,6 +134,7 @@ function sameRow(a: UserTaskRow, b: UserTaskRow): boolean {
124
134
  a.kind_label === b.kind_label &&
125
135
  a.subject_type === b.subject_type &&
126
136
  a.subject_key === b.subject_key &&
137
+ a.subject_title === b.subject_title &&
127
138
  a.subject_url === b.subject_url &&
128
139
  a.question === b.question &&
129
140
  a.process_key === b.process_key
@@ -0,0 +1,36 @@
1
+ -- 043_pr_epic_phase.sql — issue #304: surface epic / cross-slice lineage on the Convergence
2
+ -- PR-row detail. An operator triaging a PR (especially an escalation) needs to see, at the point of
3
+ -- decision, that the PR is a slice of an epic and which phase that epic is in — without navigating to
4
+ -- the Lineage page. The sibling-slice roster is already expressible in page JSON today (a `plan_tasks`
5
+ -- child grid joined `pull_requests.root_request_key → plan_tasks.plan_key`), but the epic's PHASE
6
+ -- lives on `plans.epic_phase` (038) / `lineage_threads.stage_label` (037), NOT on `pull_requests`, so
7
+ -- a `detail.field` cannot read it directly.
8
+ --
9
+ -- This migration adds the one missing projection column the read model needs:
10
+ --
11
+ -- • `pull_requests.epic_phase_label` — the parent epic's phase label for an epic slice PR (e.g.
12
+ -- "Implementing (wave 3/5)"), NULL for a feature/self-rooted PR that is not an epic slice.
13
+ -- Written idempotently on the SAME lineage poll path that maintains the lineage projection
14
+ -- (`app/lineage.ts` `pollLineage` → `projectEpicPhaseLabels`), mirroring the existing write-time
15
+ -- projection convention (`plans.epic_phase`, `plans.delivery_label`, `feature_runs.delivery_label`).
16
+ -- The poller prefers the epic's stamped `epic_phase`, falling back to the thread's delivery-rollup
17
+ -- stage label for a grandfathered epic that never stamped one, and clears the column to NULL if a
18
+ -- PR is ever re-rooted off an epic — so no stale epic label can survive.
19
+ --
20
+ -- Forward-only, additive (expand): a nullable TEXT column with no default, display-only, that never
21
+ -- gates control flow. Numbered after the current highest prefix (042). The runner wraps each file in
22
+ -- its own transaction, so this file must NOT contain BEGIN/COMMIT.
23
+ ALTER TABLE pull_requests ADD COLUMN epic_phase_label TEXT;
24
+
25
+ -- Backfill pre-existing rows so the epic panel is populated at deploy time, not only after the first
26
+ -- poll pass writes it. An epic slice PR is one whose `root_request_key` is an epic `plans.plan_key`
27
+ -- (submitPr threads the epic origin key onto every slice PR, and migration 037 backfilled legacy
28
+ -- rows the same way); stamp it with that epic's `plans.epic_phase`. A feature/self-rooted PR has no
29
+ -- matching plan, so it stays NULL — no empty epic panel for a non-epic PR. Idempotent, and the poller
30
+ -- reconciles the delivery-rollup fallback (for a plan whose `epic_phase` is NULL) on its next pass.
31
+ UPDATE pull_requests SET epic_phase_label = (
32
+ SELECT p.epic_phase FROM plans p WHERE p.plan_key = pull_requests.root_request_key
33
+ )
34
+ WHERE EXISTS (
35
+ SELECT 1 FROM plans p WHERE p.plan_key = pull_requests.root_request_key
36
+ );
@@ -0,0 +1,24 @@
1
+ -- Surface the subject's human-readable title as the primary identity on the Tasks
2
+ -- grids (issue #308), mirroring the Features list. Every `user_tasks` row was keyed
3
+ -- only by `subject_key` (`owner/repo#N`) — meaningful while you remember the number,
4
+ -- opaque hours later. The Features list already leads with `{{title}}` and shows the
5
+ -- key as a subtitle; the Tasks grids could only show the bare key.
6
+ --
7
+ -- The title is DERIVED (no new fetch): each escalation's subject already persists a
8
+ -- title in a surviving table keyed by `subject_key` — `feature_runs.title`,
9
+ -- `plans.title`, `pull_requests.title`. `pollUserTasks` reads it from the subject row
10
+ -- already in scope this pass and coalesces to `subject_key` at write time so the
11
+ -- column is ALWAYS non-blank — the grid's `{{subject_title}}` template then needs no
12
+ -- fallback and a missing subject title still shows a usable identity.
13
+ --
14
+ -- Forward-only, additive (expand): nullable with no default, then backfilled in the
15
+ -- same migration so the column is non-blank immediately — pre-#308 rows would otherwise
16
+ -- grandfather in as NULL and the grid's `{{subject_title}}` primary line would render a
17
+ -- blank/"null" identity for the window between deploy and the first `pollUserTasks` pass.
18
+ -- The backfill coalesces existing rows to `subject_key` (matching the write-time coalesce
19
+ -- in `pollUserTasks`, which re-derives the real title in place on the next poll — a
20
+ -- completed task's row is deleted, not migrated). Idempotent: re-running is a no-op once
21
+ -- set. Numbered after the current highest prefix on origin/main (042); the runner wraps
22
+ -- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
23
+ ALTER TABLE user_tasks ADD COLUMN subject_title TEXT;
24
+ UPDATE user_tasks SET subject_title = subject_key WHERE subject_title IS NULL OR trim(subject_title) = '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.89.0",
3
+ "version": "0.91.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",
@@ -257,9 +257,62 @@
257
257
  {
258
258
  "field": "outcome",
259
259
  "label": "Outcome"
260
+ },
261
+ {
262
+ "field": "epic_phase_label",
263
+ "label": "Epic phase"
260
264
  }
261
265
  ],
262
266
  "children": [
267
+ {
268
+ "title": "Epic",
269
+ "source": "app",
270
+ "table": "plans",
271
+ "parentField": "root_request_key",
272
+ "childField": "plan_key",
273
+ "columns": [
274
+ {
275
+ "field": "title",
276
+ "header": "Epic root",
277
+ "truncate": true,
278
+ "linkField": "issue_url"
279
+ },
280
+ {
281
+ "field": "epic_phase",
282
+ "header": "Phase"
283
+ }
284
+ ]
285
+ },
286
+ {
287
+ "title": "Epic slices",
288
+ "source": "app",
289
+ "table": "plan_tasks",
290
+ "parentField": "root_request_key",
291
+ "childField": "plan_key",
292
+ "orderBy": {
293
+ "field": "task_index",
294
+ "dir": "asc"
295
+ },
296
+ "columns": [
297
+ {
298
+ "field": "task_id",
299
+ "header": "Slice"
300
+ },
301
+ {
302
+ "field": "title",
303
+ "header": "Title",
304
+ "truncate": true
305
+ },
306
+ {
307
+ "field": "status",
308
+ "header": "Status"
309
+ },
310
+ {
311
+ "field": "pr_key",
312
+ "header": "PR"
313
+ }
314
+ ]
315
+ },
263
316
  {
264
317
  "title": "Rounds",
265
318
  "source": "app",
@@ -84,8 +84,12 @@
84
84
  },
85
85
  "columns": [
86
86
  {
87
- "field": "subject_key",
88
- "header": "Feature"
87
+ "field": "subject_title",
88
+ "template": "{{subject_title}}",
89
+ "header": "Item",
90
+ "subtitleField": "subject_key",
91
+ "truncate": true,
92
+ "linkField": "subject_url"
89
93
  },
90
94
  {
91
95
  "field": "subject_url",
@@ -141,8 +145,12 @@
141
145
  },
142
146
  "columns": [
143
147
  {
144
- "field": "subject_key",
145
- "header": "Epic"
148
+ "field": "subject_title",
149
+ "template": "{{subject_title}}",
150
+ "header": "Item",
151
+ "subtitleField": "subject_key",
152
+ "truncate": true,
153
+ "linkField": "subject_url"
146
154
  },
147
155
  {
148
156
  "field": "question",
@@ -202,8 +210,12 @@
202
210
  },
203
211
  "columns": [
204
212
  {
205
- "field": "subject_key",
206
- "header": "Epic"
213
+ "field": "subject_title",
214
+ "template": "{{subject_title}}",
215
+ "header": "Item",
216
+ "subtitleField": "subject_key",
217
+ "truncate": true,
218
+ "linkField": "subject_url"
207
219
  },
208
220
  {
209
221
  "field": "question",
@@ -264,8 +276,12 @@
264
276
  },
265
277
  "columns": [
266
278
  {
267
- "field": "subject_key",
268
- "header": "PR"
279
+ "field": "subject_title",
280
+ "template": "{{subject_title}}",
281
+ "header": "Item",
282
+ "subtitleField": "subject_key",
283
+ "truncate": true,
284
+ "linkField": "subject_url"
269
285
  },
270
286
  {
271
287
  "field": "kind_label",
@@ -343,8 +359,12 @@
343
359
  },
344
360
  "columns": [
345
361
  {
346
- "field": "subject_key",
347
- "header": "Feature"
362
+ "field": "subject_title",
363
+ "template": "{{subject_title}}",
364
+ "header": "Item",
365
+ "subtitleField": "subject_key",
366
+ "truncate": true,
367
+ "linkField": "subject_url"
348
368
  },
349
369
  {
350
370
  "field": "question",