@nanobpm/nano-workforce 0.169.0 → 0.170.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.170.1](https://github.com/nanobpm/nano-workforce/compare/v0.170.0...v0.170.1) (2026-08-31)
2
+
3
+ ### Documentation
4
+
5
+ * **agent-guide:** make the operator guide tool-aware + tool↔curl crosswalk (N1) ([#669](https://github.com/nanobpm/nano-workforce/issues/669)) ([44c5937](https://github.com/nanobpm/nano-workforce/commit/44c5937fb3a1cdaa5a106d67ed16225c35d614f6)), closes [#664](https://github.com/nanobpm/nano-workforce/issues/664) [#666](https://github.com/nanobpm/nano-workforce/issues/666) [#667](https://github.com/nanobpm/nano-workforce/issues/667) [#665](https://github.com/nanobpm/nano-workforce/issues/665) [666/#667](https://github.com/666/nano-workforce/issues/667) [#666](https://github.com/nanobpm/nano-workforce/issues/666) [#667](https://github.com/nanobpm/nano-workforce/issues/667) [#665](https://github.com/nanobpm/nano-workforce/issues/665)
6
+
7
+ ## [0.170.0](https://github.com/nanobpm/nano-workforce/compare/v0.169.0...v0.170.0) (2026-08-31)
8
+
9
+ ### Features
10
+
11
+ * **api:** add listEscalations read tool + structured openEscalation ([#672](https://github.com/nanobpm/nano-workforce/issues/672)) ([893d39a](https://github.com/nanobpm/nano-workforce/commit/893d39a644cd18102422186b4baddc6779b3510d)), closes [#666](https://github.com/nanobpm/nano-workforce/issues/666) [#666](https://github.com/nanobpm/nano-workforce/issues/666) [#358](https://github.com/nanobpm/nano-workforce/issues/358) [owner/repo#N](https://github.com/owner/repo/issues/N)
12
+
1
13
  ## [0.169.0](https://github.com/nanobpm/nano-workforce/compare/v0.168.2...v0.169.0) (2026-08-31)
2
14
 
3
15
  ### Features
package/app/agentGuide.ts CHANGED
@@ -106,6 +106,7 @@ export const GUIDE_SECTIONS: readonly GuideSectionMeta[] = [
106
106
  { id: "unstick", summary: "Unstick a wedged process — publish a correlating message, cancel, or otherwise recover a stalled instance." },
107
107
  { id: "raise-issue", summary: "Raise an issue or open a PR against the nano-workforce repository itself." },
108
108
  { id: "delivery-graphs", summary: "Author, preview, compile/stage and run an agent-authored delivery graph (ADR 0005): node/wait/connector vocabulary." },
109
+ { id: "tool-crosswalk", summary: "Tool↔curl crosswalk: map every guide action to its projected MCP tool (status, version, urban_debug_* engine reads, escalation answer, cancel) with the curl no-MCP fallback." },
109
110
  ] as const;
110
111
 
111
112
  /** A parsed section: its stable id + summary (from the registry), the derived heading `title` (the
@@ -46,6 +46,7 @@ const EXPECTED_EXPOSED = [
46
46
  "previewDeliveryGraph",
47
47
  "listStagedProposals",
48
48
  "listActivePrs",
49
+ "listEscalations",
49
50
  "getAgentInstructions",
50
51
  "getVersion",
51
52
  ];
package/app/prParse.ts ADDED
@@ -0,0 +1,34 @@
1
+ // Canonical PR-key parser (extracted from app/service.ts so leaf modules can reuse the ONE
2
+ // implementation of the `owner/repo#N` shape without importing the heavy service module — which
3
+ // imports them, so a back-import would cycle). `app/service.ts` re-exports `parsePr`/`ParsedPr`
4
+ // from here, so every existing `import { parsePr } from "./service.ts"` keeps resolving. This is
5
+ // the single source of truth for "is this string a PR key?" — do not add a second shape regex.
6
+ export interface ParsedPr {
7
+ repo: string;
8
+ number: number;
9
+ url: string;
10
+ prKey: string;
11
+ }
12
+
13
+ /** Parse "owner/repo#123" or a canonical PR URL into its parts, or `null` when the input is not a
14
+ * PR key/URL. */
15
+ export function parsePr(input: unknown): ParsedPr | null {
16
+ // Total on any input: a process-variable regression (or an older in-flight instance) can carry a
17
+ // non-string prKey, and `.trim()` on a non-string throws — turning a should-fail-open caller into
18
+ // a retrying job. Fail closed to `null` here so every caller resolves safely instead of throwing.
19
+ if (typeof input !== "string") return null;
20
+ const s = input.trim();
21
+ let m = s.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/i);
22
+ if (m) {
23
+ const repo = `${m[1]}/${m[2]}`;
24
+ const number = Number(m[3]);
25
+ return { repo, number, url: `https://github.com/${repo}/pull/${number}`, prKey: `${repo}#${number}` };
26
+ }
27
+ m = s.match(/^([^/]+\/[^#]+)#(\d+)$/);
28
+ if (m) {
29
+ const repo = m[1];
30
+ const number = Number(m[2]);
31
+ return { repo, number, url: `https://github.com/${repo}/pull/${number}`, prKey: `${repo}#${number}` };
32
+ }
33
+ return null;
34
+ }
package/app/service.ts CHANGED
@@ -75,6 +75,7 @@ import {
75
75
  planTasks,
76
76
  } from "./plan.ts";
77
77
  import { derivePromotionState, isEpicIntegrationBranch, isPromotable, promotionPrBody, promotionPrTitle } from "./promotion.ts";
78
+ import { type ParsedPr, parsePr } from "./prParse.ts";
78
79
  import {
79
80
  defaultProbeExec,
80
81
  type ProbeExec,
@@ -93,12 +94,14 @@ import {
93
94
  latestOpenEscalationQuestion,
94
95
  latestPlanReviewFindings,
95
96
  latestTrialMergeQuestion,
97
+ type OpenEscalation,
96
98
  PLAN_REVIEW_ELEMENT,
97
99
  PR_WAIT_ANSWER_ELEMENT,
98
100
  PR_WAIT_MERGE_ANSWER_ELEMENT,
99
101
  prEscalations,
100
102
  reconcileUserTasks,
101
103
  TRIAL_MERGE_ELEMENT,
104
+ toOpenEscalation,
102
105
  type UserTaskContext,
103
106
  type UserTaskRow,
104
107
  userTaskKindLabel,
@@ -314,13 +317,6 @@ const prsTracking = (data: DataLayer) =>
314
317
  const escs = (data: DataLayer) => data.table<Escalation>("escalations", "id");
315
318
  const deps = (data: DataLayer) => data.table<PrDependency>("pr_dependencies", "pr_key");
316
319
 
317
- export interface ParsedPr {
318
- repo: string;
319
- number: number;
320
- url: string;
321
- prKey: string;
322
- }
323
-
324
320
  /** Canonical GitHub PR URL for a repo + number. Matches the `url` `parsePr` derives, so a
325
321
  * reconstructed row is indistinguishable from one registered at submit time. */
326
322
  export function canonicalPrUrl(repo: string, number: number): string {
@@ -379,27 +375,10 @@ export async function ensurePr(
379
375
  }
380
376
  }
381
377
 
382
- /** Parse "owner/repo#123" or a canonical PR URL into its parts. */
383
- export function parsePr(input: unknown): ParsedPr | null {
384
- // Total on any input: a process-variable regression (or an older in-flight instance) can carry a
385
- // non-string prKey, and `.trim()` on a non-string throws — turning a should-fail-open caller into
386
- // a retrying job. Fail closed to `null` here so every caller resolves safely instead of throwing.
387
- if (typeof input !== "string") return null;
388
- const s = input.trim();
389
- let m = s.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/i);
390
- if (m) {
391
- const repo = `${m[1]}/${m[2]}`;
392
- const number = Number(m[3]);
393
- return { repo, number, url: `https://github.com/${repo}/pull/${number}`, prKey: `${repo}#${number}` };
394
- }
395
- m = s.match(/^([^/]+\/[^#]+)#(\d+)$/);
396
- if (m) {
397
- const repo = m[1];
398
- const number = Number(m[2]);
399
- return { repo, number, url: `https://github.com/${repo}/pull/${number}`, prKey: `${repo}#${number}` };
400
- }
401
- return null;
402
- }
378
+ /** Parse "owner/repo#123" or a canonical PR URL into its parts. Canonical implementation lives in
379
+ * `./prParse.ts` (a leaf module other leaf modules can reuse without cycling through this one); re-exported
380
+ * here so existing `import { parsePr } from "./service.ts"` call sites keep resolving. */
381
+ export { type ParsedPr, parsePr };
403
382
 
404
383
  /** Extract `Depends-on: owner/repo#N[, owner/repo#N …]` (or PR URLs) from a PR body. Multiple
405
384
  * `Depends-on:` lines accumulate; each line may list several comma/space-separated refs. Returns
@@ -785,7 +764,7 @@ export interface ActivePr {
785
764
  round: number;
786
765
  processKey: string | null;
787
766
  waitingSince: string | null;
788
- openEscalation: string | null;
767
+ openEscalation: OpenEscalation | null;
789
768
  updatedAt: string;
790
769
  /** Leasing worker while an agent is actively working the review round; null when queued
791
770
  * (job created, not yet activated) or not at the review-round task. */
@@ -796,15 +775,15 @@ export interface ActivePr {
796
775
 
797
776
  /** Every tracked PR not in a terminal state (converged/abandoned), newest-updated first. Backs
798
777
  * the GET status endpoint so an operator or an external harness can see what is in flight
799
- * without reading the datasource directly. The open-escalation question is derived from the
800
- * canonical `escalations` audit row the single source of truth (no denormalised PR-row
801
- * pointer). A PR reads `status="escalated"` only while a token is parked awaiting a human answer,
802
- * and the row it raised carries `status="open"` until that answer is recorded by the
803
- * `pr.answer-escalation` step on the `wait-answer` (review loop) or `wait-merge-answer` (merge loop)
804
- * user-task completion. Both loops now park on a native user task answered through the one canonical
805
- * `completeUserTask` door (#256), so deriving from the row (not a per-loop wait mechanism) surfaces
806
- * BOTH loops' escalations uniformly. Once answered the row leaves `open`, so `openEscalation`
807
- * derives back to null. */
778
+ * without reading the datasource directly. The structured `openEscalation` pointer (issue #666:
779
+ * `{ userTaskKey, kind, summary }`) is derived from the canonical `user_tasks` read model the SAME
780
+ * user-task surface `listEscalations` and the Convergence/Tasks page consume, so `/status` carries the
781
+ * completable `userTaskKey` without a denormalised PR-row pointer or a second source of truth. A
782
+ * `user_tasks` row for a PR subject exists iff its review/merge-loop escalation task is currently OPEN
783
+ * (parked awaiting a human answer) the same live-escalation signal a `status="escalated"` PR carries.
784
+ * Both loops park on a native user task answered through the one canonical `completeUserTask` door
785
+ * (#256), so deriving from the read model surfaces BOTH loops' escalations uniformly. Once the task is
786
+ * completed its row is removed, so `openEscalation` derives back to null. */
808
787
  export async function activePrs(data: DataLayer): Promise<ActivePr[]> {
809
788
  const all = await prsTracking(data).all();
810
789
  const active = all
@@ -818,15 +797,20 @@ export async function activePrs(data: DataLayer): Promise<ActivePr[]> {
818
797
  // `derived_status === status`.
819
798
  .filter((p) => !TERMINAL_STATUSES.includes(p.derived_status))
820
799
  .sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0));
821
- // Only an `escalated` PR is parked awaiting a human answer (either loop). Surface the question
822
- // from its latest still-open `escalations` row; a resubmit retires stale rows and finalize/merge
823
- // move the PR off `escalated`, so an open row on an escalated PR is a genuinely live escalation.
824
- // Fetch every open row in one query (avoids an N+1 over escalated PRs), then keep the newest per PR.
825
- const openEscByPr = new Map<string, string>();
826
- const escalatedPrs = new Set(active.filter((p) => p.status === "escalated").map((p) => p.pr_key));
827
- for (const e of (await escs(data).find({ status: "open" })).sort((a, b) => b.id - a.id)) {
828
- if (!escalatedPrs.has(e.pr_key) || openEscByPr.has(e.pr_key)) continue;
829
- if (e.question) openEscByPr.set(e.pr_key, e.question);
800
+ // Derive the STRUCTURED open-escalation pointer from the ONE `user_tasks` read model (issue #666)
801
+ // the same user-task surface `listEscalations` and the Convergence/Tasks page consume so `/status`
802
+ // carries the completable `userTaskKey` (plus the escalation `kind` and a one-line `summary`) without
803
+ // a second source of truth. A `user_tasks` row for a PR subject exists iff its review/merge-loop
804
+ // escalation task is currently OPEN (parked awaiting a human answer), so its presence is exactly the
805
+ // live-escalation signal the old `escalations`-table derivation computed now unified across BOTH
806
+ // loops. Keep the newest per PR (a PR parks on at most one such task at a time; order by recency for
807
+ // determinism).
808
+ const openEscByPr = new Map<string, OpenEscalation>();
809
+ for (const t of (await userTasks(data).find({ subject_type: "pr" })).sort((a, b) =>
810
+ a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0,
811
+ )) {
812
+ if (openEscByPr.has(t.subject_key)) continue;
813
+ openEscByPr.set(t.subject_key, toOpenEscalation(t));
830
814
  }
831
815
  return active.map((p) => ({
832
816
  prKey: p.pr_key,
@@ -19,6 +19,7 @@ import {
19
19
  latestTrialMergeQuestion,
20
20
  type PrEscalationRow,
21
21
  reconcileUserTasks,
22
+ toEscalationView,
22
23
  TRIAL_MERGE_ELEMENT,
23
24
  userTaskKindLabel,
24
25
  type UserTaskRow,
@@ -337,3 +338,38 @@ test("latestFeatureEscalationQuestion: picks the newest audit row (highest id),
337
338
  test("latestFeatureEscalationQuestion: null when the feature has no recorded escalation", () => {
338
339
  assertEquals(latestFeatureEscalationQuestion([]), null);
339
340
  });
341
+
342
+ // toEscalationView.prKey must be a genuine PR key (owner/repo#N), never a subject key that fell back
343
+ // to a non-PR value. `buildUserTaskRow` coalesces a blank subjectKey to `processKey`/`userTaskKey`
344
+ // for an orphaned/untracked instance (#358); for a PR-subject task that yields `subject_key` = a
345
+ // numeric engine key, which must NOT be emitted as `prKey` (OpenAPI: prKey is the PR key only when
346
+ // known). Copilot review suppressed advisory app/userTasks.ts:136.
347
+ const escRow = (over: Partial<UserTaskRow>): UserTaskRow => ({
348
+ user_task_key: "ut-1",
349
+ element_id: PR_WAIT_ANSWER_ELEMENT,
350
+ kind_label: "PR review",
351
+ subject_type: "pr",
352
+ subject_key: "o/r#7",
353
+ subject_title: "o/r#7",
354
+ subject_url: null,
355
+ question: null,
356
+ process_key: null,
357
+ form_key: null,
358
+ created_at: AT,
359
+ updated_at: AT,
360
+ ...over,
361
+ });
362
+
363
+ test("toEscalationView: PR-subject row with a PR-shaped subject key emits it as prKey", () => {
364
+ assertEquals(toEscalationView(escRow({ subject_key: "o/r#7" })).prKey, "o/r#7");
365
+ });
366
+
367
+ test("toEscalationView: PR-subject row whose subject key fell back to a non-PR value yields prKey null (still correlatable via subjectKey)", () => {
368
+ const view = toEscalationView(escRow({ subject_key: "19153" }));
369
+ assertEquals(view.prKey, null);
370
+ assertEquals(view.subjectKey, "19153");
371
+ });
372
+
373
+ test("toEscalationView: non-PR subject always yields prKey null", () => {
374
+ assertEquals(toEscalationView(escRow({ subject_type: "plan", subject_key: "o/r#7" })).prKey, null);
375
+ });
package/app/userTasks.ts CHANGED
@@ -22,6 +22,7 @@ import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
22
22
  import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement } from "./deliveryHuman.ts";
23
23
  import { FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, type FeatureEscalationRow } from "./feature.ts";
24
24
  import type { PlanReview } from "./plan.ts";
25
+ import { parsePr } from "./prParse.ts";
25
26
  import type { TrialMergeAuditRow } from "./trialMerge.ts";
26
27
 
27
28
  const now = () => new Date().toISOString();
@@ -98,6 +99,80 @@ export interface UserTaskRow {
98
99
 
99
100
  export const userTasks = (data: DataLayer) => data.table<UserTaskRow>("user_tasks", "user_task_key");
100
101
 
102
+ /** The read-tool projection of ONE open escalation user task (issue #666, epic #664 — retire the
103
+ * `/tasks/api/tasks` inbox curl). Sourced from the SAME `user_tasks` read model the Tasks inbox and
104
+ * Convergence page consume — NOT a second source of truth. It carries the completable `userTaskKey`
105
+ * an agent answers via `completeUserTask` / `agentCompleteEscalation`, the escalation `kind` (the
106
+ * BPMN `elementId`), the denormalised decision text (`question` — the question / findings / task the
107
+ * loop or agent raised, uniform across all four kinds), and the deployed-form context so a tool-aware
108
+ * agent can discover and answer an escalation without curling the un-projected task inbox. */
109
+ export interface EscalationView {
110
+ userTaskKey: string;
111
+ kind: string;
112
+ kindLabel: string;
113
+ /** The PR key when this escalation belongs to a PR (review/merge loop): the subject key, but ONLY
114
+ * when it is actually PR-key-shaped (`owner/repo#N`). Null for feature / plan / delivery / agent
115
+ * subjects — and also null for a PR-subject task whose subject key fell back to a non-PR value
116
+ * (`processKey`/`userTaskKey`) for an orphaned/untracked instance (see `buildUserTaskRow`), so
117
+ * `prKey` never emits a non-PR key. Use `subjectKey` for raw correlation in that case. */
118
+ prKey: string | null;
119
+ subjectType: string;
120
+ subjectKey: string;
121
+ subjectTitle: string;
122
+ subjectUrl: string | null;
123
+ question: string | null;
124
+ formKey: string | null;
125
+ processKey: string | null;
126
+ /** The denormalised decision/form context the Tasks inbox renders for this task (the same
127
+ * `question` + subject the deployed `.form` is seeded with). The typed answer fields an agent
128
+ * submits depend on `kind` (e.g. a PR `{ answer }`, a plan-review `{ directive, notes }`). */
129
+ formVariables: Record<string, unknown>;
130
+ }
131
+
132
+ /** Pure: project one open `user_tasks` row into its `listEscalations` read-tool entry. Reuses the
133
+ * read model verbatim (no new query / source of truth). `prKey` is the subject key only when the
134
+ * subject is a PR AND the subject key is genuinely PR-key-shaped (`owner/repo#N`, validated by the
135
+ * canonical `parsePr`) — an orphaned PR-loop instance whose subject key fell back to a numeric
136
+ * `processKey`/`userTaskKey` (see `buildUserTaskRow`) yields `prKey: null`, not a non-PR key that
137
+ * would contradict the OpenAPI contract. `subjectKey` still carries the raw value for correlation. */
138
+ export function toEscalationView(row: UserTaskRow): EscalationView {
139
+ return {
140
+ userTaskKey: row.user_task_key,
141
+ kind: row.element_id,
142
+ kindLabel: row.kind_label,
143
+ prKey: row.subject_type === "pr" && parsePr(row.subject_key) ? row.subject_key : null,
144
+ subjectType: row.subject_type,
145
+ subjectKey: row.subject_key,
146
+ subjectTitle: row.subject_title,
147
+ subjectUrl: row.subject_url,
148
+ question: row.question,
149
+ formKey: row.form_key,
150
+ processKey: row.process_key,
151
+ formVariables: {
152
+ question: row.question,
153
+ subjectTitle: row.subject_title,
154
+ subjectUrl: row.subject_url,
155
+ formKey: row.form_key,
156
+ },
157
+ };
158
+ }
159
+
160
+ /** The structured open-escalation pointer surfaced on each active PR by `/status` (issue #666): the
161
+ * completable `userTaskKey`, the escalation `kind` (BPMN `elementId`), and a one-line `summary` (the
162
+ * raised question / findings). Derived from the SAME `user_tasks` read model as `listEscalations`
163
+ * (no denormalised PR-row pointer, no second source of truth); the field is null when the PR is not
164
+ * parked on an open escalation. */
165
+ export interface OpenEscalation {
166
+ userTaskKey: string;
167
+ kind: string;
168
+ summary: string | null;
169
+ }
170
+
171
+ /** Pure: the structured `/status` open-escalation pointer for one open PR user-task row. */
172
+ export function toOpenEscalation(row: UserTaskRow): OpenEscalation {
173
+ return { userTaskKey: row.user_task_key, kind: row.element_id, summary: row.question };
174
+ }
175
+
101
176
  /** Human-readable label per escalation element. The set of keys is the closed set of user-task
102
177
  * elements the Tasks inbox surfaces — an element absent from here is not an escalation and is
103
178
  * ignored by `buildUserTaskRow`, so an arbitrary internal user task can never leak into the inbox. */