@nanobpm/nano-workforce 0.200.4 → 0.200.5

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.
@@ -23,6 +23,7 @@ import { isPlausibleBranchName } from "./baseBranch.ts";
23
23
  import { isEnvKey } from "./contracts.ts";
24
24
  import { isConvergeTarget } from "./convergeTargets.ts";
25
25
  import { isRawConvergeMergeJobType, NODE_COMPLETION_POLICIES } from "./nodePolicy.ts";
26
+ import { parseIssue } from "./plan.ts";
26
27
  import { BACKOFFS, hasEmbeddedCredential, hasEmbeddedUrl, hasSchemeRelativeAuthority, isBackoff, isUrlShaped, redactEmbeddedCredentialUrl, redactEmbeddedSchemeRelativeUrl, redactEmbeddedUrl, redactString } from "./readiness.ts";
27
28
  import { isResolvableRepo } from "./repoEnvelope.ts";
28
29
 
@@ -108,7 +109,8 @@ export type DeliveryGraphErrorCode =
108
109
  | "scheme-relative-url-in-job-type"
109
110
  | "invalid-credential-env"
110
111
  | "invalid-backoff"
111
- | "unbound-pr";
112
+ | "unbound-pr"
113
+ | "partial-scope-close";
112
114
 
113
115
  /** A single semantic validation failure. `path` is a JSON-path-qualified pointer at the offending
114
116
  * input (`nodes[2].kind`, `edges[1].from`, `nodes[0].emits[1].name`), `message` is human-actionable,
@@ -267,6 +269,1261 @@ export const FACT_NAME_MAX_LENGTH = 128;
267
269
  const NODE_ID_PATTERN = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
268
270
  const NODE_ID_MAX_LENGTH = 128;
269
271
 
272
+ /** Issue #858: a delivery-graph `agent` node's `prompt` is PLANNER/authored free text — no code path
273
+ * injects a `Closes #N` for it (unlike the single-issue `feature.ts` path, which owns its whole
274
+ * issue). The field case (delivery graph `5e36636255ab`, node `i12`) paired a brief scoped to ONE of
275
+ * an issue's three acceptance criteria with "…and open a PR that closes it", so the agent wrote
276
+ * `Closes #N` and the remainder was silently dropped. The planner/feature/scope-gate prompt contracts
277
+ * (resources/prompts/{plan,feature,scope-classify}.md) carry the prose rule; THIS is the deterministic
278
+ * compile/lint-time guard the issue asks for ("validate this at graph compile/lint time: an agent
279
+ * prompt that says 'close(s) #N' must cover all of #N's checkboxes, or carry an explicit partial-scope
280
+ * marker"). The validator is pure — it cannot read the GitHub issue to count its checkboxes — so the
281
+ * enforceable, self-contained form is: a prompt that closes an issue must ALSO carry an explicit
282
+ * full-scope acknowledgement marker (the exact phrase the planner is told to write when a slice
283
+ * legitimately closes, e.g. "full stated scope" / "every acceptance criterion"). A closing keyword
284
+ * with NO such marker is the defect class — a partial brief told to close — and is rejected.
285
+ *
286
+ * `ISSUE_REF_PATTERN` is the "the prompt references an issue SOMEWHERE" guard (so a bare prose "close
287
+ * the door" / "closes it" with no `#N` never matches). It is NOT a closing-language grammar — closing
288
+ * detection AND targeting are done by the SINGLE authoritative, negation-aware grammar
289
+ * `CLOSING_TARGET_PATTERN` (via `closingTargets`). There used to be a second `CLOSING_ACTION_PATTERN`
290
+ * pre-filter here that duplicated that whole active+passive grammar; it was removed (issue #858
291
+ * round-16 review) because the duplication was a DRIFT SURFACE that violated the one-canonical rule
292
+ * (AGENTS.md "derivation over duplication"): any syntax added only to `CLOSING_TARGET_PATTERN` was
293
+ * silently unreachable through the prefilter and failed OPEN, and the review history repeatedly forced
294
+ * the two regexes to be edited in lock-step. `closingTargets` already answers "is there an active,
295
+ * non-negated close?" (it returns no target when there is none), so `isPartialScopeClose` derives that
296
+ * answer from the one grammar instead of gating it behind a divergent copy. */
297
+ const ISSUE_REF_PATTERN =
298
+ /(?:#[0-9]+|[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#[0-9]+|https?:\/\/[^\s)]*\/issues\/[0-9]+)/i;
299
+
300
+ /** A repo-qualified issue IDENTITY key (issue #858 round-4 review). The accepted issue syntax includes
301
+ * `owner/repo#N` and issue URLs, and this validator supports cross-repository graphs, so collapsing an
302
+ * issue to its bare number `N` would let an acknowledgement of `owner/alpha#12`'s scope licence closing
303
+ * a DIFFERENT issue `owner/beta#12` — both become `12`. The key preserves repository identity: an
304
+ * EXPLICIT `owner/repo` (from `owner/repo#N` or an issue URL) keys as `owner/repo#N` (lowercased); a
305
+ * BARE `#N` keys as `#N` (the implicit node/run repository). Two keys name the same issue only when
306
+ * equal, so `owner/alpha#12`, `owner/beta#12`, and bare `#12` are three distinct issues. This is
307
+ * fail-closed: a bare acknowledgement never credits an explicitly-qualified close of a different repo
308
+ * (and vice-versa), so a cross-repo number collision can no longer bypass the guard. */
309
+ function issueKey(repo: string | null | undefined, num: string | number): string {
310
+ const r = (repo ?? "").trim().toLowerCase();
311
+ return `${r}#${num}`;
312
+ }
313
+
314
+ /** Every issue referenced in `text`, as a repo-qualified identity key (see `issueKey`), in any accepted
315
+ * form (`#N`, `owner/repo#N`, or an issue URL `…/owner/repo/issues/N`). Used to (a) anchor a full-scope
316
+ * acknowledgement to the issue it names and (b) decide whether a prompt is single-issue (so a generic,
317
+ * un-numbered marker is unambiguous).
318
+ *
319
+ * The `owner/repo#N` and issue-URL shapes are NOT re-declared here as a local capture regex — that
320
+ * duplicate grammar is exactly what the single-grammar guard (`app/prShapeSingleGrammar.test.ts`,
321
+ * #856/#857) bans. Instead each candidate token is classified by the CANONICAL `parseIssue`
322
+ * (`app/prParse.ts`), the one source of the issue-shape grammar. `ISSUE_REF_TOKEN` only finds the
323
+ * candidate tokens (a bare `#N`, an `owner/repo#N`, or an `http(s)` URL); it carries NO digit-capture
324
+ * group, so it is not a parser. A token that merely LOOKS like a reference but is not the canonical
325
+ * shape (a non-`github.com` issue URL, a multi-slash `a/b/c#9`) is rejected by `parseIssue` and
326
+ * contributes nothing — fail-closed, matching the canonical grammar exactly. */
327
+ const ISSUE_REF_TOKEN = /#[0-9]+\b|[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#[0-9]+\b|https?:\/\/[^\s)]+/gi;
328
+ function issueRefsIn(text: string): string[] {
329
+ const out: string[] = [];
330
+ for (const m of text.matchAll(ISSUE_REF_TOKEN)) {
331
+ const tok = m[0];
332
+ // Bare `#N` has no repo — key it as the implicit node/run repository (parseIssue rejects a
333
+ // repo-less ref, so handle it directly rather than through the canonical parser).
334
+ if (tok.startsWith("#")) {
335
+ out.push(issueKey(null, tok.slice(1)));
336
+ continue;
337
+ }
338
+ const parsed = parseIssue(tok);
339
+ if (parsed) out.push(issueKey(parsed.repo, parsed.number));
340
+ }
341
+ return out;
342
+ }
343
+
344
+ /** The closing TARGETS of a prompt: which issue the closing verb actually acts on, as repo-qualified
345
+ * identity keys (see `issueKey`). A numbered target (`closes #N`, `fixes owner/repo#N`, `resolves
346
+ * <url>/.../issues/N`, or the `issue #N` / `GitHub issue #N` noun-phrase form) yields its key; a pronoun
347
+ * target (`closes it` / `close the issue`) can't name an issue, so it is reported via `pronoun` and the
348
+ * caller falls back to "the sole issue referenced". This is the SINGLE authoritative closing grammar —
349
+ * both "is there a close?" (detection) and "which issue?" (targeting) derive from it, with no separate
350
+ * pre-filter to drift from (issue #858 round-16 review removed the duplicate `CLOSING_ACTION_PATTERN`).
351
+ * It recognises the verb+object forms (`close/closes/closed`, `fix/fixes/fixed`,
352
+ * `resolve/resolves/resolved` applied to `#N`, `owner/repo#N`, an issue URL, the `issue #N` /
353
+ * `GitHub issue #N` noun phrase, or a pronoun), the optional colon (`Closes: #12`), and the
354
+ * quantifier/determiner on either side of `issues?` (`close both issues #12 and #13`); it
355
+ * preserves the `owner/repo` prefix (or URL repo) so a close of `owner/beta#12` is
356
+ * NOT satisfied by an acknowledgement anchored to `owner/alpha#12`.
357
+ *
358
+ * A DIRECTLY-NEGATED close is NOT a closing target (issue #858 round-7 review). A brief that explicitly
359
+ * forbids the close (`Do not close #12; use Part of #12.`, `never close #12`, `don't resolve #12`) is a
360
+ * SAFE partial-slice brief following the partial-slice contract — the OPPOSITE of the defect (a
361
+ * part-scope node told TO close) — so flagging it blocks a legitimate graph. Each match is dropped when
362
+ * the text immediately before its verb ends in a negator (`NEGATED_CLOSE_PREFIX`), so a negated close
363
+ * no longer contributes a target. The negator→verb gap admits only ADVERBS (`do not simply close`), not
364
+ * arbitrary words, so a meaning-flipping idiom (`do not forget to close #12` — "forget to" is a verb,
365
+ * not an adverb) stays an ACTIVE close and is still flagged; and negating ONE close never masks a
366
+ * DIFFERENT active close in the same prompt (`do not close #12, but close #34` still targets #34).
367
+ *
368
+ * The SECOND alternative is the ISSUE-FIRST / PASSIVE mirror of the verb-first grammar (issue #858
369
+ * round-10 review): `<issue-ref> [auxiliaries] closed|fixed|resolved` — `ensure issue #12 is closed by
370
+ * the PR`, `#12 will be closed by the PR`, `see #12 closed`, `mark #12 as resolved`, `the issue gets
371
+ * fixed`. It captures the SAME target shapes (groups 5/6 = repo/number for `#N`, 7/8 for a URL, a
372
+ * bare pronoun subject otherwise) so a passive close is attributed to its issue exactly like the
373
+ * active form, and captures the auxiliary window (group 19) so a negator INSIDE it (`#12 is NOT
374
+ * closed`) can drop the match — see `NEGATED_PASSIVE_WINDOW`. The passive subject carries the SAME
375
+ * coordination tail as the active arm (group 14, re-scanned in `closingTargets`), so a coordinated
376
+ * passive close (`issues #12 and #13 are closed`) attributes the close to EVERY subject, not just the
377
+ * first (issue #858 round-18 review). This passive arm lives ONLY here, in the
378
+ * one authoritative grammar — there is no duplicate pre-filter carrying a second copy of it.
379
+ *
380
+ * The issue-REFERENCE shape inside this grammar is NOT a local capture regex. The single-grammar guard
381
+ * (`app/prShapeSingleGrammar.test.ts`, #856/#857) bans any module but `app/prParse.ts`/`app/plan.ts`
382
+ * from carrying a `#(<digits>` or `/issues/(<digits>` capture group, so this pattern matches the
383
+ * reference with the CAPTURE-FREE {@link ISSUE_REF_SRC} fragment (no digit-capture group) and
384
+ * `closingTargets` then classifies the matched reference substring through the CANONICAL `parseIssue`
385
+ * — the one source of the issue-shape grammar. The grammar's structure (which target shapes exist, the
386
+ * coordination tails, the passive auxiliary window) stays here; only the reference *lexing* delegates
387
+ * to the canonical parser. */
388
+ const ISSUE_REF_SRC = "(?:[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+#[0-9]+|https?://[^\\s)]+|#[0-9]+)";
389
+ const CLOSING_TARGET_PATTERN = new RegExp(
390
+ String.raw`(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\b\s*(?::\s*){0,2}(?:(?:both|all|each|every|the)\s+)?(?:(?:github\s+)?issues?\s+)?(?:(?:both|all|each|every|the)\s+)?(${ISSUE_REF_SRC}|it\b|its\s+issue\b|the issue\b|that issue\b|this issue\b|them\b)((?:\s*(?:,|and\b|&|\+|along\s+with|as\s+well\s+as|plus)\s*(?:(?:(?:both|all|each|every|the)\s+)?(?:(?:github\s+)?issues?\s+)?(?:(?:both|all|each|every|the)\s+)?${ISSUE_REF_SRC})*)*)|(?:(?<![A-Za-z0-9_.-])(?:(?:github\s+)?issues?\s+)?(${ISSUE_REF_SRC})\b|\b(?:the|that|this|its)\s+issue\b|\bit\b|\bthem\b)((?:\s*(?:,|and\b|&|\+|along\s+with|as\s+well\s+as|plus)\s*(?:(?:(?:both|all|each|every|the)\s+)?(?:(?:github\s+)?issues?\s+)?(?:(?:both|all|each|every|the)\s+)?${ISSUE_REF_SRC})*)*)\s+((?:\w+\s+){0,4}(?:gets?\s+|get\s+)?(?:closed|fixed|resolved)\b)`,
391
+ "gi",
392
+ );
393
+ /** A negator DIRECTLY governing a closing verb, anchored (`$`) to the text ending right before the verb.
394
+ * Covers auxiliary+not (`do/does/did/will/would/shall/should/must/may/might not`), the common
395
+ * contractions, bare `not`/`never`/`cannot`, and `no need to`. Between the negator and the verb only
396
+ * ADVERBS and the infinitive marker `to` are admitted (`(?:\w+ly|ever|just|simply|only|then|also|now|
397
+ * yet|…|to)\s+){0,3}`) — NOT arbitrary words — so a verb between them (`do not forget to close` /
398
+ * `do not hesitate to close` — "forget"/"hesitate" is a verb, not an adverb or `to`) does not bridge
399
+ * and the close stays active, and an earlier unrelated negation blocked by punctuation/a verb (`do not
400
+ * introduce regressions; close #12`) never reaches the verb. Admitting `to` covers the `not to close`
401
+ * INFINITIVE (`Remember not to close #12.`, `Be sure not to close #12.`) — a brief that forbids the
402
+ * close that way is a safe partial-slice brief, not a partial-scope-close (issue #858 round-8
403
+ * adversarial review). */
404
+ const NEGATED_CLOSE_PREFIX =
405
+ /(?:\b(?:do|does|did|will|would|shall|should|must|may|might)\s+not|\b(?:don|doesn|didn|won|wouldn|shouldn|mustn|mightn|shan|can)['’]t|\bcannot|\bnever|\bnot|\bno\s+need\s+to)\s+(?:(?:\w+ly|ever|just|simply|only|then|also|now|yet|automatically|silently|blindly|actually|really|to|use|using|write|writing|include|including|add|adding)\s+){0,3}(?:(?:a|an|the)\s+)?$/i;
406
+ /** The CORRELATIVE additive negation `not <adverb> …` is ADDITIVE, not prohibitive, when paired with an
407
+ * additive continuation: `Do not just close #12; also add a release note` still INSTRUCTS the close
408
+ * (`not only X but/also Y` keeps X), so treating it as a negated close drops a real close and lets the
409
+ * partial brief validate (issue #858 round-8 review). This prefix matches a negator whose gap is
410
+ * governed by a correlative/manner adverb immediately before the verb; paired with a following additive
411
+ * continuation (`ADDITIVE_CONTINUATION`) it RE-ACTIVATES the close in `closingTargets`. The class spans
412
+ * the correlative `just`/`only` AND any `-ly` manner adverb (`merely`/`simply`/`basically`/`hardly`/…) —
413
+ * the additive signal is the `also`/`as well` CONTINUATION, not the specific adverb, so `Do not merely
414
+ * close #12; also …` is the same additive construction (issue #858 round-8 adversarial review). A manner
415
+ * adverb with NO additive continuation (`do not simply close it; leave the parent open`) genuinely
416
+ * forbids the close and stays a safe negated close — the continuation, not the adverb, is what
417
+ * re-activates. */
418
+ const CORRELATIVE_NEGATED_CLOSE_PREFIX =
419
+ /(?:\b(?:do|does|did|will|would|shall|should|must|may|might)\s+not|\b(?:don|doesn|didn|won|wouldn|shouldn|mustn|mightn|shan|can)['’]t|\bcannot|\bnever|\bnot|\bno\s+need\s+to)\s+(?:(?:\w+ly|ever|then|also|now|yet|automatically|silently|blindly|actually|really|to)\s+){0,2}(?:just|only|\w+ly)\s+(?:(?:\w+ly|ever|then|now|yet|automatically|silently|blindly|actually|really|to)\s+){0,2}$/i;
420
+ /** The additive continuation that reinstates a `not <adverb>` close: an additive marker — `also` or
421
+ * `as well` — within the SAME sentence after the close (bounded at `.`/newline so an unrelated later
422
+ * sentence never re-activates the close). The signal is the ADDITIVE word, never a bare `but`: a bare
423
+ * contrastive `but` (`Do not just close it; but leave the parent open`) introduces a CONTRAST, not an
424
+ * added action, so it is a SAFE negated close — admitting it was a false-positive REGRESSION vs. round-7
425
+ * (issue #858 round-8 adversarial review). The correlative `not only X but ALSO Y` still carries `also`,
426
+ * so it is caught here without the bare-`but` false positive. `as well` is matched as a phrase (it never
427
+ * leads a clause, so a word-boundary false positive is not a concern). A bare `too` is deliberately NOT
428
+ * an additive trigger: it is also the INTENSIFIER (`too risky`/`too early`), so matching it over-fired on
429
+ * a safe negated close followed by a `too <adj>` constraint — a fail-closed tradeoff that keeps `too` a
430
+ * safe negation. Tested on the text immediately AFTER the matched close. */
431
+ const ADDITIVE_CONTINUATION = /^[^.\n]*?\b(?:also|as\s+well)\b/i;
432
+ /** A negator inside the AUXILIARY WINDOW of an issue-first/passive close (`issue #12 is NOT closed by
433
+ * the PR`, `#12 will NEVER be fixed by the PR`, `the issue is not getting resolved`) — the passive
434
+ * mirror of `NEGATED_CLOSE_PREFIX` (issue #858 round-10 review). A brief that explicitly forbids the
435
+ * passive close (`Implement criterion 1 of #12; issue #12 is NOT closed by this PR`) is a SAFE
436
+ * partial-slice brief, exactly like its active-voice sibling (`do not close #12`), so the match is
437
+ * dropped. The negator is searched only inside the match's own captured auxiliary window (group 19 of
438
+ * `CLOSING_TARGET_PATTERN`) — never across the whole prompt — so an unrelated earlier negation
439
+ * (`do not introduce regressions; issue #12 is closed by the PR`) cannot mask an ACTIVE passive close,
440
+ * and a `not` AFTER the participle (`#12 is closed, not merely referenced`) is not read as negating
441
+ * the close. The window is at most four words plus an optional `get(s)`, so a plain substring test
442
+ * suffices — no anchoring needed. */
443
+ const NEGATED_PASSIVE_WINDOW = /\b(?:not|never|cannot)\b|\b\w+n['’]t\b/i;
444
+ /** Classify a matched issue-REFERENCE substring (from {@link ISSUE_REF_SRC}) into its repo-qualified
445
+ * identity key via the CANONICAL `parseIssue`, or null when the substring is not the canonical shape
446
+ * (a non-`github.com` issue URL, a multi-slash `a/b/c#9`). A bare `#N` (no repo) keys as the implicit
447
+ * node/run repository — `parseIssue` rejects a repo-less ref, so it is keyed directly. Returning null
448
+ * (rather than a key) for a non-canonical reference keeps the close UN-attributed, so the validator
449
+ * fails closed exactly as the canonical grammar does. */
450
+ function issueRefKey(ref: string | undefined): string | null {
451
+ if (!ref) return null;
452
+ if (ref.startsWith("#")) return issueKey(null, ref.slice(1));
453
+ // A URL token is lexed as a maximal `[^\s)]+` run, so it can carry a trailing possessive (`'s`) or
454
+ // prose punctuation (`;`, `,`, `.`, `:`, `!`, `?`) that is not part of the reference (`…/issues/12's
455
+ // full scope`, `…/issues/12; note …`). The canonical parser is anchored, so strip the trailing
456
+ // possessive/punctuation before classifying — a bare `#N` token ends in a digit and never needs this.
457
+ const cleaned = ref.replace(/(?:['’]s|s['’]|['’])?[;.,:!?]*$/, "");
458
+ const parsed = parseIssue(cleaned);
459
+ return parsed ? issueKey(parsed.repo, parsed.number) : null;
460
+ }
461
+ function closingTargets(prompt: string): { numbered: string[]; pronoun: boolean } {
462
+ const numbered: string[] = [];
463
+ let pronoun = false;
464
+ for (const m of prompt.matchAll(CLOSING_TARGET_PATTERN)) {
465
+ // A directly-negated close (`do not close #12`) is not a close TARGET — skip it. The negation never
466
+ // masks a sibling ACTIVE close: each match is judged on the text before its OWN verb. EXCEPTION: an
467
+ // additive `not just/only … ; also/but …` correlative still INSTRUCTS the close, so it stays a target
468
+ // (issue #858 round-8 review).
469
+ const before = prompt.slice(0, m.index);
470
+ if (NEGATED_CLOSE_PREFIX.test(before)) {
471
+ const additive =
472
+ CORRELATIVE_NEGATED_CLOSE_PREFIX.test(before) &&
473
+ ADDITIVE_CONTINUATION.test(prompt.slice(m.index + m[0].length));
474
+ if (!additive) continue;
475
+ }
476
+ // The issue-first/passive arm carries its negation INSIDE the match's auxiliary window (`#12 is
477
+ // NOT closed`), which the before-verb prefix check above cannot see — the negator sits AFTER the
478
+ // arm's issue-ref start, so the text ending at `m.index` does not reach it. Drop a passive close
479
+ // whose own window is negated (issue #858 round-10 review). The window is group 5.
480
+ if (m[5] !== undefined && NEGATED_PASSIVE_WINDOW.test(m[5])) continue;
481
+ // Group 1 is the ACTIVE arm's issue reference (or a pronoun); group 3 is the PASSIVE arm's subject
482
+ // reference. Classify each through the canonical parser; a pronoun (`it`/`them`/`the issue`) is not
483
+ // a reference, so it sets `pronoun` instead.
484
+ const activeRef = m[1];
485
+ const passiveRef = m[3];
486
+ const activeKey = issueRefKey(activeRef);
487
+ const passiveKey = issueRefKey(passiveRef);
488
+ if (activeKey) numbered.push(activeKey);
489
+ else if (passiveKey) numbered.push(passiveKey);
490
+ else pronoun = true;
491
+ // A COORDINATED close names every target (`close #12 and #13`, `close #12, #13, and #14`). The
492
+ // active arm captures only the FIRST; group 2 is the whole coordinated tail, re-scanned for every
493
+ // extra numbered target so each closed issue needs its own acknowledgement (issue #858 round-11
494
+ // review — `close #12 and #13` previously validated with only #12 acknowledged, a fail-open bypass).
495
+ if (m[2] !== undefined && m[2] !== "") {
496
+ for (const key of issueRefsIn(m[2])) numbered.push(key);
497
+ }
498
+ // The issue-first/passive arm's subject is coordinated the same way (`issues #12 and #13 are
499
+ // closed`); group 4 is ITS whole coordinated tail, re-scanned identically so a passive close of
500
+ // several issues checks every one (issue #858 round-18 review — `issues #12 and #13 are closed`
501
+ // previously checked only the first subject, the same fail-open bypass one arm over).
502
+ if (m[4] !== undefined && m[4] !== "") {
503
+ for (const key of issueRefsIn(m[4])) numbered.push(key);
504
+ }
505
+ }
506
+ return { numbered, pronoun };
507
+ }
508
+
509
+ /** A whole-scope phrase ATTRIBUTED to someone other than this brief ("…is handled BY siblings",
510
+ * "delivered BY the other slices", "owned BY another slice") does NOT acknowledge that THIS brief
511
+ * owns the scope — it says the opposite. Such an occurrence is disqualified so it cannot licence a
512
+ * close (issue #858 round-3: the full-scope marker must assert this node's ownership of the closing
513
+ * target, not merely mention the scope). Matches a completion verb immediately followed by `by` SOME
514
+ * OTHER agent, or a `by <sibling/other/peer/the rest>` agent phrase. Attribution to the CURRENT slice
515
+ * is an AFFIRMATIVE ownership assertion, not a disclaimer, so the `<verb> by` alternative excludes a
516
+ * self-reference agent (`by this slice` / `by the current slice` / `by me` / `by us` / `by me here`)
517
+ * via a negative lookahead — only attribution to a non-self agent disqualifies (issue #858 round-5
518
+ * review: "allow current-slice ownership in passive attribution"). The sibling/peer/other alternative
519
+ * allows an optional POSSESSIVE (`our`/`their`/`the`) before the noun — `delivered by OUR siblings` is
520
+ * still attribution to OTHERS even though `our` is a self word (the round-5 self-exclusion lookahead
521
+ * added `our`/`us`/`my`, which let `by our siblings` slip through both alternatives; issue #858
522
+ * round-5 adversarial review). A possessive before a SELF noun (`by our team` / `by our slice`) is
523
+ * unaffected — those nouns are not in the others list, so they still fail the lookahead-excluded self
524
+ * branch and do NOT disqualify.
525
+ *
526
+ * The `<verb> by` branch ALSO excludes an implementation METHOD — a `by <gerund>` means-clause that
527
+ * says HOW this slice delivers the scope (`implemented by updating the parser`, `satisfied by adding
528
+ * the migration`, `delivered by carefully refactoring`), NOT attribution to another owner (issue #858
529
+ * round-7 review). A negative lookahead right after `by\s+` skips a (optionally adverb-prefixed)
530
+ * gerund, so an "implemented by doing X" acknowledgement stays valid. The gerund is excluded only
531
+ * when it GOVERNS an object — i.e. it is followed by whitespace plus a continuation that is not a
532
+ * coordinator (`and`/`or`/`then`) or a relative pronoun (`that`/`which`/`who`): `by updating the
533
+ * parser` / `by doing it` / `by filing tickets` are means-clauses. A BARE terminal `-ing` word (end
534
+ * of the assertion, or punctuation next) is an ACTOR noun, not a method — `owned by engineering`,
535
+ * `handled by marketing`, `covered by staffing`, `provided by consulting` all still disqualify
536
+ * (issue #858 round-8 adversarial review: the round-7 lookahead excluded ANY bare `-ing` word after
537
+ * `by`, a fail-OPEN regression vs. round-6 that let an `-ing`-named actor slip past attribution).
538
+ * The coordinator/relative-pronoun guard keeps an `-ing` noun CONJUNCT or noun+relative-clause an
539
+ * actor (`by engineering and product`, `by engineering that reports to product`). A DETERMINER before
540
+ * an `-ing` word (`by the training team`) makes it an actor noun phrase, not a bare gerund, so it is
541
+ * NOT excluded and still disqualifies — attribution to a real actor is preserved. One accepted
542
+ * tradeoff, fail-CLOSED: an intransitive or adverb-led gerund with no object (`by pairing`, `by
543
+ * carefully refactoring` at assertion end) reads as an actor noun and still disqualifies — a rare
544
+ * phrasing that errs toward flagging, never toward letting an attribution through.
545
+ *
546
+ * Tested against the marker's DELIVERY ASSERTION, not its whole comma-bounded clause (see
547
+ * `deliveryAssertionAround`): an attribution governing an UNRELATED constraint coordinated onto the
548
+ * clause by `and` (`Deliver the full scope of #12 AND the regression suite is handled by another team;
549
+ * close #12.`) attributes that other constraint, not the marker's scope, so it must NOT disqualify the
550
+ * close — the SAME false-positive class round-6 scoped the two negation disqualifiers for (issue #858
551
+ * round-6 adversarial review). An attribution in the marker's OWN segment (`the full scope of #12 is
552
+ * handled by siblings`) has no coordinator between it and the marker, so it stays in-segment and still
553
+ * disqualifies. */
554
+ const SCOPE_ATTRIBUTED_TO_OTHERS =
555
+ /(?:handled|delivered|covered|owned|done|provided|implemented|built|completed|satisfied|addressed|met)\s+by\s+(?!(?:the\s+)?(?:this|current|present|me|us|our|my|myself|ourselves|here)\b)(?!(?:\w+ly\s+)?\w+ing\s+(?:[^\w\s]|(?!and\b|or\b|then\b|that\b|which\b|who\b)\w))|\bby\s+(?:(?:the|our|their|its|his|her)\s+)?(?:siblings?|others?|another|peers?|other\s+slices?|sibling\s+slices?|the\s+rest|the\s+others?)\b/i;
556
+
557
+ /** ACTIVE-VOICE attribution to others: a sibling/other-slice SUBJECT performing a completion verb on the
558
+ * scope (`Siblings deliver the full scope of #12`, `the other slices own every criterion`, `another
559
+ * slice covers the whole issue`) credits OTHERS with the delivery exactly as the passive `delivered by
560
+ * siblings` does, so it must disqualify the marker too (issue #858 round-8 review: the passive-only
561
+ * `SCOPE_ATTRIBUTED_TO_OTHERS` let an active-voice sibling-ownership sentence restore the attribution
562
+ * bypass, contradicting the per-node ownership invariant). Matches an OTHERS-noun subject (siblings /
563
+ * peers / another slice / other slices / the rest / the others, optionally possessive) followed within a
564
+ * couple of words by a completion verb — the active voice of `SCOPE_ATTRIBUTED_TO_OTHERS`'s verb list.
565
+ * The subject allowlist is OTHERS only (never `this`/`current` slice, `I`, `we`), so active-voice SELF
566
+ * ownership (`this slice delivers the full scope`) stays an affirmative assertion. A `our`-POSSESSIVE
567
+ * self-reference (`our team delivers the full scope`) is likewise SELF-ownership — the passive equivalent
568
+ * `delivered by our team` is already treated as valid self-ownership (the `SCOPE_ATTRIBUTED_TO_OTHERS`
569
+ * self-exclusion) — so the possessive before a `team` noun excludes `our` specifically while staying
570
+ * OPTIONAL: a BARE `team`/`teams` (`Team delivers the full scope`) is still OTHERS-attribution exactly
571
+ * as the round-entry code flagged it — requiring a determiner there was a fail-open regression (issue
572
+ * #858 round-9 adversarial review). Tested against the
573
+ * marker's DELIVERY ASSERTION (like the passive check), so a sibling clause coordinated onto an
574
+ * UNRELATED constraint by `and`, or sitting in a different comma/`;`-bounded clause, does not over-fire.
575
+ *
576
+ * The subject-to-verb window spans up to four words AND tolerates a coordinator (`and`/`or`/`then`)
577
+ * inside it: the compound-predicate subject retention (see `deliveryAssertionAround`) deliberately keeps
578
+ * the subject for `Siblings plan AND deliver the full scope` — including its ADVERB-LED form
579
+ * (`COMPOUND_PREDICATE_LEAD` admits `(?:\w+ly\s+)?`, e.g. `Siblings plan and CAREFULLY deliver …`) — so
580
+ * this check must reach the verb across the coordinator gap, or the very attribution bypass the
581
+ * retention exists to close re-opens the moment one adverb is inserted (issue #858 round-9 adversarial
582
+ * review). Widening the window cannot over-fire onto a SELF assertion (`this slice plans and carefully
583
+ * delivers …`): the subject allowlist is OTHERS-only, so a self-subject never matches regardless of how
584
+ * many words precede the verb. */
585
+ const SCOPE_ACTIVE_VOICE_OTHERS =
586
+ /\b(?:(?:(?:the|their)\s+)?(?:siblings?|other\s+slices?|sibling\s+slices?|peers?|another\s+slice|the\s+rest|the\s+others?|others|upstream\s+slice|upstream\s+slices?)|(?:(?:(?!our\b)(?:the|their|another|other)\s+)?(?<!\bour )teams?))\s+(?:(?:(?:\w+ly|and|or|then|\w+)\s+)){0,4}?(?:handle|deliver|cover|own|provide|implement|build|complete|satisfy|address|meet|do|finish|ship)(?:s|es|ed|ing)?\b/i;
587
+
588
+ /** A whole-scope phrase whose CLAUSE explicitly NEGATES or DISCLAIMS it ("this slice does NOT deliver
589
+ * the full scope of #12", "we won't cover every acceptance criterion", "the full scope of #12 is NOT
590
+ * delivered here") does NOT acknowledge that this brief owns the scope — it asserts the opposite, yet
591
+ * the bare substring `full scope` is still present (issue #858 round-4 review). Such an occurrence is
592
+ * disqualified so a negated clause cannot licence a close and re-open the partial-close bypass.
593
+ *
594
+ * Negation comes in TWO grammatical shapes, handled separately so an UNRELATED trailing constraint is
595
+ * not mistaken for scope negation (issue #858 round-5 review — `Deliver the full scope of #12 without
596
+ * regressions; close #12.` must validate):
597
+ * - `SCOPE_NEGATED_CORE` — core negators (`not`/`never`/`cannot`/`n't`) and the delivery-failure
598
+ * idioms (`fails to`/`unable to`) that negate the assertion when they sit in the marker's OWN
599
+ * `and`-segment (`does not deliver the full scope`, `…full scope… is NOT delivered`). Tested against
600
+ * the marker's DELIVERY ASSERTION, not its whole comma-bounded clause (see
601
+ * `deliveryAssertionAround`): a core negator governing an UNRELATED constraint coordinated onto the
602
+ * clause by `and` (`Deliver the full scope of #12 AND do NOT introduce regressions; close #12.`)
603
+ * negates that other constraint, not the marker, so it must NOT disqualify the close (issue #858
604
+ * round-6 review). The assertion is the clause narrowed to the `and`-bounded segment the marker sits
605
+ * in, so a negator in a sibling coordinated segment is excluded while one in the marker's own segment
606
+ * (`does not deliver the full scope`) still disqualifies. The guarantee is therefore NARROWED from
607
+ * "wherever they sit": a negator split into a SIBLING `and`-segment no longer disqualifies via this
608
+ * pattern — unless it REFERENCES DELIVERY, which `SCOPE_NEGATED_AFTER_MARKER_DELIVERY` re-catches
609
+ * against the whole clause (issue #858 round-6 adversarial review).
610
+ * - `SCOPE_NEGATED_PREFIX` — exception/redirection PREFIXES (`without`, `other than`, `rather than`,
611
+ * `instead of`, `apart from`, `all but`, `excluding`, …) that negate only the phrase they GOVERN,
612
+ * i.e. the one that FOLLOWS them. They disqualify the marker only when they sit BEFORE it in the
613
+ * clause; a TRAILING occurrence governs some other phrase (`…full scope… WITHOUT regressions`,
614
+ * `…full scope… RATHER THAN a piecemeal split`) and is an affirmative closer.
615
+ *
616
+ * Conservative/fail-closed: the planner contract (plan.md) directs a genuine full-scope closer to
617
+ * carry a plain AFFIRMATIVE acknowledgement, so negating the marker's assertion is a disclaimer, not an
618
+ * assertion. The exception idiom covers `but` only in its narrow "except" phrases (`all but` /
619
+ * `everything but` / `anything but` / `nothing but`) — a BARE `but` is left out deliberately: it is a
620
+ * common affirmative conjunction ("the full scope of #12, but split across two commits"), so matching
621
+ * it would over-fire on legitimate closers. The assertion-narrowing coordinator is `and`/`plus` ONLY
622
+ * (NOT `but`/`or`): `but` is the exception idiom's own keyword (`but without covering X` must stay a
623
+ * disclaimer of the marker, so its segment must still include the trailing negation), and `and` is the
624
+ * unambiguous "independent additional constraint" conjunction the round-6 false positive turned on. */
625
+ const SCOPE_NEGATED_CORE = /\b(?:not|never|cannot|fail(?:s|ing|ed)?\s+to|unable\s+to)\b|n['’]t\b/i;
626
+ const SCOPE_NEGATED_PREFIX =
627
+ /\b(?:without|exclud(?:e|es|ing|ed)|omit(?:s|ting|ted)?|aside\s+from|apart\s+from|other\s+than|rather\s+than|instead\s+of|short\s+of|all\s+but|everything\s+but|anything\s+but|nothing\s+but)\b/i;
628
+ /** Global-flag twin of `SCOPE_NEGATED_PREFIX`, derived from its `source` so the two never drift. Used by
629
+ * `earliestNegatorEnd` to find, in ONE left-to-right scan of a clause, the earliest offset at which a
630
+ * prefix negator completes (so each marker can be tested against its OWN before-marker prefix without
631
+ * re-running the regex per marker — issue #858 round-14 review). */
632
+ const SCOPE_NEGATED_PREFIX_G = new RegExp(SCOPE_NEGATED_PREFIX.source, "gi");
633
+
634
+ /** A TRAILING `without <delivery gerund>` ("Deliver the full scope of #12 WITHOUT COVERING the edge
635
+ * cases") still disclaims completeness — it is a negation, not the benign `without <noun>` constraint
636
+ * ("without regressions") the before-marker-only split was carved out for. Restricting
637
+ * `SCOPE_NEGATED_PREFIX` to the before-marker text (so a trailing "without regressions" stays
638
+ * affirmative) was a fail-open regression for this trailing delivery-negating shape (issue #858
639
+ * round-5 adversarial review): round-4's whole-clause check caught it, the split let it through. This
640
+ * pattern distinguishes the two by what `without` GOVERNS: a delivery gerund (covering / delivering /
641
+ * implementing / finishing / …) means part of the scope is left undelivered, so the marker is
642
+ * disqualified; a plain noun (regressions / tests / breaking changes) is an unrelated trailing
643
+ * constraint and stays affirmative. Tested against the marker's DELIVERY ASSERTION (the gerund sits
644
+ * AFTER the marker; see `deliveryAssertionAround`) — like `SCOPE_NEGATED_CORE`, a `without <gerund>`
645
+ * coordinated onto the clause by `and` (`Deliver the full scope of #12 AND refactor without breaking
646
+ * the build; close #12.`) governs that other constraint, not the marker, and must NOT disqualify
647
+ * (issue #858 round-6 review). A `but without covering X` stays IN the marker's assertion (`but` is not
648
+ * an assertion-splitting coordinator), so that trailing disclaimer still disqualifies. Two guards keep
649
+ * it from over-firing on a benign noun that merely LOOKS like a gerund:
650
+ * - an article/determiner between `without` and the word ("without A covering letter", "without THE
651
+ * building blocks") makes the word a NOUN, so the lookbehinds exclude it; and
652
+ * - `meeting` is deliberately left OUT of the gerund list — "without meeting notes" (a benign noun)
653
+ * is more common in a brief than "without meeting every criterion", and the core-negator /
654
+ * scope-classify layers backstop that phrasing.
655
+ * The gerund list is the delivery vocabulary; a `without <noun>` never matches it. */
656
+ const SCOPE_NEGATED_WITHOUT_DELIVERY =
657
+ /\bwithout\s+(?:\w+\s+){0,2}(?<!the\s)(?<!a\s)(?<!an\s)(?<!any\s)(?<!its\s)(?<!their\s)(?<!our\s)(?<!my\s)(?<!your\s)(?<!his\s)(?<!her\s)(?:covering|delivering|implementing|finishing|completing|building|satisfying|addressing|providing|handling|doing|shipping|including)\b/i;
658
+
659
+ /** A core negator (`not`/`never`/`cannot`/`n't`/`fails to`/`unable to`) that sits AFTER the marker and
660
+ * REFERENCES DELIVERY still disclaims the marker's scope — `Deliver the full scope of #12 AND it is not
661
+ * fully delivered; close #12.` asserts the scope is NOT delivered, yet the `and`-coordinator splits that
662
+ * trailing disclaimer into a sibling segment the assertion-scoped `SCOPE_NEGATED_CORE` never sees, so
663
+ * round-6's narrowing let it VALIDATE (a fail-open regression vs. the pre-round whole-clause test; issue
664
+ * #858 round-6 adversarial review). Tested against the marker's whole comma-bounded CLAUSE (not the
665
+ * `and`-segment): the negator may sit in a trailing coordinated segment, and the DELIVERY reference is
666
+ * what ties it back to the marker. The delivery reference is EITHER a delivery noun/verb
667
+ * (`delivered`/`cover`/`implement`/`scope`/`criterion`…) OR the anaphoric `it`/`that` referring back to
668
+ * the just-named scope. An after-marker negation with NO delivery reference (`…AND do not introduce
669
+ * regressions`, `…AND never break the build`) governs an UNRELATED constraint and stays affirmative —
670
+ * this pattern does NOT match it, so the round-6 false-positive fix is preserved. The delivery
671
+ * vocabulary mirrors the attribution/gerund lists (deliver/cover/implement/complete/scope/criterion/
672
+ * checkbox/…) plus the anaphoric `it`/`that`. */
673
+ const SCOPE_NEGATED_AFTER_MARKER_DELIVERY =
674
+ /(?:\b(?:not|never|cannot|fail(?:s|ing|ed)?\s+to|unable\s+to)\b|\b\w+n['’]t\b)(?:\s+[\w'’-]+){0,4}?\s+(?:it|that|deliver(?:y|s|ed|ing)?|cover(?:s|ed|ing)?|implement(?:s|ed|ing)?|complet(?:e|es|ed|ing)|finish(?:es|ed|ing)?|satisf(?:y|ies|ied|ying)|acceptance\s+criteri\w+|checkbox\w*|scope|criteri\w+|done)\b/i;
675
+
676
+ /** The explicit full-scope acknowledgement markers that licence a closing keyword. These are the
677
+ * phrases the planner contract (resources/prompts/plan.md) directs a full-scope slice to carry, so a
678
+ * legitimately-closing brief already contains one and is NOT rejected. Matching is case-insensitive
679
+ * substring (not a bareword regex) so inflections ("full stated scope", "the full scope", "every
680
+ * acceptance criterion", "all acceptance criteria", "owns the whole issue") all count.
681
+ *
682
+ * Every marker is ISSUE-ANCHORED — it names the whole issue/scope as the thing delivered ("the whole
683
+ * issue", "all of #N", "the entire issue", "every acceptance criterion"). A BARE adverb of
684
+ * completeness (`fully` / `completely` / `end-to-end` / `in full` / `in its entirety`) is
685
+ * deliberately NOT a marker: it can modify a PARTIAL deliverable ("implement one criterion of #12
686
+ * fully, then close #12"), so accepting it would silently disable the guard for exactly the partial
687
+ * brief it exists to catch. The contract (plan.md) tells the planner to anchor the acknowledgement
688
+ * to the issue's whole scope, so a legitimate closer always has an anchored form available.
689
+ *
690
+ * These phrases are whole-scope LANGUAGE only; `isPartialScopeClose` decides, per closing target,
691
+ * whether a phrase occurrence is actually TIED to the issue being closed (same clause / sole issue)
692
+ * and not attributed to others — a phrase alone, anywhere in the prompt, is NOT sufficient. */
693
+ const FULL_SCOPE_MARKERS: readonly string[] = [
694
+ "full stated scope",
695
+ "full scope",
696
+ "every acceptance criterion",
697
+ "all acceptance criteria",
698
+ "every checkbox",
699
+ "all checkboxes",
700
+ "owns the whole",
701
+ "own the whole",
702
+ "complete stated scope",
703
+ "entire scope",
704
+ // Issue-anchored whole-scope paraphrases — the natural phrasings a planner uses when it genuinely
705
+ // scopes a slice to the whole issue (the under-inclusive-marker false positive this widens for).
706
+ "the whole issue",
707
+ "whole issue",
708
+ "the entire issue",
709
+ "entire issue",
710
+ "the complete issue",
711
+ "complete issue",
712
+ "all of #",
713
+ "all of the issue",
714
+ "the whole of #",
715
+ "the whole of the issue",
716
+ ];
717
+
718
+ /** True when `prompt` pairs a GitHub closing action with NO full-scope acknowledgement TIED to the
719
+ * issue it closes — the partial-scope-close defect class (issue #858). Requires BOTH a closing action
720
+ * and an issue reference (so a bare prose "close the door" or "Part of #12" never matches).
721
+ *
722
+ * The acknowledgement is TARGET-ASSOCIATED, not a global substring (issue #858 round-3): a whole-scope
723
+ * phrase is credited to issue `N` only when `#N` is ANCHORED to that phrase (immediately adjacent —
724
+ * "all of #N", "the whole issue #N", "full scope of #N", "#N's full scope"), or — when the whole
725
+ * prompt references exactly one issue — to that sole issue (unambiguous). A marker anchored to a
726
+ * DIFFERENT issue than the one closed (acknowledge #11's scope, close #12), or one ATTRIBUTED to
727
+ * others ("the full scope of #12 is handled by siblings; … close #12"), does NOT licence the close.
728
+ * Every numbered closing target must be acknowledged; a pronoun close ("close it") requires every
729
+ * referenced issue to be acknowledged (we can't tell which "it" means, so fail closed).
730
+ *
731
+ * This is a lexical lint that cannot read the issue body, so it is the first of a two-layer guard:
732
+ * the scope-classify gate (resources/prompts/scope-classify.md) reads each closed issue's checkboxes
733
+ * at PR time and catches an under-delivery that is semantically — not lexically — a partial close. */
734
+ const CLAUSE_DELIMITERS = new Set([".", ";", ":", ",", "\n", "—"]);
735
+
736
+ /** An `http(s)://…` URL span. The body runs to whitespace or `)`; trailing clause-delimiter punctuation
737
+ * (`.`/`,`/`;`/`:`/`!`/`?`) is trimmed because it is sentence punctuation, not URL structure (a URL
738
+ * written at the end of a clause must not swallow that clause's boundary). Delimiters INSIDE the trimmed
739
+ * span (the `.` in `github.com`, the `:` in `https:`) are part of the URL, not clause boundaries. */
740
+ const URL_SPAN = /https?:\/\/[^\s)]+/gi;
741
+ const URL_TRAILING_PUNCT = /[.;:,!?]+$/;
742
+
743
+ /** The `[start,end)` spans of every URL in `prompt`, with trailing sentence punctuation excluded. */
744
+ function urlSpans(prompt: string): Array<[number, number]> {
745
+ const out: Array<[number, number]> = [];
746
+ const re = new RegExp(URL_SPAN.source, "gi");
747
+ let m = re.exec(prompt);
748
+ while (m !== null) {
749
+ let end = m.index + m[0].length;
750
+ const trailing = m[0].match(URL_TRAILING_PUNCT);
751
+ if (trailing) end -= trailing[0].length;
752
+ out.push([m.index, end]);
753
+ m = re.exec(prompt);
754
+ }
755
+ return out;
756
+ }
757
+
758
+ /** The sorted clause-delimiter POSITIONS in `prompt`, computed once in a single O(n) pass. The
759
+ * full-scope acknowledgement scan tests each marker occurrence against its surrounding clause; locating
760
+ * that clause by scanning back/forward per occurrence makes the whole scan QUADRATIC in prompt length
761
+ * when a prompt repeats a marker (issue #858 round-11 review — a valid 20,000-char prompt with repeated
762
+ * `full scope` markers was rescanned thousands of times, monopolising the event loop across the
763
+ * allowed 256 nodes). Precomputing the boundaries once and binary-searching them per occurrence keeps
764
+ * the aggregate scan O(n log n) regardless of marker count.
765
+ *
766
+ * A delimiter INSIDE a URL is NOT a boundary (issue #858 round-12 review): treating the `:` in `https:`
767
+ * or the `.` in `github.com` as a boundary split an issue URL, so the attribution/negation checks saw
768
+ * only the fragment before the URL (`The full scope of https`) and missed the assertion that followed it
769
+ * (`… is handled by siblings`). URL spans are detected once and their interior delimiters skipped. */
770
+ function clauseBoundaries(prompt: string): number[] {
771
+ const spans = urlSpans(prompt);
772
+ let spanIdx = 0;
773
+ const out: number[] = [];
774
+ for (let i = 0; i < prompt.length; i++) {
775
+ // Advance past any span that ends at/before i (spans are non-overlapping and in order).
776
+ while (spanIdx < spans.length && spans[spanIdx][1] <= i) spanIdx++;
777
+ const inUrl = spanIdx < spans.length && i >= spans[spanIdx][0] && i < spans[spanIdx][1];
778
+ if (inUrl) continue;
779
+ if (CLAUSE_DELIMITERS.has(prompt.charAt(i))) out.push(i);
780
+ }
781
+ return out;
782
+ }
783
+
784
+ /** The start of the clause containing `idx`: one past the last delimiter strictly before `idx`. */
785
+ function clauseStartAt(b: number[], idx: number): number {
786
+ let lo = 0;
787
+ let hi = b.length; // first index with b[i] >= idx
788
+ while (lo < hi) {
789
+ const mid = (lo + hi) >> 1;
790
+ if (b[mid] < idx) lo = mid + 1;
791
+ else hi = mid;
792
+ }
793
+ return lo === 0 ? 0 : b[lo - 1] + 1;
794
+ }
795
+
796
+ /** The end of the clause containing `idx`: the first delimiter at or after `idx` (exclusive). */
797
+ function clauseEndAt(b: number[], idx: number, len: number): number {
798
+ let lo = 0;
799
+ let hi = b.length; // first index with b[i] >= idx
800
+ while (lo < hi) {
801
+ const mid = (lo + hi) >> 1;
802
+ if (b[mid] < idx) lo = mid + 1;
803
+ else hi = mid;
804
+ }
805
+ return lo === b.length ? len : b[lo];
806
+ }
807
+
808
+ /** The earliest absolute offset in `[start, end)` at which a `SCOPE_NEGATED_PREFIX` exception/redirection
809
+ * negator COMPLETES, or `+Infinity` when the span holds none. `SCOPE_NEGATED_PREFIX` disqualifies a
810
+ * marker only when a negator sits fully BEFORE it, and it is an UNANCHORED "contains" test monotonic in
811
+ * the marker offset `i` (once a negator is inside `slice(start, i)` it stays inside as `i` grows). So a
812
+ * marker at offset `i` in this clause is prefix-negated iff this value is `<= i` — computing it ONCE per
813
+ * clause (via a single global-regex scan, taking the minimum match-END over every match) lets each of
814
+ * the clause's markers be judged against its OWN prefix, instead of reusing one clause-wide boolean that
815
+ * let the first (shortest-prefix) marker decide every later one (issue #858 round-14 review). The single
816
+ * scan keeps the per-clause cost linear — no O(markers · clause-length) quadratic on a long
817
+ * delimiter-free clause. */
818
+ function earliestNegatorEnd(prompt: string, start: number, end: number): number {
819
+ const clause = prompt.slice(start, end);
820
+ SCOPE_NEGATED_PREFIX_G.lastIndex = 0;
821
+ let min = Number.POSITIVE_INFINITY;
822
+ let m: RegExpExecArray | null = SCOPE_NEGATED_PREFIX_G.exec(clause);
823
+ while (m !== null) {
824
+ const matchEnd = start + m.index + m[0].length;
825
+ if (matchEnd < min) min = matchEnd;
826
+ if (m[0].length === 0) SCOPE_NEGATED_PREFIX_G.lastIndex++; // defensive: never loop on a zero-width match
827
+ m = SCOPE_NEGATED_PREFIX_G.exec(clause);
828
+ }
829
+ return min;
830
+ }
831
+
832
+ /** The bounded text of the marker's own clause that lies BEFORE the marker occurrence at `idx`, used to
833
+ * test the part-qualifier `PART_QUALIFIER_BEFORE_MARKER` — which narrows only the phrase that FOLLOWS it,
834
+ * so it disqualifies the marker only when it precedes it (issue #858 round-5 review).
835
+ *
836
+ * `PART_QUALIFIER_BEFORE_MARKER` is a `$`-anchored SUFFIX pattern matching a bounded handful of words (a
837
+ * partitive plus `of` and a determiner), so only the trailing few words can match: return at most the
838
+ * last `PREFIX_WINDOW` chars — cut at a word boundary so the leading `\b` never sees a spliced token.
839
+ * Testing the WHOLE growing before-clause per marker made the `$`-anchored regex scan O(clause-length)
840
+ * per marker — O(markers · clause) overall, quadratic on a long delimiter-free clause (issue #858
841
+ * round-12 review).
842
+ *
843
+ * This window is sound ONLY for a `$`-anchored suffix pattern. The OTHER before-marker consumer,
844
+ * `SCOPE_NEGATED_PREFIX`, is an UNANCHORED "contains" test (a negator anywhere in the before-clause
845
+ * disqualifies), so capping it to this window silently drops a negator sitting more than `PREFIX_WINDOW`
846
+ * chars before the marker — a fail-open regression (issue #858 round-12 adversarial review). That
847
+ * consumer therefore tests the WHOLE before-marker text, with the earliest negator offset cached per
848
+ * clause start (see `negatedPrefixEndByClause` / `earliestNegatorEnd` in `isPartialScopeClose`); it does
849
+ * NOT use this bounded window. */
850
+ const PREFIX_WINDOW = 96;
851
+ function clauseBeforeMarker(prompt: string, idx: number, b: number[]): string {
852
+ const start = clauseStartAt(b, idx);
853
+ if (idx - start <= PREFIX_WINDOW) return prompt.slice(start, idx);
854
+ // Cut at a whitespace boundary at/after `idx - PREFIX_WINDOW` so no token is spliced (a mid-token cut
855
+ // could create a spurious `\b` for the consumer's leading anchor).
856
+ let cut = idx - PREFIX_WINDOW;
857
+ while (cut < idx && !/\s/.test(prompt.charAt(cut))) cut++;
858
+ return prompt.slice(cut, idx);
859
+ }
860
+
861
+ /** A coordinating conjunction that joins an INDEPENDENT additional constraint onto a clause. Only `and`
862
+ * (and its `plus`/`&` kin) qualifies: it is the unambiguous "and also do X" additive conjunction, so a
863
+ * negation in the segment it introduces (`…full scope of #12 AND do not introduce regressions`) governs
864
+ * that other constraint, not the marker. `but`/`or`/`nor` are deliberately EXCLUDED — `but` is the
865
+ * exception idiom's own keyword (`but without covering X` must keep the trailing negation inside the
866
+ * marker's assertion so it still disqualifies), and `or` rarely coordinates an independent constraint in
867
+ * a planner brief. (issue #858 round-6 review) */
868
+ const ASSERTION_COORDINATOR = /\b(?:and|plus)\b|&&?/gi;
869
+
870
+ /** Test-only instrumentation: the number of clause-scan steps (coordinator-threshold sweep iterations +
871
+ * per-marker coordinator examinations) performed by the full-scope acknowledgement scan since the last
872
+ * `resetClauseScanSteps()`. This lets the complexity regression test assert the scan is LINEAR in clause
873
+ * size with a deterministic operation count instead of a nondeterministic wall-clock threshold (issue
874
+ * #858 round-12 review). Not read in production logic. */
875
+ let clauseScanSteps = 0;
876
+
877
+ /** Test-only: read and reset the clause-scan step counter. Returns the count since the previous reset. */
878
+ export function drainClauseScanSteps(): number {
879
+ const n = clauseScanSteps;
880
+ clauseScanSteps = 0;
881
+ return n;
882
+ }
883
+
884
+ /** Precomputed, per-clause data that lets each marker's delivery-assertion segment be located WITHOUT
885
+ * re-scanning the clause. Round-11 precomputed the delimiter boundaries once and binary-searched them,
886
+ * but `deliveryAssertionAround` still re-ran the coordinator regex over the whole clause and re-derived
887
+ * every coordinator's compound-predicate status for EACH marker — so a long delimiter-free clause with M
888
+ * markers and C coordinators cost O(M·C) slice+scan work, and the old O(tokens²)
889
+ * `isCompoundPredicateContinuation` made it worse (issue #858 round-12 review: a valid 20,000-char
890
+ * `full scope … and …` prompt took minutes across the 256 allowed nodes). This precompute is built ONCE
891
+ * per clause in a single forward pass; each marker then costs O(number of coordinators in its clause)
892
+ * with O(1) compound lookups and no re-slicing. */
893
+ interface ClauseAssertionData {
894
+ /** The clause text (delimiter-bounded). */
895
+ clause: string;
896
+ /** Coordinator occurrences in order: `[bStart, bEnd]` char offsets within `clause`. */
897
+ coords: Array<[number, number]>;
898
+ /** Per coordinator, the minimal MARKER token index at which the text it introduces (`bEnd` up to the
899
+ * marker) becomes a compound-predicate continuation — i.e. the coordinator stops splitting. `Infinity`
900
+ * when the introduced text never leads with a verb. Lets the per-marker compound test be O(1). */
901
+ compoundAtToken: number[];
902
+ /** Token start offsets within `clause` (for mapping a marker char offset to its token index). */
903
+ tokenStarts: number[];
904
+ /** Per token `t`, the smallest token index `s` such that every token in `[s, t)` is an
905
+ * adverb/coordinator (`isSkip`). The "near window" before a marker at token `t` is `[s, t)`; a
906
+ * coordinator ending inside it has an empty/adverbs-only `introduced`, which is exactly when the
907
+ * marker-lead compound test applies. */
908
+ nearStart: number[];
909
+ /** Per coordinator, `tokenIndexAt(bEnd)` — the first token at/after the coordinator's end. */
910
+ coordTok: number[];
911
+ /** Whether each token is an adverb/coordinator (`quickly`/`and`/`or`/`then`/`-ly`). */
912
+ isSkip: boolean[];
913
+ /** The first token index at/after a char offset (binary search over `tokenStarts`). */
914
+ tokenIndexAt: (pos: number) => number;
915
+ // --- Mutable ascending-sweep state (reset per clause; markers are fed in ascending position order) ---
916
+ /** Index into `coords` of the next coordinator not yet pushed onto the far heap. */
917
+ farPtr: number;
918
+ /** Max-heap (by `bEnd`) of far coordinators' `[bEnd, compoundAtToken]`, with lazy expiry. */
919
+ farHeap: Array<[number, number]>;
920
+ }
921
+
922
+ /** A token that can lead a compound predicate's adverb/coordinator run (`quickly`, `and`, `or`, `then`). */
923
+ const COMPOUND_SKIP_TOKEN = /^(?:\w+ly|and|or|then)$/i;
924
+ /** A token that is a delivery/completion verb lead (the compound predicate's head). Mirrors the verb
925
+ * alternation in `COMPOUND_PREDICATE_LEAD`; matched against a whole token so a trailing `\b` inside the
926
+ * token (e.g. `handle-bar`) still counts, exactly as the sticky lead pattern does. */
927
+ const COMPOUND_VERB_TOKEN =
928
+ /^(?:handle|deliver|cover|own|provide|implement|build|complete|satisfy|address|meet|do|finish|ship|plan|design|scope|close|fix|resolve|add|update|open|write|create|land|merge|test|verify|document)(?:s|es|ed|ing)?\b/i;
929
+
930
+ /** Build the per-clause precompute in ONE forward pass (see `ClauseAssertionData`). Pure string/regex
931
+ * tokenisation plus a monotonic sweep — O(tokens + coordinators) per clause. */
932
+ function buildClauseAssertionData(clause: string): ClauseAssertionData {
933
+ // Tokenise once (whitespace-separated runs), recording each token's start offset.
934
+ const tokenStarts: number[] = [];
935
+ const isSkip: boolean[] = [];
936
+ const isVerb: boolean[] = [];
937
+ {
938
+ const re = /\S+/g;
939
+ let m = re.exec(clause);
940
+ while (m !== null) {
941
+ tokenStarts.push(m.index);
942
+ isSkip.push(COMPOUND_SKIP_TOKEN.test(m[0]));
943
+ isVerb.push(COMPOUND_VERB_TOKEN.test(m[0]));
944
+ m = re.exec(clause);
945
+ }
946
+ }
947
+ const T = tokenStarts.length;
948
+ // nextContent[k] = smallest token index >= k that is NOT an adverb/coordinator (the lead candidate of
949
+ // the suffix starting at k); T when the rest is all adverbs/coordinators.
950
+ const nextContent = new Array<number>(T + 1).fill(T);
951
+ for (let k = T - 1; k >= 0; k--) nextContent[k] = isSkip[k] ? nextContent[k + 1] : k;
952
+ // leadVerbReach[k] = the verb's token index when the suffix starting at k leads with a verb, else Infinity.
953
+ const leadVerbReach = new Array<number>(T).fill(Number.POSITIVE_INFINITY);
954
+ for (let k = 0; k < T; k++) {
955
+ const nc = nextContent[k];
956
+ if (nc < T && isVerb[nc]) leadVerbReach[k] = nc;
957
+ }
958
+ // Coordinator occurrences in order.
959
+ const coords: Array<[number, number]> = [];
960
+ {
961
+ const re = new RegExp(ASSERTION_COORDINATOR.source, "gi");
962
+ let m = re.exec(clause);
963
+ while (m !== null) {
964
+ coords.push([m.index, m.index + m[0].length]);
965
+ m = re.exec(clause);
966
+ }
967
+ }
968
+ // The first token index at/after a char offset (binary search over tokenStarts).
969
+ const tokenIndexAt = (pos: number): number => {
970
+ let lo = 0;
971
+ let hi = T;
972
+ while (lo < hi) {
973
+ const mid = (lo + hi) >> 1;
974
+ if (tokenStarts[mid] < pos) lo = mid + 1;
975
+ else hi = mid;
976
+ }
977
+ return lo;
978
+ };
979
+ // Per coordinator, the marker token index at which it stops splitting when its introduced text is a
980
+ // compound-predicate continuation. The introduced text must be VERB-LED — its FIRST content token,
981
+ // after skipping a leading adverb/coordinator run, is a completion verb (`and deliver …`, `and quickly
982
+ // own …`). `leadVerbReach[te]` answers exactly that lead question for the suffix starting at the
983
+ // coordinator's first introduced token `te`: it is the verb's token index (finite) iff the introduced
984
+ // text leads with a verb, else `Infinity`.
985
+ //
986
+ // Testing only the LEAD (not any later suffix) is the correct semantics (issue #858 round-13 review):
987
+ // scanning every later token until SOME suffix reaches a verb misclassifies an INDEPENDENT clause whose
988
+ // verb merely sits later (`… and this slice delivers the full scope of #12`) as a compound predicate,
989
+ // retaining an unrelated earlier negation/attribution; and that nested scan was itself O(coords ·
990
+ // tokens) on a clause whose introduced text never leads with a verb. When the introduced text leads
991
+ // with a verb the coordinator shares the prior subject for EVERY marker at/after the first introduced
992
+ // token, so the threshold is the constant `te + 1` (the marker sits at/after token `te`); when it does
993
+ // not, the coordinator always splits (`Infinity`). This is O(1) per coordinator — no sweep.
994
+ const compoundAtToken = coords.map(([, bEnd]) => {
995
+ const te = tokenIndexAt(bEnd);
996
+ return te < T && leadVerbReach[te] !== Number.POSITIVE_INFINITY ? te + 1 : Number.POSITIVE_INFINITY;
997
+ });
998
+ // nearStart[t] = smallest token index `s` such that tokens [s, t) are all adverb/coordinator (`isSkip`).
999
+ // Computed in one forward pass: as t advances, the window's left edge resets to t whenever token t-1 is
1000
+ // NOT a skip token, and otherwise extends.
1001
+ const nearStart = new Array<number>(T + 1).fill(0);
1002
+ {
1003
+ let left = 0;
1004
+ for (let t = 0; t <= T; t++) {
1005
+ if (t > 0 && !isSkip[t - 1]) left = t;
1006
+ nearStart[t] = left;
1007
+ }
1008
+ }
1009
+ const coordTok = coords.map(([, bEnd]) => tokenIndexAt(bEnd));
1010
+ return {
1011
+ clause,
1012
+ coords,
1013
+ compoundAtToken,
1014
+ tokenStarts,
1015
+ nearStart,
1016
+ coordTok,
1017
+ isSkip,
1018
+ tokenIndexAt,
1019
+ farPtr: 0,
1020
+ farHeap: [],
1021
+ };
1022
+ }
1023
+
1024
+ /** Max-heap (by element `[0]`, the coordinator `bEnd`) operations for the far-coordinator sweep. A tiny
1025
+ * binary heap; expiry of compound coordinators is LAZY (skipped at the top on read), so a coordinator is
1026
+ * pushed/popped at most once per clause — keeping the ascending marker sweep O((markers + coordinators)
1027
+ * log coordinators) per clause instead of O(markers · coordinators). */
1028
+ function heapPush(heap: Array<[number, number]>, item: [number, number]): void {
1029
+ heap.push(item);
1030
+ let i = heap.length - 1;
1031
+ while (i > 0) {
1032
+ const parent = (i - 1) >> 1;
1033
+ if (heap[parent][0] >= heap[i][0]) break;
1034
+ [heap[parent], heap[i]] = [heap[i], heap[parent]];
1035
+ i = parent;
1036
+ }
1037
+ }
1038
+
1039
+ function heapPop(heap: Array<[number, number]>): void {
1040
+ const last = heap.pop();
1041
+ if (heap.length === 0 || last === undefined) return;
1042
+ heap[0] = last;
1043
+ let i = 0;
1044
+ for (;;) {
1045
+ const l = 2 * i + 1;
1046
+ const r = 2 * i + 2;
1047
+ let m = i;
1048
+ if (l < heap.length && heap[l][0] > heap[m][0]) m = l;
1049
+ if (r < heap.length && heap[r][0] > heap[m][0]) m = r;
1050
+ if (m === i) break;
1051
+ [heap[m], heap[i]] = [heap[i], heap[m]];
1052
+ i = m;
1053
+ }
1054
+ }
1055
+
1056
+ /** The marker's DELIVERY ASSERTION: its own comma-bounded clause, further narrowed to the
1057
+ * `and`-coordinated segment the marker occurrence at `[markerStart,markerEnd)` sits in. Used by the
1058
+ * whole-assertion negation disqualifiers (`SCOPE_NEGATED_CORE`, `SCOPE_NEGATED_WITHOUT_DELIVERY`) so a
1059
+ * negator governing an UNRELATED constraint coordinated onto the clause by `and` does not disqualify the
1060
+ * marker, while a negator in the marker's own segment still does (issue #858 round-6 review). A negator
1061
+ * directly on the marker (`does not deliver the full scope`) has no coordinator between it and the
1062
+ * marker, so it stays in-segment and still disqualifies; the fail-closed default is the whole clause
1063
+ * when no coordinator splits it.
1064
+ *
1065
+ * `cache` memoises `buildClauseAssertionData` per clause start so the many markers sharing one clause do
1066
+ * not each rebuild it (issue #858 round-12 review).
1067
+ *
1068
+ * Returns the segment text AND its absolute (`prompt`-relative) `[absStart, absEnd)` bounds. The bounds
1069
+ * let the caller group occurrences that share a segment start: those segments are nested prefixes
1070
+ * (same start, growing end), so a "contains" disqualifier regex over them is MONOTONIC in the end offset
1071
+ * — the basis for the per-group memoisation in `isPartialScopeClose` (issue #858 round-12 review). */
1072
+ function deliveryAssertionAround(
1073
+ prompt: string,
1074
+ markerStart: number,
1075
+ markerEnd: number,
1076
+ b: number[],
1077
+ cache: Map<number, ClauseAssertionData>,
1078
+ ): { text: string; absStart: number; absEnd: number } {
1079
+ const cStart = clauseStartAt(b, markerStart);
1080
+ let data = cache.get(cStart);
1081
+ if (data === undefined) {
1082
+ data = buildClauseAssertionData(prompt.slice(cStart, clauseEndAt(b, markerStart, prompt.length)));
1083
+ cache.set(cStart, data);
1084
+ }
1085
+ const { clause, coords, compoundAtToken, nearStart, coordTok, tokenIndexAt } = data;
1086
+ // The clause spans the marker; locate the marker's offset within it (the clause start is the first
1087
+ // delimiter boundary at or before markerStart).
1088
+ const relStart = markerStart - cStart;
1089
+ const relEnd = markerEnd - cStart;
1090
+ // The marker's token index: the first token starting at/after relStart. Markers are whole words found
1091
+ // by substring scan, so relStart is always a token start.
1092
+ const markerToken = tokenIndexAt(relStart);
1093
+
1094
+ // The marker's assertion segment is `[segStart, segEnd)`: segStart is the end of the LAST coordinator
1095
+ // before the marker that genuinely SPLITS (is not a compound-predicate continuation), segEnd the start
1096
+ // of the first coordinator at/after the marker. A coordinator BEFORE the marker normally splits, but a
1097
+ // coordinator introducing a COMPOUND PREDICATE — a bare verb phrase with no new subject — shares the
1098
+ // prior segment's subject, so it must NOT split (else the active-voice attribution check loses the
1099
+ // subject; issue #858 round-9 review).
1100
+ //
1101
+ // Markers are fed in ASCENDING position order, so this is a single forward sweep per clause (issue
1102
+ // #858 round-12 review). Coordinators split into two sets relative to the marker:
1103
+ // - FAR coordinators end before the marker's "near window" (the trailing adverb/coordinator run), so
1104
+ // their introduced text contains a real (non-adverb) token and the marker-lead test canNOT apply —
1105
+ // their compound status is purely the precomputed threshold. The max valid far `bEnd` is tracked on
1106
+ // a max-heap, advanced and expired monotonically.
1107
+ // - NEAR coordinators end inside the near window (empty/adverbs-only introduced), so the marker-lead
1108
+ // test (mlc) CAN apply; there are only as many as the adverb run is long, so they are checked
1109
+ // individually.
1110
+ const nStart = nearStart[markerToken];
1111
+
1112
+ // FAR set: coordinators with coordTok <= nStart-1 (they end at/before the near window's start, so a
1113
+ // non-skip token lies between them and the marker). Push newly-in-range coordinators; expire those whose
1114
+ // compound threshold the marker has passed (they no longer split). The heap top is the max valid bEnd.
1115
+ while (data.farPtr < coords.length && coordTok[data.farPtr] <= nStart - 1) {
1116
+ heapPush(data.farHeap, [coords[data.farPtr][1], compoundAtToken[data.farPtr]]);
1117
+ data.farPtr++;
1118
+ }
1119
+ while (data.farHeap.length > 0 && data.farHeap[0][1] <= markerToken) heapPop(data.farHeap);
1120
+ const far = data.farHeap.length > 0 ? data.farHeap[0][0] : 0;
1121
+
1122
+ // NEAR set: coordinators from farPtr onward whose bEnd <= relStart (they end inside the near window or
1123
+ // right at the marker). These are few (the adverb run is short); test each with the full compound rule.
1124
+ let near = 0;
1125
+ for (let ci = data.farPtr; ci < coords.length; ci++) {
1126
+ clauseScanSteps++; // test instrumentation: one near-coordinator examined per marker
1127
+ const bEnd = coords[ci][1];
1128
+ if (bEnd > relStart) break; // coords are ordered; past the marker
1129
+ // The marker's OWN lead is the continuation when the coordinator sits right at the marker
1130
+ // (`… and OWN the whole`, `introduced` empty) OR when only adverbs/coordinators intervene
1131
+ // (`… and QUICKLY own the whole`). In both the predicate's head verb is inside the marker, so test
1132
+ // the marker's lead.
1133
+ const introduced = clause.slice(bEnd, relStart);
1134
+ const markerLeadIsCompound =
1135
+ (introduced.trim() === "" || /^(?:\s*(?:\w+ly|and|or|then))*\s*$/.test(introduced)) &&
1136
+ isCompoundPredicateContinuation(clause.slice(relStart, relEnd));
1137
+ const compound = markerToken >= compoundAtToken[ci] || markerLeadIsCompound;
1138
+ if (bEnd > near && !compound) {
1139
+ near = bEnd;
1140
+ }
1141
+ }
1142
+ const segStart = Math.max(far, near);
1143
+
1144
+ // segEnd: the start of the first coordinator at/after the marker's end (binary search — coords are
1145
+ // ordered by bStart).
1146
+ let segEnd = clause.length;
1147
+ {
1148
+ let lo = 0;
1149
+ let hi = coords.length;
1150
+ while (lo < hi) {
1151
+ const mid = (lo + hi) >> 1;
1152
+ if (coords[mid][0] < relEnd) lo = mid + 1;
1153
+ else hi = mid;
1154
+ }
1155
+ if (lo < coords.length) segEnd = coords[lo][0];
1156
+ }
1157
+ // Return ABSOLUTE (prompt-relative) bounds so the caller can group occurrences sharing a segment start.
1158
+ return { text: clause.slice(segStart, segEnd), absStart: cStart + segStart, absEnd: cStart + segEnd };
1159
+ }
1160
+
1161
+ /** True when the text between a coordinator and the marker is a COMPOUND PREDICATE continuation — a bare
1162
+ * verb phrase (optionally adverb-led) with NO subject noun of its own. `Siblings plan and DELIVER the
1163
+ * full scope` has `deliver` right after `and` (a verb, no new subject), so the `and` shares the prior
1164
+ * subject `Siblings`. By contrast `… and THE REGRESSION SUITE is handled …` leads with a determiner/noun
1165
+ * (a new subject), so it is an independent constraint, not a compound predicate. The heuristic: the
1166
+ * continuation leads with an optional adverb then a VERB (a delivery/completion verb or a generic
1167
+ * `-ing`/`-s`/base verb), never a determiner/pronoun/noun. Conservative — it only suppresses the split
1168
+ * when the lead word is clearly verb-like, so an independent constraint with a nominal subject still
1169
+ * splits (issue #858 round-9 review). */
1170
+ const COMPOUND_PREDICATE_LEAD =
1171
+ /^\s*(?:(?:\w+ly|and|or|then)\s+)*(?:handle|deliver|cover|own|provide|implement|build|complete|satisfy|address|meet|do|finish|ship|plan|design|scope|close|fix|resolve|add|update|open|write|create|land|merge|test|verify|document)(?:s|es|ed|ing)?\b/i;
1172
+ /** Sticky (anchored at `lastIndex`) form of the lead test, WITHOUT the leading `^\s*` — the caller
1173
+ * positions `lastIndex` at a token start. Derived from `COMPOUND_PREDICATE_LEAD.source` (single source of
1174
+ * truth — no divergent copy of the verb list) by stripping the leading `^\s*` anchor and adding the
1175
+ * sticky flag. Used by the linear token scan in `isCompoundPredicateContinuation` so testing every
1176
+ * token-suffix of a segment is O(tokens), not the O(tokens²) of re-joining and re-matching each suffix
1177
+ * (`tokens.slice(k).join(" ")` rebuilt a fresh string per suffix — issue #858 round-12 review: on a long
1178
+ * delimiter-free clause that per-suffix re-join made the whole full-scope scan superlinear). */
1179
+ const COMPOUND_PREDICATE_LEAD_STICKY = new RegExp(
1180
+ // Strip the leading `^` anchor and the `\s*` run (the caller positions lastIndex at a token start).
1181
+ COMPOUND_PREDICATE_LEAD.source.replace(/^\^/, "").replace(/^\\s\*/, ""),
1182
+ "iy",
1183
+ );
1184
+ // Reference the anchored form so the canonical pattern stays a live symbol (the sticky derivative is the
1185
+ // one used in the hot scan); this also guards the derivation above against a source drift that drops the
1186
+ // anchor the `.replace` expects.
1187
+ void COMPOUND_PREDICATE_LEAD;
1188
+ function isCompoundPredicateContinuation(between: string): boolean {
1189
+ // A compound predicate is a bare VERB PHRASE with no subject of its own. It can be verb-led
1190
+ // (`and deliver …`), adverb-led (`and carefully deliver …`), or a coordinator+adverb chain whose
1191
+ // LAST token is the verb (`and quickly own …`, `and then handle …`) — the verb need not be the FIRST
1192
+ // word, only the predicate's head. So test the lead at the start AND after each adverb/coordinator
1193
+ // boundary: if any suffix leads with a completion verb, the segment is a compound continuation and
1194
+ // the coordinator shares the prior subject (issue #858 round-9 adversarial review — requiring the
1195
+ // verb first dropped the subject for `design and quickly own`, re-opening the attribution bypass).
1196
+ //
1197
+ // Test each token start with the STICKY lead pattern (O(1) per position, O(tokens) total) instead of
1198
+ // rebuilding every suffix string (O(tokens²)). Behaviour-identical to the suffix-join form: the lead
1199
+ // pattern only inspects a leading run of adverb/coordinator tokens then one verb, so anchoring it at
1200
+ // each token start matches exactly the suffixes the old loop re-joined (verified by differential fuzz).
1201
+ const n = between.length;
1202
+ let i = 0;
1203
+ while (i < n) {
1204
+ while (i < n && /\s/.test(between.charAt(i))) i++; // skip whitespace to the next token start
1205
+ if (i >= n) break;
1206
+ COMPOUND_PREDICATE_LEAD_STICKY.lastIndex = i;
1207
+ if (COMPOUND_PREDICATE_LEAD_STICKY.test(between)) return true;
1208
+ while (i < n && !/\s/.test(between.charAt(i))) i++; // advance past this token
1209
+ }
1210
+ return false;
1211
+ }
1212
+
1213
+ /** A whole-scope marker narrowed by a PREFIX PARTITIVE ("half of every acceptance criterion", "part of
1214
+ * the whole issue", "a subset of the full scope") scopes the acknowledgement DOWN to a part, so it must
1215
+ * NOT licence a close — the mirror of `PART_QUALIFIER_AFTER_MARKER` on the LEADING side (issue #858
1216
+ * round-5 review: "detect partial-scope qualifiers before the issue marker"). Matches a partitive
1217
+ * quantifier at the END of the marker's before-clause text, so it is adjacent to the marker. The `of`
1218
+ * is OPTIONAL: the most common English partitive drops it ("half the full scope", "part the whole
1219
+ * issue", "half my scope"), so requiring a literal `of` let exactly those siblings bypass the guard
1220
+ * (issue #858 round-5 adversarial review). An optional determiner/possessive (`the`/`its`/`my`/`our`/…)
1221
+ * may sit between the partitive and the marker. Whole quantifiers (`all of`, `the whole of`) are
1222
+ * deliberately excluded — they denote the WHOLE, not a part — and an unrelated earlier "… of …" ("as
1223
+ * part of the milestone, deliver the full scope …") is not adjacent to the marker, so it does not
1224
+ * disqualify. */
1225
+ const PART_QUALIFIER_BEFORE_MARKER =
1226
+ /\b(?:(?:a|one|two|three|four|five)\s+)?(?:half|part|portion|some|subset|fraction|piece|bit|chunk|segment|slice|section|fragment|sliver|handful|couple|number|few|several|most|many|much|majority|minority|remainder|rest)\s+(?:of\s+)?(?:(?:the|its|this|that|each|every|all|a|my|our|your|their|his|her)\s+)?$/i;
1227
+
1228
+ /** A whole-scope marker immediately followed by a PART-QUALIFIER scopes the acknowledgement DOWN to a
1229
+ * part, so it must NOT licence a close (issue #858 round-3 adversarial review). Matching is a bare
1230
+ * substring, so "the whole issue's parser slice", "all of #12's backend", and "every acceptance
1231
+ * criterion's auth half" all credit the marker even though each describes a PARTIAL deliverable — the
1232
+ * exact defect class the guard exists to catch. This pattern matches the text IMMEDIATELY after a
1233
+ * marker occurrence when that text is a possessive (`'s <part>`) or partitive (`of <part>` / `of the
1234
+ * <part>`) that narrows the whole to one slice. The marker is anchored at its end (`end`), so the
1235
+ * qualifier must be adjacent — a part-word appearing LATER in the clause ("the whole issue #12,
1236
+ * including the parser slice") does NOT disqualify. The part-word list is the vocabulary a planner
1237
+ * uses to name a sub-scope; it is deliberately broad (any of these words right after the marker means
1238
+ * the acknowledgement is not whole-scope). */
1239
+ const PART_QUALIFIER_AFTER_MARKER =
1240
+ /^(?:['’]s|of)\s+(?:(?:the|a|an|one|first|second|third|single|only|just)\s+){0,2}(?:parser|slice|part|portion|half|backend|frontend|auth|criteri(?:on|a)|checkbox|front|back|ui|api|db|database|server|client|component|module|piece|section|stage|step|phase|bit|chunk|segment|subset|subpart|aspect|layer|tier|side|edge|corner|fragment|shard|sliver|remnant|rest|remainder)\b/i;
1241
+
1242
+ /** True when the text immediately after a whole-scope marker occurrence (at `[start,end)`) is a
1243
+ * part-qualifier that scopes the acknowledgement DOWN to a part — disqualifying the occurrence so it
1244
+ * cannot licence a close. The qualifier can follow the marker directly ("the whole issue's parser
1245
+ * slice"), follow a connective word the marker is a prefix of ("own the whole issue's backend" — the
1246
+ * marker "own the whole" ends before "issue"), or follow the `#N` anchor the marker attaches to ("all
1247
+ * of #12's backend"). So we first consume any run of connective/anchor tokens (whitespace, `issue`,
1248
+ * `scope`, `of`, `the`, `#N`) and then test for the part-qualifier at each step. */
1249
+ function isPartQualified(prompt: string, end: number): boolean {
1250
+ const rest = prompt.slice(end, end + 64);
1251
+ if (PART_QUALIFIER_AFTER_MARKER.test(rest)) return true;
1252
+ // Walk past one connective/anchor token at a time, re-testing for the qualifier after each, so a
1253
+ // qualifier that follows "issue" / "scope" / "#N" (etc.) right after the marker still disqualifies.
1254
+ const step = /^(\s+|issue\b|scope\b|of\b|the\b|#[0-9]+|[0-9]+)/i;
1255
+ let offset = 0;
1256
+ for (let n = 0; n < 4; n++) {
1257
+ const m = rest.slice(offset).match(step);
1258
+ if (!m) break;
1259
+ offset += m[0].length;
1260
+ if (PART_QUALIFIER_AFTER_MARKER.test(rest.slice(offset))) return true;
1261
+ }
1262
+ return false;
1263
+ }
1264
+
1265
+ /** A TRAILING scope-EXCLUSION connective (`except`/`excluding`/`excluded`/`omitting`/`other than`/
1266
+ * `apart from`/`aside from`/`but not`/`save for`/`with the exception of`/`minus`) that narrows a whole-scope marker DOWN to "the
1267
+ * whole MINUS a named part" — so it must NOT licence a close (issue #858 round-15 review). This is the
1268
+ * AFTER-anchor mirror of the before-marker `SCOPE_NEGATED_PREFIX` exception vocabulary: in `Deliver the
1269
+ * full scope of #12, excluding the parser; close #12.` the exclusion sits AFTER the marker's `#12`
1270
+ * anchor and the comma ends the marker's clause before it, so neither the clause-bounded
1271
+ * `SCOPE_NEGATED_AFTER_MARKER_DELIVERY` re-catch nor the before-marker `SCOPE_NEGATED_PREFIX` ever sees
1272
+ * it, and the explicitly-partial brief validated. Keyed `^\s*,?\s*…` so it may cross at most ONE comma
1273
+ * (the clause boundary the exclusion typically follows); the negative lookahead keeps an EXCLUSION OF
1274
+ * NOTHING (`excluding nothing`, `except none`, `other than no part`) affirmative — that still delivers
1275
+ * the whole scope. A BARE trailing `but` (`…, but split across two commits`) is deliberately NOT here —
1276
+ * only the `but not` exception idiom is — mirroring `SCOPE_NEGATED_PREFIX`, which leaves bare `but` out
1277
+ * as a common affirmative conjunction. Trailing `without`/`rather than`/`instead of` are likewise
1278
+ * excluded: those govern a manner/constraint and stay affirmative (the `without <delivery gerund>` case
1279
+ * is already caught by `SCOPE_NEGATED_WITHOUT_DELIVERY`). */
1280
+ const SCOPE_EXCLUSION_LEAD =
1281
+ /^\s*,?\s*\b(?:except(?:ing|\s+for)?|exclud(?:e|es|ed|ing)|omit(?:s|ted|ting)?|other\s+than|apart\s+from|aside\s+from|save\s+for|but\s+not|with\s+the\s+exception\s+of|minus)\b\s+(?!nothing\b|none\b|no\b|any\s+other\b)\S/i;
1282
+
1283
+ /** True when a whole-scope marker ending at `end` is narrowed by a TRAILING exclusion phrase that sits
1284
+ * after the marker's issue anchor (and at most one comma). We walk past the anchor run — whitespace,
1285
+ * `of`/`the`/`issue`/`scope`/`'s`, a bare or repo-qualified `#N`, or an issue URL — re-testing for the
1286
+ * exclusion connective after each token, so `full scope of #12, excluding the parser` disqualifies even
1287
+ * though the exclusion is several anchor tokens and a comma past the marker. The walk is bounded (at
1288
+ * most 8 anchor tokens) and `SCOPE_EXCLUSION_LEAD` crosses at most one comma, so a distant `excluding`
1289
+ * governing an unrelated later phrase cannot reach back and disqualify this marker. */
1290
+ function isExclusionQualified(prompt: string, end: number): boolean {
1291
+ const rest = prompt.slice(end, end + 96);
1292
+ const step = /^(?:\s+|['’]s|of\b|the\b|issue\b|scope\b|#?\s*[0-9]+|[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+|https?:\/\/[^\s)]+)/i;
1293
+ let offset = 0;
1294
+ for (let n = 0; n < 8; n++) {
1295
+ if (SCOPE_EXCLUSION_LEAD.test(rest.slice(offset))) return true;
1296
+ const m = rest.slice(offset).match(step);
1297
+ if (!m || m[0].length === 0) break;
1298
+ offset += m[0].length;
1299
+ }
1300
+ return false;
1301
+ }
1302
+
1303
+ /** The repo-qualified issue KEY (see `issueKey`) ANCHORED to a whole-scope phrase occupying
1304
+ * `[start,end)` in `prompt`, or null if none is adjacent. Checks, in order: a `#N` (optionally
1305
+ * `owner/repo#N`) or an issue URL (`…/owner/repo/issues/N`) immediately AFTER the phrase (through at
1306
+ * most a few connective words — "of", "the", "issue"; or directly, for markers that already end in
1307
+ * `#`), then a possessive/adjacent `owner/repo#N`, bare `#N`, or issue URL immediately BEFORE it.
1308
+ * Issue URLs are a first-class issue reference (parsed by `issueRefsIn`/`closingTargets`), so the
1309
+ * anchor must recognise them too — otherwise a prompt whose only full-scope acknowledgement is
1310
+ * URL-anchored, and which references a second issue (so the sole-issue fallback is unavailable), is
1311
+ * wrongly rejected (issue #858 round-5 review). Proximity is what ties the acknowledgement to a
1312
+ * specific issue, so a phrase next to `owner/alpha#12` (or its URL) cannot license closing
1313
+ * `owner/beta#12` (or a bare `#12`) in the same clause — the repository prefix is preserved, not
1314
+ * collapsed to the bare number. */
1315
+ function anchoredIssueKey(prompt: string, start: number, end: number): string | null {
1316
+ const after = prompt.slice(end, end + 96);
1317
+ // Lex the candidate reference that immediately FOLLOWS the marker (through at most a few connective
1318
+ // words — "of", "the", "issue"), with NO digit-capture group (the single-grammar guard bans a local
1319
+ // `#(<digits>`/`/issues/(<digits>` capture), then classify it through the canonical parser
1320
+ // (`issueRefKey`). A marker that already ends in `#` is anchored to the bare number that follows.
1321
+ if (prompt[end - 1] === "#") {
1322
+ const num = after.match(/^[0-9]+/);
1323
+ if (num) return issueKey(null, num[0]);
1324
+ } else {
1325
+ const afterRef = after.match(/^\s*(?:(?:of|the|issue)\s+){0,3}(https?:\/\/[^\s)]+|[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#[0-9]+|#[0-9]+)/i);
1326
+ if (afterRef) {
1327
+ const key = issueRefKey(afterRef[1]);
1328
+ if (key) return key;
1329
+ }
1330
+ }
1331
+ const before = prompt.slice(Math.max(0, start - 96), start);
1332
+ // The reference immediately BEFORE the marker (a possessive/adjacent `owner/repo#N`, bare `#N`, or an
1333
+ // issue URL), optionally followed by `'s`/`of`. Lexed capture-free, classified via `issueRefKey`.
1334
+ const beforeRef = before.match(/(https?:\/\/[^\s)]+|[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#[0-9]+|#[0-9]+)(?:['’]s|s['’]|['’])?\s*(?:of\s+)?$/i);
1335
+ if (beforeRef) {
1336
+ const key = issueRefKey(beforeRef[1]);
1337
+ if (key) return key;
1338
+ }
1339
+ return null;
1340
+ }
1341
+
1342
+ function isPartialScopeClose(prompt: string): boolean {
1343
+ if (!ISSUE_REF_PATTERN.test(prompt)) return false;
1344
+
1345
+ // The AUTHORITATIVE closing targets (negation-aware): a directly-negated close (`do not close #12`)
1346
+ // is not a target. If every close in the prompt is negated — or there is no closing LANGUAGE at all —
1347
+ // there is no ACTIVE close to guard, so the brief is a safe partial-slice brief, not a
1348
+ // partial-scope-close (issue #858 round-7 review). `closingTargets` is the SINGLE source of the
1349
+ // closing grammar (`CLOSING_TARGET_PATTERN`); there is no separate pre-filter to drift from it
1350
+ // (issue #858 round-16 review removed the duplicate `CLOSING_ACTION_PATTERN`).
1351
+ const { numbered, pronoun } = closingTargets(prompt);
1352
+ if (numbered.length === 0 && !pronoun) return false;
1353
+
1354
+ const distinct = new Set(issueRefsIn(prompt));
1355
+ const sole = distinct.size === 1 ? [...distinct][0] : null;
1356
+
1357
+ // Which issues does the brief genuinely acknowledge owning the FULL scope of? Scan each whole-scope
1358
+ // phrase occurrence, anchor it to the issue immediately adjacent to it (or the sole issue), and skip
1359
+ // any occurrence whose clause attributes the scope to others, NEGATES/disclaims it ("does not deliver
1360
+ // the full scope of #12"), or that is immediately qualified DOWN to a part ("the whole issue's parser
1361
+ // slice") — each is a non-assertion, not a whole-scope acknowledgement.
1362
+ const lower = prompt.toLowerCase();
1363
+ const acknowledged = new Set<string>();
1364
+ // Clause-delimiter positions, computed ONCE (issue #858 round-11 review): the per-occurrence
1365
+ // disqualifiers below each locate the marker's clause, and doing that by scanning back/forward per
1366
+ // occurrence makes the whole scan quadratic in prompt length when a marker repeats. Binary-searching
1367
+ // the precomputed boundaries keeps it O(n log n).
1368
+ const bounds = clauseBoundaries(prompt);
1369
+ // Per-clause coordinator/segment precompute, built lazily ONCE per clause and shared across every
1370
+ // marker occurrence in that clause (issue #858 round-12 review): without it, a long delimiter-free
1371
+ // clause with many markers re-derives each coordinator's compound-predicate status per marker, which is
1372
+ // quadratic in clause length.
1373
+ const assertionCache = new Map<number, ClauseAssertionData>();
1374
+ // Collect every whole-scope marker occurrence across all marker phrases, then process them in
1375
+ // ASCENDING position order. The per-clause sweeper (`deliveryAssertionAround`) advances a coordinator
1376
+ // pointer and a far/near split monotonically as the marker position advances, so feeding occurrences
1377
+ // in ascending order keeps the whole scan LINEAR in clause size (issue #858 round-12 review); feeding
1378
+ // them phrase-by-phrase (all of one marker, then the next) would revisit earlier positions and break
1379
+ // the monotonic sweep.
1380
+ const occurrences: Array<[number, number]> = [];
1381
+ for (const marker of FULL_SCOPE_MARKERS) {
1382
+ for (let i = lower.indexOf(marker); i >= 0; i = lower.indexOf(marker, i + marker.length)) {
1383
+ occurrences.push([i, i + marker.length]);
1384
+ }
1385
+ }
1386
+ occurrences.sort((a, b) => a[0] - b[0]);
1387
+ // `SCOPE_NEGATED_AFTER_MARKER_DELIVERY` re-catches a negator that sits AFTER the marker yet still
1388
+ // disclaims its scope (`…full scope of #12 AND it is not fully delivered`). It must therefore run
1389
+ // against the MARKER-RELATIVE SUFFIX — the text from the marker to the clause end — NOT the whole
1390
+ // clause: scanning the text BEFORE the marker matched a negator that PRECEDES it (`The tests must not
1391
+ // regress and this slice delivers the full scope of #12` matches `not … delivers`), rejecting a
1392
+ // legitimate acknowledgement for a negation that governs an unrelated earlier constraint (issue #858
1393
+ // round-13 review). The suffix differs per marker, so memoise the boolean keyed by the marker's own
1394
+ // offset `i` — re-running the backtracking regex per marker over a long clause would be O(markers ·
1395
+ // clause-length), quadratic on a long delimiter-free clause (issue #858 round-12 review).
1396
+ const negatedAfterMarkerByMarker = new Map<number, boolean>();
1397
+ // `SCOPE_NEGATED_PREFIX` is an UNANCHORED "contains" test over the marker's BEFORE-MARKER text
1398
+ // (`slice(clauseStart, i)`, a negator anywhere before the marker disqualifies it), so it must NOT be
1399
+ // capped to the bounded `clauseBeforeMarker` window — that window is sound only for the `$`-anchored
1400
+ // `PART_QUALIFIER_BEFORE_MARKER`, and capping the contains test to it silently dropped a negator
1401
+ // sitting more than `PREFIX_WINDOW` chars before the marker, failing the guard open (issue #858
1402
+ // round-12 adversarial review). The before-marker text is NOT identical across a clause's markers — it
1403
+ // GROWS with each marker's offset `i` — so a single boolean keyed by clause start let the first
1404
+ // (shortest-prefix) marker decide every later one, crediting a disclaimed trailing marker whose own
1405
+ // prefix DOES hold a negator (issue #858 round-14 review). Because the test is monotonic in `i`, cache
1406
+ // instead the earliest absolute offset at which a negator completes (one O(clause-length) scan per
1407
+ // clause, via `earliestNegatorEnd`); a marker at `i` is negated iff that offset `<= i`.
1408
+ const negatedPrefixEndByClause = new Map<number, number>();
1409
+ // The four assertion disqualifiers (`SCOPE_ATTRIBUTED_TO_OTHERS`, `SCOPE_ACTIVE_VOICE_OTHERS`,
1410
+ // `SCOPE_NEGATED_CORE`, `SCOPE_NEGATED_WITHOUT_DELIVERY`) are "contains" tests over the marker's
1411
+ // assertion segment. Occurrences that share a segment START sit in nested segments (same start, growing
1412
+ // end — e.g. a delimiter-free clause where every coordinator is a compound predicate), so each such test
1413
+ // is MONOTONIC in the segment end: a prefix that matches stays matching as it grows, and a prefix that
1414
+ // does NOT match means no shorter prefix in the group matches either. So, per absStart-group, test each
1415
+ // regex ONCE against the group's LONGEST segment: if it does not match, no occurrence in the group is
1416
+ // disqualified by it; if it does match, only the occurrences at/past the minimal matching end are
1417
+ // disqualified (found by a binary search over the group's sorted ends). This collapses the per-marker
1418
+ // regex work on a pathological clause from O(markers · segment) to O(segment) per group (issue #858
1419
+ // round-12 review).
1420
+ const ASSERTION_DISQUALIFIERS: ReadonlyArray<RegExp> = [
1421
+ SCOPE_ATTRIBUTED_TO_OTHERS,
1422
+ SCOPE_ACTIVE_VOICE_OTHERS,
1423
+ SCOPE_NEGATED_CORE,
1424
+ SCOPE_NEGATED_WITHOUT_DELIVERY,
1425
+ ];
1426
+
1427
+ // First pass: compute each surviving occurrence's assertion segment and group occurrences by segment
1428
+ // start (occurrences are already in ascending position order, so each group's ends come out sorted).
1429
+ interface Occurrence {
1430
+ i: number;
1431
+ markerEnd: number;
1432
+ absStart: number;
1433
+ absEnd: number;
1434
+ text: string;
1435
+ }
1436
+ const groups = new Map<number, Occurrence[]>();
1437
+ for (const [i, markerEnd] of occurrences) {
1438
+ if (isPartQualified(prompt, markerEnd)) continue;
1439
+ if (isExclusionQualified(prompt, markerEnd)) continue;
1440
+ const assertion = deliveryAssertionAround(prompt, i, markerEnd, bounds, assertionCache);
1441
+ const occ: Occurrence = { i, markerEnd, absStart: assertion.absStart, absEnd: assertion.absEnd, text: assertion.text };
1442
+ const g = groups.get(assertion.absStart);
1443
+ if (g === undefined) groups.set(assertion.absStart, [occ]);
1444
+ else g.push(occ);
1445
+ }
1446
+
1447
+ // Per group + per regex, the minimal segment-end that matches (Infinity if the longest segment is
1448
+ // clean). Computed once per group against the longest segment, refined by binary search only on a hit.
1449
+ const groupMinMatch = new Map<number, number[]>();
1450
+ for (const [absStart, group] of groups) {
1451
+ const longest = group[group.length - 1]; // ascending ends ⇒ last is longest
1452
+ const minMatch = ASSERTION_DISQUALIFIERS.map((re) => {
1453
+ if (!re.test(longest.text)) return Number.POSITIVE_INFINITY; // clean for the whole group
1454
+ // The regex matches somewhere in the longest segment. Find the minimal group-end that still matches
1455
+ // (monotonic ⇒ binary search over the group's sorted absEnd values).
1456
+ let lo = 0;
1457
+ let hi = group.length - 1; // invariant: group[hi] matches, group[lo-1] (virtual) does not
1458
+ while (lo < hi) {
1459
+ const mid = (lo + hi) >> 1;
1460
+ if (re.test(group[mid].text)) hi = mid;
1461
+ else lo = mid + 1;
1462
+ }
1463
+ return group[lo].absEnd;
1464
+ });
1465
+ groupMinMatch.set(absStart, minMatch);
1466
+ }
1467
+
1468
+ for (const [, group] of groups) {
1469
+ const minMatch = groupMinMatch.get(group[0].absStart);
1470
+ for (const occ of group) {
1471
+ const { i, markerEnd, absEnd } = occ;
1472
+ // Disqualified iff ANY assertion disqualifier matches at a segment-end <= this occurrence's end.
1473
+ let disqualified = false;
1474
+ if (minMatch !== undefined) {
1475
+ for (const m of minMatch) {
1476
+ if (m <= absEnd) {
1477
+ disqualified = true;
1478
+ break;
1479
+ }
1480
+ }
1481
+ }
1482
+ if (disqualified) continue;
1483
+ // …but a negator AFTER the marker that REFERENCES DELIVERY (`…full scope of #12 AND it is not
1484
+ // fully delivered`) still disclaims the marker even though the `and`-coordinator splits it into a
1485
+ // sibling segment the assertion above never sees — re-catch it against the marker-relative suffix
1486
+ // (the text from the marker to the clause end) so the assertion-scoping does not fail open, without
1487
+ // matching a negator that PRECEDES the marker (issue #858 round-6 + round-13 adversarial reviews).
1488
+ const clauseStart = clauseStartAt(bounds, i);
1489
+ let negatedAfterMarker = negatedAfterMarkerByMarker.get(i);
1490
+ if (negatedAfterMarker === undefined) {
1491
+ negatedAfterMarker = SCOPE_NEGATED_AFTER_MARKER_DELIVERY.test(prompt.slice(i, clauseEndAt(bounds, i, prompt.length)));
1492
+ negatedAfterMarkerByMarker.set(i, negatedAfterMarker);
1493
+ }
1494
+ if (negatedAfterMarker) continue;
1495
+ // `SCOPE_NEGATED_PREFIX` is an unanchored "contains" test, so it must run against the WHOLE
1496
+ // before-marker text — not the bounded window, which would drop a negator sitting more than
1497
+ // `PREFIX_WINDOW` chars before the marker (issue #858 round-12 adversarial review). The earliest
1498
+ // negator offset is cached per clause and compared with THIS marker's own `i` (issue #858 round-14
1499
+ // review): a clause-wide boolean let the first marker's clean prefix credit a later disclaimed one.
1500
+ // Only the genuinely `$`-anchored `PART_QUALIFIER_BEFORE_MARKER` may use the window.
1501
+ let negatedPrefixEnd = negatedPrefixEndByClause.get(clauseStart);
1502
+ if (negatedPrefixEnd === undefined) {
1503
+ negatedPrefixEnd = earliestNegatorEnd(prompt, clauseStart, clauseEndAt(bounds, i, prompt.length));
1504
+ negatedPrefixEndByClause.set(clauseStart, negatedPrefixEnd);
1505
+ }
1506
+ if (negatedPrefixEnd <= i) continue;
1507
+ if (PART_QUALIFIER_BEFORE_MARKER.test(clauseBeforeMarker(prompt, i, bounds))) continue;
1508
+ const anchored = anchoredIssueKey(prompt, i, markerEnd);
1509
+ if (anchored !== null) acknowledged.add(anchored);
1510
+ else if (sole !== null) acknowledged.add(sole);
1511
+ }
1512
+ }
1513
+
1514
+ for (const n of numbered) {
1515
+ if (!acknowledged.has(n)) return true;
1516
+ }
1517
+ // A pronoun close (or a closing verb with no resolvable numbered target) can't name its issue, so
1518
+ // every referenced issue must be acknowledged for the close to be licensed.
1519
+ if (pronoun || numbered.length === 0) {
1520
+ for (const n of distinct) {
1521
+ if (!acknowledged.has(n)) return true;
1522
+ }
1523
+ }
1524
+ return false;
1525
+ }
1526
+
270
1527
  /** The graph's optional top-level `name` must match openapi's `DeliveryGraph.name` `maxLength: 255`.
271
1528
  * Re-enforced here INDEPENDENTLY of the OpenAPI shape gate because later steps trust it: the compiler
272
1529
  * feeds `graph.name` straight into `escapeXml(processName)` / `escapeMermaid`, so a NON-STRING name
@@ -784,6 +2041,25 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
784
2041
  code: "invalid-node-base-branch",
785
2042
  });
786
2043
  }
2044
+ // Issue #858: the deterministic compile/lint-time guard for the partial-scope-close defect
2045
+ // class. An `agent` node's `prompt` is planner/authored free text; when it pairs a GitHub
2046
+ // closing keyword (`Closes/Fixes/Resolves #N`) with NO explicit full-scope acknowledgement
2047
+ // marker, it is the field-case defect (a brief scoped to PART of an issue told to close it).
2048
+ // Reject it path-qualified so the planner must either scope the brief to the issue's FULL
2049
+ // stated scope (and say so) or reference the issue non-blockingly (`Part of #N` / `Refs #N`).
2050
+ if (kind === "agent" && typeof config.prompt === "string" && isPartialScopeClose(config.prompt)) {
2051
+ errors.push({
2052
+ path: `${path}.${configKey}.prompt`,
2053
+ message:
2054
+ "`agent.prompt` closes an issue (`Closes/Fixes/Resolves #N`) but carries no full-scope " +
2055
+ "acknowledgement TIED to that issue — a brief scoped to PART of an issue must not be " +
2056
+ "told to close it (issue #858). Anchor the acknowledgement to the SAME issue you close " +
2057
+ "(e.g. \"delivers #N's full stated scope\" / \"every acceptance criterion of #N\"); a " +
2058
+ "marker for a different issue, or one attributed to siblings, does not count. Otherwise " +
2059
+ "reference the issue non-blockingly (`Part of #N` / `Refs #N`) and leave it open.",
2060
+ code: "partial-scope-close",
2061
+ });
2062
+ }
787
2063
  // #548: register a converge-connector / pr-wait as a PR-binding consumer (pass 4 validates the
788
2064
  // binding once edges are resolved). Only when the id is usable so pass 4 can key by node id.
789
2065
  if (typeof id === "string" && id.length > 0) {