@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/openapi.yaml CHANGED
@@ -108,8 +108,31 @@ components:
108
108
  type: string
109
109
  nullable: true
110
110
  openEscalation:
111
- type: string
111
+ type: object
112
112
  nullable: true
113
+ additionalProperties: false
114
+ description: >-
115
+ The structured pointer to this PR's OPEN escalation when it is parked awaiting a human/agent
116
+ answer (issue #666), else null. Derived from the same `user_tasks` read model as
117
+ `listEscalations`, so `userTaskKey` is the completable key an agent answers via
118
+ `completeUserTask` / `agentCompleteEscalation` (no `/tasks/api/tasks` curl needed). `kind`
119
+ is the BPMN escalation elementId (e.g. `wait-answer`, `wait-merge-answer`); `summary` is the
120
+ raised question/findings (null when none was recorded).
121
+ required:
122
+ - userTaskKey
123
+ - kind
124
+ - summary
125
+ properties:
126
+ userTaskKey:
127
+ type: string
128
+ description: The completable engine user-task key (answer via completeUserTask / agentCompleteEscalation).
129
+ kind:
130
+ type: string
131
+ description: The BPMN escalation elementId (the escalation kind).
132
+ summary:
133
+ type: string
134
+ nullable: true
135
+ description: The raised question / findings, denormalised for display; null when none.
113
136
  updatedAt:
114
137
  type: string
115
138
  activeWorker:
@@ -130,6 +153,85 @@ components:
130
153
  type: array
131
154
  items:
132
155
  $ref: "#/components/schemas/ActivePr"
156
+ Escalation:
157
+ type: object
158
+ description: >-
159
+ One OPEN native user-task escalation awaiting a human/agent decision (issue #666), projected
160
+ from the `user_tasks` read model. `userTaskKey` is the completable key an agent answers via
161
+ `completeUserTask` / `agentCompleteEscalation`; `kind` is the BPMN escalation elementId.
162
+ additionalProperties: false
163
+ required:
164
+ - userTaskKey
165
+ - kind
166
+ - kindLabel
167
+ - prKey
168
+ - subjectType
169
+ - subjectKey
170
+ - subjectTitle
171
+ - subjectUrl
172
+ - question
173
+ - formKey
174
+ - processKey
175
+ - formVariables
176
+ properties:
177
+ userTaskKey:
178
+ type: string
179
+ description: The completable engine user-task key (answer via completeUserTask / agentCompleteEscalation).
180
+ kind:
181
+ type: string
182
+ description: The BPMN escalation elementId (e.g. wait-answer, wait-merge-answer, plan-review-decision, trial-merge-decision, feature-escalation).
183
+ kindLabel:
184
+ type: string
185
+ description: Human-readable kind label (e.g. "PR review", "Plan review", "Trial merge").
186
+ prKey:
187
+ type: string
188
+ nullable: true
189
+ description: The PR key when this escalation belongs to a PR (review/merge loop); null for feature / plan / delivery / agent subjects.
190
+ subjectType:
191
+ type: string
192
+ description: The domain subject kind — feature | plan | pr | delivery | agent.
193
+ subjectKey:
194
+ type: string
195
+ description: The subject aggregate key (feature_key / plan_key / pr_key).
196
+ subjectTitle:
197
+ type: string
198
+ description: The subject's human-readable title (coalesced to subjectKey when unknown).
199
+ subjectUrl:
200
+ type: string
201
+ nullable: true
202
+ description: An optional external link (the issue/PR URL); null when none.
203
+ question:
204
+ type: string
205
+ nullable: true
206
+ description: The raised question / findings / task text the loop or agent recorded; null when none.
207
+ formKey:
208
+ type: string
209
+ nullable: true
210
+ description: The deployed `.form` key of the parked user task, for rendering/answering; null when unresolved.
211
+ processKey:
212
+ type: string
213
+ nullable: true
214
+ description: The owning engine process-instance key; null when unknown.
215
+ formVariables:
216
+ type: object
217
+ additionalProperties: true
218
+ description: >-
219
+ The denormalised decision/form context the Tasks inbox renders for this task (the same
220
+ question + subject the deployed form is seeded with). The typed answer fields an agent
221
+ submits depend on `kind` (e.g. a PR `{ answer }`, a plan-review `{ directive, notes }`).
222
+ EscalationList:
223
+ type: object
224
+ additionalProperties: false
225
+ required:
226
+ - count
227
+ - escalations
228
+ properties:
229
+ count:
230
+ type: integer
231
+ escalations:
232
+ type: array
233
+ items:
234
+ $ref: "#/components/schemas/Escalation"
133
235
  LineagePrView:
134
236
  type: object
135
237
  description: A member PR of a lineage thread (issue #245).
@@ -3057,6 +3159,36 @@ paths:
3057
3159
  application/json:
3058
3160
  schema:
3059
3161
  $ref: "#/components/schemas/ErrorBody"
3162
+ /escalations:
3163
+ get:
3164
+ operationId: listEscalations
3165
+ summary: List every OPEN escalation awaiting a human/agent decision, with the completable userTaskKey.
3166
+ description: >-
3167
+ Discovery for the escalation-answer path (epic #664, issue #666): across every surfaced
3168
+ escalation kind (PR review/merge loop, plan-review, empty-plan, trial-merge,
3169
+ conformance-review, delivery human-step, feature/blocked, agent-permission and the shared
3170
+ human-escalation cell), list each currently-open native user-task escalation with the
3171
+ completable `userTaskKey` an agent then answers via `completeUserTask` /
3172
+ `agentCompleteEscalation` — so a tool-aware agent never has to curl the un-projected
3173
+ `/tasks/api/tasks` inbox to find keys. Read-only projection over the ONE `user_tasks` read
3174
+ model the Tasks inbox and Convergence page consume (no second source of truth); a row is
3175
+ present iff its task is open, so the list reflects live pending work.
3176
+ security:
3177
+ - hookSecret: []
3178
+ - {}
3179
+ responses:
3180
+ "200":
3181
+ description: The open escalations.
3182
+ content:
3183
+ application/json:
3184
+ schema:
3185
+ $ref: "#/components/schemas/EscalationList"
3186
+ "401":
3187
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
3188
+ content:
3189
+ application/json:
3190
+ schema:
3191
+ $ref: "#/components/schemas/ErrorBody"
3060
3192
  /lineage:
3061
3193
  get:
3062
3194
  operationId: getLineage
@@ -3496,7 +3628,7 @@ paths:
3496
3628
  example: delivery-graphs
3497
3629
  description: OPTIONAL stable section id (from the table of contents `getAgentGuide` returns
3498
3630
  with no argument), e.g. `orient`, `submit-pr`, `submit-epic`, `escalations`, `lifecycle`,
3499
- `debug`, `debug-models`, `unstick`, `raise-issue`, `delivery-graphs`. Omit it to get the
3631
+ `debug`, `debug-models`, `unstick`, `raise-issue`, `delivery-graphs`, `tool-crosswalk`. Omit it to get the
3500
3632
  table of contents. An unknown id yields a 400 listing the valid ids.
3501
3633
  responses:
3502
3634
  "200":
@@ -8,14 +8,15 @@ import { noopLog } from "../test/log.ts";
8
8
  import { withTrackingViews } from "../test/trackingViews.ts";
9
9
  import handler from "./listActivePrs.ts";
10
10
 
11
- function memApp(rows: any[], escalations: any[] = []): AppApi {
11
+ function memApp(rows: any[], userTaskRows: any[] = []): AppApi {
12
12
  const table = (name: string) => {
13
- if (name === "escalations") {
13
+ if (name === "user_tasks") {
14
14
  return {
15
+ async all() {
16
+ return userTaskRows;
17
+ },
15
18
  async find(where: Record<string, unknown>) {
16
- return escalations.filter((e) =>
17
- Object.entries(where).every(([k, v]) => e[k] === v)
18
- );
19
+ return userTaskRows.filter((t) => Object.entries(where).every(([k, v]) => t[k] === v));
19
20
  },
20
21
  };
21
22
  }
@@ -58,25 +59,42 @@ test("returns 200 with a count + projected active PRs", async () => {
58
59
  assertEquals(r.body.prs[0].processKey, "9");
59
60
  });
60
61
 
61
- test("surfaces openEscalation for an escalated PR from its open escalations row (both loops)", async () => {
62
- // Regression: a merge-loop escalation parks on a message catch (no user task), so deriving
63
- // openEscalation from a user-task probe hid it. Deriving from the canonical `escalations` row
64
- // surfaces it. Two escalated PRs one with an open row (visible), one already answered (null).
62
+ test("surfaces the structured openEscalation from the user_tasks read model (both loops)", async () => {
63
+ // Issue #666: openEscalation is derived from the ONE `user_tasks` read model (the same surface
64
+ // `listEscalations` and the Convergence page consume), so `/status` carries the completable
65
+ // userTaskKey. A `user_tasks` row for a PR subject exists iff its review/merge-loop escalation task
66
+ // is currently open; a PR with no such row derives null. Cover a review-loop and a merge-loop PR.
65
67
  const app = memApp(
66
68
  [
67
69
  { pr_key: "o/r#10", repo: "o/r", number: 10, url: "u10", title: "merge blocked", status: "escalated", current_round: 3, process_key: "m1", updated_at: "2026-02-02" },
68
- { pr_key: "o/r#11", repo: "o/r", number: 11, url: "u11", title: "answered", status: "escalated", current_round: 4, process_key: "m2", updated_at: "2026-02-01" },
70
+ { pr_key: "o/r#11", repo: "o/r", number: 11, url: "u11", title: "no open task", status: "escalated", current_round: 4, process_key: "m2", updated_at: "2026-02-01" },
69
71
  ],
70
72
  [
71
- { id: 1, pr_key: "o/r#10", status: "open", question: "Resolve the conflict on the branch, then retry?" },
72
- { id: 2, pr_key: "o/r#11", status: "answered", question: "old question" },
73
+ {
74
+ user_task_key: "ut-10",
75
+ element_id: "wait-merge-answer",
76
+ kind_label: "PR merge",
77
+ subject_type: "pr",
78
+ subject_key: "o/r#10",
79
+ subject_title: "merge blocked",
80
+ subject_url: null,
81
+ question: "Resolve the conflict on the branch, then retry?",
82
+ process_key: "m1",
83
+ form_key: null,
84
+ created_at: "2026-02-02",
85
+ updated_at: "2026-02-02",
86
+ },
73
87
  ],
74
88
  );
75
89
  const res = (await handler(input(), app)) as any;
76
90
  assertEquals(res.status, 200);
77
91
  const p10 = res.body.prs.find((p: any) => p.prKey === "o/r#10");
78
92
  const p11 = res.body.prs.find((p: any) => p.prKey === "o/r#11");
79
- assertEquals(p10.openEscalation, "Resolve the conflict on the branch, then retry?");
93
+ assertEquals(p10.openEscalation, {
94
+ userTaskKey: "ut-10",
95
+ kind: "wait-merge-answer",
96
+ summary: "Resolve the conflict on the branch, then retry?",
97
+ });
80
98
  assertEquals(p11.openEscalation, null);
81
99
  });
82
100
 
@@ -0,0 +1,215 @@
1
+ // Tests for GET /app/api/escalations operation `listEscalations` (epic #664, issue #666).
2
+ //
3
+ // The read tool that lists EVERY open native user-task escalation with its completable `userTaskKey`,
4
+ // so a tool-aware agent discovers keys on-tool instead of curling the un-projected `/tasks/api/tasks`
5
+ // inbox. It projects the ONE `user_tasks` read model (the same surface the Tasks inbox / Convergence
6
+ // page consume) via the pure `toEscalationView` derivation — no second source of truth.
7
+ //
8
+ // The headline round-trip test proves the acceptance criterion: an open escalation is listed by
9
+ // `listEscalations` with the EXACT `userTaskKey` that `completeUserTask` then resolves.
10
+ import { test } from "node:test";
11
+ import { assert, assertEquals } from "#test-assert";
12
+ import type { AppApi } from "@nanobpm/urban";
13
+ import { noopLog } from "../test/log.ts";
14
+ import completeHandler from "./completeUserTask.ts";
15
+ import listHandler from "./listEscalations.ts";
16
+
17
+ // biome-ignore lint/suspicious/noExplicitAny: in-memory doubles, mirrors sibling op tests
18
+ function memApp(
19
+ seedUserTasks: Record<string, unknown>[],
20
+ openTasks: { userTaskKey: string; elementId?: string }[],
21
+ ): {
22
+ app: AppApi;
23
+ // biome-ignore lint/suspicious/noExplicitAny: see above
24
+ stores: Record<string, any[]>;
25
+ completed: { userTaskKey: string; variables: Record<string, unknown> }[];
26
+ } {
27
+ // biome-ignore lint/suspicious/noExplicitAny: see above
28
+ const stores: Record<string, any[]> = { user_tasks: [...seedUserTasks] };
29
+ const completed: { userTaskKey: string; variables: Record<string, unknown> }[] = [];
30
+ function tbl(name: string, pk: string) {
31
+ // biome-ignore lint/suspicious/noExplicitAny: see above
32
+ const rows = (stores[name] ??= [] as any[]);
33
+ return {
34
+ // biome-ignore lint/suspicious/noExplicitAny: see above
35
+ async insert(row: any) {
36
+ rows.push({ ...row });
37
+ return rows.length;
38
+ },
39
+ // biome-ignore lint/suspicious/noExplicitAny: see above
40
+ async get(id: any) {
41
+ return rows.find((r) => r[pk] === id);
42
+ },
43
+ async all() {
44
+ return [...rows];
45
+ },
46
+ // biome-ignore lint/suspicious/noExplicitAny: see above
47
+ async find(where: any = {}) {
48
+ return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
49
+ },
50
+ // biome-ignore lint/suspicious/noExplicitAny: see above
51
+ async delete(id: any) {
52
+ const i = rows.findIndex((r) => r[pk] === id);
53
+ if (i >= 0) rows.splice(i, 1);
54
+ },
55
+ // biome-ignore lint/suspicious/noExplicitAny: see above
56
+ async update(id: any, patch: any) {
57
+ const r = rows.find((row) => row[pk] === id);
58
+ if (r) Object.assign(r, patch);
59
+ },
60
+ };
61
+ }
62
+ const engine = {
63
+ openUserTasks: async () => openTasks,
64
+ searchUserTasks: async () => openTasks,
65
+ completeUserTask: async (userTaskKey: string, variables: Record<string, unknown>) => {
66
+ completed.push({ userTaskKey, variables });
67
+ },
68
+ };
69
+ const app = {
70
+ data: { table: (n: string, pk: string) => tbl(n, pk) },
71
+ engine,
72
+ log: noopLog(),
73
+ // biome-ignore lint/suspicious/noExplicitAny: test harness cast, mirrors sibling op tests
74
+ } as any as AppApi;
75
+ return { app, stores, completed };
76
+ }
77
+
78
+ // biome-ignore lint/suspicious/noExplicitAny: test harness cast, mirrors sibling op tests
79
+ async function callList(app: AppApi): Promise<any> {
80
+ // biome-ignore lint/suspicious/noExplicitAny: see above
81
+ return (await listHandler({ req: { headers: new Headers() } as any, params: {}, query: {}, body: undefined } as any, app)) as any;
82
+ }
83
+
84
+ // biome-ignore lint/suspicious/noExplicitAny: test harness cast, mirrors sibling op tests
85
+ async function callComplete(app: AppApi, body: unknown): Promise<any> {
86
+ // biome-ignore lint/suspicious/noExplicitAny: see above
87
+ return (await completeHandler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
88
+ }
89
+
90
+ function utRow(over: Record<string, unknown>): Record<string, unknown> {
91
+ return {
92
+ user_task_key: "ut-x",
93
+ element_id: "wait-answer",
94
+ kind_label: "PR review",
95
+ subject_type: "pr",
96
+ subject_key: "acme/repo#7",
97
+ subject_title: "Add widget",
98
+ subject_url: "https://github.com/acme/repo/pull/7",
99
+ question: "Which API version?",
100
+ process_key: "pi-1",
101
+ form_key: "form-pr",
102
+ created_at: "2026-01-01T00:00:00.000Z",
103
+ updated_at: "2026-01-01T00:00:00.000Z",
104
+ ...over,
105
+ };
106
+ }
107
+
108
+ test("listEscalations: round-trip — the listed userTaskKey is exactly what completeUserTask resolves", async () => {
109
+ const { app, stores, completed } = memApp(
110
+ [utRow({ user_task_key: "ut-answer", element_id: "wait-answer" })],
111
+ [{ userTaskKey: "ut-answer", elementId: "wait-answer" }],
112
+ );
113
+
114
+ const listed = await callList(app);
115
+ assertEquals(listed.status, 200);
116
+ assertEquals(listed.body.count, 1);
117
+ const esc = listed.body.escalations[0];
118
+ assertEquals(esc.userTaskKey, "ut-answer");
119
+ assertEquals(esc.kind, "wait-answer");
120
+ assertEquals(esc.prKey, "acme/repo#7");
121
+ assertEquals(esc.question, "Which API version?");
122
+ assertEquals(esc.formKey, "form-pr");
123
+
124
+ // Answer the exact key the list handed back — it resolves via the canonical completer.
125
+ const done = await callComplete(app, { userTaskKey: esc.userTaskKey, variables: { answer: "v2" } });
126
+ assertEquals(done.status, 200);
127
+ assertEquals(done.body.ok, true);
128
+ assertEquals(done.body.elementId, "wait-answer");
129
+ assertEquals(completed, [{ userTaskKey: "ut-answer", variables: { answer: "v2" } }]);
130
+ // The answered task's read-model row is dropped, so a re-list no longer shows it.
131
+ assertEquals(stores.user_tasks, []);
132
+ const reListed = await callList(app);
133
+ assertEquals(reListed.body.count, 0);
134
+ });
135
+
136
+ test("listEscalations: lists across all four escalation kinds, newest-updated first", async () => {
137
+ const { app } = memApp(
138
+ [
139
+ utRow({ user_task_key: "ut-pr", element_id: "wait-answer", updated_at: "2026-01-04T00:00:00.000Z" }),
140
+ utRow({
141
+ user_task_key: "ut-plan",
142
+ element_id: "plan-review-decision",
143
+ kind_label: "Plan review",
144
+ subject_type: "plan",
145
+ subject_key: "acme/repo#99",
146
+ updated_at: "2026-01-03T00:00:00.000Z",
147
+ }),
148
+ utRow({
149
+ user_task_key: "ut-trial",
150
+ element_id: "trial-merge-decision",
151
+ kind_label: "Trial merge",
152
+ subject_type: "plan",
153
+ subject_key: "acme/repo#99",
154
+ updated_at: "2026-01-02T00:00:00.000Z",
155
+ }),
156
+ utRow({
157
+ user_task_key: "ut-feat",
158
+ element_id: "feature-escalation",
159
+ kind_label: "Feature escalation",
160
+ subject_type: "feature",
161
+ subject_key: "acme/repo#42",
162
+ updated_at: "2026-01-01T00:00:00.000Z",
163
+ }),
164
+ ],
165
+ [],
166
+ );
167
+
168
+ const res = await callList(app);
169
+ assertEquals(res.status, 200);
170
+ assertEquals(res.body.count, 4);
171
+ assertEquals(
172
+ res.body.escalations.map((e: { userTaskKey: string }) => e.userTaskKey),
173
+ ["ut-pr", "ut-plan", "ut-trial", "ut-feat"],
174
+ );
175
+ // Non-PR subjects carry a null prKey; the PR subject carries the pr key.
176
+ const byKey = Object.fromEntries(res.body.escalations.map((e: { userTaskKey: string }) => [e.userTaskKey, e]));
177
+ assertEquals(byKey["ut-pr"].prKey, "acme/repo#7");
178
+ assertEquals(byKey["ut-plan"].prKey, null);
179
+ assertEquals(byKey["ut-feat"].subjectType, "feature");
180
+ });
181
+
182
+ test("listEscalations: empty when no open escalations", async () => {
183
+ const { app } = memApp([], []);
184
+ const res = await callList(app);
185
+ assertEquals(res.status, 200);
186
+ assertEquals(res.body, { count: 0, escalations: [] });
187
+ });
188
+
189
+ // The optional shared-secret guard is captured at module load from NANO_PR_WEBHOOK_SECRET, so
190
+ // cache-bust re-import the handler with the env set to exercise both the rejected (401, missing
191
+ // header) and authorized (200, correct header) paths deterministically — mirrors the read-door
192
+ // guard tests on sibling ops (listActivePrs, listLibrary).
193
+ test("listEscalations: shared-secret guard — 401 without x-hook-secret, 200 with it", async () => {
194
+ const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
195
+ process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
196
+ try {
197
+ const mod = await import(`./listEscalations.ts?guard=${Date.now()}`);
198
+ const guarded = mod.default as typeof listHandler;
199
+ const { app } = memApp([], []);
200
+ // biome-ignore lint/suspicious/noExplicitAny: test harness cast, mirrors sibling op tests
201
+ const bad = (await guarded({ req: { headers: new Headers() } as any, params: {}, query: {}, body: undefined } as any, app)) as any;
202
+ assertEquals(bad.status, 401);
203
+ const ok = (await guarded(
204
+ // biome-ignore lint/suspicious/noExplicitAny: test harness cast, mirrors sibling op tests
205
+ { req: { headers: new Headers({ "x-hook-secret": "s3cr3t" }) } as any, params: {}, query: {}, body: undefined } as any,
206
+ app,
207
+ // biome-ignore lint/suspicious/noExplicitAny: test harness cast, mirrors sibling op tests
208
+ )) as any;
209
+ assertEquals(ok.status, 200);
210
+ assert("count" in ok.body);
211
+ } finally {
212
+ if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
213
+ else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
214
+ }
215
+ });
@@ -0,0 +1,32 @@
1
+ // GET /app/api/escalations → operationId `listEscalations` (epic #664, issue #666). Discovery for the
2
+ // escalation-answer path: list EVERY currently-open native user-task escalation — across every
3
+ // surfaced kind (PR review/merge loop, plan-review, empty-plan, trial-merge, conformance-review,
4
+ // delivery human-step, feature/blocked, agent-permission and the shared human-escalation cell) —
5
+ // with the completable `userTaskKey` an agent then answers via
6
+ // `completeUserTask` / `agentCompleteEscalation`. This closes the fallback where an agent had to curl
7
+ // the un-projected `/tasks/api/tasks` inbox to find keys before answering.
8
+ //
9
+ // Read-only projection over the ONE `user_tasks` read model the Tasks inbox and Convergence page
10
+ // consume (`userTasks` + the pure `toEscalationView` derivation in app/userTasks.ts) — NOT a second
11
+ // source of truth. A row exists iff its task is open, so the list reflects live pending work.
12
+ //
13
+ // The optional shared-secret guard stays HERE (the runtime does not enforce OpenAPI `security`):
14
+ // when NANO_PR_WEBHOOK_SECRET is set, callers must present it via the x-hook-secret header. Unset →
15
+ // open (unchanged default), mirroring `listActivePrs`.
16
+ import { toEscalationView, userTasks } from "../app/userTasks.ts";
17
+ import { envVar } from "../app/version.ts";
18
+ import { defineOperation } from "../nano-generated/operations.ts";
19
+
20
+ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
21
+
22
+ export default defineOperation("listEscalations", async ({ req }, app) => {
23
+ if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
24
+ app.log.warn("listEscalations rejected: missing/invalid shared secret");
25
+ return { status: 401, body: { error: "unauthorized" } };
26
+ }
27
+ const rows = await userTasks(app.data).all();
28
+ const escalations = rows
29
+ .sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0))
30
+ .map(toEscalationView);
31
+ return { status: 200, body: { count: escalations.length, escalations } };
32
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.169.0",
3
+ "version": "0.170.1",
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",
@@ -82,9 +82,9 @@
82
82
  "variant": "ghost",
83
83
  "modal": {
84
84
  "title": "Point your agent at Nano Workforce",
85
- "description": "Copy this prompt and paste it into your coding agent (Copilot, Claude, etc.). It connects the agent to this instance's MCP server \u2014 the workforce's operations become native tools (including the live operator guide as `getAgentInstructions`), so it can drive and debug your workforce; agents with no MCP client fall back to fetching the operator skill. Note: `/app/mcp` is served on the same HTTP surface as the rest of the app \u2014 reachable on loopback by default, and from a remote instance (merlin, an ngrok tunnel) only when the app is bound wide (`network.bind`) or fronted by a reverse proxy, per the MCP runbook.",
85
+ "description": "Copy this prompt and paste it into your coding agent (Copilot, Claude, etc.). It connects the agent to this instance's MCP server \u2014 the workforce's operations become native tools (including the live operator guide as `getAgentInstructions`, or its addressable companion `getAgentGuide(section?)` \u2014 preferred over the ~43KB blob to avoid tool-result overrun), so it can drive and debug your workforce; agents with no MCP client fall back to fetching the operator skill. Note: `/app/mcp` is served on the same HTTP surface as the rest of the app \u2014 reachable on loopback by default, and from a remote instance (merlin, an ngrok tunnel) only when the app is bound wide (`network.bind`) or fronted by a reverse proxy, per the MCP runbook.",
86
86
  "copyLabel": "Copy prompt",
87
- "copyText": "Add this running Nano Workforce instance as an MCP server, then help me drive and debug my workforce \u2014 a durable orchestration app that drives pull requests to review convergence against an automated reviewer, merges them, and can take a whole issue and plan \u2192 implement \u2192 converge it across a fleet of coding agents.\n\nRegister ONE MCP server entry per instance (streamable-HTTP transport, URL {{appBase}}app/mcp). Naming the instance makes targeting the wrong one structurally impossible \u2014 its tools are namespaced under that name:\n\n copilot mcp add --transport http workforce-local {{appBase}}app/mcp\n\nGive each instance its own entry (e.g. `workforce-merlin` for a LAN/remote node). In config form (~/.copilot/mcp-config.json or repo-scoped .mcp.json):\n\n {\n \"mcpServers\": {\n \"workforce-local\": {\n \"type\": \"http\",\n \"url\": \"{{appBase}}app/mcp\",\n \"tools\": [\"*\"]\n }\n }\n }\n\nIf this instance is secured with a shared secret (NANO_PR_WEBHOOK_SECRET), pass it on the MCP connection \u2014 reads AND mutations both require it:\n\n copilot mcp add --transport http workforce-local {{appBase}}app/mcp \\\n --header \"x-hook-secret: $NANO_PR_WEBHOOK_SECRET\"\n\nIn config form, add a `headers` entry alongside `url` on that server (per the MCP runbook) \u2014 the config path has no header flag, so omitting this yields 401s:\n\n \"headers\": { \"x-hook-secret\": \"$NANO_PR_WEBHOOK_SECRET\" }\n\nA Basic-Auth-fronted instance (behind a reverse proxy) additionally needs `Authorization: Basic \u2026` on the connection.\n\nMCP servers register at host startup \u2014 add the entry, THEN start a new session so its tools load. The `workforce-*` tools then appear (the full set of operations projected from this instance's OpenAPI contract, including the live operator guide itself as the `getAgentInstructions` read tool, plus the `urban_debug_*` family for inspecting a wedged instance's process instances, wait states, variables, and incidents).\n\nThe instance's operations are now native tools. Ask, naming the instance: \"Using workforce-local, show what's in flight and any open escalations.\" It should call the status tool, not curl. Operator-only doors (the delivery-graph stage/dispatch/dismiss lifecycle \u2014 the human click IS the approval) are deliberately not tools.\n\nNo MCP client? The curl path is unchanged \u2014 fetch and follow the live guide (the response is JSON with a `skill` markdown field), adding `-H \"x-hook-secret: <secret>\"` if this instance is secured:\n\n curl -sS {{appBase}}app/api/agent/skill\n\nThat skill bootstraps you to this instance's live operator guide at {{appBase}}app/api/agent (the same guide MCP exposes as `getAgentInstructions`). If you find a bug or a stuck process, the guide explains how to raise an issue or a PR against nanobpm/nano-workforce."
87
+ "copyText": "Add this running Nano Workforce instance as an MCP server, then help me drive and debug my workforce \u2014 a durable orchestration app that drives pull requests to review convergence against an automated reviewer, merges them, and can take a whole issue and plan \u2192 implement \u2192 converge it across a fleet of coding agents.\n\nRegister ONE MCP server entry per instance (streamable-HTTP transport, URL {{appBase}}app/mcp). Naming the instance makes targeting the wrong one structurally impossible \u2014 its tools are namespaced under that name:\n\n copilot mcp add --transport http workforce-local {{appBase}}app/mcp\n\nGive each instance its own entry (e.g. `workforce-merlin` for a LAN/remote node). In config form (~/.copilot/mcp-config.json or repo-scoped .mcp.json):\n\n {\n \"mcpServers\": {\n \"workforce-local\": {\n \"type\": \"http\",\n \"url\": \"{{appBase}}app/mcp\",\n \"tools\": [\"*\"]\n }\n }\n }\n\nIf this instance is secured with a shared secret (NANO_PR_WEBHOOK_SECRET), pass it on the MCP connection \u2014 reads AND mutations both require it:\n\n copilot mcp add --transport http workforce-local {{appBase}}app/mcp \\\n --header \"x-hook-secret: $NANO_PR_WEBHOOK_SECRET\"\n\nIn config form, add a `headers` entry alongside `url` on that server (per the MCP runbook) \u2014 the config path has no header flag, so omitting this yields 401s:\n\n \"headers\": { \"x-hook-secret\": \"$NANO_PR_WEBHOOK_SECRET\" }\n\nA Basic-Auth-fronted instance (behind a reverse proxy) additionally needs `Authorization: Basic \u2026` on the connection.\n\nMCP servers register at host startup \u2014 add the entry, THEN start a new session so its tools load. The `workforce-*` tools then appear (the full set of operations projected from this instance's OpenAPI contract, including the live operator guide itself as the `getAgentInstructions` read tool \u2014 or its addressable companion `getAgentGuide(section?)`, which the MCP runbook recommends over the ~43KB blob to avoid tool-result overrun \u2014 plus the `urban_debug_*` family for inspecting a wedged instance's process instances, wait states, and incidents).\n\nThe instance's operations are now native tools. Ask, naming the instance: \"Using workforce-local, show what's in flight and any open escalations.\" It should call the status tool, not curl. Operator-only doors (the delivery-graph stage/dispatch/dismiss lifecycle \u2014 the human click IS the approval) are deliberately not tools.\n\nNo MCP client? The curl path is unchanged \u2014 fetch and follow the live guide (the response is JSON with a `skill` markdown field), adding `-H \"x-hook-secret: <secret>\"` if this instance is secured:\n\n curl -sS {{appBase}}app/api/agent/skill\n\nThat skill bootstraps you to this instance's live operator guide at {{appBase}}app/api/agent (the same guide MCP exposes as `getAgentInstructions`, or section-addressably as `getAgentGuide`). If you find a bug or a stuck process, the guide explains how to raise an issue or a PR against nanobpm/nano-workforce."
88
88
  }
89
89
  }
90
90
  },