@nanobpm/nano-workforce 0.90.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,10 @@
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
+
1
8
  # [0.90.0](https://github.com/nanobpm/nano-workforce/compare/v0.89.0...v0.90.0) (2026-08-19)
2
9
 
3
10
 
@@ -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,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.90.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",
@@ -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",