forgedock 1.3.0 → 1.5.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 (58) hide show
  1. package/bin/batch-runner.mjs +31 -2
  2. package/bin/engine/admission.mjs +294 -0
  3. package/bin/engine/admission.test.mjs +265 -0
  4. package/bin/engine/invariants.mjs +17 -4
  5. package/bin/engine/phases.mjs +201 -35
  6. package/bin/engine/resolve.mjs +256 -0
  7. package/bin/engine/resolve.test.mjs +149 -0
  8. package/bin/engine/runlog.mjs +11 -1
  9. package/bin/engine/state.mjs +20 -28
  10. package/bin/engine-cli.mjs +86 -5
  11. package/bin/engine.mjs +543 -36
  12. package/bin/forgedock.mjs +397 -59
  13. package/bin/hooks/interactive-engine.mjs +50 -24
  14. package/bin/hooks/pre-tool-use.mjs +191 -13
  15. package/bin/journey.mjs +586 -44
  16. package/bin/labels.json +5 -0
  17. package/bin/runner.mjs +478 -10
  18. package/bin/watch-utils.mjs +258 -0
  19. package/commands/cleanup.md +34 -5
  20. package/commands/issue.md +14 -4
  21. package/commands/orchestrate/config.md +43 -1
  22. package/commands/orchestrate/phase-1-resolve.md +348 -11
  23. package/commands/orchestrate/phase-3-dependency.md +272 -49
  24. package/commands/orchestrate/phase-4-execution.md +622 -60
  25. package/commands/orchestrate/phase-5-cleanup.md +2 -0
  26. package/commands/quality-gate.md +6 -6
  27. package/commands/review-pr-staging.md +51 -8
  28. package/commands/review-pr.md +120 -39
  29. package/commands/upgrade-deps.md +7 -2
  30. package/commands/work-on/decompose.md +2 -0
  31. package/commands/work-on/investigate.md +22 -4
  32. package/commands/work-on/remediate.md +55 -4
  33. package/commands/work-on/review.md +40 -1
  34. package/commands/work-on.md +19 -4
  35. package/docs/CONFIG.md +120 -0
  36. package/docs/spec/eval-run-result.md +8 -4
  37. package/docs/spec/label-state-machine.md +9 -0
  38. package/package.json +2 -2
  39. package/packages/protocol/src/emit.js +5 -22
  40. package/packages/protocol/src/html-comment-escape.js +68 -0
  41. package/packages/protocol/src/index.js +1 -0
  42. package/packages/protocol/src/parse.js +5 -6
  43. package/packages/protocol/src/phases.js +105 -0
  44. package/packages/protocol/src/types.js +17 -0
  45. package/packages/protocol/src/validate.js +24 -1
  46. package/scripts/check-admission-parity.mjs +167 -0
  47. package/scripts/check-command-side-effects.sh +181 -66
  48. package/scripts/check-phase-registry-drift.mjs +118 -0
  49. package/scripts/check-spec-markers.sh +3 -0
  50. package/scripts/classify-lane.sh +29 -3
  51. package/scripts/derive-finding-milestone.sh +184 -0
  52. package/scripts/doctor-pipeline-state.sh +1 -0
  53. package/scripts/extract-affected-files.sh +232 -0
  54. package/scripts/extract-affected-files.test.sh +331 -0
  55. package/scripts/forge-run.sh +39 -4
  56. package/scripts/issue-dedup.sh +69 -8
  57. package/scripts/issue-dedup.test.sh +142 -0
  58. package/scripts/severity-to-priority.sh +81 -0
@@ -6,7 +6,7 @@
6
6
  * Checks:
7
7
  * - Completion sentinel presence (§3.2) — for types that define one
8
8
  * - Required field presence (§4.1 per-type field tables)
9
- * - Required field enum values (Verdict, Confidence, Severity, Task Type)
9
+ * - Required field enum values (Verdict, Confidence, Severity, Task Type, Re-gate outcome)
10
10
  * - Inline-value non-empty (§3.4)
11
11
  * - CARD inline-value decodability + sha8 integrity (§4.2 — see card.js)
12
12
  * - Partial sentinel marking (§3.3) — generates a warning, not an error
@@ -129,5 +129,28 @@ export function validate(annotation) {
129
129
  }
130
130
  }
131
131
 
132
+ // REMEDIATION-specific enum validation (forge#2450): the Re-gate outcome field is
133
+ // optional at the required-field-presence layer (it is absent until
134
+ // commands/work-on/remediate.md's Phase M8 posts it), but when present its value
135
+ // must come from the single-sourced registry list — not a second hardcoded copy.
136
+ //
137
+ // Phase M8 posts this field as "**Re-gate outcome**: ${RE_GATE_OUTCOME} ${OUTCOME_DETAIL}"
138
+ // (see commands/work-on/remediate.md), so the parsed field value carries trailing free-text
139
+ // detail (e.g. "AUTO-LANDED to staging"), not just the bare outcome token. Only the leading
140
+ // token is the actual outcome — extract it the same way bin/engine/phases.mjs's own
141
+ // `/\*\*Re-gate outcome\*\*:\s*([A-Z-]+)/` regex does, so real production annotations with
142
+ // trailing detail text are not falsely rejected.
143
+ if (type === 'REMEDIATION') {
144
+ const reGateOutcome = fields['Re-gate outcome'];
145
+ if (reGateOutcome) {
146
+ const outcomeToken = reGateOutcome.match(/^([A-Z-]+)/)?.[1] ?? reGateOutcome;
147
+ if (!typeDef.reGateOutcomeValues.includes(outcomeToken)) {
148
+ errors.push(
149
+ `Invalid Re-gate outcome "${outcomeToken}" — must be one of: ${typeDef.reGateOutcomeValues.join(', ')}`,
150
+ );
151
+ }
152
+ }
153
+ }
154
+
132
155
  return { valid: errors.length === 0, errors, warnings };
133
156
  }
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CI parity check: bin/engine/admission.mjs's `CASCADE_PRESETS` table is the
4
+ * typed, unit-tested reference implementation of the cascade admission
5
+ * policy (forge#2234). The logic actually EXECUTED at runtime is a
6
+ * hand-written bash/yq mirror in commands/orchestrate/phase-4-execution.md's
7
+ * Step 4A.pre `case "$CASCADE_POLICY_NAME" in ... esac` block — the two are
8
+ * kept in sync by hand, with no automated check catching divergence
9
+ * (forge#2340). This script closes that gap: it imports the real
10
+ * `CASCADE_PRESETS` table and diffs it against the values parsed out of the
11
+ * bash mirror, failing non-zero on any mismatch.
12
+ *
13
+ * Usage: node scripts/check-admission-parity.mjs
14
+ */
15
+
16
+ import { readFileSync } from "node:fs";
17
+ import { fileURLToPath, pathToFileURL } from "node:url";
18
+ import path from "node:path";
19
+
20
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
+ const REPO_ROOT = path.resolve(__dirname, "..");
22
+ const ADMISSION_MJS_PATH = path.join(REPO_ROOT, "bin/engine/admission.mjs");
23
+ const SPEC_PATH = path.join(REPO_ROOT, "commands/orchestrate/phase-4-execution.md");
24
+
25
+ /**
26
+ * Parse a single bash literal captured from the mirror's case arm into the
27
+ * same JS types `CASCADE_PRESETS` uses (number | "unlimited" | boolean).
28
+ * @param {string} raw
29
+ */
30
+ function parseBashValue(raw) {
31
+ const trimmed = raw.trim().replace(/^"(.*)"$/, "$1");
32
+ if (trimmed === "true") return true;
33
+ if (trimmed === "false") return false;
34
+ if (trimmed === "unlimited") return "unlimited";
35
+ const n = Number(trimmed);
36
+ return Number.isFinite(n) && trimmed !== "" ? n : trimmed;
37
+ }
38
+
39
+ /**
40
+ * Extract each `PRESET_*` assignment from a single case-arm body.
41
+ * @param {string} body - text between `{name})` and the arm's `;;`
42
+ */
43
+ function parseArmBody(body) {
44
+ const get = (key) => {
45
+ const m = body.match(new RegExp(`${key}=("?[^;"]*"?)`));
46
+ return m ? parseBashValue(m[1]) : undefined;
47
+ };
48
+ return {
49
+ maxGeneration: get("PRESET_MAX_GEN"),
50
+ tokenBudget: get("PRESET_TOKEN_BUDGET"),
51
+ deferOnBatchGated: get("PRESET_DEFER_GATED"),
52
+ keywordHeuristic: get("PRESET_KEYWORD"),
53
+ p3SameFileDefer: get("PRESET_P3_SAME_FILE"),
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Isolate the `case "$CASCADE_POLICY_NAME" in ... esac` block and return a
59
+ * map of arm-name -> resolved preset object. The wildcard `*` fallback arm
60
+ * is excluded (it re-declares "balanced", not a distinct preset name).
61
+ */
62
+ function extractBashMirrorPresets(specText) {
63
+ const caseMatch = specText.match(/case "\$CASCADE_POLICY_NAME" in([\s\S]*?)\nesac/);
64
+ if (!caseMatch) {
65
+ return null;
66
+ }
67
+ const caseBlock = caseMatch[1];
68
+
69
+ const armRe = /\n\s*([a-zA-Z0-9_*]+)\)([\s\S]*?);;/g;
70
+ const presets = {};
71
+ let m;
72
+ while ((m = armRe.exec(caseBlock))) {
73
+ const [, name, body] = m;
74
+ if (name === "*") continue; // wildcard fallback arm — not a named preset
75
+ presets[name] = parseArmBody(body);
76
+ }
77
+ return presets;
78
+ }
79
+
80
+ async function main() {
81
+ let admissionModule;
82
+ try {
83
+ // pathToFileURL is required for cross-platform dynamic import() of an absolute
84
+ // path — a bare Windows path like "C:\..." is not a valid ESM specifier and
85
+ // throws ERR_UNSUPPORTED_ESM_URL_SCHEME ("Only URLs with a scheme in: file,
86
+ // data, and node are supported"). POSIX absolute paths import fine either way,
87
+ // but file:// URLs work identically on both, so always convert.
88
+ admissionModule = await import(pathToFileURL(ADMISSION_MJS_PATH));
89
+ } catch (err) {
90
+ console.error(`ERROR: failed to import ${ADMISSION_MJS_PATH}: ${err.message}`);
91
+ process.exit(1);
92
+ }
93
+ const { CASCADE_PRESETS } = admissionModule;
94
+ if (!CASCADE_PRESETS) {
95
+ console.error(`ERROR: ${ADMISSION_MJS_PATH} does not export CASCADE_PRESETS — has it been renamed?`);
96
+ process.exit(1);
97
+ }
98
+
99
+ let specText;
100
+ try {
101
+ specText = readFileSync(SPEC_PATH, "utf8");
102
+ } catch (err) {
103
+ console.error(`ERROR: failed to read ${SPEC_PATH}: ${err.message}`);
104
+ process.exit(1);
105
+ }
106
+
107
+ const bashPresets = extractBashMirrorPresets(specText);
108
+ if (!bashPresets) {
109
+ console.error(
110
+ `ERROR: could not locate the case "$CASCADE_POLICY_NAME" in ... esac bash mirror block in ${SPEC_PATH}.`,
111
+ );
112
+ console.error(
113
+ " (parity check has nothing to compare admission.mjs against — has the mirror been renamed or restructured?)",
114
+ );
115
+ process.exit(1);
116
+ }
117
+
118
+ let failed = false;
119
+ const mjsNames = Object.keys(CASCADE_PRESETS);
120
+ const bashNames = Object.keys(bashPresets);
121
+
122
+ for (const name of mjsNames) {
123
+ if (!(name in bashPresets)) {
124
+ console.error(
125
+ `MISMATCH: preset "${name}" exists in admission.mjs CASCADE_PRESETS but has no matching bash case arm in ${SPEC_PATH}`,
126
+ );
127
+ failed = true;
128
+ continue;
129
+ }
130
+ const mjsPreset = CASCADE_PRESETS[name];
131
+ const bashPreset = bashPresets[name];
132
+ for (const key of Object.keys(mjsPreset)) {
133
+ if (mjsPreset[key] !== bashPreset[key]) {
134
+ console.error(
135
+ `MISMATCH: preset "${name}".${key} — admission.mjs=${JSON.stringify(mjsPreset[key])} bash-mirror=${JSON.stringify(bashPreset[key])}`,
136
+ );
137
+ failed = true;
138
+ }
139
+ }
140
+ }
141
+
142
+ for (const name of bashNames) {
143
+ if (!(name in CASCADE_PRESETS)) {
144
+ console.error(
145
+ `MISMATCH: bash case arm "${name}" in ${SPEC_PATH} has no matching preset in admission.mjs CASCADE_PRESETS`,
146
+ );
147
+ failed = true;
148
+ }
149
+ }
150
+
151
+ if (failed) {
152
+ console.error("");
153
+ console.error(
154
+ "admission.mjs CASCADE_PRESETS and the phase-4-execution.md bash mirror have drifted out of sync.",
155
+ );
156
+ console.error(
157
+ "Update both together — see the admission.mjs module docstring and phase-4-execution.md Step 4A.pre ('Cascade admission policy resolution').",
158
+ );
159
+ process.exit(1);
160
+ }
161
+
162
+ console.log(
163
+ `OK: admission.mjs CASCADE_PRESETS matches the phase-4-execution.md bash mirror for: ${mjsNames.join(", ")}`,
164
+ );
165
+ }
166
+
167
+ main();
@@ -14,11 +14,17 @@
14
14
  # Gists created by the pipeline MUST be secret — --public exposes private repo
15
15
  # titles, root causes, and file paths to the world. (Ref: forge#1587)
16
16
  #
17
- # Class B — Side-effect verbs in sections with no DRY_RUN/governor guard (diff-aware):
17
+ # Class B — Side-effect verbs in code blocks with no DRY_RUN/governor guard (diff-aware):
18
18
  # When added lines introduce a side-effect verb (gh issue create, gh pr merge,
19
19
  # git push, gh issue edit|comment, --auto-merge, --add/remove-label) inside a
20
- # code block in a section that has no guard expression (DRY_RUN, GOVERNOR,
21
- # --dry-run) anywhere in that section's code blocks, the change is flagged.
20
+ # code block that has no guard expression (DRY_RUN, GOVERNOR, --dry-run)
21
+ # anywhere in THAT SAME code block, the change is flagged. Guard/side-effect
22
+ # correlation is scoped to the individual fenced code block, not the whole
23
+ # section — a guard mentioned in an unrelated code block elsewhere in the
24
+ # same section (before or after the flagged verb) does not silence the
25
+ # finding. (Ref: forge#2289 — a decoy guard anywhere in the section, in any
26
+ # code block, previously silenced detection of an actually-unguarded side
27
+ # effect in a different block of that same section.)
22
28
  # Operates on the diff (GITHUB_BASE_SHA or HEAD^) to avoid flagging legacy corpus.
23
29
  # (Ref: forge#1609 — signal-planner.md DRY_RUN guard placed after the create it guards)
24
30
  #
@@ -99,45 +105,99 @@ class_a_scan() {
99
105
  local HAS_GIST=0
100
106
  local BLOCK_LINES=""
101
107
  local BLOCK_START=0
102
- local LINENO=0
103
- local line PUBLIC_LINENO ACTUAL_LINE
108
+ local LN=0
109
+ local line PUBLIC_LN ACTUAL_LINE IS_FENCE IS_BARE
110
+
111
+ # flush_a_block — evaluate the currently-accumulated block's HAS_GIST/
112
+ # BLOCK_LINES state for a 'gh gist create/edit --public' violation, then
113
+ # reset it. Defined inside class_a_scan (not at file scope) so it shares
114
+ # this invocation's `local` variables via bash's dynamic scoping — the same
115
+ # mechanism Class B's flush_block() relies on for its own loop-local state.
116
+ # Called from two sites: (1) the existing fence-close path below, when a
117
+ # bare fence brings IN_CB back to 0, and (2) once more, defensively, after
118
+ # the read loop ends — covering a file that ends with an unterminated
119
+ # fence (IN_CB still > 0 at EOF), which previously discarded any
120
+ # accumulated violation silently. (forge#2289 added the equivalent
121
+ # end-of-file flush for Class B only; this closes the same gap in Class A.)
122
+ flush_a_block() {
123
+ if [ "$HAS_GIST" -eq 1 ] && echo "$BLOCK_LINES" | grep -qE '^[[:space:]]*--public([[:space:]]|$)'; then
124
+ # Find the line number of --public within the block
125
+ PUBLIC_LN=$(echo "$BLOCK_LINES" | grep -n '^[[:space:]]*--public' | head -1 | cut -d: -f1)
126
+ ACTUAL_LINE=$((BLOCK_START + PUBLIC_LN))
127
+ if ! echo "$BLOCK_LINES" | grep -qF "$ALLOWLIST_TOKEN"; then
128
+ echo "HIGH | $file | line ~$ACTUAL_LINE | Class A: 'gh gist create/edit --public' in code block — gists MUST be secret (omit --public); --public exposes private repo data. (forge#1587)" >&2
129
+ VIOLATIONS=$((VIOLATIONS + 1))
130
+ fi
131
+ fi
132
+ HAS_GIST=0
133
+ BLOCK_LINES=""
134
+ }
104
135
 
105
136
  while IFS= read -r line; do
106
- LINENO=$((LINENO + 1))
137
+ LN=$((LN + 1))
138
+
139
+ # Fence detection is anchored to line-start (ignoring leading whitespace).
140
+ # IN_CB is a true nesting-DEPTH counter, not a 0/1 flag: a fence line
141
+ # carrying an info string (e.g. a nested ```bash) while already inside a
142
+ # block opens one more nesting level (increment); a bare fence (nothing
143
+ # after the backticks but optional trailing whitespace) closes exactly
144
+ # one level (decrement). The accumulated block is only evaluated once
145
+ # depth returns to 0 — i.e. once the OUTERMOST fence has actually closed,
146
+ # not merely an inner one. A binary flag cannot distinguish "still inside
147
+ # the outer fence, past an inner fence's close" from "outside all
148
+ # fences"; depth tracking can. (forge#2210 fixed anchoring/info-string-is-
149
+ # content; forge#2288 adds true depth so 2+ nesting levels close correctly.)
150
+ IS_FENCE=0
151
+ IS_BARE=0
152
+ if echo "$line" | grep -qE "^[[:space:]]*${FENCE}"; then
153
+ IS_FENCE=1
154
+ if echo "$line" | grep -qE "^[[:space:]]*${FENCE}[[:space:]]*\$"; then
155
+ IS_BARE=1
156
+ fi
157
+ fi
107
158
 
108
- if echo "$line" | grep -qF "$FENCE"; then
109
- if [ "$IN_CB" -eq 0 ]; then
159
+ if [ "$IN_CB" -eq 0 ]; then
160
+ if [ "$IS_FENCE" -eq 1 ]; then
110
161
  IN_CB=1
111
162
  HAS_GIST=0
112
163
  BLOCK_LINES=""
113
- BLOCK_START=$LINENO
114
- else
115
- # End of code block — check if it had both gh gist and --public
116
- if [ "$HAS_GIST" -eq 1 ] && echo "$BLOCK_LINES" | grep -qE '^[[:space:]]*--public([[:space:]]|$)'; then
117
- # Find the line number of --public within the block
118
- PUBLIC_LINENO=$(echo "$BLOCK_LINES" | grep -n '^[[:space:]]*--public' | head -1 | cut -d: -f1)
119
- ACTUAL_LINE=$((BLOCK_START + PUBLIC_LINENO))
120
- if ! echo "$BLOCK_LINES" | grep -qF "$ALLOWLIST_TOKEN"; then
121
- echo "HIGH | $file | line ~$ACTUAL_LINE | Class A: 'gh gist create/edit --public' in code block — gists MUST be secret (omit --public); --public exposes private repo data. (forge#1587)" >&2
122
- VIOLATIONS=$((VIOLATIONS + 1))
123
- fi
124
- fi
125
- IN_CB=0
126
- HAS_GIST=0
127
- BLOCK_LINES=""
164
+ BLOCK_START=$LN
128
165
  fi
129
166
  continue
130
167
  fi
131
168
 
132
- if [ "$IN_CB" -eq 1 ]; then
169
+ # IN_CB >= 1 — inside a block, possibly nested.
170
+ if [ "$IS_FENCE" -eq 1 ]; then
171
+ if [ "$IS_BARE" -eq 1 ]; then
172
+ IN_CB=$((IN_CB - 1))
173
+ else
174
+ IN_CB=$((IN_CB + 1))
175
+ fi
176
+ if [ "$IN_CB" -eq 0 ]; then
177
+ # Fully closed (outermost fence) — evaluate the accumulated block now.
178
+ flush_a_block
179
+ continue
180
+ fi
181
+ # Still inside (nested transition) — the fence line itself is block content.
133
182
  BLOCK_LINES="${BLOCK_LINES}${line}
134
183
  "
135
- # Check if this line has gh gist create/edit (even with line continuation \)
136
- if echo "$line" | grep -qE 'gh[[:space:]]+gist[[:space:]]+(create|edit)'; then
137
- HAS_GIST=1
138
- fi
184
+ continue
185
+ fi
186
+
187
+ # Regular content line while inside (at any depth).
188
+ BLOCK_LINES="${BLOCK_LINES}${line}
189
+ "
190
+ # Check if this line has gh gist create/edit (even with line continuation \)
191
+ if echo "$line" | grep -qE 'gh[[:space:]]+gist[[:space:]]+(create|edit)'; then
192
+ HAS_GIST=1
139
193
  fi
140
194
  done < "$file"
195
+
196
+ # Defensive final flush — evaluates any block state left accumulated if the
197
+ # file ended with an unterminated fence (IN_CB never returned to 0). A
198
+ # no-op if the last block already closed and was flushed above. Mirrors
199
+ # Class B's identical end-of-file `flush_block` call. (forge#2305)
200
+ flush_a_block
141
201
  }
142
202
 
143
203
  while IFS= read -r file; do
@@ -185,64 +245,119 @@ else
185
245
  continue
186
246
  fi
187
247
 
188
- # Parse the full file to map sections → (has_guard, has_side_effect_in_added_lines)
248
+ # Parse the full file to map code blocks → (has_guard, has_side_effect_in_added_lines).
249
+ # SECTION is tracked only as a human-readable label for the violation message —
250
+ # it plays NO role in guard/side-effect correlation. Correlation is scoped to
251
+ # the individual fenced code block (BLOCK_* state), reset on every block open
252
+ # and evaluated on every block close, so a guard mentioned in a different code
253
+ # block — even one earlier or later in the very same section — cannot silence
254
+ # an unguarded side effect in another block. (forge#2289)
189
255
  IN_CB=0
190
256
  SECTION="(top)"
191
- SECTION_HAS_GUARD=0
192
- SECTION_HAS_ADDED_SE=0
193
- SECTION_SE_LINE=0
194
- SECTION_SE_VERB=""
195
- LINENO=0
196
-
197
- flush_and_reset() {
198
- local new_heading="$1"
199
- if [ "$SECTION_HAS_ADDED_SE" -eq 1 ] && [ "$SECTION_HAS_GUARD" -eq 0 ]; then
200
- echo "HIGH | $file | line $SECTION_SE_LINE | Class B: side-effect '$SECTION_SE_VERB' added to section '$SECTION' which has no DRY_RUN/governor guard — add guard before this line or wrap in DRY_RUN check" >&2
257
+ BLOCK_HAS_GUARD=0
258
+ BLOCK_HAS_ADDED_SE=0
259
+ BLOCK_SE_LINE=0
260
+ BLOCK_SE_VERB=""
261
+ LN=0
262
+
263
+ flush_block() {
264
+ # Evaluate and reset the current code block's guard/side-effect state.
265
+ # Called whenever a fenced code block's outermost fence closes (depth
266
+ # returns to 0), and once more, defensively, at end-of-file to cover a
267
+ # malformed/unterminated block (mirrors the pre-forge#2289 behavior of
268
+ # unconditionally evaluating accumulated state before resetting).
269
+ if [ "$BLOCK_HAS_ADDED_SE" -eq 1 ] && [ "$BLOCK_HAS_GUARD" -eq 0 ]; then
270
+ echo "HIGH | $file | line $BLOCK_SE_LINE | Class B: side-effect '$BLOCK_SE_VERB' added in section '$SECTION' with no DRY_RUN/governor guard in the same code block — add a guard inside this code block or wrap the effect in a DRY_RUN check" >&2
201
271
  VIOLATIONS=$((VIOLATIONS + 1))
202
272
  fi
203
- SECTION="$new_heading"
204
- SECTION_HAS_GUARD=0
205
- SECTION_HAS_ADDED_SE=0
206
- SECTION_SE_LINE=0
207
- SECTION_SE_VERB=""
208
- IN_CB=0
273
+ BLOCK_HAS_GUARD=0
274
+ BLOCK_HAS_ADDED_SE=0
275
+ BLOCK_SE_LINE=0
276
+ BLOCK_SE_VERB=""
209
277
  }
210
278
 
211
279
  while IFS= read -r line; do
212
- LINENO=$((LINENO + 1))
280
+ LN=$((LN + 1))
281
+
282
+ # Code block fence — checked BEFORE the heading test so IN_CB reflects
283
+ # this line's actual state. IN_CB is a true nesting-DEPTH counter, not
284
+ # a 0/1 flag: a fence line carrying an info string (e.g. nested
285
+ # ```bash) encountered while already inside a block opens one more
286
+ # nesting level (increment); a bare fence (nothing after the
287
+ # backticks) closes exactly one level (decrement). Heading detection
288
+ # below only fires once depth returns to 0 — i.e. once we are outside
289
+ # ALL fences, not merely an inner one. A binary flag cannot
290
+ # distinguish "still inside the outer fence, past an inner fence's
291
+ # close" from "outside all fences"; this is exactly the gap that let
292
+ # embedded `## Step N` headers inside one continuous outer code block
293
+ # (e.g. commands/review-pr-agents/spec-cli.md) be misdetected as real
294
+ # section boundaries. (forge#2210 fixed anchoring/info-string-is-
295
+ # content for a single nesting level; forge#2288 adds true depth so
296
+ # 2+ nesting levels close correctly.)
297
+ IS_FENCE=0
298
+ IS_BARE=0
299
+ if echo "$line" | grep -qE "^[[:space:]]*${FENCE}"; then
300
+ IS_FENCE=1
301
+ if echo "$line" | grep -qE "^[[:space:]]*${FENCE}[[:space:]]*\$"; then
302
+ IS_BARE=1
303
+ fi
304
+ fi
305
+
306
+ if [ "$IN_CB" -eq 0 ]; then
307
+ if [ "$IS_FENCE" -eq 1 ]; then
308
+ IN_CB=1
309
+ continue
310
+ fi
213
311
 
214
- # Section heading reset
215
- if echo "$line" | grep -qE '^#{1,4}[[:space:]]+'; then
216
- heading=$(echo "$line" | sed 's/^#*[[:space:]]*//' | sed 's/[[:space:]]*$//')
217
- flush_and_reset "$heading"
312
+ # Section heading — label-only update (forge#2210). This no longer
313
+ # flushes guard/side-effect state: that state is now block-scoped
314
+ # (forge#2289) and is always already at rest here, since this branch
315
+ # is only reached when IN_CB is 0 — i.e. any block that was open has
316
+ # already been closed and flushed at its own fence-close below.
317
+ if echo "$line" | grep -qE '^#{1,6}[[:space:]]+'; then
318
+ heading=$(echo "$line" | sed 's/^#*[[:space:]]*//' | sed 's/[[:space:]]*$//')
319
+ SECTION="$heading"
320
+ continue
321
+ fi
218
322
  continue
219
323
  fi
220
324
 
221
- # Code block fence
222
- if echo "$line" | grep -qF "$FENCE"; then
223
- [ "$IN_CB" -eq 0 ] && IN_CB=1 || IN_CB=0
325
+ # IN_CB >= 1 — inside a block, possibly nested.
326
+ if [ "$IS_FENCE" -eq 1 ]; then
327
+ if [ "$IS_BARE" -eq 1 ]; then
328
+ IN_CB=$((IN_CB - 1))
329
+ else
330
+ IN_CB=$((IN_CB + 1))
331
+ fi
332
+ if [ "$IN_CB" -eq 0 ]; then
333
+ # Outermost fence just closed — evaluate this block's guard/SE
334
+ # correlation now, before starting the next block. (forge#2289)
335
+ flush_block
336
+ fi
224
337
  continue
225
338
  fi
226
339
 
227
- if [ "$IN_CB" -eq 1 ]; then
228
- if echo "$line" | grep -qF "$ALLOWLIST_TOKEN"; then continue; fi
340
+ # Content line while inside (at any depth).
341
+ if echo "$line" | grep -qF "$ALLOWLIST_TOKEN"; then continue; fi
229
342
 
230
- # Check for guard
231
- echo "$line" | grep -qE "$GUARD_PATTERN" && SECTION_HAS_GUARD=1
343
+ # Check for guard — scoped to THIS code block only (forge#2289)
344
+ echo "$line" | grep -qE "$GUARD_PATTERN" && BLOCK_HAS_GUARD=1
232
345
 
233
- # Check if this line is in the diff's added lines AND has a side-effect verb
234
- if [ "$SECTION_HAS_ADDED_SE" -eq 0 ] && echo "$line" | grep -qE "$SIDE_EFFECT_PATTERN"; then
235
- # Is this specific line in the added content?
236
- if echo "$ADDED_CONTENT" | grep -qF "${line:0:80}" 2>/dev/null; then
237
- SECTION_HAS_ADDED_SE=1
238
- SECTION_SE_LINE=$LINENO
239
- SECTION_SE_VERB=$(echo "$line" | grep -oE "$SIDE_EFFECT_PATTERN" | head -1 || echo "side-effect")
240
- fi
346
+ # Check if this line is in the diff's added lines AND has a side-effect verb
347
+ if [ "$BLOCK_HAS_ADDED_SE" -eq 0 ] && echo "$line" | grep -qE "$SIDE_EFFECT_PATTERN"; then
348
+ # Is this specific line in the added content?
349
+ if echo "$ADDED_CONTENT" | grep -qF "${line:0:80}" 2>/dev/null; then
350
+ BLOCK_HAS_ADDED_SE=1
351
+ BLOCK_SE_LINE=$LN
352
+ BLOCK_SE_VERB=$(echo "$line" | grep -oE "$SIDE_EFFECT_PATTERN" | head -1 || echo "side-effect")
241
353
  fi
242
354
  fi
243
355
  done < "$file"
244
356
 
245
- flush_and_reset "(end-of-file)"
357
+ # Defensive final flush — evaluates any block state left accumulated if
358
+ # the file ended with an unterminated fence (malformed input); a no-op
359
+ # if the last block already closed and was flushed above.
360
+ flush_block
246
361
 
247
362
  done <<< "$CHANGED_SPECS"
248
363
  fi
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CI drift check: bin/engine/phases.mjs's `PHASES` array (the authoritative
4
+ * engine phase table) must have exactly the same phase-id list, in the same
5
+ * order, as packages/protocol/src/phases.js's `PHASE_IDS` — the single-source
6
+ * registry both bin/engine/phases.mjs and bin/hooks/interactive-engine.mjs
7
+ * import their marker strings from (forge#2378).
8
+ *
9
+ * This does NOT re-validate the marker *strings* themselves — both consumer
10
+ * files import the registry directly, so a string-level mismatch between them
11
+ * is now structurally impossible (there is exactly one literal declaration of
12
+ * each marker, in packages/protocol/src/phases.js / types.js). What CAN still
13
+ * drift is the *phase-id list itself*: someone adds/removes/reorders a phase
14
+ * in bin/engine/phases.mjs's PHASES array without updating the registry's
15
+ * PHASE_IDS (or vice versa). This script catches that.
16
+ *
17
+ * Usage: node scripts/check-phase-registry-drift.mjs
18
+ * Exit codes: 0 = no drift, 1 = drift detected, 2 = usage/import error.
19
+ */
20
+
21
+ import { fileURLToPath, pathToFileURL } from "node:url";
22
+ import path from "node:path";
23
+
24
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
+ const REPO_ROOT = path.resolve(__dirname, "..");
26
+
27
+ async function main() {
28
+ let registryModule;
29
+ let phasesModule;
30
+ try {
31
+ registryModule = await import(
32
+ pathToFileURL(path.join(REPO_ROOT, "packages/protocol/src/phases.js")).href
33
+ );
34
+ } catch (err) {
35
+ console.error(`ERROR: failed to import packages/protocol/src/phases.js: ${err.message}`);
36
+ process.exit(2);
37
+ }
38
+ try {
39
+ phasesModule = await import(
40
+ pathToFileURL(path.join(REPO_ROOT, "bin/engine/phases.mjs")).href
41
+ );
42
+ } catch (err) {
43
+ console.error(`ERROR: failed to import bin/engine/phases.mjs: ${err.message}`);
44
+ process.exit(2);
45
+ }
46
+
47
+ const registryIds = registryModule.PHASE_IDS;
48
+ const enginePhaseIds = (phasesModule.PHASES || []).map((p) => p.id);
49
+
50
+ if (!Array.isArray(registryIds)) {
51
+ console.error("ERROR: packages/protocol/src/phases.js did not export a PHASE_IDS array.");
52
+ process.exit(2);
53
+ }
54
+ if (!Array.isArray(enginePhaseIds) || enginePhaseIds.length === 0) {
55
+ console.error("ERROR: bin/engine/phases.mjs did not export a non-empty PHASES array.");
56
+ process.exit(2);
57
+ }
58
+
59
+ const drifted =
60
+ registryIds.length !== enginePhaseIds.length ||
61
+ registryIds.some((id, i) => id !== enginePhaseIds[i]);
62
+
63
+ if (drifted) {
64
+ console.error("ERROR: phase-id list drift detected between the registry and the engine.");
65
+ console.error(` packages/protocol/src/phases.js PHASE_IDS: ${JSON.stringify(registryIds)}`);
66
+ console.error(` bin/engine/phases.mjs PHASES ids: ${JSON.stringify(enginePhaseIds)}`);
67
+ console.error(" Update packages/protocol/src/phases.js's PHASE_IDS to match bin/engine/phases.mjs's");
68
+ console.error(" PHASES array (or vice versa) so the two stay single-sourced. See forge#2378.");
69
+ process.exit(1);
70
+ }
71
+
72
+ // Verify every id in PHASE_IDS has a corresponding key in PHASE_MARKERS.
73
+ // The empty-marker check below iterates Object.entries(PHASE_MARKERS), which
74
+ // only visits keys that actually exist on the object — a phase id that is
75
+ // entirely absent from PHASE_MARKERS (as opposed to present-but-empty) never
76
+ // appears in that iteration, so it would pass silently here and only surface
77
+ // later as a runtime TypeError in bin/engine/phases.mjs (e.g.
78
+ // `PHASE_MARKERS.someId.completionMarker` on an undefined `someId` entry).
79
+ // Catch that gap explicitly before the empty-marker check below.
80
+ const phaseMarkers = registryModule.PHASE_MARKERS || {};
81
+ const missingKeys = registryIds.filter(
82
+ (id) => !Object.prototype.hasOwnProperty.call(phaseMarkers, id)
83
+ );
84
+ if (missingKeys.length > 0) {
85
+ console.error(
86
+ `ERROR: PHASE_MARKERS is missing an entry entirely for: ${missingKeys.join(", ")}`
87
+ );
88
+ console.error(
89
+ " Add a corresponding key to packages/protocol/src/phases.js's PHASE_MARKERS for each id above."
90
+ );
91
+ process.exit(1);
92
+ }
93
+
94
+ // Also verify every registry marker entry that has a `completionMarker` or
95
+ // `completionLabel` field is a non-empty string — catches an accidental
96
+ // undefined/null slipping in (e.g. a typo'd RESERVED_TYPES key whose
97
+ // completionSentinel is null) which would otherwise fail silently at
98
+ // has(blob, undefined) → substring "undefined" never matches, masking a
99
+ // real bug as "phase never completes".
100
+ const emptyMarkers = [];
101
+ for (const [phaseId, entry] of Object.entries(phaseMarkers)) {
102
+ const marker = entry.completionMarker ?? entry.completionLabel;
103
+ if (typeof marker !== "string" || marker.length === 0) {
104
+ emptyMarkers.push(phaseId);
105
+ }
106
+ }
107
+ if (emptyMarkers.length > 0) {
108
+ console.error(
109
+ `ERROR: PHASE_MARKERS entries with no usable completionMarker/completionLabel: ${emptyMarkers.join(", ")}`
110
+ );
111
+ process.exit(1);
112
+ }
113
+
114
+ console.log("OK: phase-id list and marker registry are consistent (no drift detected).");
115
+ process.exit(0);
116
+ }
117
+
118
+ main();
@@ -117,6 +117,8 @@ MARKER_REGISTRY="
117
117
  KNOWLEDGE_GIST
118
118
  LEARNED
119
119
  LEARNED_RULES
120
+ LEASE
121
+ LEASE_RELEASED
120
122
  LEDGER_INDEXED
121
123
  MEMORY_INDEX
122
124
  MEMORY_INDEXED
@@ -135,6 +137,7 @@ MARKER_REGISTRY="
135
137
  PUSH_BLOCKED_EMPTY_BRANCH
136
138
  PUSH_FAILED
137
139
  REMEDIATION
140
+ REREVIEW_SKIPPED
138
141
  REVIEW
139
142
  REVIEWER
140
143
  REVIEW_ROUTE