@nanobpm/nano-workforce 0.92.0 → 0.94.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.94.0](https://github.com/nanobpm/nano-workforce/compare/v0.93.0...v0.94.0) (2026-08-19)
2
+
3
+
4
+ ### Features
5
+
6
+ * **pages:** feature-runs current stage links to the process instance ([#316](https://github.com/nanobpm/nano-workforce/issues/316)) ([4984f13](https://github.com/nanobpm/nano-workforce/commit/4984f13c1a3bd101499763731843ee4807bec49e)), closes [#315](https://github.com/nanobpm/nano-workforce/issues/315) [nano-ide#347](https://github.com/nano-ide/issues/347) [nanobpm/nano-ide#348](https://github.com/nanobpm/nano-ide/issues/348) [#311](https://github.com/nanobpm/nano-workforce/issues/311) [#307](https://github.com/nanobpm/nano-workforce/issues/307)
7
+
8
+ # [0.93.0](https://github.com/nanobpm/nano-workforce/compare/v0.92.0...v0.93.0) (2026-08-19)
9
+
10
+
11
+ ### Features
12
+
13
+ * enforce scope-integrity guards in the review-convergence loop ([#314](https://github.com/nanobpm/nano-workforce/issues/314)) ([0a2a012](https://github.com/nanobpm/nano-workforce/commit/0a2a012b5d11e129de27633f1ef8508bc0fb457b)), closes [#631](https://github.com/nanobpm/nano-workforce/issues/631) [#863](https://github.com/nanobpm/nano-workforce/issues/863) [#872](https://github.com/nanobpm/nano-workforce/issues/872) [#N](https://github.com/nanobpm/nano-workforce/issues/N) [#N](https://github.com/nanobpm/nano-workforce/issues/N) [#N](https://github.com/nanobpm/nano-workforce/issues/N) [#313](https://github.com/nanobpm/nano-workforce/issues/313) [#N](https://github.com/nanobpm/nano-workforce/issues/N) [owner/repo#N](https://github.com/owner/repo/issues/N)
14
+
1
15
  # [0.92.0](https://github.com/nanobpm/nano-workforce/compare/v0.91.0...v0.92.0) (2026-08-19)
2
16
 
3
17
 
@@ -217,9 +217,12 @@ 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>;
220
221
  }) {
221
222
  const { makeHandler } = await import("../workers/converge-gate/worker.ts");
222
- return makeHandler(deps);
223
+ // Default the scope-guard PR-body read to a verified-empty description so the comment-gate tests
224
+ // below exercise only the review-comment dimension; scope-guard tests pass an explicit body.
225
+ return makeHandler({ readPrBody: async () => "", ...deps });
223
226
  }
224
227
 
225
228
  test("converge-gate: a clean PR is allowed to converge", async () => {
@@ -342,6 +345,82 @@ test("converge-gate: resolves repo/prNumber from the prKey when the vars are abs
342
345
  assertEquals(seen, ["o/r", 7]);
343
346
  });
344
347
 
348
+ // ── The scope-integrity guard through the worker (#313) ─────────────────────
349
+
350
+ test("converge-gate: a partial delivery that Closes a broader-scoped parent blocks convergence", async () => {
351
+ const handler = await makeUnderTest({
352
+ readThreads: async () => [],
353
+ readReviewBody: async () => "",
354
+ readPrBody: async () =>
355
+ "Delivers the nested ad-hoc half.\n\n## Scope\nEmbedded SUB_PROCESS tools remain the deferred refinement.\n\nCloses #631",
356
+ });
357
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
358
+ assertEquals(out.convergeBlocked, true);
359
+ assertStringIncludes(out.convergeBlockReason ?? "", "Scope integrity blocked");
360
+ assertStringIncludes(out.convergeBlockReason ?? "", "#631");
361
+ });
362
+
363
+ test("converge-gate: a deferral with a filed follow-up issue and a non-closing ref converges", async () => {
364
+ const handler = await makeUnderTest({
365
+ readThreads: async () => [],
366
+ readReviewBody: async () => "",
367
+ readPrBody: async () =>
368
+ "Delivers the nested ad-hoc half.\n\n## Scope\nEmbedded SUB_PROCESS tools are deferred.\nTracked-in: #872\n\nRefs #631",
369
+ });
370
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
371
+ assertEquals(out, { convergeBlocked: false, convergeBlockReason: "" });
372
+ });
373
+
374
+ test("converge-gate: a full-scope Closes PR with no deferral prose converges", async () => {
375
+ const handler = await makeUnderTest({
376
+ readThreads: async () => [],
377
+ readReviewBody: async () => "",
378
+ readPrBody: async () => "Implements the feature end to end.\n\nCloses #313",
379
+ });
380
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
381
+ assertEquals(out, { convergeBlocked: false, convergeBlockReason: "" });
382
+ });
383
+
384
+ test("converge-gate: a scope block and a comment block are reported together", async () => {
385
+ const handler = await makeUnderTest({
386
+ readThreads: async () => [{ isResolved: false, path: "a.ts", bodies: ["please fix"] }],
387
+ readReviewBody: async () => "",
388
+ readPrBody: async () => "Ships one half.\n\nDeferred: the rest.\n\nCloses #631",
389
+ });
390
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
391
+ assertEquals(out.convergeBlocked, true);
392
+ assertStringIncludes(out.convergeBlockReason ?? "", "unresolved review thread");
393
+ assertStringIncludes(out.convergeBlockReason ?? "", "Scope integrity blocked");
394
+ });
395
+
396
+ test("converge-gate: FAILS CLOSED when the PR-body read returns null (no transport)", async () => {
397
+ const handler = await makeUnderTest({
398
+ readThreads: async () => [{ isResolved: true, path: "a.ts", bodies: ["ok"] }],
399
+ readReviewBody: async () => "",
400
+ readPrBody: async () => null,
401
+ });
402
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
403
+ assertEquals(out.convergeBlocked, true);
404
+ assertStringIncludes(out.convergeBlockReason ?? "", "could not read the PR description");
405
+ });
406
+
407
+ test("converge-gate: FAILS CLOSED with the SCOPE reason when the PR-body read throws", async () => {
408
+ // A transport failure while reading/parsing the PR body is a scope-integrity read failure, not a
409
+ // review-comment verification failure: it must surface BLOCK_UNVERIFIABLE_BODY, not the generic
410
+ // review-comment BLOCK_UNVERIFIABLE — otherwise the human escalation is pointed at review threads
411
+ // when the real problem is the PR description could not be read.
412
+ const handler = await makeUnderTest({
413
+ readThreads: async () => [{ isResolved: true, path: "a.ts", bodies: ["ok"] }],
414
+ readReviewBody: async () => "",
415
+ readPrBody: async () => {
416
+ throw new Error("boom");
417
+ },
418
+ });
419
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
420
+ assertEquals(out.convergeBlocked, true);
421
+ assertStringIncludes(out.convergeBlockReason ?? "", "could not read the PR description");
422
+ });
423
+
345
424
  // ── Structural guard over the committed BPMN (no engine) ─────────────────────
346
425
 
347
426
  const bpmn = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
@@ -0,0 +1,147 @@
1
+ // Scope-integrity guard — unit tests for the canonical router (app/scopeGuard.ts) and its parsing
2
+ // helpers.
3
+ //
4
+ // A parity slice can be silently under-delivered: an agent splits a large slice, ships one half,
5
+ // then `Closes #N` a broader-scoped parent while recording the deferred remainder only in PR prose
6
+ // (a `## Scope` section) with no filed follow-up issue. Magikcraft/nano-bpm#631 → PR #863 did
7
+ // exactly this and the deferred half was lost until a human re-filed it as #872. These two guards
8
+ // (#313) block that class: a partial delivery may not close-keyword a broader-scoped parent, and any
9
+ // deferral must link a filed follow-up issue rather than live in prose.
10
+ import { test } from "node:test";
11
+ import { assert, assertEquals, assertStringIncludes } from "#test-assert";
12
+ import {
13
+ evaluateScopeGuard,
14
+ findClosingKeywordRefs,
15
+ hasDeferralMarker,
16
+ hasFollowupIssueRef,
17
+ } from "./scopeGuard.ts";
18
+
19
+ // ── The canonical router ────────────────────────────────────────────────────
20
+
21
+ test("evaluateScopeGuard: a full-scope PR that Closes its parent, no deferral, is allowed", () => {
22
+ const r = evaluateScopeGuard({ prBody: "Implements the feature end to end.\n\nCloses #313" });
23
+ assertEquals(r.scopeBlocked, false);
24
+ assertEquals(r.scopeBlockReason, "");
25
+ });
26
+
27
+ test("evaluateScopeGuard: a plain PR with no closing keyword and no deferral is allowed", () => {
28
+ const r = evaluateScopeGuard({ prBody: "A small refactor. Refs #10" });
29
+ assertEquals(r.scopeBlocked, false);
30
+ });
31
+
32
+ test("evaluateScopeGuard: Closes a broader parent AND defers scope → blocked (guard 1)", () => {
33
+ const r = evaluateScopeGuard({
34
+ prBody:
35
+ "Delivers the nested ad-hoc half.\n\n## Scope\nEmbedded SUB_PROCESS tools remain the deferred refinement.\n\nCloses #631",
36
+ });
37
+ assertEquals(r.scopeBlocked, true);
38
+ assertStringIncludes(r.scopeBlockReason, "must not close a broader-scoped issue");
39
+ assertStringIncludes(r.scopeBlockReason, "#631");
40
+ });
41
+
42
+ test("evaluateScopeGuard: defers scope but links NO follow-up issue → blocked (guard 2)", () => {
43
+ const r = evaluateScopeGuard({
44
+ prBody: "Ships the first half.\n\n## Scope\nThe rest is deferred.\n\nRefs #631",
45
+ });
46
+ assertEquals(r.scopeBlocked, true);
47
+ assertStringIncludes(r.scopeBlockReason, "no filed follow-up issue");
48
+ // Guard 1 must NOT fire — this PR correctly used a non-closing ref.
49
+ assert(
50
+ !r.scopeBlockReason.includes("must not close"),
51
+ "a non-closing ref must not trip the closing-keyword guard",
52
+ );
53
+ });
54
+
55
+ test("evaluateScopeGuard: defers scope AND links a filed follow-up AND uses a non-closing ref → allowed", () => {
56
+ const r = evaluateScopeGuard({
57
+ prBody:
58
+ "Ships the first half.\n\n## Scope\nThe embedded SUB_PROCESS half is deferred.\nTracked-in: #872\n\nRefs #631",
59
+ });
60
+ assertEquals(r.scopeBlocked, false);
61
+ assertEquals(r.scopeBlockReason, "");
62
+ });
63
+
64
+ test("evaluateScopeGuard: the motivating incident (Closes #631 + ## Scope + no follow-up) trips BOTH guards", () => {
65
+ const r = evaluateScopeGuard({
66
+ prBody:
67
+ "## Summary\nNested ad-hoc / agent-of-agents delivered.\n\n## Scope\nembedded `SUB_PROCESS` tools whose multi-element body runs by token flow remain the deferred refinement.\n\nCloses #631",
68
+ });
69
+ assertEquals(r.scopeBlocked, true);
70
+ assertStringIncludes(r.scopeBlockReason, "must not close a broader-scoped issue");
71
+ assertStringIncludes(r.scopeBlockReason, "no filed follow-up issue");
72
+ });
73
+
74
+ test("evaluateScopeGuard: a follow-up link alone does not excuse a closing keyword on a split", () => {
75
+ // Even with the remainder tracked, closing the broader parent is still wrong — it reads as done.
76
+ const r = evaluateScopeGuard({
77
+ prBody: "Ships half.\n\nDeferred: the rest. Follow-up: #872\n\nCloses #631",
78
+ });
79
+ assertEquals(r.scopeBlocked, true);
80
+ assertStringIncludes(r.scopeBlockReason, "must not close a broader-scoped issue");
81
+ assert(!r.scopeBlockReason.includes("no filed follow-up issue"), "the follow-up was linked");
82
+ });
83
+
84
+ test("evaluateScopeGuard: tolerates null / empty bodies", () => {
85
+ assertEquals(evaluateScopeGuard({ prBody: null }).scopeBlocked, false);
86
+ assertEquals(evaluateScopeGuard({ prBody: undefined }).scopeBlocked, false);
87
+ assertEquals(evaluateScopeGuard({ prBody: "" }).scopeBlocked, false);
88
+ });
89
+
90
+ // ── The parsers ─────────────────────────────────────────────────────────────
91
+
92
+ test("findClosingKeywordRefs: extracts bare, cross-repo, and URL closing refs; dedupes", () => {
93
+ const body = [
94
+ "Closes #12",
95
+ "fixes: owner/repo#34",
96
+ "Resolved https://github.com/owner/repo/issues/56",
97
+ "Closes #12", // duplicate
98
+ ].join("\n");
99
+ assertEquals(findClosingKeywordRefs(body), [
100
+ "#12",
101
+ "owner/repo#34",
102
+ "https://github.com/owner/repo/issues/56",
103
+ ]);
104
+ });
105
+
106
+ test("findClosingKeywordRefs: a non-closing ref (Refs / Part of) is not a closing keyword", () => {
107
+ assertEquals(findClosingKeywordRefs("Refs #12\nPart of #34\nDepends-on: #56"), []);
108
+ });
109
+
110
+ test("hasDeferralMarker: detects a ## Scope heading and deferral phrases; ignores clean prose", () => {
111
+ assert(hasDeferralMarker("## Scope\nfoo"), "a Scope heading defers");
112
+ assert(hasDeferralMarker("### scope of work"), "any heading level counts");
113
+ assert(hasDeferralMarker("The rest is deferred to later."), "'deferred' defers");
114
+ assert(hasDeferralMarker("This is out of scope for now."), "'out of scope' defers");
115
+ assert(hasDeferralMarker("The remainder is left for a follow-up."), "'remainder' defers");
116
+ assert(!hasDeferralMarker("Implements everything. Closes #1."), "clean prose does not defer");
117
+ });
118
+
119
+ test("hasDeferralMarker: a bare 'remain*' without deferral context is not a deferral", () => {
120
+ // "all done" phrasing must not be read as a scope deferral (Copilot advisory,
121
+ // app/scopeGuard.ts:48): a full-scope PR that merely reports nothing outstanding
122
+ // would otherwise be blocked from converging.
123
+ assert(!hasDeferralMarker("No issues remain.\n\nCloses #123"), "'No issues remain' is not a deferral");
124
+ assert(!hasDeferralMarker("All checks remain green."), "'remain green' is not a deferral");
125
+ assert(!hasDeferralMarker("No failing tests remaining. Closes #7"), "'remaining' alone is not a deferral");
126
+ // ...but a remainder mention near genuine deferral context still defers.
127
+ assert(hasDeferralMarker("The remaining scope is tracked separately."), "'remaining' near 'scope' defers");
128
+ assert(hasDeferralMarker("Remaining work is a follow-up."), "'remaining' near 'follow-up' defers");
129
+ });
130
+
131
+ test("hasFollowupIssueRef: only an explicit tracking marker + issue ref counts", () => {
132
+ assert(hasFollowupIssueRef("Deferred-to: #872"), "Deferred-to marker");
133
+ assert(hasFollowupIssueRef("Tracked-in: owner/repo#872"), "cross-repo tracking marker");
134
+ assert(hasFollowupIssueRef("Follow-up: #900"), "Follow-up marker");
135
+ assert(hasFollowupIssueRef("Follow up issue: #900"), "Follow up issue marker");
136
+ assert(!hasFollowupIssueRef("The rest is deferred."), "bare deferral prose is not a filed link");
137
+ assert(!hasFollowupIssueRef("Refs #631"), "a parent ref is not a remainder tracker");
138
+ // A full GitHub issue URL is a valid filed follow-up link, same as the closing-keyword parser accepts.
139
+ assert(
140
+ hasFollowupIssueRef("Deferred-to: https://github.com/owner/repo/issues/872"),
141
+ "Deferred-to marker with a full issue URL",
142
+ );
143
+ assert(
144
+ hasFollowupIssueRef("Follow-up issue: https://github.com/nanobpm/nano-workforce/issues/900"),
145
+ "Follow-up marker with a full issue URL",
146
+ );
147
+ });
@@ -0,0 +1,131 @@
1
+ // Scope-integrity gate for the review-convergence loop (issue #313).
2
+ //
3
+ // A parity slice can be silently under-delivered: an agent legitimately splits a large slice, ships
4
+ // one half, but then (a) uses a `Closes #N` closing keyword on an issue whose stated scope was
5
+ // broader than what shipped, and (b) records the deferred remainder only in PR/commit prose (a
6
+ // `## Scope` section) rather than as a filed, tracked issue. The parent then reads as fully done —
7
+ // `gh issue list` shows nothing outstanding — and downstream consumers trust "issue closed =
8
+ // capability present". This is exactly how Magikcraft/nano-bpm#631 → PR #863 (`## Scope` deferral,
9
+ // `Closes #631`, no follow-up) lost the deferred half until a human re-filed it by hand as #872.
10
+ //
11
+ // This pure router encodes the two guards proposed in #313, evaluated over the PR description body
12
+ // so the deterministic converge-gate (`workers/converge-gate`) can block a partial delivery from
13
+ // closing a broader-scoped parent, escalating to the human `wait-answer` task instead of merging:
14
+ //
15
+ // 1. Closing-keyword integrity — a PR may only carry `Closes/Fixes/Resolves #N` when it delivers
16
+ // #N's full stated scope. When the same body ALSO defers scope (a `## Scope` section /
17
+ // "deferred" / "out of scope" / "remains"), the closing keyword is flagged: the PR must instead
18
+ // use a non-closing ref (`Refs #N` / `Part of #N`) and leave #N open (or convert #N into a
19
+ // tracking issue).
20
+ // 2. Deferred ⇒ filed issue, not prose — any PR that defers part of its scope must LINK a filed
21
+ // follow-up issue for the remainder (`Deferred-to: #N` / `Tracked-in: #N` / `Follow-up: #N`). A
22
+ // deferral that exists only in commit/ADR/PR text is a drift surface (invisible, unclaimable
23
+ // work); mirror the repo's "no drift surfaces" rule, applied to scope.
24
+ //
25
+ // Both guards fire only when the body actually DEFERS scope, so a full-scope `Closes #N` PR with no
26
+ // deferral prose passes untouched.
27
+
28
+ export interface ScopeGuardInput {
29
+ /** The PR description body (the text `gh pr create --body` set). */
30
+ prBody: string | null | undefined;
31
+ }
32
+
33
+ export interface ScopeGuardResult {
34
+ scopeBlocked: boolean;
35
+ scopeBlockReason: string;
36
+ }
37
+
38
+ // GitHub's closing keywords (close/closes/closed, fix/fixes/fixed, resolve/resolves/resolved)
39
+ // followed by an issue ref: a bare `#123`, a cross-repo `owner/repo#123`, or a full issue URL. The
40
+ // keyword and ref may be separated by whitespace and/or a colon (`Closes: #1`, `Closes #1`).
41
+ const CLOSING_KEYWORD =
42
+ /\b(close[sd]?|fix(?:e[sd])?|resolve[sd]?)[\s:]+(?:https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/\d+|(?:[\w.-]+\/[\w.-]+)?#\d+)/gi;
43
+
44
+ // A body DEFERS scope when it carries a `## Scope` (any heading level) section OR names a deferral in
45
+ // prose. A bare `remain*`/`remainder` is deliberately NOT enough on its own — normal "all done"
46
+ // phrasing ("No issues remain.", "no failing tests remaining") uses it without deferring any scope,
47
+ // and flagging that would block a full-scope PR from converging. A remainder mention only defers when
48
+ // a deferral-context term (scope / follow-up / later / to-do / tracking) sits near it; the incident's
49
+ // honest deferral read "…remain the deferred refinement", which the explicit `defer*` branch catches.
50
+ const DEFERRAL_HEADING = /^#{1,6}\s+scope\b/im;
51
+ const DEFERRAL_PHRASE = /\bdefer(?:s|red|ral|ring)?\b|\bout[- ]of[- ]scope\b/i;
52
+ const REMAINDER_WORD = /\bremain(?:s|der|ing)?\b/gi;
53
+ const REMAINDER_CONTEXT = /\b(?:scope|follow[- ]?ups?|later|to[- ]?dos?|track(?:s|ed|ing)?|next[- ]steps?)\b/i;
54
+ const REMAINDER_WINDOW = 48;
55
+
56
+ // Whether any `remain*` mention sits within a short window of a deferral-context term.
57
+ function remainderDefersScope(text: string): boolean {
58
+ for (const m of text.matchAll(REMAINDER_WORD)) {
59
+ const idx = m.index ?? 0;
60
+ const window = text.slice(Math.max(0, idx - REMAINDER_WINDOW), idx + m[0].length + REMAINDER_WINDOW);
61
+ if (REMAINDER_CONTEXT.test(window)) {
62
+ return true;
63
+ }
64
+ }
65
+ return false;
66
+ }
67
+
68
+ // A FILED follow-up issue link for the deferred remainder: an explicit tracking marker followed by
69
+ // an issue ref — a bare `#123`, a cross-repo `owner/repo#123`, or a full issue URL (matching the
70
+ // closing-keyword parser, so an explicit `https://github.com/<owner>/<repo>/issues/<n>` link counts
71
+ // as a tracked follow-up rather than being mistaken for untracked prose). This is the
72
+ // machine-checkable contract feature.md asks split slices to emit.
73
+ const FOLLOWUP_MARKER =
74
+ /\b(?:deferred[- ]to|tracked[- ]in|tracking issue|follow[- ]?ups?(?:\s+issue)?)\b[\s:]*(?:https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/\d+|(?:[\w.-]+\/[\w.-]+)?#\d+)/i;
75
+
76
+ /** The distinct issue refs a body closes via a GitHub closing keyword, in first-seen order. */
77
+ export function findClosingKeywordRefs(body: string | null | undefined): string[] {
78
+ const text = body ?? "";
79
+ const refs: string[] = [];
80
+ const seen = new Set<string>();
81
+ for (const m of text.matchAll(CLOSING_KEYWORD)) {
82
+ const ref = m[0].slice(m[1].length).replace(/^[\s:]+/, "").trim();
83
+ if (!seen.has(ref)) {
84
+ seen.add(ref);
85
+ refs.push(ref);
86
+ }
87
+ }
88
+ return refs;
89
+ }
90
+
91
+ /** Whether the body defers part of its scope (a `## Scope` section or a deferral phrase). */
92
+ export function hasDeferralMarker(body: string | null | undefined): boolean {
93
+ const text = body ?? "";
94
+ return DEFERRAL_HEADING.test(text) || DEFERRAL_PHRASE.test(text) || remainderDefersScope(text);
95
+ }
96
+
97
+ /** Whether the body links a filed follow-up issue for the deferred remainder. */
98
+ export function hasFollowupIssueRef(body: string | null | undefined): boolean {
99
+ return FOLLOWUP_MARKER.test(body ?? "");
100
+ }
101
+
102
+ /** Decide whether a PR's scope framing is safe to converge/merge. Pure; the worker feeds it the
103
+ * live PR body and fails CLOSED (blocks) when that body cannot be read. */
104
+ export function evaluateScopeGuard(input: ScopeGuardInput): ScopeGuardResult {
105
+ const body = input.prBody ?? "";
106
+ const defers = hasDeferralMarker(body);
107
+ const reasons: string[] = [];
108
+
109
+ if (defers) {
110
+ const closing = findClosingKeywordRefs(body);
111
+ if (closing.length > 0) {
112
+ const noun = closing.length === 1 ? "issue" : "issues";
113
+ reasons.push(
114
+ `this PR defers part of its scope yet closing-keywords ${noun} ${closing.join(", ")} — a partial delivery must not close a broader-scoped issue; use a non-closing ref (Refs #N / Part of #N) and leave it open (or convert it into a tracking issue)`,
115
+ );
116
+ }
117
+ if (!hasFollowupIssueRef(body)) {
118
+ reasons.push(
119
+ "this PR defers part of its scope but links no filed follow-up issue for the remainder — file a tracking issue for each deferred item and link it (Deferred-to: #N / Tracked-in: #N / Follow-up: #N) so the remainder is tracked, not left in PR prose",
120
+ );
121
+ }
122
+ }
123
+
124
+ if (reasons.length === 0) {
125
+ return { scopeBlocked: false, scopeBlockReason: "" };
126
+ }
127
+ return {
128
+ scopeBlocked: true,
129
+ scopeBlockReason: `Scope integrity blocked: ${reasons.join("; ")}.`,
130
+ };
131
+ }
@@ -18,7 +18,8 @@
18
18
  -- The backfill coalesces existing rows to `subject_key` (matching the write-time coalesce
19
19
  -- in `pollUserTasks`, which re-derives the real title in place on the next poll — a
20
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
21
+ -- set. Renumbered from 043 to 046 to resolve an apply-order prefix collision with
22
+ -- 043_pr_epic_phase.sql (both landed ~concurrently on main); the runner wraps
22
23
  -- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
23
24
  ALTER TABLE user_tasks ADD COLUMN subject_title TEXT;
24
25
  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.92.0",
3
+ "version": "0.94.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",
@@ -89,7 +89,7 @@
89
89
  "stateField": "stage_state",
90
90
  "badgeField": "attention",
91
91
  "notInPathField": "stage_skipped",
92
- "locus": { "field": "pr_key", "link": { "kind": "page", "page": "home", "keyField": "pr_key" } }
92
+ "locus": { "field": "process_key", "link": { "kind": "processExplorer", "keyField": "process_key" } }
93
93
  },
94
94
  { "field": "updated_at", "header": "Updated", "width": "9rem" }
95
95
  ],
@@ -80,9 +80,38 @@ against the wrong base will not be merged into the epic.
80
80
  3. Implement `task.prompt`. Keep the change scoped to this slice only.
81
81
  4. Commit (sign off — this repo family enforces DCO: `git commit -s`), push the
82
82
  branch, and open a pull request with `gh pr create` describing the slice and
83
- linking the parent issue (`Depends-on:`/`Closes` as appropriate).
83
+ linking the parent issue (`Depends-on:`/`Closes` as appropriate — but read the
84
+ scope-split rule below before you reach for `Closes`).
84
85
  5. Clean up any scratch clone/worktree you created outside the commit.
85
86
 
87
+ ## Closing keywords vs. scope splits — don't close a broader-scoped parent
88
+
89
+ The convergence loop runs a deterministic **scope-integrity gate** on your PR
90
+ before it can merge (`workers/converge-gate` → `app/scopeGuard.ts`). It exists
91
+ because a parity slice was once silently under-delivered: an agent shipped one
92
+ half, documented the deferred remainder honestly in a `## Scope` section, yet
93
+ still `Closes #N`'d the broader parent and filed **no** follow-up. The issue read
94
+ as done, `gh issue list` showed nothing outstanding, and a downstream consumer was
95
+ blocked on exactly the deferred half. Two rules keep that from recurring — the
96
+ gate **blocks and escalates to a human** if you break either:
97
+
98
+ 1. **A `Closes/Fixes/Resolves #N` closing keyword means you delivered #N's FULL
99
+ stated scope.** If you split scope — shipping only part and deferring the rest
100
+ — do **not** close-keyword the parent. Use a non-closing ref instead
101
+ (`Refs #N` / `Part of #N`) and **leave #N open** (or convert #N into a
102
+ tracking/umbrella issue for the remainder). The gate flags any PR that both
103
+ closes #N and also contains deferral prose (a `## Scope` section, "deferred",
104
+ "out of scope").
105
+ 2. **A deferred remainder must be a FILED, tracked issue — never just prose.** If
106
+ your PR defers part of its scope, **file a follow-up issue for each deferred
107
+ item** and link it in the PR body with an explicit tracking marker the gate can
108
+ see: `Deferred-to: #N`, `Tracked-in: #N`, or `Follow-up: #N`. A deferral that
109
+ lives only in commit/PR/ADR text is an invisible, unclaimable drift surface.
110
+
111
+ So: deliver the whole thing → `Closes #N`. Split it → `Refs #N`, file the
112
+ remainder, and link it with `Deferred-to: #<new-issue>`.
113
+
114
+
86
115
  > **Do not request the Copilot review yourself.** When you open a *ready* PR the
87
116
  > app enrolls it into the review-convergence loop and requests the initial
88
117
  > Copilot review for you. In particular, **never escalate because Copilot is
@@ -11,6 +11,14 @@
11
11
  // A blocked gate returns `convergeBlocked = true`; the model's `gw-converge-gate` gateway routes to
12
12
  // the human `wait-answer` escalation (recoverable), never a hard wedge.
13
13
  //
14
+ // It ALSO enforces the scope-integrity guards (#313) over the PR description, blocking handoff when
15
+ // the PR under-delivers a broader-scoped parent:
16
+ // • a partial delivery that `Closes/Fixes/Resolves #N` while ALSO deferring scope (a `## Scope`
17
+ // section / "deferred" / "out of scope"), or
18
+ // • a deferral recorded only in PR prose with no filed follow-up issue linked for the remainder.
19
+ // This is the enforcement backstop for the Magikcraft/nano-bpm#631 → PR #863 (`Closes #631`, `##
20
+ // Scope` deferral, no follow-up → re-filed by hand as #872) failure class. See app/scopeGuard.ts.
21
+ //
14
22
  // It FAILS CLOSED: if the live GitHub state cannot be read, it blocks (escalates) rather than
15
23
  // letting an unverifiable "converged" through — the opposite of the no-progress guard, because a
16
24
  // merge-gating check must escalate-on-uncertainty so #770 cannot recur.
@@ -18,11 +26,13 @@ import type { AppJobHandler } from "@nanobpm/urban";
18
26
  import { type ConvergeGateResult, evaluateConvergeGate } from "../../app/convergeGate.ts";
19
27
  import {
20
28
  fetchLatestCopilotReviewBody,
29
+ fetchPrMeta,
21
30
  fetchReviewThreads,
22
31
  parseAckedAdvisories,
23
32
  parseSuppressedAdvisories,
24
33
  type ReviewThread,
25
34
  } from "../../app/github.ts";
35
+ import { evaluateScopeGuard } from "../../app/scopeGuard.ts";
26
36
  import { parsePr } from "../../app/service.ts";
27
37
  import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io.d.ts";
28
38
 
@@ -37,20 +47,31 @@ export type ThreadsReader = (repo: string, prNumber: number) => Promise<ReviewTh
37
47
  // Reads the latest Copilot review body. `null` = no usable transport (unverifiable → fail closed);
38
48
  // `""` = transport usable but no Copilot review yet (verified: no suppressed advisories).
39
49
  export type ReviewBodyReader = (repo: string, prNumber: number) => Promise<string | null>;
50
+ // Reads the PR's own description body. `null` = no usable transport (unverifiable → fail closed);
51
+ // `""` = transport usable but the PR has an empty description (verified: nothing to scope-check).
52
+ export type PrBodyReader = (repo: string, prNumber: number) => Promise<string | null>;
40
53
 
41
54
  const defaultReadThreads: ThreadsReader = (repo, prNumber) =>
42
55
  fetchReviewThreads(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
43
56
  const defaultReadReviewBody: ReviewBodyReader = (repo, prNumber) =>
44
57
  fetchLatestCopilotReviewBody(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
58
+ const defaultReadPrBody: PrBodyReader = async (repo, prNumber) => {
59
+ const meta = await fetchPrMeta(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
60
+ return meta ? meta.body : null;
61
+ };
45
62
 
46
63
  const BLOCK_UNVERIFIABLE =
47
64
  "Convergence blocked: could not verify the PR's review comments against GitHub. A human must confirm every Copilot review thread is resolved and every suppressed advisory acknowledged before this PR converges (reply to resume the loop).";
48
65
 
66
+ const BLOCK_UNVERIFIABLE_BODY =
67
+ "Convergence blocked: could not read the PR description from GitHub to verify scope integrity. A human must confirm this PR does not close a broader-scoped parent with an untracked deferred remainder before it converges (reply to resume the loop).";
68
+
49
69
  /** Build the handler with injectable GitHub readers. The default export binds the real readers;
50
70
  * tests inject stubs. Fails CLOSED — any unreadable/errored state blocks convergence. */
51
71
  export function makeHandler(deps: {
52
72
  readThreads: ThreadsReader;
53
73
  readReviewBody: ReviewBodyReader;
74
+ readPrBody: PrBodyReader;
54
75
  }): AppJobHandler<In, Out> {
55
76
  return async (job) => {
56
77
  const { prKey, repo, prNumber } = job.variables;
@@ -64,6 +85,7 @@ export function makeHandler(deps: {
64
85
  }
65
86
 
66
87
  let result: ConvergeGateResult;
88
+ let scopeReason: string;
67
89
  try {
68
90
  const threads = await deps.readThreads(ghRepo, ghNumber);
69
91
  // A null threads read is an unverifiable gate — fail closed. (An empty ARRAY is a verified
@@ -87,9 +109,30 @@ export function makeHandler(deps: {
87
109
  return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
88
110
  }
89
111
 
112
+ // The scope-integrity guard (#313) reads/parses the PR description in its OWN try — a transport
113
+ // or parse failure here is a scope read failure, so it must surface BLOCK_UNVERIFIABLE_BODY, not
114
+ // the review-comment BLOCK_UNVERIFIABLE above. Sharing one catch would mislabel a description
115
+ // read failure as a review-thread verification failure and point the human escalation at the
116
+ // wrong place.
117
+ try {
118
+ // The PR description drives the scope-integrity guard (#313). A null read is unverifiable —
119
+ // fail closed with a scope-specific reason. (An empty STRING is a verified empty description:
120
+ // no closing keyword, no deferral, so the scope guard passes.)
121
+ const prBody = await deps.readPrBody(ghRepo, ghNumber);
122
+ if (prBody === null) {
123
+ return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE_BODY };
124
+ }
125
+ scopeReason = evaluateScopeGuard({ prBody }).scopeBlockReason;
126
+ } catch {
127
+ return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE_BODY };
128
+ }
129
+
130
+ // Both guards gate the same handoff to the merge loop: block if EITHER the review-comment gate
131
+ // or the scope-integrity gate blocks, joining their reasons so the human sees every cause.
132
+ const reason = [result.convergeBlockReason, scopeReason].filter((r) => r !== "").join(" ");
90
133
  return {
91
- convergeBlocked: result.convergeBlocked,
92
- convergeBlockReason: result.convergeBlockReason,
134
+ convergeBlocked: result.convergeBlocked || scopeReason !== "",
135
+ convergeBlockReason: reason,
93
136
  };
94
137
  };
95
138
  }
@@ -97,5 +140,6 @@ export function makeHandler(deps: {
97
140
  const handler = makeHandler({
98
141
  readThreads: defaultReadThreads,
99
142
  readReviewBody: defaultReadReviewBody,
143
+ readPrBody: defaultReadPrBody,
100
144
  });
101
145
  export default handler;