@nanobpm/nano-workforce 0.115.0 → 0.116.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.116.0](https://github.com/nanobpm/nano-workforce/compare/v0.115.0...v0.116.0) (2026-08-20)
2
+
3
+
4
+ ### Features
5
+
6
+ * replace deterministic scope regex with a scope-integrity agent classifier ([#403](https://github.com/nanobpm/nano-workforce/issues/403)) ([588b869](https://github.com/nanobpm/nano-workforce/commit/588b8699ea738ff0772f23d68bc051e8ff417d49)), closes [#395](https://github.com/nanobpm/nano-workforce/issues/395) [#395](https://github.com/nanobpm/nano-workforce/issues/395) [#395](https://github.com/nanobpm/nano-workforce/issues/395) [398/#399](https://github.com/nanobpm/nano-workforce/issues/399) [#395](https://github.com/nanobpm/nano-workforce/issues/395) [#395](https://github.com/nanobpm/nano-workforce/issues/395) [#395](https://github.com/nanobpm/nano-workforce/issues/395)
7
+
1
8
  # [0.115.0](https://github.com/nanobpm/nano-workforce/compare/v0.114.1...v0.115.0) (2026-08-20)
2
9
 
3
10
 
@@ -217,15 +217,9 @@ test("pickLatestCopilotReviewBody: FAILS CLOSED (null) when the reviews read was
217
217
  async function makeUnderTest(deps: {
218
218
  readThreads: (repo: string, n: number) => Promise<ReviewThread[] | null>;
219
219
  readReviewBody: (repo: string, n: number) => Promise<string | null>;
220
- readPrBody?: (repo: string, n: number) => Promise<string | null>;
221
- readHeadSha?: (repo: string, n: number) => Promise<string | null>;
222
220
  }) {
223
221
  const { makeHandler } = await import("../workers/converge-gate/worker.ts");
224
- // Default the scope-guard PR-body read to a verified-empty description so the comment-gate tests
225
- // below exercise only the review-comment dimension; scope-guard tests pass an explicit body. The
226
- // HEAD read defaults to null (unreadable) so a scope block stays blocked unless a test opts into
227
- // the #395 override door with an explicit HEAD — see workers/converge-gate/worker.test.ts.
228
- return makeHandler({ readPrBody: async () => "", readHeadSha: async () => null, ...deps });
222
+ return makeHandler(deps);
229
223
  }
230
224
 
231
225
  test("converge-gate: a clean PR is allowed to converge", async () => {
@@ -348,82 +342,6 @@ test("converge-gate: resolves repo/prNumber from the prKey when the vars are abs
348
342
  assertEquals(seen, ["o/r", 7]);
349
343
  });
350
344
 
351
- // ── The scope-integrity guard through the worker (#313) ─────────────────────
352
-
353
- test("converge-gate: a partial delivery that Closes a broader-scoped parent blocks convergence", async () => {
354
- const handler = await makeUnderTest({
355
- readThreads: async () => [],
356
- readReviewBody: async () => "",
357
- readPrBody: async () =>
358
- "Delivers the nested ad-hoc half.\n\n## Scope\nEmbedded SUB_PROCESS tools remain the deferred refinement.\n\nCloses #631",
359
- });
360
- const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
361
- assertEquals(out.convergeBlocked, true);
362
- assertStringIncludes(out.convergeBlockReason ?? "", "Scope integrity blocked");
363
- assertStringIncludes(out.convergeBlockReason ?? "", "#631");
364
- });
365
-
366
- test("converge-gate: a deferral with a filed follow-up issue and a non-closing ref converges", async () => {
367
- const handler = await makeUnderTest({
368
- readThreads: async () => [],
369
- readReviewBody: async () => "",
370
- readPrBody: async () =>
371
- "Delivers the nested ad-hoc half.\n\n## Scope\nEmbedded SUB_PROCESS tools are deferred.\nTracked-in: #872\n\nRefs #631",
372
- });
373
- const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
374
- assertEquals(out, { convergeBlocked: false, convergeBlockReason: "" });
375
- });
376
-
377
- test("converge-gate: a full-scope Closes PR with no deferral prose converges", async () => {
378
- const handler = await makeUnderTest({
379
- readThreads: async () => [],
380
- readReviewBody: async () => "",
381
- readPrBody: async () => "Implements the feature end to end.\n\nCloses #313",
382
- });
383
- const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
384
- assertEquals(out, { convergeBlocked: false, convergeBlockReason: "" });
385
- });
386
-
387
- test("converge-gate: a scope block and a comment block are reported together", async () => {
388
- const handler = await makeUnderTest({
389
- readThreads: async () => [{ isResolved: false, path: "a.ts", bodies: ["please fix"] }],
390
- readReviewBody: async () => "",
391
- readPrBody: async () => "Ships one half.\n\nDeferred: the rest.\n\nCloses #631",
392
- });
393
- const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
394
- assertEquals(out.convergeBlocked, true);
395
- assertStringIncludes(out.convergeBlockReason ?? "", "unresolved review thread");
396
- assertStringIncludes(out.convergeBlockReason ?? "", "Scope integrity blocked");
397
- });
398
-
399
- test("converge-gate: FAILS CLOSED when the PR-body read returns null (no transport)", async () => {
400
- const handler = await makeUnderTest({
401
- readThreads: async () => [{ isResolved: true, path: "a.ts", bodies: ["ok"] }],
402
- readReviewBody: async () => "",
403
- readPrBody: async () => null,
404
- });
405
- const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
406
- assertEquals(out.convergeBlocked, true);
407
- assertStringIncludes(out.convergeBlockReason ?? "", "could not read the PR description");
408
- });
409
-
410
- test("converge-gate: FAILS CLOSED with the SCOPE reason when the PR-body read throws", async () => {
411
- // A transport failure while reading/parsing the PR body is a scope-integrity read failure, not a
412
- // review-comment verification failure: it must surface BLOCK_UNVERIFIABLE_BODY, not the generic
413
- // review-comment BLOCK_UNVERIFIABLE — otherwise the human escalation is pointed at review threads
414
- // when the real problem is the PR description could not be read.
415
- const handler = await makeUnderTest({
416
- readThreads: async () => [{ isResolved: true, path: "a.ts", bodies: ["ok"] }],
417
- readReviewBody: async () => "",
418
- readPrBody: async () => {
419
- throw new Error("boom");
420
- },
421
- });
422
- const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
423
- assertEquals(out.convergeBlocked, true);
424
- assertStringIncludes(out.convergeBlockReason ?? "", "could not read the PR description");
425
- });
426
-
427
345
  // ── Structural guard over the committed BPMN (no engine) ─────────────────────
428
346
 
429
347
  const bpmn = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
@@ -460,16 +378,108 @@ test("gw-converge-gate blocks on an explicit convergeBlocked = true condition",
460
378
  assertStringIncludes(f, "convergeBlocked = true");
461
379
  });
462
380
 
463
- test("gw-converge-gate default arm finalizes with no condition", () => {
381
+ test("gw-converge-gate default arm routes to the scope classifier (not straight to finalize)", () => {
464
382
  const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-converge-gate"[^>]*>/);
465
383
  assert(gw, "gw-converge-gate gateway missing");
466
384
  assertStringIncludes(gw[0], 'default="f_convergeOk"');
467
385
  const ok = flowElement("f_convergeOk");
468
386
  assert(ok, "f_convergeOk flow missing");
387
+ assertStringIncludes(ok, 'targetRef="classify-scope"');
388
+ assert(!/conditionExpression/.test(ok), "the default arm must carry no conditionExpression");
389
+ });
390
+
391
+ // ── The scope classifier (agent task) replaces the deterministic scope regex ──
392
+
393
+ test("classify-scope is an agent task servicing senior:scope-classify with a linked prompt", () => {
394
+ const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="classify-scope"[^>]*>.*?<\/bpmn:serviceTask>/);
395
+ assert(task, "classify-scope service task missing");
396
+ assertStringIncludes(task[0], 'type="senior:scope-classify"');
397
+ assertStringIncludes(task[0], 'resourceId="scope-classify.md"');
398
+ assertStringIncludes(task[0], 'linkName="prompt"');
399
+ // After honouring (or ignoring) the human's scope answer, it clears the one-shot
400
+ // scopeAnswer so a later round does not re-honour a stale decision.
401
+ assertStringIncludes(task[0], 'source="=null" target="scopeAnswer"');
402
+ });
403
+
404
+ // ── The scope-answer plumbing (#395 loop-defect fix) ─────────────────────────
405
+ // review-round clears `answer` on every round, so a human's scope answer cannot reach
406
+ // the downstream classify-scope via `answer`. A dedicated `scopeAnswer` variable, gated
407
+ // by a `scopePending` marker, carries the decision across the review round without being
408
+ // confused with answers to other escalation kinds.
409
+
410
+ test("PrScopeClassifyIn feeds the classifier the surviving scopeAnswer, not the cleared answer", () => {
411
+ const shape = flat.match(/<nano:shape\b[^>]*\bid="PrScopeClassifyIn"[^>]*>.*?<\/nano:shape>/);
412
+ assert(shape, "PrScopeClassifyIn envelope missing");
413
+ assertStringIncludes(shape[0], 'name="scopeAnswer"');
414
+ assert(!/name="answer"/.test(shape[0]), "classifier must read scopeAnswer, not the shared answer");
415
+ });
416
+
417
+ test("PrScopeClassifyOut.scopeBlockReason is required (always emitted, empty when not blocked)", () => {
418
+ const shape = flat.match(/<nano:shape\b[^>]*\bid="PrScopeClassifyOut"[^>]*>.*?<\/nano:shape>/);
419
+ assert(shape, "PrScopeClassifyOut envelope missing");
420
+ assert(
421
+ /name="scopeBlockReason"(?![^>]*optional)/.test(shape[0]),
422
+ "scopeBlockReason must not be optional — the wire contract requires it always present",
423
+ );
424
+ });
425
+
426
+ test("persist-escalation-scope marks the open escalation as scope-kind (scopePending = true)", () => {
427
+ const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="persist-escalation-scope"[^>]*>.*?<\/bpmn:serviceTask>/);
428
+ assert(task, "persist-escalation-scope task missing");
429
+ assertStringIncludes(task[0], 'source="=true" target="scopePending"');
430
+ });
431
+
432
+ test("record-answer captures a scope answer into scopeAnswer only while scopePending", () => {
433
+ const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="record-answer"[^>]*>.*?<\/bpmn:serviceTask>/);
434
+ assert(task, "record-answer task missing");
435
+ // Capture is gated on scopePending so an answer to a *different* escalation is not
436
+ // mis-read as a scope override; otherwise scopeAnswer is preserved.
437
+ assertStringIncludes(
438
+ task[0],
439
+ 'source="=(if scopePending = true then answer else scopeAnswer)" target="scopeAnswer"',
440
+ );
441
+ });
442
+
443
+ test("review-round resets the scopePending marker after record-answer has consumed it", () => {
444
+ const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="review-round"[^>]*>.*?<\/bpmn:serviceTask>/);
445
+ assert(task, "review-round task missing");
446
+ assertStringIncludes(task[0], 'source="=false" target="scopePending"');
447
+ });
448
+
449
+ test("classify-scope feeds gw-scope-gate, which blocks on scopeBlocked = true", () => {
450
+ const toGate = flowElement("f_toScopeGate");
451
+ assert(toGate, "f_toScopeGate flow missing");
452
+ assertStringIncludes(toGate, 'sourceRef="classify-scope"');
453
+ assertStringIncludes(toGate, 'targetRef="gw-scope-gate"');
454
+ const blocked = flowElement("f_scopeBlocked");
455
+ assert(blocked, "f_scopeBlocked flow missing");
456
+ assertStringIncludes(blocked, 'targetRef="persist-escalation-scope"');
457
+ assertStringIncludes(blocked, "scopeBlocked = true");
458
+ });
459
+
460
+ test("gw-scope-gate default arm finalizes (scope ok → persist-converged)", () => {
461
+ const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-scope-gate"[^>]*>/);
462
+ assert(gw, "gw-scope-gate gateway missing");
463
+ assertStringIncludes(gw[0], 'default="f_scopeOk"');
464
+ const ok = flowElement("f_scopeOk");
465
+ assert(ok, "f_scopeOk flow missing");
469
466
  assertStringIncludes(ok, 'targetRef="persist-converged"');
470
467
  assert(!/conditionExpression/.test(ok), "the default arm must carry no conditionExpression");
471
468
  });
472
469
 
470
+ test("the scope escalation routes through gw-escalated with the classifier's specific reason", () => {
471
+ const f = flowElement("f_scopeEscGate");
472
+ assert(f, "f_scopeEscGate flow missing");
473
+ assertStringIncludes(f, 'sourceRef="persist-escalation-scope"');
474
+ assertStringIncludes(f, 'targetRef="gw-escalated"');
475
+ const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="persist-escalation-scope"[^>]*>.*?<\/bpmn:serviceTask>/);
476
+ assert(task, "persist-escalation-scope task missing");
477
+ assertStringIncludes(task[0], 'type="pr.persist-escalation"');
478
+ assertStringIncludes(task[0], 'target="question"');
479
+ // The human sees the classifier's specific finding, not a generic boilerplate reason.
480
+ assertStringIncludes(task[0], "scopeBlockReason");
481
+ });
482
+
473
483
  test("the blocked-comments escalation routes through gw-escalated toward an answerable wait-answer", () => {
474
484
  // #333: previously this flowed UNCONDITIONALLY into wait-answer, so a blank convergeBlockReason
475
485
  // (the question is mapped from that OPTIONAL variable) opened no escalation yet still parked a
@@ -212,36 +212,3 @@ test("a control-flow arm with a blank question opens nothing so gw-escalated re-
212
212
  assertEquals(inserts.escalations.length, 0, "no dead escalation is fabricated");
213
213
  assertEquals(updates.pull_requests?.length ?? 0, 0, "the PR is never flipped to escalated");
214
214
  });
215
-
216
- // The scope-integrity arm (persist-escalation-blockedcomments) binds the escalation to the reviewed
217
- // commit (issue #395): it stamps `head_sha` and marks `scope_block` so the converge-gate can honour
218
- // a same-HEAD human answer as an override instead of re-deriving the block and re-escalating forever.
219
- test("persist-escalation binds a scope-integrity escalation to the reviewed HEAD (head_sha + scope_block)", async () => {
220
- const { app, inserts } = fakeApp();
221
- const job = {
222
- variables: {
223
- prKey: "o/r#5",
224
- round: 2,
225
- status: "blocked",
226
- question: "Scope integrity blocked: ...",
227
- recordRound: false,
228
- headSha: "HEAD1",
229
- scopeBlock: true,
230
- },
231
- };
232
- await handler(job as any, app as any);
233
- assertEquals(inserts.escalations.length, 1);
234
- assertEquals((inserts.escalations[0] as any).head_sha, "HEAD1", "the escalation carries the reviewed commit");
235
- assertEquals((inserts.escalations[0] as any).scope_block, 1, "flagged as a scope-integrity block");
236
- });
237
-
238
- // Every other escalation arm (agent verdict, no-progress, max-rounds, stalled) omits the scope
239
- // binding: head_sha stays absent and scope_block defaults to 0, so the override door opens ONLY for
240
- // the block a human can actually answer.
241
- test("persist-escalation: a non-scope escalation records no HEAD binding and scope_block 0", async () => {
242
- const { app, inserts } = fakeApp();
243
- const job = { variables: { prKey: "o/r#1", round: 3, status: "blocked", question: "max rounds" } };
244
- await handler(job as any, app as any);
245
- assertEquals((inserts.escalations[0] as any).head_sha, undefined, "no reviewed HEAD to bind");
246
- assertEquals((inserts.escalations[0] as any).scope_block, 0, "not a scope-integrity block");
247
- });
@@ -0,0 +1,16 @@
1
+ -- Contract phase for the escalation scope-override columns (migration 056, issue #395).
2
+ --
3
+ -- Migration 056 added `escalations.head_sha` + `escalations.scope_block` to give the DETERMINISTIC
4
+ -- scope-integrity gate a human-override door: it bound a scope escalation to the reviewed HEAD so a
5
+ -- same-HEAD human answer could override the block instead of re-escalating forever. That whole
6
+ -- deterministic gate has since been replaced by the `senior:scope-classify` AGENT classifier, which
7
+ -- reads each closed issue's acceptance criteria and honours the recorded human `answer` directly —
8
+ -- so nothing writes or reads these two columns any more. They are dead schema (a drift surface with
9
+ -- no source of truth behind them), so drop them.
10
+ --
11
+ -- 056 is forward-only and immutable (its ledger row stays), so this is the standard expand→contract
12
+ -- follow-up rather than an edit to 056. Both drops are safe: the columns were nullable/defaulted and
13
+ -- have no remaining writer or reader. Numbered after the current highest prefix (056); the runner
14
+ -- wraps each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
15
+ ALTER TABLE escalations DROP COLUMN scope_block;
16
+ ALTER TABLE escalations DROP COLUMN head_sha;
@@ -87,6 +87,12 @@ describe("nano-workforce PR review-loop escalation (U4 userTask)", () => {
87
87
  capturedAnswer = (job.variables as Record<string, unknown>).answer;
88
88
  return { status: "converged", summary: "resolved after the human answer" };
89
89
  });
90
+ // `senior:scope-classify` is likewise an externalTaskType (no app worker): the converged round
91
+ // routes through it before finalizing. Stub it as "scope delivered" so the happy path reaches
92
+ // persist-converged rather than parking on an unserviced agent job.
93
+ await app.engine.registerWorker("senior:scope-classify", () => {
94
+ return { scopeBlocked: false, scopeBlockReason: "" };
95
+ });
90
96
  });
91
97
 
92
98
  after(async () => {
package/nano.app.json CHANGED
@@ -214,7 +214,8 @@
214
214
  "senior:feature",
215
215
  "senior:trial-merge",
216
216
  "senior:retro",
217
- "senior:conformance"
217
+ "senior:conformance",
218
+ "senior:scope-classify"
218
219
  ],
219
220
  "surfaces": {
220
221
  "taskInbox": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.115.0",
3
+ "version": "0.116.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",