forgedock 1.13.1 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/bin/engine/phases-args.test.mjs +11 -0
  2. package/bin/engine/phases.mjs +268 -25
  3. package/bin/engine/phases.test.mjs +24 -0
  4. package/bin/engine-cli.mjs +18 -5
  5. package/bin/engine.mjs +149 -12
  6. package/bin/forgedock.mjs +4 -1
  7. package/commands/issue.md +17 -2
  8. package/commands/orchestrate/phase-3-dependency.md +5 -2
  9. package/commands/orchestrate/phase-4-execution.md +154 -27
  10. package/commands/pipeline-health/phase-2-metrics.md +5 -3
  11. package/commands/review-pr-staging.md +554 -54
  12. package/commands/review-pr.md +662 -51
  13. package/commands/validate.md +11 -3
  14. package/commands/work-on/build/architect.md +11 -8
  15. package/commands/work-on/build/implement.md +15 -1
  16. package/commands/work-on/build.md +34 -10
  17. package/commands/work-on/close.md +5 -4
  18. package/commands/work-on/decompose.md +7 -2
  19. package/commands/work-on/investigate.md +13 -0
  20. package/commands/work-on/remediate.md +332 -91
  21. package/commands/work-on/review.md +3 -3
  22. package/docs/CONFIG.md +14 -1
  23. package/docs/RELEASE-NOTES.md +20 -0
  24. package/package.json +1 -1
  25. package/packages/protocol/src/phases.js +6 -0
  26. package/scripts/architect-resume-match.test.sh +53 -0
  27. package/scripts/check-spec-bash.sh +73 -3
  28. package/scripts/check-spec-bash.test.sh +50 -0
  29. package/scripts/check-spec-markers.sh +2 -0
  30. package/scripts/checkbox-sections.test.sh +19 -0
  31. package/scripts/classify-finding.sh +103 -1
  32. package/scripts/classify-finding.test.sh +56 -0
  33. package/scripts/forge-root.test.sh +86 -2
  34. package/scripts/orchestrate-soft-edge.test.sh +58 -0
  35. package/scripts/remediate-approved-head.test.sh +43 -0
  36. package/scripts/remediate-basesync.test.sh +62 -3
  37. package/scripts/remediation-marker-match.test.sh +6 -0
  38. package/scripts/resolve-deploy-gate.sh +51 -0
  39. package/scripts/resolve-deploy-gate.test.sh +59 -0
  40. package/scripts/review-delta.sh +3 -2
  41. package/scripts/review-delta.test.sh +1 -1
  42. package/scripts/review-pr-delta-wiring.test.sh +274 -0
  43. package/scripts/review-pr-domain-map.test.sh +10 -0
  44. package/scripts/review-pr-staging-scope.test.sh +159 -0
  45. package/scripts/trusted-comments.test.sh +11 -0
@@ -529,7 +529,7 @@ else:
529
529
 
530
530
  Wait for that task's completed result before Phase R4. Propagate its `REVIEW_RESULT` as this module's child state; do not return `REVIEW_RESULT`, report progress, release an orchestrator slot, or begin close work while the child is running. If the child errors or returns no parseable `REVIEW_RESULT`, return `REVIEW_RESULT: status: BLOCKED` with the child failure as the blocker. The normal `Skill(...)` invocation above remains the non-OpenCode path.
531
531
 
532
- /review-pr handles: full domain-agent review → post findings as separate issues (non-blocking) → merge the PR. It does NOT close the issue or clean up the worktree — `work-on:close` does both.
532
+ /review-pr handles: the domain-agent review (it picks FULL, DELTA or BASE_SYNC_ONLY itself: a head that only merged the base is carried forward without reviewers) → post findings as separate issues (non-blocking) → merge the PR. It does NOT close the issue or clean up the worktree — `work-on:close` does both.
533
533
 
534
534
  If `review-pr` is not found under `{FORGE_SKILL_PREFIX}review-pr` or `review-pr`, print `REVIEW_RESULT: status: BLOCKED`, blocker: "skill not found: review-pr" and STOP. Never review inline.
535
535
 
@@ -564,7 +564,7 @@ gh issue view {NUMBER} {GH_FLAG} --json state --jq '.state'
564
564
 
565
565
  - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker mentions the phase trail (any blocker containing "phase trail": "phase trail unreadable" when the Phase 8 verifier exited ≥2 / 127, "phase trail incomplete…", or "auto-merge requires --issue", forge#3147): the merge gate refused or could not run. Do NOT re-run phases (nothing is missing) and do NOT attempt the manual merge below, because that would bypass the gate. Add `needs-human` and return `REVIEW_RESULT: status: BLOCKED` with the same blocker. The PR stays open and unmerged.
566
566
 
567
- - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker contains "stale review" (a commit landed on the PR after the verdict, or the head moved during the CI wait, so the code that would merge is not the reviewed code): do NOT merge. The re-review bound is persisted, not remembered: count `<!-- FORGE:STALE_REREVIEW: pr={PR_NUMBER} -->` comments on the issue. If the count is 0, post that marker (with the new head SHA), run the quality gate on the new head (`Skill(skill="{FORGE_SKILL_PREFIX}quality-gate", args="<changed files> --worktree {WORKTREE_PATH}")`, which posts a fresh `FORGE:QUALITY_GATE` for the code that will actually merge), then re-invoke Phase R3 once — a full review of the new head. If the count is already ≥ 1 (the head keeps moving after review), add `needs-human` and return `REVIEW_RESULT: status: BLOCKED`, blocker: "PR head keeps moving after review". <!-- forge#3188 -->
567
+ - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker contains "stale review" (a commit landed on the PR after the verdict, or the head moved during the CI wait, so the code that would merge is not the reviewed code): do NOT merge. The re-review bound is persisted, not remembered: count `<!-- FORGE:STALE_REREVIEW: pr={PR_NUMBER} -->` comments on the issue. If the count is 0, post that marker (with the new head SHA), run the quality gate on the new head (`Skill(skill="{FORGE_SKILL_PREFIX}quality-gate", args="<changed files> --worktree {WORKTREE_PATH}")`, which posts a fresh `FORGE:QUALITY_GATE` for the code that will actually merge), then re-invoke Phase R3 once — a review of the new head (scoped by /review-pr: full, delta or carried forward). If the count is already ≥ 1 (the head keeps moving after review), add `needs-human` and return `REVIEW_RESULT: status: BLOCKED`, blocker: "PR head keeps moving after review". <!-- forge#3188 -->
568
568
 
569
569
  - **Remediation-pending handoff (shared by the ci-gate, in-pr-fix and base-conflict cases below)** <!-- Added: forge#3541 -->: these three cases are autonomous pipeline work, not a human decision, so the issue must NOT carry `needs-human` while remediation is pending. `needs-human` is a human-gate signal (operators alert on it, and orchestrate holds DAG dependents as GATED on it). Mark the handoff with the non-human state `workflow:remediating` instead, and clear any `needs-human` that review-pr Phase 8 already set for the ci-gate refusal. `work-on:remediate` accepts either label (a bare `needs-human` from an older run still works), clears `workflow:remediating` on every exit, and re-asserts `needs-human` only when it cannot fix the PR. Run this block, then return `REVIEW_RESULT: status: NEXT`:
570
570
  ```bash
@@ -577,7 +577,7 @@ gh issue view {NUMBER} {GH_FLAG} --json state --jq '.state'
577
577
  gh pr edit {PR_NUMBER} {GH_FLAG} --remove-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
578
578
  ```
579
579
 
580
- - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker contains "ci gate" (Phase 8 refused to merge because checks failed, were cancelled, or stayed pending past the gate retries): do NOT attempt the manual merge below — that would bypass the CI gate. Fixing red CI is pipeline work, not a human decision: hand remediation to the router **once**. Bound: count `<!-- FORGE:CI_REMEDIATION: pr={PR_NUMBER} -->` comments on the issue first; if ≥ 1, do not remediate again — remove `workflow:remediating`, label the issue `needs-human` (the bound is exhausted, so a human is now genuinely needed) and return `REVIEW_RESULT: status: BLOCKED` with blocker "ci gate not green after remediation". Otherwise run the remediation-pending handoff block above (sets `workflow:remediating`, removes the `needs-human` that Phase 8 set) and return `REVIEW_RESULT: status: NEXT` with `next: remediate` and `remediation: ci-gate` (see Output). Do not post the marker here: the router posts it immediately before it invokes remediation (Phase 4R), so a handoff interrupted before remediation starts is retried instead of being counted as used. **Never invoke `work-on:remediate` from this phase** (forge#3398): remediation re-reviews through `/review-pr`, which spawns domain-reviewer sub-agents, so it must run one level below the router, not nested inside this fork. Nested here, it lands at the sub-agent depth limit under `/orchestrate` with no `Agent` tool and strands the PR at `REREVIEW-REQUIRED` (forge#3391). The router runs `work-on:remediate`, which classifies a CI-gate refusal as FIXABLE, swaps `workflow:remediating` (or a legacy `needs-human`) for `workflow:in-review`, fixes the failing jobs on the PR branch, re-runs the quality gate and a full review, and auto-lands through the same CI gate. <!-- forge#3191 -->
580
+ - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker contains "ci gate" (Phase 8 refused to merge because checks failed, were cancelled, or stayed pending past the gate retries): do NOT attempt the manual merge below — that would bypass the CI gate. Fixing red CI is pipeline work, not a human decision: hand remediation to the router **once**. Bound: count `<!-- FORGE:CI_REMEDIATION: pr={PR_NUMBER} -->` comments on the issue first; if ≥ 1, do not remediate again — remove `workflow:remediating`, label the issue `needs-human` (the bound is exhausted, so a human is now genuinely needed) and return `REVIEW_RESULT: status: BLOCKED` with blocker "ci gate not green after remediation". Otherwise run the remediation-pending handoff block above (sets `workflow:remediating`, removes the `needs-human` that Phase 8 set) and return `REVIEW_RESULT: status: NEXT` with `next: remediate` and `remediation: ci-gate` (see Output). Do not post the marker here: the router posts it immediately before it invokes remediation (Phase 4R), so a handoff interrupted before remediation starts is retried instead of being counted as used. **Never invoke `work-on:remediate` from this phase** (forge#3398): remediation re-reviews through `/review-pr`, which spawns domain-reviewer sub-agents, so it must run one level below the router, not nested inside this fork. Nested here, it lands at the sub-agent depth limit under `/orchestrate` with no `Agent` tool and strands the PR at `REREVIEW-REQUIRED` (forge#3391). The router runs `work-on:remediate`, which classifies a CI-gate refusal as FIXABLE, swaps `workflow:remediating` (or a legacy `needs-human`) for `workflow:in-review`, fixes the failing jobs on the PR branch, re-runs the quality gate and re-reviews the new head (scoped by /review-pr: full, delta or carried forward), and auto-lands through the same CI gate. <!-- forge#3191 -->
581
581
 
582
582
  - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker contains "in-pr fix required" (review-pr §6B.6: CONFIRMED MEDIUM findings in files this PR changed, requested on the PR in a `FORGE:INPR_FIX` comment at the current head): do NOT merge and do NOT leave the issue at `needs-human` because of this gate. Run **one** fix round, bounded by `<!-- FORGE:INPR_REMEDIATION: pr={PR_NUMBER} -->` on the issue (`{BOUND}` = `INPR_REMEDIATION` below).
583
583
  - **Bound unused**: run the remediation-pending handoff block above (set `workflow:remediating`, no `needs-human`; remediation accepts that state and its Phase M1 clears it as FIXABLE). Then return `REVIEW_RESULT: status: NEXT` with `next: remediate` and `remediation: inpr-fix` (the router posts the bound marker, as in the ci-gate case) — never invoke `work-on:remediate` from this phase (same reason as the ci-gate case). The router runs remediation, which reads the `FORGE:INPR_FIX` work order, fixes exactly those findings, re-reviews the new head and auto-lands. On the re-review, any finding still present is filed as an issue (the round is used). If remediation does not land, the router re-invokes this phase, which then finds the bound used and takes the branch below.
package/docs/CONFIG.md CHANGED
@@ -496,6 +496,15 @@ build:
496
496
  # Changed-line ceiling. Default: 1000. 0 disables the gate.
497
497
  threshold: 1000
498
498
 
499
+ # Pre-build estimate (investigate): an estimate above threshold x factor routes
500
+ # straight to decompose without building. Default: 1.2. Skipped when threshold is 0.
501
+ estimate_factor: 1.2
502
+
503
+ # Pre-build estimate (investigate): a batch with at least this many independent
504
+ # seams defaults to one sub-issue per seam unless the estimate is at or under
505
+ # half the threshold. Default: 3.
506
+ batch_seam_min: 3
507
+
499
508
  # Extra exclusion globs, ADDITIVE to the built-in defaults below.
500
509
  exclude_globs:
501
510
  - "docs/api/*"
@@ -505,11 +514,15 @@ build:
505
514
  | Field | Type | Required | Description |
506
515
  |-------|------|----------|-------------|
507
516
  | `diff_size.threshold` | integer | No | Changed lines allowed before the gate trips. Default: 1000. `0` disables the gate |
517
+ | `diff_size.estimate_factor` | number | No | Multiplier on `threshold` for the pre-build estimate: the investigator forces a decomposition when `ESTIMATED_DIFF_LINES` > `threshold` x `estimate_factor`. The investigator emits the effective value as an own-line `ESTIMATE_FACTOR` field, and the engine's fail-toward-decompose net (`isDecomposed`) uses it even if the verdict says NO (1.2 when the field is absent or invalid). That net is suppressed (treated as not decomposed) for a sub-issue (`**Parent**: #N`), an issue with a trusted `FORGE:DECOMPOSED` comment, or a justified `FORGE:SIZE_OVERRIDE`, because decompose Guard 1 refuses those. Default: 1.2. Skipped when `threshold` is `0` |
518
+ | `diff_size.batch_seam_min` | integer | No | Independent seams (batch members) at which an issue defaults to one sub-issue per seam, unless `ESTIMATED_DIFF_LINES` is at or under half of `threshold`. Default: 3. Skipped when `threshold` is `0` |
508
519
  | `diff_size.exclude_globs` | list of strings | No | Globs excluded from the measurement, in addition to the built-in defaults. Default: `[]` |
509
520
 
510
521
  **Built-in default exclusions** (always applied, defined once in `scripts/diff-size.sh`): lockfiles (`package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `Cargo.lock`, `poetry.lock`, `uv.lock`, `go.sum`, `Gemfile.lock`, `composer.lock`), `*.min.*`, `*.snap`, and the directories `dist/`, `build/`, `generated/`, `fixtures/`, `vendor/` (anchored to the repo root, so first-party directories such as `commands/work-on/build/` are counted) plus `__generated__/` and `__snapshots__/` (any depth). The diff is measured against the merge base of `HEAD` and `origin/<base>`, so commits that landed on the base after the branch point are not counted. A configured glob ending in `/` matches that directory at any depth; any other glob is matched against the file basename and the full path. Globs are treated as data, never evaluated.
511
522
 
512
- **Over the threshold**: the build posts a `FORGE:DIFF_SIZE` comment with a split proposal and exits `BUILD_RESULT: status: NEEDS_DECOMPOSE`; the router then runs `work-on:decompose`. To build the oversized diff anyway, post a `FORGE:SIZE_OVERRIDE` comment whose body has a non-empty justification on the line after the marker (only comments from trusted authors count). An issue that is already a decomposed child, or that already has `FORGE:DECOMPOSED`, is not decomposed again: the build blocks until an override is posted. The measured `diff_lines` is recorded in the `FORGE:TRAJECTORY` comment.
523
+ **Over the threshold**: the build posts a `FORGE:DIFF_SIZE` comment with a split proposal and exits `BUILD_RESULT: status: NEEDS_DECOMPOSE`; the router then runs `work-on:decompose`. To build the oversized diff anyway, post a `FORGE:SIZE_OVERRIDE` comment whose body has a non-empty justification on the line after the marker (only comments from trusted authors count). An issue that is already a decomposed child, or that already has `FORGE:DECOMPOSED`, is not decomposed again: the build blocks until an override is posted. The staged diff is also persisted before the exit (pushed ref `forge/wip/{NUMBER}`, or a patch file when the push fails) and each split-proposal line links its hunks, so they survive worktree cleanup; decompose copies the link into each sub-issue. The measured `diff_lines` is recorded in the `FORGE:TRAJECTORY` comment.
524
+
525
+ **Pre-build estimate**: `work-on:investigate` emits `ESTIMATED_DIFF_LINES`, `INDEPENDENT_SEAMS`, `GATE_THRESHOLD` and `ESTIMATE_FACTOR` inside `### Decomposition Assessment`. An estimate above `threshold` x `estimate_factor` (or a batch of `batch_seam_min`+ seams whose estimate is not well under the gate) answers `**YES**`, so the issue is decomposed before any build. The estimate rule is skipped when `threshold` is `0`.
513
526
 
514
527
  **Commands that use this section**: `work-on` (build phase B5.5, `decompose`, `close`)
515
528
 
@@ -2,6 +2,26 @@
2
2
 
3
3
  Operator-facing notes for behavior changes that need action or awareness. Newest first.
4
4
 
5
+ ## Re-flag gate ignores generic words (#3719)
6
+
7
+ `scripts/classify-finding.sh` no longer counts generic stopwords (`the`, `with`, `before`, `write`, ...) as topical similarity in the cross-head/cross-PR re-flag gate. The line-overlap match needs >= 1 shared non-stopword title token, and the title-only match needs >= 3 shared tokens of which >= 2 are non-stopword. Domain words such as `lock` and `retry` still count. `/review-pr` §6B.5 and `/review-pr-staging` §7B.5 carry the same wording.
8
+
9
+ Action: none. Fewer real findings are demoted to `NOTE reflag-note`; a duplicate is preferred over a suppressed finding.
10
+
11
+ ## Unlinked prior re-flag rows no longer suppress MEDIUM findings (#3718)
12
+
13
+ The prior-findings TSV now marks a reviewer-body finding whose issue linkage failed as prior-disposition `unknown` (a recorded `FORGE:NOTE_DISPOSITION` row without a linked issue stays `note`). `scripts/classify-finding.sh` ignores an `unknown` row for MEDIUM CONFIRMED/LIKELY findings, so a regression of a finding filed under a reworded title is filed instead of demoted to `NOTE reflag-note`. LOW and POSSIBLE findings still demote against an `unknown` row. `/review-pr` §6B.5 and `/review-pr-staging` §7B.5 carry the same change.
14
+
15
+ Action: none. A re-flagged MEDIUM finding with a reworded prior issue may now be filed again; a duplicate is preferred over a suppressed regression.
16
+
17
+ ## Cross-head and cross-PR re-flag dedup (#3641)
18
+
19
+ `scripts/classify-finding.sh` takes `--prior-findings <TSV>` (file, line-start, line-end, title, prior-disposition, issue#) plus `--title`, `--line-start`/`--line-end` and an optional `--diff-map`. A finding that re-raises one already posted or noted earlier (same file AND line IoU >= 0.6 or >= 3 shared title tokens) becomes `NOTE reflag-filed #N` or `NOTE reflag-note` instead of a new issue. HIGH/CRITICAL are never demoted, and a prior row whose issue is closed is ignored so a regression is not swallowed.
20
+
21
+ `/review-pr` §6B.5 builds the TSV from trusted reviewer comments of earlier heads and earlier `FORGE:NOTE_DISPOSITION` records; `/review-pr-staging` §7B.5 builds it from every bundle member PR. Both read comments only through `scripts/trusted-comments.sh`. In the staging spec §7B.6 (provenance gates) now comes before §7B.5. The `FORGE:NOTE_DISPOSITION` marker gains `notes_reflag=` (and `/review-pr` also `reviewed_sha=`); consumers match the marker prefix only.
22
+
23
+ Action: none. Records posted before this change lack `reviewed_sha=` and are not read as prior findings by `/review-pr`.
24
+
5
25
  ## Review damper: LOW needs keyword + CONFIRMED; a missing classifier blocks the review (#3639)
6
26
 
7
27
  `scripts/classify-finding.sh` is stricter for LOW findings. A safety keyword now files a LOW finding only when it is also CONFIRMED (LOW LIKELY/POSSIBLE with a keyword is a NOTE), and a dedicated domain agent (Auth, Billing, Concurrency, Database) rescues MEDIUM findings only, no longer LOW CONFIRMED. MEDIUM behaviour is unchanged.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.13.1",
3
+ "version": "1.15.0",
4
4
  "description": "Autonomous development pipeline for coding agents. GitHub as institutional memory, with native Claude Code and OpenCode entrypoints.",
5
5
  "bin": {
6
6
  "forgedock": "bin/forgedock.mjs"
@@ -59,6 +59,8 @@ export const PHASE_IDS = ['investigate', 'decompose', 'build', 'review', 'remedi
59
59
  * verdict, emitted as `<!-- DECOMPOSE:NO -->`.
60
60
  * @property {string} [sizeGateMarker] - (build only) bare annotation-opener of the diff-size gate record.
61
61
  * @property {string} [sizeOverrideMarker] - (build only) bare annotation-opener of the size-gate override.
62
+ * @property {string} [investigationMarker] - (build only) bare marker of the durable
63
+ * investigation-deliverables comment, posted as `<!-- FORGE:INVESTIGATION:DELIVERABLES -->`.
62
64
  * @property {string} [partialMarker] - sentinel for an interrupted/partial annotation.
63
65
  * @property {string} [presenceMarker] - bare annotation-opener substring, used only by
64
66
  * phases whose completion is non-critical (see `context` below) — presence alone
@@ -111,6 +113,10 @@ export const PHASE_MARKERS = {
111
113
  // these to route an oversized build to decompose instead of retrying it.
112
114
  sizeGateMarker: `FORGE:${RESERVED_TYPES.DIFF_SIZE.type}`, // 'FORGE:DIFF_SIZE'
113
115
  sizeOverrideMarker: `FORGE:${RESERVED_TYPES.SIZE_OVERRIDE.type}`, // 'FORGE:SIZE_OVERRIDE'
116
+ // Investigation exit (implement.md I2): no code, no PR, so no FORGE:BUILDER:COMPLETE.
117
+ // This comment is the engine's ground truth that the build ended INVESTIGATION_COMPLETE.
118
+ // Not a completion sentinel: the drift script only checks completionMarker/completionLabel.
119
+ investigationMarker: 'FORGE:INVESTIGATION:DELIVERABLES',
114
120
  },
115
121
  review: {
116
122
  // No RESERVED_TYPES entry defines this sentinel (REVIEWER has no
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env bash
2
+ # architect-resume-match.test.sh — the anchored, trusted FORGE:ARCHITECT resume selector (forge#3648)
3
+ # Exercises the exact jq used by commands/work-on/build/architect.md Resume Check. No network.
4
+ # Usage: bash scripts/architect-resume-match.test.sh
5
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+
8
+ set -uo pipefail
9
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+ ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
11
+ SPEC="$ROOT/commands/work-on/build/architect.md"
12
+
13
+ PASS=0; FAILN=0
14
+ ok() { PASS=$((PASS+1)); echo "ok - $1"; }
15
+ bad() { FAILN=$((FAILN+1)); echo "FAIL - $1"; }
16
+ eq() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (got '$2', want '$3')"; fi; }
17
+
18
+ SELECT_JQ='[.[][] | select((.body // "") | startswith("<!-- FORGE:ARCHITECT -->")) | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR"))) | {id: .id, complete: (.body | contains("<!-- FORGE:ARCHITECT:COMPLETE -->")), other: (.body | test("<!-- FORGE:(INVESTIGATOR|CONTEXT|CONTRACT|BUILDER)"))}]'
19
+ DELETE_JQ='[.[] | select(.complete | not)] | if length == 1 and (.[0].other | not) then .[0].id else empty end'
20
+
21
+ # c <id> <assoc> <type> <body>
22
+ c() { jq -nc --argjson i "$1" --arg a "$2" --arg t "$3" --arg b "$4" '{id:$i,author_association:$a,user:{type:$t},body:$b}'; }
23
+ arch() { printf '%s' "$1" | jq -sc . | jq -s "$SELECT_JQ"; }
24
+ n_complete() { arch "$1" | jq '[.[] | select(.complete)] | length'; }
25
+ del_id() { arch "$1" | jq -r "$DELETE_JQ"; }
26
+
27
+ PLAN=$'<!-- FORGE:ARCHITECT -->\n## Plan\n\n<!-- FORGE:ARCHITECT:COMPLETE -->'
28
+ PARTIAL=$'<!-- FORGE:ARCHITECT -->\n## Plan (in progress)'
29
+ INVEST=$'<!-- FORGE:INVESTIGATOR -->\nThe architect phase posts FORGE:ARCHITECT and FORGE:ARCHITECT:COMPLETE later.'
30
+ CTX=$'<!-- FORGE:CONTEXT -->\nmentions <!-- FORGE:ARCHITECT -->'
31
+ MIXED=$'<!-- FORGE:ARCHITECT -->\nquoted <!-- FORGE:INVESTIGATOR -->'
32
+
33
+ eq "INVESTIGATOR mentioning FORGE:ARCHITECT is not selected" "$(arch "$(c 1 NONE Bot "$INVEST")" | jq length)" 0
34
+ eq "INVESTIGATOR mentioning FORGE:ARCHITECT is never deleted" "$(del_id "$(c 1 NONE Bot "$INVEST")")" ""
35
+ eq "CONTEXT mentioning the marker mid-body is not selected" "$(arch "$(c 2 NONE Bot "$CTX")" | jq length)" 0
36
+ eq "complete plan by bot is complete" "$(n_complete "$(c 3 NONE Bot "$PLAN")")" 1
37
+ eq "complete plan by MEMBER is complete" "$(n_complete "$(c 3 MEMBER User "$PLAN")")" 1
38
+ eq "single partial plan -> delete its id" "$(del_id "$(c 4 NONE Bot "$PARTIAL")")" 4
39
+ eq "two partial plans -> delete nothing" "$(del_id "$(c 4 NONE Bot "$PARTIAL")"$'\n'"$(c 5 NONE Bot "$PARTIAL")")" ""
40
+ eq "untrusted author partial ignored" "$(del_id "$(c 6 NONE User "$PARTIAL")")" ""
41
+ eq "untrusted author complete ignored" "$(n_complete "$(c 6 NONE User "$PLAN")")" 0
42
+ eq "partial carrying another FORGE marker is not deleted" "$(del_id "$(c 7 NONE Bot "$MIXED")")" ""
43
+ eq "investigator + partial plan -> only the plan id" "$(del_id "$(c 1 NONE Bot "$INVEST")"$'\n'"$(c 8 NONE Bot "$PARTIAL")")" 8
44
+ eq "paginated concatenated arrays" "$(printf '[%s]\n[%s]' "$(c 1 NONE Bot "$INVEST")" "$(c 9 NONE Bot "$PLAN")" | jq -s "$SELECT_JQ" | jq '[.[] | select(.complete)] | length')" 1
45
+
46
+ # Spec drift guard: the spec carries this exact predicate and no unanchored selector.
47
+ ! grep -qF 'contains("FORGE:ARCHITECT")' "$SPEC" && ok "architect.md has no unanchored contains(FORGE:ARCHITECT)" || bad "architect.md still uses unanchored contains"
48
+ grep -qF 'startswith("<!-- FORGE:ARCHITECT -->")' "$SPEC" && ok "architect.md anchors with startswith" || bad "architect.md lacks the startswith anchor"
49
+ grep -qF "$SELECT_JQ" "$SPEC" && ok "architect.md uses the tested select jq" || bad "architect.md select jq drifted from this test"
50
+ grep -qF "$DELETE_JQ" "$SPEC" && ok "architect.md uses the tested delete jq" || bad "architect.md delete jq drifted from this test"
51
+
52
+ echo "passed=$PASS failed=$FAILN"
53
+ [ "$FAILN" -eq 0 ]
@@ -14,6 +14,7 @@
14
14
  # check-spec-bash.sh [--base <git-ref>] [--shellcheck] <file.md>...
15
15
  # check-spec-bash.sh --positional [<file.md>...]
16
16
  # check-spec-bash.sh --fence-state <VAR>... -- <file.md>...
17
+ # check-spec-bash.sh --fence-funcs -- <file.md>...
17
18
  # check-spec-bash.sh (no args: same as --positional over all Skill-loaded commands/**/*.md,
18
19
  # excluding orchestrate/**, pipeline-health/**, review-pr-agents/**)
19
20
  #
@@ -33,6 +34,13 @@
33
34
  # does not match `$REPO_FLAG`. Suppress a deliberate read by putting
34
35
  # `allowlist:fence-state` on the same line. Pseudo-tool-call fences are scanned too.
35
36
  # Output: `FAIL <file>:<line> fence-state ...` then `SPEC-FENCE-STATE: ...`.
37
+ # --fence-funcs Different check, opt-in: shell functions are per-shell too, so a function defined
38
+ # (`name() {`, `function name`) in one ```bash/sh fence is undefined in every other
39
+ # fence. Fail any fence that calls (command position) a function that the same file
40
+ # defines only in a DIFFERENT fence. A fence that carries its own definition (also
41
+ # `declare -f name >/dev/null || name() {...}`) is fine. Comment lines are ignored.
42
+ # Suppress a deliberate call with `allowlist:fence-funcs` on the same line.
43
+ # Output: `FAIL <file>:<line> fence-funcs ...` then `SPEC-FENCE-FUNCS: ...`.
36
44
  # --base <ref> Only check blocks that contain a line changed relative to <ref>
37
45
  # (`git diff -U0 <ref> -- <file>`), so pre-existing blocks never fail
38
46
  # a new change. Without --base every block is checked.
@@ -53,8 +61,8 @@
53
61
 
54
62
  set -u
55
63
 
56
- BASE=""; SHELLCHECK=0; POSITIONAL=0; FILES=""; FENCESTATE=0; FSVARS=""
57
- usage() { echo "ERROR: Usage: check-spec-bash.sh [--base <git-ref>] [--shellcheck] <file.md>... | --positional [<file.md>...] | --fence-state <VAR>... -- <file.md>..." >&2; exit 2; }
64
+ BASE=""; SHELLCHECK=0; POSITIONAL=0; FILES=""; FENCESTATE=0; FSVARS=""; FENCEFUNCS=0
65
+ usage() { echo "ERROR: Usage: check-spec-bash.sh [--base <git-ref>] [--shellcheck] <file.md>... | --positional [<file.md>...] | --fence-state <VAR>... -- <file.md>... | --fence-funcs -- <file.md>..." >&2; exit 2; }
58
66
 
59
67
  while [ "$#" -gt 0 ]; do
60
68
  case "$1" in
@@ -69,6 +77,10 @@ while [ "$#" -gt 0 ]; do
69
77
  done
70
78
  [ "$#" -gt 0 ] && [ -n "$FSVARS" ] || usage
71
79
  shift ;;
80
+ --fence-funcs)
81
+ FENCEFUNCS=1; shift
82
+ [ "$#" -gt 0 ] && [ "$1" = "--" ] || usage
83
+ shift ;;
72
84
  --*) usage ;;
73
85
  *) FILES="$FILES
74
86
  $1"; shift ;;
@@ -78,7 +90,7 @@ done
78
90
  # No file arguments: default to the --positional check over every Skill-loaded spec under commands/
79
91
  # (orchestrate/**, pipeline-health/** and the review-pr-agents catalog are Read-loaded, not substituted).
80
92
  # Resolved relative to this script, so it works from any cwd and from linked worktrees.
81
- if [ -z "$FILES" ] && [ "$FENCESTATE" = 0 ] && { [ "$#" -eq 0 ] && [ "$POSITIONAL" = 0 ] && [ -z "$BASE" ] && [ "$SHELLCHECK" = 0 ] || [ "$POSITIONAL" = 1 ]; }; then
93
+ if [ -z "$FILES" ] && [ "$FENCESTATE" = 0 ] && [ "$FENCEFUNCS" = 0 ] && { [ "$#" -eq 0 ] && [ "$POSITIONAL" = 0 ] && [ -z "$BASE" ] && [ "$SHELLCHECK" = 0 ] || [ "$POSITIONAL" = 1 ]; }; then
82
94
  POSITIONAL=1
83
95
  cd "$(dirname "$0")/.." || exit 2
84
96
  FILES=$(find commands -name '*.md' -not -path 'commands/orchestrate/*' \
@@ -127,6 +139,64 @@ EOS
127
139
  exit $?
128
140
  fi
129
141
 
142
+ if [ "$FENCEFUNCS" = 1 ]; then
143
+ ffail=0; ffiles=0
144
+ while IFS= read -r f; do
145
+ [ -n "$f" ] || continue
146
+ case "$f" in *.md) ;; *) continue ;; esac
147
+ [ -f "$f" ] || { echo "SKIP $f missing"; continue; }
148
+ ffiles=$((ffiles + 1))
149
+ hits=$(awk -v f="$f" '
150
+ function isdef(line, name) {
151
+ return line ~ ("(^|[;&|{ \t])(function[ \t]+)?" name "[ \t]*[(][)]") || line ~ ("(^|[;&|{ \t])function[ \t]+" name "([ \t;{]|$)")
152
+ }
153
+ # A non-bash fence (prompt templates) can nest ```lang fences: track depth so they stay ignored.
154
+ other && /^[ \t]*```[A-Za-z]/ { depth++; next }
155
+ other && /^[ \t]*```[ \t]*$/ { if (depth > 0) depth--; else other = 0; next }
156
+ other { next }
157
+ !inb && /^[ \t]*```(bash|sh)[ \t]*$/ { inb = 1; nf++; next }
158
+ !inb && /^[ \t]*```/ { other = 1; depth = 0; next }
159
+ inb && /^[ \t]*```[ \t]*$/ { inb = 0; next }
160
+ inb {
161
+ n++; fen[n] = nf; ln[n] = NR; txt[n] = $0
162
+ if ($0 ~ /^[ \t]*#/) next
163
+ # Function definitions: `name()` or `function name`, wherever they sit on the line.
164
+ s = $0
165
+ while (match(s, /(^|[;&|{ \t])(function[ \t]+)?[A-Za-z_][A-Za-z0-9_]*[ \t]*[(][)]/)) {
166
+ d = substr(s, RSTART, RLENGTH); s = substr(s, RSTART + RLENGTH)
167
+ sub(/^[;&|{ \t]+/, "", d); sub(/^function[ \t]+/, "", d); sub(/[ \t]*[(][)]$/, "", d)
168
+ defn[d, nf] = 1; if (!(d in seen)) { seen[d] = 1; names[++nn] = d }
169
+ }
170
+ s = $0
171
+ while (match(s, /(^|[;&|{ \t])function[ \t]+[A-Za-z_][A-Za-z0-9_]*/)) {
172
+ d = substr(s, RSTART, RLENGTH); s = substr(s, RSTART + RLENGTH)
173
+ sub(/^[;&|{ \t]+/, "", d); sub(/^function[ \t]+/, "", d)
174
+ defn[d, nf] = 1; if (!(d in seen)) { seen[d] = 1; names[++nn] = d }
175
+ }
176
+ }
177
+ END {
178
+ for (k = 1; k <= nn; k++) {
179
+ name = names[k]
180
+ for (i = 1; i <= n; i++) {
181
+ if (defn[name, fen[i]] || reported[name, fen[i]]) continue
182
+ if (txt[i] ~ /^[ \t]*#/ || txt[i] ~ /allowlist:fence-funcs/) continue
183
+ if (txt[i] ~ ("(^[ \t]*|[;&|({][ \t]*|[$][(][ \t]*|(then|do|else|elif|if|while|until|!)[ \t]+)" name "([ \t;&|)]|$)")) {
184
+ reported[name, fen[i]] = 1
185
+ print "FAIL " f ":" ln[i] " fence-funcs: " name "() is called in a fence that does not define it (fresh shell per Bash call; defined only in another fence): " txt[i]
186
+ }
187
+ }
188
+ }
189
+ }
190
+ ' "$f" | LC_ALL=C sort -t: -k2,2n)
191
+ if [ -n "$hits" ]; then printf '%s\n' "$hits"; ffail=$((ffail + $(printf '%s\n' "$hits" | wc -l))); fi
192
+ done <<EOS
193
+ $FILES
194
+ EOS
195
+ echo "SPEC-FENCE-FUNCS: files=$ffiles violations=$ffail"
196
+ [ "$ffail" -eq 0 ]
197
+ exit $?
198
+ fi
199
+
130
200
  if [ "$POSITIONAL" = 1 ]; then
131
201
  pfail=0; pfiles=0
132
202
  while IFS= read -r f; do
@@ -165,5 +165,55 @@ check 0 "violations=0" "--fence-state passes declared, loop, e
165
165
  check 2 "Usage" "--fence-state without -- is a usage error" -- --fence-state PR_NUMBER fs-good.md
166
166
  check 2 "Usage" "--fence-state without vars is a usage error" -- --fence-state -- fs-good.md
167
167
 
168
+ # --fence-funcs: a function defined only in another fence, then called, is flagged.
169
+ cat > "$T/ff-bad.md" <<EOF
170
+ ${FENCE}bash
171
+ helper() {
172
+ echo hi
173
+ }
174
+ helper
175
+ ${FENCE}
176
+ ${FENCE}bash
177
+ X=\$(helper arg)
178
+ ${FENCE}
179
+ ${FENCE}bash
180
+ if helper; then :; fi
181
+ ${FENCE}
182
+ EOF
183
+ cat > "$T/ff-good.md" <<EOF
184
+ Prose calls helper and mentions helper() freely.
185
+ ${FENCE}bash
186
+ helper() { echo hi; }
187
+ function other_fn { :; }
188
+ helper
189
+ ${FENCE}
190
+ ${FENCE}bash
191
+ declare -f helper >/dev/null 2>&1 || helper() { echo hi; }
192
+ helper
193
+ ${FENCE}
194
+ ${FENCE}bash
195
+ # helper is only named in a comment
196
+ declare -f helper >/dev/null 2>&1
197
+ echo "call helper later"
198
+ git log --grep helper
199
+ helper # allowlist:fence-funcs
200
+ ${FENCE}
201
+ ${FENCE}text
202
+ helper
203
+ ${FENCE}
204
+ ${FENCE}
205
+ Template with a nested fence:
206
+ ${FENCE}bash
207
+ helper
208
+ ${FENCE}
209
+ ${FENCE}
210
+ EOF
211
+ check 1 "FAIL ff-bad.md:8 fence-funcs: helper()" "--fence-funcs flags a call in a fence without the definition" -- --fence-funcs -- ff-bad.md
212
+ check 1 "FAIL ff-bad.md:11 fence-funcs: helper()" "--fence-funcs flags a call after if" -- --fence-funcs -- ff-bad.md
213
+ check 1 "violations=2" "--fence-funcs counts violations" -- --fence-funcs -- ff-bad.md
214
+ check 0 "violations=0" "--fence-funcs passes same-fence defs, guarded redefinition, comments, quoted/arg uses, allowlist, prose, non-bash fences" -- --fence-funcs -- ff-good.md
215
+ check 2 "Usage" "--fence-funcs without -- is a usage error" -- --fence-funcs ff-good.md
216
+ check 2 "Usage" "--fence-funcs without files is a usage error" -- --fence-funcs --
217
+
168
218
  echo "check-spec-bash.test.sh: passed=$pass failed=$fail"
169
219
  [ "$fail" -eq 0 ]
@@ -127,6 +127,7 @@ MARKER_REGISTRY="
127
127
  INPR_FIX
128
128
  INPR_FIX_WAIVED
129
129
  INPR_REMEDIATION
130
+ INVESTIGATION
130
131
  INVESTIGATOR
131
132
  JSONL_PARSER_UTIL
132
133
  KNOWLEDGE_GIST
@@ -161,6 +162,7 @@ MARKER_REGISTRY="
161
162
  RECOVERY_CLAIM
162
163
  RECOVERY_CLAIM_RELEASED
163
164
  REMEDIATION
165
+ REMEDIATION_HELD
164
166
  REREVIEW_DISPATCHED
165
167
  REREVIEW_RELEASED
166
168
  REREVIEW_SKIPPED
@@ -49,4 +49,23 @@ SINGLE_BODY=$'## Acceptance Criteria\n- [ ] complete this work'
49
49
  exit 1
50
50
  }
51
51
 
52
+ # forge#3545: the investigate placeholder box is scaffolding and must not form a section.
53
+ PLACEHOLDER_SECOND=$'## Success Criteria\n- [ ] real work\n\n## Acceptance Criteria\n- [ ] Fix confirmed during investigation.'
54
+ [[ "$(printf '%s\n' "$PLACEHOLDER_SECOND" | awk "$CLOSE_CLASSIFIER")" == "1" ]] || {
55
+ printf 'FAIL: placeholder box must not count as a second checkbox section\n' >&2
56
+ exit 1
57
+ }
58
+ PLACEHOLDER_ONLY=$'## Acceptance Criteria\n- [ ] Fix confirmed during investigation.'
59
+ [[ "$(printf '%s\n' "$PLACEHOLDER_ONLY" | awk "$CLOSE_CLASSIFIER")" == "0" ]] || {
60
+ printf 'FAIL: placeholder-only body must count zero checkbox sections\n' >&2
61
+ exit 1
62
+ }
63
+ grep -Fq 'Fix confirmed during investigation' "$ROOT/commands/work-on/close.md" || {
64
+ printf 'FAIL: close.md must exclude the investigate placeholder\n' >&2
65
+ exit 1
66
+ }
67
+ # REMAINING_BEFORE must also ignore the placeholder (placeholder-only body => 0).
68
+ REM=$(printf '%s\n' "$PLACEHOLDER_ONLY" | grep -vE '^[-*+] \[ \] Fix confirmed during investigation\.?[[:space:]]*$' | grep -cE '^[-*+] \[ \]' || true)
69
+ [[ "$REM" == "0" ]] || { printf 'FAIL: placeholder counted in REMAINING_BEFORE\n' >&2; exit 1; }
70
+
52
71
  printf 'PASS: close-path classifier supports setext section boundaries\n'
@@ -11,6 +11,7 @@
11
11
  # classify-finding.sh --severity <S> [--confidence <C>] [--agent <A>]
12
12
  # [--lineage none|review-finding] [--text <T> | --text-file <F>]
13
13
  # [--inpr-diff <F> --file <P>] [--contract-scope <F> --contract-open <F> [--pr-files <F>] [--merge-issue <N>]]
14
+ # [--prior-findings <F> [--title <T>] [--line-start <N> [--line-end <M>]] [--diff-map <F>]]
14
15
  #
15
16
  # --severity CRITICAL | HIGH | MEDIUM | LOW (case-insensitive). Missing or
16
17
  # unparseable → ISSUE (fail toward filing, never drop).
@@ -45,6 +46,25 @@
45
46
  # `--pr-files <F>` is the PR's changed paths, one per line, supplied for this guard
46
47
  # alone (it never triggers INPR_FIX). The gate runs after the severity and in-PR gates. Without the flags output is unchanged.
47
48
  #
49
+ # --prior-findings <F> --file <P> [--title <T>] [--line-start <N> [--line-end <M>]] [--diff-map <F>]
50
+ # Cross-head / cross-PR re-flag gate: F is a TSV of findings already raised for an
51
+ # earlier head of this PR or by a bundle's member PRs, one per line, columns
52
+ # file<TAB>line-start<TAB>line-end<TAB>title<TAB>prior-disposition<TAB>issue#.
53
+ # prior-disposition is `filed` (its issue is still open), `filed-closed` (issue closed:
54
+ # the row is IGNORED so a regressed defect is classified normally), `note` (a recorded NOTE) or
55
+ # `unknown` (a reviewer-body row whose issue linkage failed: it is IGNORED for a MEDIUM
56
+ # CONFIRMED/LIKELY finding so it can never suppress one, and behaves like `note` otherwise).
57
+ # Line columns may be empty (NOTE-sourced rows carry no range). A finding re-flags a row
58
+ # when it is the same file AND ((line IoU >= 0.6 and >= 1 shared title token), or >= 3 shared title tokens). Output is
59
+ # `NOTE reflag-filed #N` (row has an issue number) or `NOTE reflag-note`; this gate never
60
+ # emits ISSUE. `--file` may carry `path:line`; explicit --line-start wins.
61
+ # `--diff-map <F>` (optional, best-effort) lists `git diff <old>..<new>` hunks as
62
+ # file<TAB>old-start<TAB>old-count<TAB>new-start<TAB>new-count; a prior range lying
63
+ # wholly after hunks is shifted by their net line delta before the IoU. A missing or
64
+ # unparseable map means the unshifted range is used. The gate runs after the severity
65
+ # parse (HIGH/CRITICAL and unparseable severity stay ISSUE) and before the in-PR gate.
66
+ # Without the flags output is unchanged.
67
+ #
48
68
  # Output (stdout, one line): `ISSUE <reason>`, `NOTE <reason>` or `INPR_FIX <reason>`.
49
69
  # Exit codes: 0 classified, 2 usage error.
50
70
  #
@@ -80,9 +100,14 @@ CONTRACT_SCOPE=""
80
100
  CONTRACT_OPEN=""
81
101
  PR_FILES=""
82
102
  MERGE_ISSUE=""
103
+ PRIOR_FINDINGS=""
104
+ TITLE=""
105
+ LINE_START=""
106
+ LINE_END=""
107
+ DIFF_MAP=""
83
108
 
84
109
  usage() {
85
- echo "ERROR: Usage: classify-finding.sh --severity <S> [--confidence <C>] [--agent <A>] [--lineage none|review-finding] [--text <T> | --text-file <F>] [--inpr-diff <F> --file <P>] [--contract-scope <F> --contract-open <F> [--pr-files <F>] [--merge-issue <N>]]" >&2
110
+ echo "ERROR: Usage: classify-finding.sh --severity <S> [--confidence <C>] [--agent <A>] [--lineage none|review-finding] [--text <T> | --text-file <F>] [--inpr-diff <F> --file <P>] [--contract-scope <F> --contract-open <F> [--pr-files <F>] [--merge-issue <N>]] [--prior-findings <F> [--title <T>] [--line-start <N> [--line-end <M>]] [--diff-map <F>]]" >&2
86
111
  exit 2
87
112
  }
88
113
 
@@ -99,6 +124,11 @@ while [ "$#" -gt 0 ]; do
99
124
  --contract-open) [ "$#" -ge 2 ] || usage; CONTRACT_OPEN="$2"; shift 2 ;;
100
125
  --pr-files) [ "$#" -ge 2 ] || usage; PR_FILES="$2"; shift 2 ;;
101
126
  --merge-issue) [ "$#" -ge 2 ] || usage; MERGE_ISSUE="${2#\#}"; shift 2 ;;
127
+ --prior-findings) [ "$#" -ge 2 ] || usage; PRIOR_FINDINGS="$2"; shift 2 ;;
128
+ --title) [ "$#" -ge 2 ] || usage; TITLE="$2"; shift 2 ;;
129
+ --line-start) [ "$#" -ge 2 ] || usage; LINE_START="$2"; shift 2 ;;
130
+ --line-end) [ "$#" -ge 2 ] || usage; LINE_END="$2"; shift 2 ;;
131
+ --diff-map) [ "$#" -ge 2 ] || usage; DIFF_MAP="$2"; shift 2 ;;
102
132
  --text-file)
103
133
  [ "$#" -ge 2 ] || usage
104
134
  [ -r "$2" ] || { echo "ERROR: --text-file not readable: $2" >&2; exit 2; }
@@ -127,6 +157,10 @@ if [ -n "$PR_FILES" ] && [ ! -r "$PR_FILES" ]; then
127
157
  echo "ERROR: --pr-files not readable: $PR_FILES" >&2; exit 2
128
158
  fi
129
159
 
160
+ if [ -n "$PRIOR_FINDINGS" ] && [ ! -r "$PRIOR_FINDINGS" ]; then
161
+ echo "ERROR: --prior-findings not readable: $PRIOR_FINDINGS" >&2; exit 2
162
+ fi
163
+
130
164
  upper() { printf '%s' "$1" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]'; }
131
165
  SEV=$(upper "$SEVERITY")
132
166
  CONF=$(upper "$CONFIDENCE")
@@ -137,6 +171,74 @@ case "$SEV" in
137
171
  *) echo "ISSUE unparseable-severity"; exit 0 ;;
138
172
  esac
139
173
 
174
+ # Cross-head / cross-PR re-flag gate (#3641). Runs after the severity parse so HIGH/CRITICAL and
175
+ # unparseable severity have already exited as ISSUE. Emits only NOTE; fails toward the normal
176
+ # classification below on a missing file, no match, or an unusable diff map.
177
+ if [ -n "$PRIOR_FINDINGS" ] && [ -n "$FINDING_FILE" ]; then
178
+ _rpath="${FINDING_FILE#./}"; _rline=""
179
+ case "$_rpath" in *:[0-9]*) _rline="${_rpath#*:}"; _rline="${_rline%%[!0-9]*}" ;; esac
180
+ _rpath="${_rpath%%:*}"
181
+ _rls="${LINE_START:-$_rline}"; _rle="${LINE_END:-$_rls}"
182
+ case "$_rls" in ''|*[!0-9]*) _rls=""; _rle="" ;; esac
183
+ case "$_rle" in ''|*[!0-9]*) _rle="$_rls" ;; esac
184
+ _dmap="$DIFF_MAP"; { [ -n "$_dmap" ] && [ -r "$_dmap" ]; } || _dmap=""
185
+ _reflag=$(awk -F '\t' -v file="$_rpath" -v ls="$_rls" -v le="$_rle" -v title="$TITLE" -v dmap="$_dmap" -v sev="$SEV" -v conf="$CONF" '
186
+ function toks(str, arr, n, i, parts, t, c) {
187
+ c = 0; str = tolower(str); gsub(/[^a-z0-9]+/, " ", str)
188
+ n = split(str, parts, " ")
189
+ for (i = 1; i <= n; i++) { t = parts[i]; if (length(t) >= 3 && !(t in arr)) { arr[t] = 1; c++ } }
190
+ return c
191
+ }
192
+ function shift(f, s, e, k, delta) {
193
+ delta = 0
194
+ for (k = 1; k <= nh; k++)
195
+ if (hf[k] == f && (hc[k] == 0 ? ho[k] < s : ho[k] + hc[k] <= s)) delta += hc2[k] - hc[k]
196
+ return delta
197
+ }
198
+ BEGIN {
199
+ if (dmap != "") {
200
+ while ((getline line < dmap) > 0) {
201
+ if (split(line, p, "\t") >= 5 && p[2] ~ /^[0-9]+$/ && p[3] ~ /^[0-9]+$/ && p[4] ~ /^[0-9]+$/ && p[5] ~ /^[0-9]+$/) {
202
+ nh++; hf[nh] = p[1]; ho[nh] = p[2] + 0; hc[nh] = p[3] + 0; hn[nh] = p[4] + 0; hc2[nh] = p[5] + 0
203
+ }
204
+ }
205
+ close(dmap)
206
+ }
207
+ # Generic words count toward the token set but never as topical similarity (#3719).
208
+ ns = split("the and with for from before after write read when that this into not are but can has have via use using does should may all any only also than then over under without within while where which what its it was were been being each both one two", sw, " ")
209
+ for (i = 1; i <= ns; i++) stop[sw[i]] = 1
210
+ ntitle = toks(title, ft)
211
+ }
212
+ {
213
+ pf = $1; sub(/^\.\//, "", pf)
214
+ if (pf != file) next
215
+ if ($5 == "filed-closed") next
216
+ if ($5 == "unknown" && sev == "MEDIUM" && (conf == "CONFIRMED" || conf == "LIKELY")) next
217
+ hit = 0
218
+ delete pt; toks($4, pt); shared = 0; cshared = 0
219
+ for (t in pt) if (t in ft) { shared++; if (!(t in stop)) cshared++ }
220
+ # Line overlap alone is not a match (an unrelated finding can land on the same line): it also
221
+ # needs at least one shared non-stopword title token. Title-only matching needs >= 3 shared
222
+ # tokens, of which >= 2 are non-stopword (generic words never count as topical similarity).
223
+ if (cshared >= 1 && ls != "" && $2 ~ /^[0-9]+$/) {
224
+ ps = $2 + 0; pe = ($3 ~ /^[0-9]+$/) ? $3 + 0 : ps
225
+ if (pe < ps) pe = ps
226
+ d = shift(pf, ps, pe); ps += d; pe += d
227
+ lo = (ps > ls) ? ps : ls; hi = (pe < le) ? pe : le
228
+ inter = hi - lo + 1; if (inter < 0) inter = 0
229
+ uni = (pe - ps + 1) + (le - ls + 1) - inter
230
+ if (uni > 0 && inter / uni >= 0.6) hit = 1
231
+ }
232
+ if (!hit && ntitle >= 3 && shared >= 3 && cshared >= 2) hit = 1
233
+ if (!hit) next
234
+ if ($5 == "filed" && $6 ~ /^#?[0-9]+$/) { n = $6; sub(/^#/, "", n); print "NOTE reflag-filed #" n; found = 1; exit }
235
+ if (!note) note = "NOTE reflag-note"
236
+ }
237
+ END { if (!found && note) print note }
238
+ ' "$PRIOR_FINDINGS" 2>/dev/null) || _reflag=""
239
+ if [ -n "$_reflag" ]; then echo "$_reflag"; exit 0; fi
240
+ fi
241
+
140
242
  # In-PR fix gate (#3387, narrowed). MEDIUM CONFIRMED is an ISSUE under every rule below
141
243
  # (lineage included), so checking here changes only where it is handled, never whether.
142
244
  if [ -n "$INPR_DIFF" ] && [ -n "$FINDING_FILE" ] && [ "$SEV" = "MEDIUM" ] && [ "$CONF" = "CONFIRMED" ]; then
@@ -152,6 +152,62 @@ rm -f "$CSCOPE" "$COPEN"
152
152
  expect_exit 2 "unreadable --contract-scope" -- --severity MEDIUM --contract-scope /nonexistent/s --file x
153
153
  expect_exit 2 "unreadable --contract-open" -- --severity MEDIUM --contract-open /nonexistent/o --file x
154
154
 
155
+ # --- Cross-head / cross-PR re-flag gate (#3641) ---
156
+ expect_out() { # expect_out <full output> <description> -- args...
157
+ want="$1"; desc="$2"; shift 3
158
+ got=$(bash "$S" "$@" 2>/dev/null)
159
+ if [ "$got" = "$want" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: $desc (want '$want', got '${got}')"; fi
160
+ }
161
+ TAB=$(printf '\t')
162
+ PRIOR=$(mktemp "${TMPDIR:-/tmp}/classify-finding-prior.XXXXXX")
163
+ DMAP=$(mktemp "${TMPDIR:-/tmp}/classify-finding-dmap.XXXXXX")
164
+ {
165
+ printf 'src/sync.sh\t40\t50\tlock released before write completes\tnote\t\n'
166
+ printf 'src/api.py\t10\t20\tmissing retry on timeout handler\tfiled\t123\n'
167
+ printf 'src/closed.py\t5\t15\tstale cache entry never evicted\tfiled-closed\t77\n'
168
+ printf 'src/notes.py\t\t\tduplicated predicate counted twice\tnote\t\n'
169
+ printf 'src/unlinked.py\t30\t40\tunvalidated redirect target in login handler\tunknown\t\n'
170
+ } > "$PRIOR"
171
+ PF=(--prior-findings "$PRIOR")
172
+ M=(--severity MEDIUM --confidence CONFIRMED)
173
+
174
+ expect_out "NOTE reflag-note" "re-flag by line IoU (same range)" -- "${M[@]}" "${PF[@]}" --file src/sync.sh --line-start 42 --line-end 50 --title "lock handling wording here"
175
+ expect_out "NOTE reflag-note" "re-flag via path:line tolerated" -- "${M[@]}" "${PF[@]}" --file src/sync.sh:41 --line-end 50 --title "lock x"
176
+ printf 'src/sync.sh\t1\t2\t1\t12\n' > "$DMAP" # 2 lines became 12: prior 40-50 is now 50-60
177
+ expect_out "NOTE reflag-note" "re-flag after the line moved, shifted by diff map" -- "${M[@]}" "${PF[@]}" --diff-map "$DMAP" --file src/sync.sh --line-start 50 --line-end 60 --title "lock something else"
178
+ expect ISSUE "line moved with no diff map does not match" -- "${M[@]}" "${PF[@]}" --file src/sync.sh --line-start 50 --line-end 60 --title "lock something else"
179
+ printf 'garbage\n' > "$DMAP"
180
+ expect_out "NOTE reflag-note" "unparseable diff map falls back to unshifted range" -- "${M[@]}" "${PF[@]}" --diff-map "$DMAP" --file src/sync.sh --line-start 41 --line-end 50 --title "lock x"
181
+ expect_out "NOTE reflag-note" "re-flag by title tokens (far line)" -- "${M[@]}" "${PF[@]}" --file src/sync.sh --line-start 300 --title "Lock released before the write"
182
+ expect_out "NOTE reflag-note" "NOTE row with empty line columns matches by title" -- "${M[@]}" "${PF[@]}" --file src/notes.py --line-start 9 --title "Duplicated predicate is counted twice"
183
+ expect ISSUE "same line, different topic MEDIUM CONFIRMED is not swallowed (SEC-1)" -- "${M[@]}" --agent security "${PF[@]}" --file src/sync.sh --line-start 42 --line-end 50 --title "SQL injection via unsanitized token"
184
+ expect ISSUE "same line with no title at all is not swallowed" -- "${M[@]}" "${PF[@]}" --file src/sync.sh --line-start 42 --line-end 50
185
+ expect_out "NOTE reflag-note" "same line, same topic (one shared token) still NOTE" -- "${M[@]}" "${PF[@]}" --file src/sync.sh --line-start 42 --line-end 50 --title "Lock handling is racy"
186
+ expect ISSUE "same line, only a stopword-class word shared is not swallowed (#3719)" -- "${M[@]}" "${PF[@]}" --file src/sync.sh --line-start 42 --line-end 50 --title "Path traversal in the write target"
187
+ expect ISSUE "far line, only generic words shared is not swallowed (#3719)" -- "${M[@]}" "${PF[@]}" --file src/sync.sh --line-start 300 --title "Command injection before write with the lock"
188
+ expect ISSUE "non-overlapping same-file finding is classified normally" -- "${M[@]}" "${PF[@]}" --file src/sync.sh --line-start 200 --line-end 210 --title "missing bounds check"
189
+ expect ISSUE "two shared tokens are not enough" -- "${M[@]}" "${PF[@]}" --file src/sync.sh --line-start 200 --title "lock released late"
190
+ expect ISSUE "same title on a different file is classified normally" -- "${M[@]}" "${PF[@]}" --file src/other.sh --line-start 45 --title "lock released before write completes"
191
+ expect ISSUE "unknown row does not demote MEDIUM CONFIRMED (unlinked prior)" -- "${M[@]}" "${PF[@]}" --file src/unlinked.py --line-start 300 --title "Unvalidated redirect target in the login handler"
192
+ expect ISSUE "unknown row does not demote MEDIUM CONFIRMED by line IoU" -- "${M[@]}" "${PF[@]}" --file src/unlinked.py --line-start 32 --line-end 40 --title "redirect target unchecked"
193
+ expect ISSUE "unknown row does not demote MEDIUM LIKELY" -- --severity MEDIUM --confidence LIKELY "${PF[@]}" --file src/unlinked.py --line-start 300 --title "Unvalidated redirect target in the login handler"
194
+ expect_out "NOTE reflag-note" "unknown row still demotes LOW" -- --severity LOW --confidence CONFIRMED "${PF[@]}" --file src/unlinked.py --line-start 300 --title "Unvalidated redirect target in the login handler"
195
+ expect_out "NOTE reflag-note" "unknown row still demotes MEDIUM POSSIBLE" -- --severity MEDIUM --confidence POSSIBLE "${PF[@]}" --file src/unlinked.py --line-start 300 --title "Unvalidated redirect target in the login handler"
196
+ expect ISSUE "HIGH unaffected by unknown row" -- --severity HIGH --confidence CONFIRMED "${PF[@]}" --file src/unlinked.py --line-start 300 --title "Unvalidated redirect target in the login handler"
197
+ expect_out "NOTE reflag-filed #123" "re-flag of an already-filed issue links it" -- "${M[@]}" "${PF[@]}" --file src/api.py --line-start 12 --line-end 18 --title "retry x"
198
+ expect_out "NOTE reflag-filed #123" "filed re-flag by title tokens" -- "${M[@]}" "${PF[@]}" --file src/api.py --line-start 99 --title "Missing retry on timeout"
199
+ expect ISSUE "filed-closed row is ignored (regression not swallowed)" -- "${M[@]}" "${PF[@]}" --file src/closed.py --line-start 5 --line-end 15 --title "stale cache entry never evicted"
200
+ expect ISSUE "HIGH is never demoted by a re-flag" -- --severity HIGH --confidence CONFIRMED "${PF[@]}" --file src/api.py --line-start 12 --line-end 18 --title "missing retry on timeout handler"
201
+ expect ISSUE "CRITICAL is never demoted by a re-flag" -- --severity CRITICAL "${PF[@]}" --file src/api.py --line-start 12 --line-end 18
202
+ expect ISSUE "unparseable severity is never demoted" -- --severity INFO "${PF[@]}" --file src/api.py --line-start 12 --line-end 18
203
+ expect ISSUE "no --file: gate skipped" -- "${M[@]}" "${PF[@]}" --title "lock released before write completes"
204
+ expect ISSUE "no --prior-findings: unchanged" -- "${M[@]}" --file src/api.py --line-start 12 --line-end 18
205
+ expect NOTE "re-flag wins over in-PR fix gate" -- "${M[@]}" "${PF[@]}" --file src/api.py --line-start 12 --line-end 18 --title "retry gap" --inpr-diff "$PRIOR"
206
+ : > "$PRIOR"
207
+ expect ISSUE "empty prior-findings: unchanged" -- "${M[@]}" "${PF[@]}" --file src/api.py --line-start 12 --line-end 18 --title "missing retry"
208
+ rm -f "$PRIOR" "$DMAP"
209
+ expect_exit 2 "unreadable --prior-findings" -- --severity MEDIUM --prior-findings /nonexistent/p --file x
210
+
155
211
  # --- usage errors ---
156
212
  expect_exit 2 "unknown flag" -- --bogus x
157
213
  expect_exit 2 "bad lineage" -- --severity LOW --lineage P3