@nanobpm/nano-workforce 0.115.0 → 0.117.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/app/scopeGuard.ts DELETED
@@ -1,165 +0,0 @@
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
- }
132
-
133
- // A recorded human answer to a scope-integrity escalation, bound to the PR HEAD it was raised
134
- // against (issue #395). This is the override door the deterministic scope gate lacked: without it,
135
- // the gate re-derives `scopeBlocked` from the PR body every round and re-escalates the identical
136
- // question, so a legitimate human override ("this fully delivers the issue — keep the closing
137
- // keyword") is unresolvable through the escalation the loop itself opens (infinite loop).
138
- export interface ScopeEscalationAnswer {
139
- /** The escalation row id, for the audit trail. */
140
- escalationId?: number;
141
- /** The PR HEAD sha this scope escalation was raised against (`escalations.head_sha`). */
142
- headSha: string | null | undefined;
143
- /** The operator's recorded answer/rationale (`escalations.answer`), surfaced in the audit. */
144
- answer: string | null | undefined;
145
- }
146
-
147
- /** Decide whether a recorded human answer overrides the scope-integrity block for the commit
148
- * currently under review. The override is honoured ONLY when the human answered a scope-integrity
149
- * escalation that was raised against the SAME HEAD sha now being checked — binding the override to
150
- * the reviewed commit so a later push (a different HEAD) re-opens the gate instead of silently
151
- * carrying the override forward. Pure and total: a missing/blank current HEAD or a missing/blank
152
- * recorded HEAD never matches, so an unverifiable HEAD fails closed (no override) rather than
153
- * waving the gate through. The answer TEXT is not parsed for intent: on an unchanged HEAD the human
154
- * completing the escalation IS the explicit approval (had they wanted a real fix, the servicing
155
- * agent would have pushed a new commit, moving the HEAD and side-stepping this override). */
156
- export function isScopeOverridden(
157
- currentHeadSha: string | null | undefined,
158
- answered: ScopeEscalationAnswer | null | undefined,
159
- ): boolean {
160
- if (!answered) return false;
161
- const current = typeof currentHeadSha === "string" ? currentHeadSha.trim() : "";
162
- const recorded = typeof answered.headSha === "string" ? answered.headSha.trim() : "";
163
- if (current === "" || recorded === "") return false;
164
- return current === recorded;
165
- }
@@ -1,116 +0,0 @@
1
- // pr.converge-gate — the human-override door for the scope-integrity block (issue #395).
2
- //
3
- // The scope-integrity gate re-derives `scopeBlocked` from the PR body every converged round. Before
4
- // this fix, answering its escalation re-entered the loop, the gate re-blocked identically, and the
5
- // operator was trapped in an infinite escalation (a fresh escalationId each cycle) — the only escape
6
- // was mangling the PR body into a non-closing ref. These tests pin the override door: an escalation
7
- // answer bound to the SAME reviewed HEAD satisfies the gate (audited), a different HEAD (a new push)
8
- // re-opens it, and an unreadable HEAD keeps the block (fail closed).
9
- import { test } from "node:test";
10
- import { assert, assertEquals } from "#test-assert";
11
- import { noopLog } from "../../test/log.ts";
12
- import { makeHandler } from "./worker.ts";
13
-
14
- // A PR body that trips the scope-integrity guard: it defers scope (`## Scope`) yet closes a
15
- // broader-scoped parent (`Closes #631`) and links no filed follow-up.
16
- const SCOPE_BLOCKING_BODY =
17
- "Delivers the first half.\n\n## Scope\nThe embedded tools remain the deferred refinement.\n\nCloses #631";
18
-
19
- // biome-ignore lint/suspicious/noExplicitAny: tiny in-memory app double, mirrors persist-escalation.test
20
- function fakeApp(escalations: Record<string, unknown>[]): any {
21
- const stores: Record<string, Record<string, unknown>[]> = { escalations };
22
- return {
23
- stores,
24
- data: {
25
- table(name: string, key: string) {
26
- const store = (stores[name] ??= []);
27
- return {
28
- // biome-ignore lint/suspicious/noExplicitAny: test double
29
- find: (q: any) => Promise.resolve(store.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
30
- };
31
- },
32
- },
33
- log: noopLog(),
34
- };
35
- }
36
-
37
- function deps(overrides: {
38
- headSha?: string | null;
39
- prBody?: string;
40
- headThrows?: boolean;
41
- }) {
42
- const headSha = "headSha" in overrides ? (overrides.headSha ?? null) : "HEAD1";
43
- return {
44
- readThreads: () => Promise.resolve([]),
45
- readReviewBody: () => Promise.resolve(""),
46
- readPrBody: () => Promise.resolve(overrides.prBody ?? SCOPE_BLOCKING_BODY),
47
- readHeadSha: () => (overrides.headThrows ? Promise.reject(new Error("gh down")) : Promise.resolve(headSha)),
48
- };
49
- }
50
-
51
- const job = { variables: { prKey: "o/r#5", repo: "o/r", prNumber: 5 } } as never;
52
-
53
- test("scope blocks with no answered escalation → blocked, and surfaces the reviewed HEAD to bind the escalation", async () => {
54
- const app = fakeApp([]);
55
- const out = (await makeHandler(deps({ headSha: "HEAD1" }))(job, app)) as Record<string, unknown>;
56
- assertEquals(out.convergeBlocked, true);
57
- assertEquals(out.scopeBlocked, true);
58
- assertEquals(out.headSha, "HEAD1", "the reviewed HEAD is returned so persist-escalation can bind it");
59
- });
60
-
61
- test("scope blocks but a human answered the escalation for the SAME HEAD → override honoured (loop broken)", async () => {
62
- const app = fakeApp([
63
- { id: 7, pr_key: "o/r#5", status: "answered", scope_block: 1, head_sha: "HEAD1", answer: "Full delivery — keep Closes." },
64
- ]);
65
- const out = (await makeHandler(deps({ headSha: "HEAD1" }))(job, app)) as Record<string, unknown>;
66
- assertEquals(out.convergeBlocked, false, "the same-HEAD human answer satisfies the scope gate");
67
- assertEquals(out.convergeBlockReason, "");
68
- // A cleared scope block routes to finalize, so the block-only binding fields are not emitted.
69
- assertEquals(out.scopeBlocked, undefined);
70
- });
71
-
72
- test("scope blocks and the answer was for a DIFFERENT HEAD (a new push) → still blocked", async () => {
73
- const app = fakeApp([
74
- { id: 7, pr_key: "o/r#5", status: "answered", scope_block: 1, head_sha: "OLDHEAD", answer: "Full delivery." },
75
- ]);
76
- const out = (await makeHandler(deps({ headSha: "HEAD1" }))(job, app)) as Record<string, unknown>;
77
- assertEquals(out.convergeBlocked, true, "a stale override never carries across a new push");
78
- assertEquals(out.scopeBlocked, true);
79
- });
80
-
81
- test("scope blocks and an answered NON-scope escalation sits at the same HEAD → not an override", async () => {
82
- const app = fakeApp([
83
- { id: 7, pr_key: "o/r#5", status: "answered", scope_block: 0, head_sha: "HEAD1", answer: "unrelated" },
84
- ]);
85
- const out = (await makeHandler(deps({ headSha: "HEAD1" }))(job, app)) as Record<string, unknown>;
86
- assertEquals(out.convergeBlocked, true, "only a scope-integrity escalation opens the scope override door");
87
- });
88
-
89
- test("scope blocks but the reviewed HEAD is unreadable → keep the block (fail closed)", async () => {
90
- const app = fakeApp([
91
- { id: 7, pr_key: "o/r#5", status: "answered", scope_block: 1, head_sha: "HEAD1", answer: "override" },
92
- ]);
93
- const nullHead = (await makeHandler(deps({ headSha: null }))(job, app)) as Record<string, unknown>;
94
- assertEquals(nullHead.convergeBlocked, true, "cannot verify an override against an unknown HEAD");
95
- const throwHead = (await makeHandler(deps({ headThrows: true }))(job, app)) as Record<string, unknown>;
96
- assertEquals(throwHead.convergeBlocked, true, "a HEAD read error keeps the block");
97
- });
98
-
99
- test("scope passes → not blocked, and no override lookup is needed", async () => {
100
- const app = fakeApp([]);
101
- const out = (await makeHandler(deps({ prBody: "Implements the whole thing.\n\nCloses #631" }))(job, app)) as Record<
102
- string,
103
- unknown
104
- >;
105
- assertEquals(out.convergeBlocked, false);
106
- assertEquals(out.scopeBlocked, undefined);
107
- });
108
-
109
- test("newest answered scope escalation wins when a re-escalation was answered again at the same HEAD", async () => {
110
- const app = fakeApp([
111
- { id: 7, pr_key: "o/r#5", status: "answered", scope_block: 1, head_sha: "HEAD1", answer: "first" },
112
- { id: 9, pr_key: "o/r#5", status: "answered", scope_block: 1, head_sha: "HEAD1", answer: "latest" },
113
- ]);
114
- const out = (await makeHandler(deps({ headSha: "HEAD1" }))(job, app)) as Record<string, unknown>;
115
- assertEquals(out.convergeBlocked, false, "a re-answered override at the unchanged HEAD is honoured");
116
- });