@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.
@@ -0,0 +1,142 @@
1
+ # Scope-integrity classifier — one verdict per converged PR
2
+
3
+ You are an autonomous engineer acting as a **scope-integrity classifier** for a
4
+ GitHub pull request that has just passed the deterministic review-comment gate
5
+ (every Copilot thread resolved, every advisory acknowledged). Your one job this
6
+ activation is to decide **whether this PR is safe to converge on scope grounds**,
7
+ then return a structured verdict. You do **not** review code, run tests, or push
8
+ anything — you read, judge, and report.
9
+
10
+ ## Why you exist (replaces a regex)
11
+
12
+ A previous version of this gate was a regex: it blocked any PR whose body carried a
13
+ `Closes/Fixes/Resolves #N` **and** any deferral word (`deferred`, `out of scope`, a
14
+ `## Scope` section) without a linked follow-up issue. That regex could not read the
15
+ closed issue, so it false-positived on PRs that merely *mention* deferral (an ADR
16
+ non-goal that says "deferred", or a PR whose subject is scope tooling) and forced
17
+ needless human escalations. You replace it with a real judgment: **read the closed
18
+ issue's stated scope and compare it against what the PR delivers.**
19
+
20
+ ## The failure class you protect against
21
+
22
+ A partial delivery silently under-delivers a broader-scoped parent: an agent splits a
23
+ large slice, ships one half, `Closes #N` an issue whose stated scope was broader than
24
+ what shipped, and records the deferred remainder only in PR/commit prose (never a
25
+ filed, tracked issue). The parent then reads as fully done — `gh issue list` shows
26
+ nothing outstanding — and downstream consumers trust "issue closed = capability
27
+ present". That lost work is what you must catch.
28
+
29
+ ## Job input (`job.variables`)
30
+
31
+ | var | meaning |
32
+ |------------|---------------------------------------------------------------------|
33
+ | `prUrl` | canonical PR URL |
34
+ | `repo` | `owner/name` |
35
+ | `prNumber` | PR number |
36
+ | `scopeAnswer` | present only when resuming from a scope escalation you raised — a human's decision |
37
+ | `prompt` | this document |
38
+
39
+ ## Abort if the run was cancelled
40
+
41
+ A human can **cancel** this run while you work. An **"Abort if this run was
42
+ cancelled"** protocol with a status URL is appended below. You produce no side
43
+ effects (no push, no PR edit), so a cancel is cheap — but still **stop immediately**
44
+ if the status check fails or reports `"abandoned": true`, and do not bother writing a
45
+ result.
46
+
47
+ ## What to do
48
+
49
+ 1. **Read the PR body.** `gh pr view <prNumber> --repo <repo> --json body,title`.
50
+ Extract every issue the body **closes with a GitHub closing keyword** —
51
+ `close/closes/closed`, `fix/fixes/fixed`, `resolve/resolves/resolved` followed by
52
+ `#N`, `owner/repo#N`, or a full issue URL. A **non-closing** reference (`Refs #N`,
53
+ `Part of #N`, `Depends-on #N`, `Follow-up: #N`) does **not** close an issue —
54
+ ignore those for the closing-scope check (but note the follow-up links; see below).
55
+
56
+ 2. **If there are no closing-keyword issues, the PR closes nothing** — there is no
57
+ broader-scoped parent to under-deliver. Return **`scopeBlocked: false`** and stop.
58
+
59
+ 3. **For each closed issue, read its stated scope.**
60
+ `gh issue view <N> --repo <repo> --json title,body`. Read its acceptance
61
+ criteria / definition of done. If the issue lives in another repo
62
+ (`owner/repo#N`), pass that repo.
63
+
64
+ 4. **Judge under-delivery.** For each closed issue, decide: **does this PR actually
65
+ deliver that issue's full stated scope?** Compare the issue's acceptance criteria
66
+ against what the PR's diff and body demonstrably deliver (`gh pr view --json files`,
67
+ `gh pr diff` if you need to confirm). Block **only** when the PR genuinely leaves
68
+ part of a *closed* issue's stated scope undelivered, with that remainder **not**
69
+ tracked by a filed, linked follow-up issue.
70
+
71
+ Explicitly **do NOT block** on any of these — they are the false positives that
72
+ motivated this classifier:
73
+ - The PR **fully delivers** the closed issue's acceptance, even if the body
74
+ discusses future work, sibling slices, or an epic. Full delivery + a `Closes` is
75
+ exactly correct.
76
+ - The deferred thing is an **explicit non-goal** of the issue/ADR (e.g. "real I/O
77
+ deferred per ADR non-goals") — a declared boundary the issue never promised, not
78
+ under-delivery.
79
+ - The deferred remainder **is** tracked: the body links a filed follow-up
80
+ (`Follow-up: #M`, `Tracked-in: #M`, `Deferred-to: #M`) or names sibling slice
81
+ issues that are themselves filed and open/closed.
82
+ - The body merely **mentions or describes** deferral/scope as its subject matter
83
+ (e.g. a PR that changes scope tooling) without the PR itself deferring a closed
84
+ issue's scope.
85
+
86
+ 5. **Honor a human decision.** If `scopeAnswer` is present, a human already ruled on a
87
+ scope escalation you raised. Treat their decision as authoritative: unless the PR
88
+ *now* clearly still under-delivers a closed issue with an untracked remainder (e.g.
89
+ they told you to proceed but the closing keyword and gap are both still there and
90
+ they did not say to close it manually), return **`scopeBlocked: false`** and record
91
+ in `scopeBlockReason`/summary that you deferred to the human. Do **not** re-raise
92
+ the identical escalation a human already answered — that is the loop defect (#395)
93
+ you must not reproduce.
94
+
95
+ ## Verdict (job result variables)
96
+
97
+ Return **exactly** these two variables:
98
+
99
+ | var | type | meaning |
100
+ |-------------------|---------|------------------------------------------------------------|
101
+ | `scopeBlocked` | boolean | `true` only when a closed issue is genuinely under-delivered with an untracked remainder |
102
+ | `scopeBlockReason`| string | when blocked: the **specific** finding — which issue, which acceptance criteria are unmet, and what the human should do (file+link a tracker and downgrade `Closes #N`→`Part of #N`, or reword). Empty string when not blocked. |
103
+
104
+ Make `scopeBlockReason` **actionable and specific** — name the issue number, quote or
105
+ paraphrase the unmet acceptance criterion, and state the fix. Never emit the old
106
+ generic "this PR defers part of its scope"; that opacity is the whole reason you
107
+ exist.
108
+
109
+ ### How to return it (the wire mechanism)
110
+
111
+ Your result only reaches the process through the harness's result channel — prose in
112
+ your output is **not** parsed. Emit a machine-readable result one of two ways:
113
+
114
+ 1. **Write a flat JSON object to `$AGENT_RESULT_FILE`** (an env var the harness sets),
115
+ once, at the very end. Example (not blocked):
116
+
117
+ ```sh
118
+ printf '%s' '{"scopeBlocked":false,"scopeBlockReason":""}' > "$AGENT_RESULT_FILE"
119
+ ```
120
+
121
+ Blocked example:
122
+
123
+ ```sh
124
+ printf '%s' '{"scopeBlocked":true,"scopeBlockReason":"#412 requires both the read AND write projection (acceptance criteria 2 + 3); this PR ships only the read side and defers the write projection with no filed tracker. File a follow-up issue for the write projection, link it (Follow-up: #N), and downgrade Closes #412 -> Part of #412 (close #412 by hand only when the write side lands)."}' > "$AGENT_RESULT_FILE"
125
+ ```
126
+
127
+ 2. **Fallback** (only if you cannot write the file): print a single last line to
128
+ stdout of the form `::nano:result:: {json}` — e.g.
129
+
130
+ ```
131
+ ::nano:result:: {"scopeBlocked":false,"scopeBlockReason":""}
132
+ ```
133
+
134
+ The harness reads the **last** such line; a trailing fenced JSON block is a last
135
+ resort.
136
+
137
+ **Emitting a machine-readable result is your mandatory final step — never exit
138
+ silently.** Exit `0` on every path (a non-zero exit means a crash and the job is
139
+ retried). If you are genuinely unable to reach a confident verdict, prefer
140
+ **`scopeBlocked: false`** with a `scopeBlockReason` explaining the uncertainty:
141
+ convergence here is recoverable (a human still reviews the merge), whereas a
142
+ false block re-introduces exactly the needless escalation this classifier removes.
@@ -122,8 +122,8 @@ test("convergence-loop golden has arbitrary-graph features the structured builde
122
122
  assertEquals(between("review-round", "serviceTask", "incoming"), 3, "review-round should merge 3 flows on the task itself");
123
123
  // (b) a single exclusive gateway forks FOUR heterogeneous-condition out-edges.
124
124
  assertEquals(between("gw-status", "exclusiveGateway", "outgoing"), 4, "gw-status should be a 4-way exclusive gateway");
125
- // (c) a single exclusive gateway is at once a 5-way merge and a 2-way split.
126
- assertEquals(between("gw-escalated", "exclusiveGateway", "incoming"), 5, "gw-escalated should merge 5 flows");
125
+ // (c) a single exclusive gateway is at once a 6-way merge and a 2-way split.
126
+ assertEquals(between("gw-escalated", "exclusiveGateway", "incoming"), 6, "gw-escalated should merge 6 flows");
127
127
  assertEquals(between("gw-escalated", "exclusiveGateway", "outgoing"), 2, "gw-escalated should also split 2 ways");
128
128
  });
129
129
 
@@ -171,7 +171,7 @@ export const PORTS: readonly PortEntry[] = [
171
171
  "directly (in=3), but loop() always inserts an exclusive-gateway head " +
172
172
  "(task stays in=1); `gw-status` is one gateway with 4 heterogeneous-" +
173
173
  "condition out-edges (no switch/branch emits that); `gw-escalated` is one " +
174
- "gateway that is at once a 5-way merge and a 2-way split. Awaits an " +
174
+ "gateway that is at once a 6-way merge and a 2-way split. Awaits an " +
175
175
  "arbitrary-graph / explicit-join (named-target) builder upstream in " +
176
176
  "@nanobpm/workflow (nano-ide) — a superset of the multi-start/end gap.",
177
177
  },
@@ -3,30 +3,22 @@
3
3
  // The loop declares convergence on the review-round AGENT's self-reported `status = "converged"`.
4
4
  // That trusts the agent to only converge once every Copilot comment is addressed — which failed on
5
5
  // Magikcraft/nano-bpm#770 (20 rounds, a suppressed advisory never applied, then auto-merged with
6
- // the comment unaddressed). This step runs on the converged path, BEFORE pr.finalize hands off to
7
- // the merge loop, and blocks handoff while GitHub still shows unaddressed comments:
6
+ // the comment unaddressed). This step runs on the converged path, BEFORE the scope classifier and
7
+ // pr.finalize hand off to the merge loop, and blocks handoff while GitHub still shows unaddressed
8
+ // comments:
8
9
  // • any review THREAD is still unresolved (GraphQL `isResolved = false`), or
9
10
  // • any SUPPRESSED advisory in the latest Copilot review body lacks a matching RESOLVED ack
10
11
  // thread (a `nano-ack: <path>:<line>` marker copied from Copilot's `**path:line**` header).
11
12
  // A blocked gate returns `convergeBlocked = true`; the model's `gw-converge-gate` gateway routes to
12
13
  // the human `wait-answer` escalation (recoverable), never a hard wedge.
13
14
  //
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
- //
22
- // The scope-integrity block also carries a HUMAN-OVERRIDE door (#395): before it re-blocks, it
23
- // reads the commit now under review and consults the `escalations` answer bound to that SAME HEAD.
24
- // An operator who answered the scope question for this exact commit ("this fully delivers the issue
25
- // — keep the closing keyword") has explicitly overridden it, so the gate honours that answer
26
- // (audited) instead of re-deriving `scopeBlocked` from the PR body and re-escalating the identical
27
- // question forever. Binding to the HEAD sha keeps the override from carrying across a new push, and
28
- // (via `PrConvergeGateOut.headSha`/`scopeBlocked`) lets `persist-escalation-blockedcomments` stamp
29
- // the escalation with the reviewed commit so the door can open on the next round.
15
+ // Scope integrity is NO LONGER judged here. A deterministic regex over the PR description could not
16
+ // read the closed issue's acceptance criteria, so it false-positived on any body that merely
17
+ // *mentioned* deferral (an ADR non-goal, a PR whose subject is scope tooling) and forced needless
18
+ // human escalations. That judgment now lives in the `classify-scope` agent task (job type
19
+ // `senior:scope-classify`, prompt `resources/prompts/scope-classify.md`), which runs immediately
20
+ // after this gate on the converged path and reads each closed issue's stated scope. This worker's
21
+ // sole responsibility is the review-comment gate.
30
22
  //
31
23
  // It FAILS CLOSED: if the live GitHub state cannot be read, it blocks (escalates) rather than
32
24
  // letting an unverifiable "converged" through — the opposite of the no-progress guard, because a
@@ -35,14 +27,11 @@ import type { AppJobHandler } from "@nanobpm/urban";
35
27
  import { type ConvergeGateResult, evaluateConvergeGate } from "../../app/convergeGate.ts";
36
28
  import {
37
29
  fetchLatestCopilotReviewBody,
38
- fetchPrHead,
39
- fetchPrMeta,
40
30
  fetchReviewThreads,
41
31
  parseAckedAdvisories,
42
32
  parseSuppressedAdvisories,
43
33
  type ReviewThread,
44
34
  } from "../../app/github.ts";
45
- import { evaluateScopeGuard, isScopeOverridden, type ScopeEscalationAnswer } from "../../app/scopeGuard.ts";
46
35
  import { parsePr } from "../../app/service.ts";
47
36
  import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io.d.ts";
48
37
 
@@ -57,82 +46,22 @@ export type ThreadsReader = (repo: string, prNumber: number) => Promise<ReviewTh
57
46
  // Reads the latest Copilot review body. `null` = no usable transport (unverifiable → fail closed);
58
47
  // `""` = transport usable but no Copilot review yet (verified: no suppressed advisories).
59
48
  export type ReviewBodyReader = (repo: string, prNumber: number) => Promise<string | null>;
60
- // Reads the PR's own description body. `null` = no usable transport (unverifiable → fail closed);
61
- // `""` = transport usable but the PR has an empty description (verified: nothing to scope-check).
62
- export type PrBodyReader = (repo: string, prNumber: number) => Promise<string | null>;
63
- // Reads the PR's current HEAD sha (the commit under review). `null` = unreadable/no transport — the
64
- // scope override cannot be verified or bound to a commit, so the gate keeps blocking (fail closed).
65
- export type HeadShaReader = (repo: string, prNumber: number) => Promise<string | null>;
66
49
 
67
50
  const defaultReadThreads: ThreadsReader = (repo, prNumber) =>
68
51
  fetchReviewThreads(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
69
52
  const defaultReadReviewBody: ReviewBodyReader = (repo, prNumber) =>
70
53
  fetchLatestCopilotReviewBody(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
71
- const defaultReadPrBody: PrBodyReader = async (repo, prNumber) => {
72
- const meta = await fetchPrMeta(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
73
- return meta ? meta.body : null;
74
- };
75
- const defaultReadHeadSha: HeadShaReader = async (repo, prNumber) => {
76
- const head = await fetchPrHead(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
77
- return head ? head.headSha : null;
78
- };
79
54
 
80
55
  const BLOCK_UNVERIFIABLE =
81
56
  "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).";
82
57
 
83
- const BLOCK_UNVERIFIABLE_BODY =
84
- "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).";
85
-
86
- // An `escalations` row as this worker reads it back when looking for a recorded human override.
87
- interface EscalationRow extends Record<string, unknown> {
88
- id: number;
89
- head_sha: string | null;
90
- answer: string | null;
91
- scope_block: number | boolean | null;
92
- }
93
-
94
- // Find the newest ANSWERED scope-integrity escalation for this PR whose recorded HEAD matches the
95
- // commit now under review (issue #395). This is the human-override door: `persist-escalation` binds
96
- // a scope block to the HEAD it was raised against, `answer-escalation` marks the row `answered`, and
97
- // here we honour that answer for the SAME HEAD so the gate stops re-deriving `scopeBlocked` from the
98
- // PR body and re-escalating the identical question forever. Newest-first so a re-escalated-then-
99
- // answered duplicate resolves to the operator's latest reply. Returns `null` on any read failure —
100
- // the caller then keeps the block (fail closed), never fabricates an override.
101
- async function findScopeOverride(
102
- app: Parameters<AppJobHandler<In, Out>>[1],
103
- prKey: string,
104
- headSha: string,
105
- ): Promise<ScopeEscalationAnswer | null> {
106
- try {
107
- // `scope_block` is a first-class column (persist-escalation writes it as 0/1), so filter on it
108
- // in the query rather than reading every answered escalation and filtering in memory — a PR with
109
- // many answered non-scope escalations no longer loads them all just to discard them.
110
- const rows = await app.data.table<EscalationRow>("escalations", "id").find({
111
- pr_key: prKey,
112
- status: "answered",
113
- scope_block: 1,
114
- });
115
- const scoped = rows
116
- .map((r) => ({ escalationId: Number(r.id), headSha: r.head_sha ?? null, answer: r.answer ?? null }))
117
- .sort((a, b) => (b.escalationId ?? 0) - (a.escalationId ?? 0));
118
- for (const candidate of scoped) {
119
- if (isScopeOverridden(headSha, candidate)) return candidate;
120
- }
121
- return null;
122
- } catch {
123
- return null;
124
- }
125
- }
126
-
127
58
  /** Build the handler with injectable GitHub readers. The default export binds the real readers;
128
59
  * tests inject stubs. Fails CLOSED — any unreadable/errored state blocks convergence. */
129
60
  export function makeHandler(deps: {
130
61
  readThreads: ThreadsReader;
131
62
  readReviewBody: ReviewBodyReader;
132
- readPrBody: PrBodyReader;
133
- readHeadSha: HeadShaReader;
134
63
  }): AppJobHandler<In, Out> {
135
- return async (job, app) => {
64
+ return async (job) => {
136
65
  const { prKey, repo, prNumber } = job.variables;
137
66
  // `parsePr` is total on any input (fails closed to `null` on a missing/non-string prKey), so
138
67
  // pass it straight through — a malformed prKey degrades to the fail-closed target check below.
@@ -142,12 +71,8 @@ export function makeHandler(deps: {
142
71
  if (!ghRepo || typeof ghNumber !== "number") {
143
72
  return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
144
73
  }
145
- // The canonical escalations key. Prefer the carried prKey; fall back to the parsed identity so
146
- // the override lookup still keys off `owner/repo#N` when only repo/prNumber survived.
147
- const escPrKey = typeof prKey === "string" && prKey !== "" ? prKey : `${ghRepo}#${ghNumber}`;
148
74
 
149
75
  let result: ConvergeGateResult;
150
- let scopeReason: string;
151
76
  try {
152
77
  const threads = await deps.readThreads(ghRepo, ghNumber);
153
78
  // A null threads read is an unverifiable gate — fail closed. (An empty ARRAY is a verified
@@ -171,83 +96,15 @@ export function makeHandler(deps: {
171
96
  return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
172
97
  }
173
98
 
174
- // The scope-integrity guard (#313) reads/parses the PR description in its OWN try — a transport
175
- // or parse failure here is a scope read failure, so it must surface BLOCK_UNVERIFIABLE_BODY, not
176
- // the review-comment BLOCK_UNVERIFIABLE above. Sharing one catch would mislabel a description
177
- // read failure as a review-thread verification failure and point the human escalation at the
178
- // wrong place.
179
- try {
180
- // The PR description drives the scope-integrity guard (#313). A null read is unverifiable —
181
- // fail closed with a scope-specific reason. (An empty STRING is a verified empty description:
182
- // no closing keyword, no deferral, so the scope guard passes.)
183
- const prBody = await deps.readPrBody(ghRepo, ghNumber);
184
- if (prBody === null) {
185
- return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE_BODY };
186
- }
187
- scopeReason = evaluateScopeGuard({ prBody }).scopeBlockReason;
188
- } catch {
189
- return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE_BODY };
190
- }
191
-
192
- // The human-override door for the scope-integrity block (issue #395). When the deterministic
193
- // scope guard would re-block, read the commit now under review and consult the recorded
194
- // escalation answer bound to that SAME HEAD: an operator who answered the scope question for
195
- // this exact commit has explicitly overridden it ("this fully delivers the issue — keep the
196
- // closing keyword"), so honour it (audited) instead of re-deriving the block from the body and
197
- // re-escalating forever. Binding to the HEAD sha keeps the override from carrying across a new
198
- // push (a different HEAD legitimately re-opens the gate); and if the human instead asked for a
199
- // real split, the servicing agent pushes a fix — moving the HEAD so this stale override never
200
- // fires. This is what turns the infinite escalation loop into a resolvable one.
201
- let headSha: string | null = null;
202
- let scopeBlocked = scopeReason !== "";
203
- if (scopeBlocked) {
204
- try {
205
- headSha = await deps.readHeadSha(ghRepo, ghNumber);
206
- } catch {
207
- headSha = null;
208
- }
209
- if (headSha) {
210
- const override = await findScopeOverride(app, escPrKey, headSha);
211
- if (override) {
212
- app.log.info("converge-gate: scope-integrity block overridden by human answer", {
213
- prKey: escPrKey,
214
- headSha,
215
- escalationId: override.escalationId ?? null,
216
- // The human answer is free-form operator input — never log it verbatim (it can carry
217
- // sensitive content into application logs). Record only stable identifiers plus a
218
- // minimal presence/length signal for debugging.
219
- hasAnswer: override.answer != null && override.answer !== "",
220
- answerLength: override.answer?.length ?? 0,
221
- });
222
- scopeReason = "";
223
- scopeBlocked = false;
224
- }
225
- }
226
- }
227
-
228
- // Both guards gate the same handoff to the merge loop: block if EITHER the review-comment gate
229
- // or the scope-integrity gate blocks, joining their reasons so the human sees every cause.
230
- const reason = [result.convergeBlockReason, scopeReason].filter((r) => r !== "").join(" ");
231
- const out: Out = {
232
- convergeBlocked: result.convergeBlocked || scopeBlocked,
233
- convergeBlockReason: reason,
99
+ return {
100
+ convergeBlocked: result.convergeBlocked,
101
+ convergeBlockReason: result.convergeBlockReason,
234
102
  };
235
- // Surface the reviewed HEAD and the scope-block flag ONLY when scope actually blocks — the
236
- // `persist-escalation-blockedcomments` arm (which runs only on a blocked gate) binds the
237
- // escalation to this commit with them, opening the override door on the next round. A clean
238
- // converge keeps its original `{ convergeBlocked, convergeBlockReason }` shape.
239
- if (scopeBlocked) {
240
- out.scopeBlocked = true;
241
- out.headSha = headSha ?? undefined;
242
- }
243
- return out;
244
103
  };
245
104
  }
246
105
 
247
106
  const handler = makeHandler({
248
107
  readThreads: defaultReadThreads,
249
108
  readReviewBody: defaultReadReviewBody,
250
- readPrBody: defaultReadPrBody,
251
- readHeadSha: defaultReadHeadSha,
252
109
  });
253
110
  export default handler;
@@ -50,7 +50,7 @@ function workerOf(vars: Record<string, unknown>): string | undefined {
50
50
  }
51
51
 
52
52
  const handler: AppJobHandler<In> = async (job, app) => {
53
- const { prKey, round, summary, repo, prNumber, prUrl, abandonUrl, headSha, scopeBlock } = job.variables;
53
+ const { prKey, round, summary, repo, prNumber, prUrl, abandonUrl } = job.variables;
54
54
  // `status` drives the escalation kind (control flow); a blank/absent status is an
55
55
  // unclassified escalation -> a question needing input. `question` is returned as a
56
56
  // process variable below so the downstream `wait-answer` userTask + `pr-escalation.form`
@@ -116,13 +116,6 @@ const handler: AppJobHandler<In> = async (job, app) => {
116
116
  worker,
117
117
  status: "open",
118
118
  asked_at: now,
119
- // Bind a scope-integrity escalation to the reviewed commit (issue #395) so the converge-gate
120
- // can honour a human answer as an override for THIS HEAD instead of re-deriving the block from
121
- // the PR body and re-escalating forever. Only the scope-integrity arm passes these; every other
122
- // arm leaves them absent (→ head_sha NULL, scope_block DEFAULT 0), so the override door opens
123
- // exclusively for the block the human can actually answer.
124
- head_sha: headSha,
125
- scope_block: scopeBlock === true ? 1 : 0,
126
119
  });
127
120
  await app.data.table("pull_requests", "pr_key").update(prKey, {
128
121
  status: "escalated",
@@ -1,185 +0,0 @@
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
- isScopeOverridden,
18
- } from "./scopeGuard.ts";
19
-
20
- // ── The canonical router ────────────────────────────────────────────────────
21
-
22
- test("evaluateScopeGuard: a full-scope PR that Closes its parent, no deferral, is allowed", () => {
23
- const r = evaluateScopeGuard({ prBody: "Implements the feature end to end.\n\nCloses #313" });
24
- assertEquals(r.scopeBlocked, false);
25
- assertEquals(r.scopeBlockReason, "");
26
- });
27
-
28
- test("evaluateScopeGuard: a plain PR with no closing keyword and no deferral is allowed", () => {
29
- const r = evaluateScopeGuard({ prBody: "A small refactor. Refs #10" });
30
- assertEquals(r.scopeBlocked, false);
31
- });
32
-
33
- test("evaluateScopeGuard: Closes a broader parent AND defers scope → blocked (guard 1)", () => {
34
- const r = evaluateScopeGuard({
35
- prBody:
36
- "Delivers the nested ad-hoc half.\n\n## Scope\nEmbedded SUB_PROCESS tools remain the deferred refinement.\n\nCloses #631",
37
- });
38
- assertEquals(r.scopeBlocked, true);
39
- assertStringIncludes(r.scopeBlockReason, "must not close a broader-scoped issue");
40
- assertStringIncludes(r.scopeBlockReason, "#631");
41
- });
42
-
43
- test("evaluateScopeGuard: defers scope but links NO follow-up issue → blocked (guard 2)", () => {
44
- const r = evaluateScopeGuard({
45
- prBody: "Ships the first half.\n\n## Scope\nThe rest is deferred.\n\nRefs #631",
46
- });
47
- assertEquals(r.scopeBlocked, true);
48
- assertStringIncludes(r.scopeBlockReason, "no filed follow-up issue");
49
- // Guard 1 must NOT fire — this PR correctly used a non-closing ref.
50
- assert(
51
- !r.scopeBlockReason.includes("must not close"),
52
- "a non-closing ref must not trip the closing-keyword guard",
53
- );
54
- });
55
-
56
- test("evaluateScopeGuard: defers scope AND links a filed follow-up AND uses a non-closing ref → allowed", () => {
57
- const r = evaluateScopeGuard({
58
- prBody:
59
- "Ships the first half.\n\n## Scope\nThe embedded SUB_PROCESS half is deferred.\nTracked-in: #872\n\nRefs #631",
60
- });
61
- assertEquals(r.scopeBlocked, false);
62
- assertEquals(r.scopeBlockReason, "");
63
- });
64
-
65
- test("evaluateScopeGuard: the motivating incident (Closes #631 + ## Scope + no follow-up) trips BOTH guards", () => {
66
- const r = evaluateScopeGuard({
67
- prBody:
68
- "## 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",
69
- });
70
- assertEquals(r.scopeBlocked, true);
71
- assertStringIncludes(r.scopeBlockReason, "must not close a broader-scoped issue");
72
- assertStringIncludes(r.scopeBlockReason, "no filed follow-up issue");
73
- });
74
-
75
- test("evaluateScopeGuard: a follow-up link alone does not excuse a closing keyword on a split", () => {
76
- // Even with the remainder tracked, closing the broader parent is still wrong — it reads as done.
77
- const r = evaluateScopeGuard({
78
- prBody: "Ships half.\n\nDeferred: the rest. Follow-up: #872\n\nCloses #631",
79
- });
80
- assertEquals(r.scopeBlocked, true);
81
- assertStringIncludes(r.scopeBlockReason, "must not close a broader-scoped issue");
82
- assert(!r.scopeBlockReason.includes("no filed follow-up issue"), "the follow-up was linked");
83
- });
84
-
85
- test("evaluateScopeGuard: tolerates null / empty bodies", () => {
86
- assertEquals(evaluateScopeGuard({ prBody: null }).scopeBlocked, false);
87
- assertEquals(evaluateScopeGuard({ prBody: undefined }).scopeBlocked, false);
88
- assertEquals(evaluateScopeGuard({ prBody: "" }).scopeBlocked, false);
89
- });
90
-
91
- // ── The parsers ─────────────────────────────────────────────────────────────
92
-
93
- test("findClosingKeywordRefs: extracts bare, cross-repo, and URL closing refs; dedupes", () => {
94
- const body = [
95
- "Closes #12",
96
- "fixes: owner/repo#34",
97
- "Resolved https://github.com/owner/repo/issues/56",
98
- "Closes #12", // duplicate
99
- ].join("\n");
100
- assertEquals(findClosingKeywordRefs(body), [
101
- "#12",
102
- "owner/repo#34",
103
- "https://github.com/owner/repo/issues/56",
104
- ]);
105
- });
106
-
107
- test("findClosingKeywordRefs: a non-closing ref (Refs / Part of) is not a closing keyword", () => {
108
- assertEquals(findClosingKeywordRefs("Refs #12\nPart of #34\nDepends-on: #56"), []);
109
- });
110
-
111
- test("hasDeferralMarker: detects a ## Scope heading and deferral phrases; ignores clean prose", () => {
112
- assert(hasDeferralMarker("## Scope\nfoo"), "a Scope heading defers");
113
- assert(hasDeferralMarker("### scope of work"), "any heading level counts");
114
- assert(hasDeferralMarker("The rest is deferred to later."), "'deferred' defers");
115
- assert(hasDeferralMarker("This is out of scope for now."), "'out of scope' defers");
116
- assert(hasDeferralMarker("The remainder is left for a follow-up."), "'remainder' defers");
117
- assert(!hasDeferralMarker("Implements everything. Closes #1."), "clean prose does not defer");
118
- });
119
-
120
- test("hasDeferralMarker: a bare 'remain*' without deferral context is not a deferral", () => {
121
- // "all done" phrasing must not be read as a scope deferral (Copilot advisory,
122
- // app/scopeGuard.ts:48): a full-scope PR that merely reports nothing outstanding
123
- // would otherwise be blocked from converging.
124
- assert(!hasDeferralMarker("No issues remain.\n\nCloses #123"), "'No issues remain' is not a deferral");
125
- assert(!hasDeferralMarker("All checks remain green."), "'remain green' is not a deferral");
126
- assert(!hasDeferralMarker("No failing tests remaining. Closes #7"), "'remaining' alone is not a deferral");
127
- // ...but a remainder mention near genuine deferral context still defers.
128
- assert(hasDeferralMarker("The remaining scope is tracked separately."), "'remaining' near 'scope' defers");
129
- assert(hasDeferralMarker("Remaining work is a follow-up."), "'remaining' near 'follow-up' defers");
130
- });
131
-
132
- test("hasFollowupIssueRef: only an explicit tracking marker + issue ref counts", () => {
133
- assert(hasFollowupIssueRef("Deferred-to: #872"), "Deferred-to marker");
134
- assert(hasFollowupIssueRef("Tracked-in: owner/repo#872"), "cross-repo tracking marker");
135
- assert(hasFollowupIssueRef("Follow-up: #900"), "Follow-up marker");
136
- assert(hasFollowupIssueRef("Follow up issue: #900"), "Follow up issue marker");
137
- assert(!hasFollowupIssueRef("The rest is deferred."), "bare deferral prose is not a filed link");
138
- assert(!hasFollowupIssueRef("Refs #631"), "a parent ref is not a remainder tracker");
139
- // A full GitHub issue URL is a valid filed follow-up link, same as the closing-keyword parser accepts.
140
- assert(
141
- hasFollowupIssueRef("Deferred-to: https://github.com/owner/repo/issues/872"),
142
- "Deferred-to marker with a full issue URL",
143
- );
144
- assert(
145
- hasFollowupIssueRef("Follow-up issue: https://github.com/nanobpm/nano-workforce/issues/900"),
146
- "Follow-up marker with a full issue URL",
147
- );
148
- });
149
-
150
- // ── The human-override door (#395) ──────────────────────────────────────────
151
- // The scope-integrity gate re-derives `scopeBlocked` from the PR body every round, so answering
152
- // its escalation used to re-block identically (an infinite loop). An answer bound to the SAME
153
- // reviewed HEAD is now honoured as an explicit override; a different HEAD (a new push) is not.
154
-
155
- test("isScopeOverridden: an answer bound to the same HEAD overrides the block", () => {
156
- assert(
157
- isScopeOverridden("abc123", { escalationId: 7, headSha: "abc123", answer: "Full delivery — keep Closes." }),
158
- "same-HEAD answered escalation is an override",
159
- );
160
- });
161
-
162
- test("isScopeOverridden: an answer for a DIFFERENT HEAD does not override (a new push re-opens)", () => {
163
- assert(
164
- !isScopeOverridden("newHEAD", { escalationId: 7, headSha: "oldHEAD", answer: "Full delivery." }),
165
- "an override never carries across a new push",
166
- );
167
- });
168
-
169
- test("isScopeOverridden: no recorded answer is never an override", () => {
170
- assertEquals(isScopeOverridden("abc123", null), false);
171
- assertEquals(isScopeOverridden("abc123", undefined), false);
172
- });
173
-
174
- test("isScopeOverridden: a missing/blank HEAD on either side fails closed (no override)", () => {
175
- assertEquals(isScopeOverridden(null, { headSha: "abc123", answer: "x" }), false, "unreadable current HEAD");
176
- assertEquals(isScopeOverridden("", { headSha: "abc123", answer: "x" }), false, "blank current HEAD");
177
- assertEquals(isScopeOverridden("abc123", { headSha: null, answer: "x" }), false, "unrecorded escalation HEAD");
178
- assertEquals(isScopeOverridden("abc123", { headSha: " ", answer: "x" }), false, "blank escalation HEAD");
179
- });
180
-
181
- test("isScopeOverridden: the answer text is not parsed for intent — presence at the HEAD is the signal", () => {
182
- // On an unchanged HEAD, the operator completing the escalation IS the explicit approval: had they
183
- // wanted a real split, the servicing agent would have pushed a fix, moving the HEAD.
184
- assert(isScopeOverridden("abc123", { headSha: "abc123", answer: null }), "a null answer at the HEAD still overrides");
185
- });