forgedock 1.2.0 → 1.4.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 (78) hide show
  1. package/README.md +8 -2
  2. package/bin/batch-runner.mjs +31 -2
  3. package/bin/engine/admission.mjs +294 -0
  4. package/bin/engine/admission.test.mjs +265 -0
  5. package/bin/engine/invariants.mjs +17 -4
  6. package/bin/engine/phases.mjs +358 -42
  7. package/bin/engine/resolve.mjs +256 -0
  8. package/bin/engine/resolve.test.mjs +149 -0
  9. package/bin/engine/state.mjs +31 -7
  10. package/bin/engine-cli.mjs +138 -2
  11. package/bin/engine.mjs +414 -21
  12. package/bin/forgedock.mjs +552 -44
  13. package/bin/hooks/interactive-engine.mjs +50 -24
  14. package/bin/hooks/pre-tool-use.mjs +189 -19
  15. package/bin/journey.mjs +109 -11
  16. package/bin/labels.json +5 -0
  17. package/bin/runner.mjs +321 -10
  18. package/bin/watch-utils.mjs +255 -0
  19. package/commands/cleanup.md +7 -3
  20. package/commands/forge-stats.md +5 -4
  21. package/commands/issue.md +5 -1
  22. package/commands/orchestrate/config.md +41 -1
  23. package/commands/orchestrate/phase-1-resolve.md +234 -8
  24. package/commands/orchestrate/phase-3-dependency.md +27 -18
  25. package/commands/orchestrate/phase-4-execution.md +223 -22
  26. package/commands/qa-sweep.md +17 -0
  27. package/commands/quality-gate.md +6 -6
  28. package/commands/replay.md +16 -2
  29. package/commands/review-pr-agents/spec-cli.md +179 -0
  30. package/commands/review-pr.md +65 -0
  31. package/commands/upgrade-deps.md +523 -0
  32. package/commands/work-on/build/validate.md +61 -1
  33. package/commands/work-on/build.md +9 -1
  34. package/commands/work-on/decompose.md +10 -1
  35. package/commands/work-on/investigate.md +22 -4
  36. package/commands/work-on/remediate.md +2 -0
  37. package/commands/work-on/review.md +40 -1
  38. package/commands/work-on.md +19 -4
  39. package/docs/AIDER.md +264 -0
  40. package/docs/AUTOPILOT-CRON.md +130 -0
  41. package/docs/CONFIG.md +156 -0
  42. package/docs/CURSOR.md +135 -0
  43. package/docs/OPENCODE.md +312 -0
  44. package/docs/demo.gif +0 -0
  45. package/docs/eval/README.md +4 -4
  46. package/docs/launch/show-hn.md +3 -3
  47. package/docs/site/architecture.md +180 -0
  48. package/docs/site/command-reference.md +17 -0
  49. package/docs/site/configuration.md +243 -0
  50. package/docs/site/for-companies.md +4 -4
  51. package/docs/site/forge-annotation-protocol.md +1 -1
  52. package/docs/site/github-is-the-memory.md +2 -2
  53. package/docs/site/how-it-works.md +1 -1
  54. package/docs/site/public/llms-full.txt +380 -0
  55. package/docs/site/public/llms.txt +35 -0
  56. package/docs/spec/eval-run-result.md +8 -4
  57. package/docs/spec/label-state-machine.md +9 -0
  58. package/docs/spec-graph-schema.md +3 -3
  59. package/package.json +3 -3
  60. package/packages/protocol/README.md +75 -0
  61. package/packages/protocol/src/card.js +114 -0
  62. package/packages/protocol/src/cli.js +4 -77
  63. package/packages/protocol/src/emit.js +5 -11
  64. package/packages/protocol/src/html-comment-escape.js +68 -0
  65. package/packages/protocol/src/index.js +3 -1
  66. package/packages/protocol/src/parse.js +19 -11
  67. package/packages/protocol/src/phases.js +105 -0
  68. package/packages/protocol/src/types.js +18 -1
  69. package/packages/protocol/src/validate.js +11 -0
  70. package/scripts/check-admission-parity.mjs +167 -0
  71. package/scripts/check-command-side-effects.sh +376 -0
  72. package/scripts/check-phase-registry-drift.mjs +96 -0
  73. package/scripts/check-spec-markers.sh +274 -0
  74. package/scripts/classify-lane.sh +29 -7
  75. package/scripts/forge-run.sh +353 -0
  76. package/templates/cross-repo-trigger.yml +282 -0
  77. package/templates/cursor/forge.mdc +148 -0
  78. package/templates/github-actions/forgedock-autopilot.yml +113 -0
@@ -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();
@@ -0,0 +1,376 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # check-command-side-effects.sh — Scan commands/**/*.md for two classes of
6
+ # spec-as-code side-effect defects.
7
+ #
8
+ # Command specs are spec-as-code: they drive autonomous side effects (auto-merge,
9
+ # gh gist create, git push, label mutations, issue creation). This script catches
10
+ # two defect classes before they reach staging:
11
+ #
12
+ # Class A — Unconditionally prohibited patterns (full-corpus, always blocked):
13
+ # `gh gist create --public` or `gh gist edit --public` anywhere in a code block.
14
+ # Gists created by the pipeline MUST be secret — --public exposes private repo
15
+ # titles, root causes, and file paths to the world. (Ref: forge#1587)
16
+ #
17
+ # Class B — Side-effect verbs in code blocks with no DRY_RUN/governor guard (diff-aware):
18
+ # When added lines introduce a side-effect verb (gh issue create, gh pr merge,
19
+ # git push, gh issue edit|comment, --auto-merge, --add/remove-label) inside a
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.)
28
+ # Operates on the diff (GITHUB_BASE_SHA or HEAD^) to avoid flagging legacy corpus.
29
+ # (Ref: forge#1609 — signal-planner.md DRY_RUN guard placed after the create it guards)
30
+ #
31
+ # Allowlist: add <!-- allowlist:check-command-side-effects --> on the same line as
32
+ # the side-effect verb to suppress that specific hit.
33
+ #
34
+ # Usage:
35
+ # check-command-side-effects.sh [--full] [<commands_dir>]
36
+ # --full Scan entire corpus (not just diff) for Class A violations only.
37
+ # Class B is diff-aware by design — --full does not change Class B.
38
+ # commands_dir Path to the commands/ directory (default: ./commands)
39
+ #
40
+ # Environment:
41
+ # GITHUB_BASE_SHA When set, used as the base commit for Class B diff mode.
42
+ #
43
+ # Exit codes:
44
+ # 0 no violations found
45
+ # 1 one or more violations found (listed to stderr)
46
+ # 2 usage / dependency error
47
+ #
48
+ # <!-- Added: forge#1609 -->
49
+
50
+ set -euo pipefail
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Argument parsing
54
+ # ---------------------------------------------------------------------------
55
+
56
+ FULL_MODE=0
57
+ COMMANDS_DIR="./commands"
58
+
59
+ for arg in "$@"; do
60
+ case "$arg" in
61
+ -h|--help)
62
+ sed -n '2,/^set -/p' "$0" | grep '^#' | sed 's/^# *//'
63
+ exit 0
64
+ ;;
65
+ --full) FULL_MODE=1 ;;
66
+ -*) echo "ERROR: unknown option: $arg" >&2; exit 2 ;;
67
+ *) COMMANDS_DIR="$arg" ;;
68
+ esac
69
+ done
70
+
71
+ if [ ! -d "$COMMANDS_DIR" ]; then
72
+ echo "ERROR: commands directory not found: $COMMANDS_DIR" >&2
73
+ echo "Usage: $0 [--full] [<commands_dir>]" >&2
74
+ exit 2
75
+ fi
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Constants
79
+ # ---------------------------------------------------------------------------
80
+
81
+ # Code fence marker — stored in variable to avoid backtick shell-expansion bugs.
82
+ # Do NOT inline as grep -E '^[[:space:]]*\`\`\`' — escaped backticks in grep
83
+ # patterns undergo shell expansion, turning the pattern into '^[[:space:]]*'
84
+ # which matches every line. (forge#1609)
85
+ FENCE='```'
86
+
87
+ # Guard expressions: any occurrence in a section's code blocks marks it as guarded
88
+ GUARD_PATTERN='DRY_RUN|GOVERNOR|--dry-run|DRY_RUN_MODE|DryRun'
89
+
90
+ # Class B side-effect verbs (in code blocks)
91
+ SIDE_EFFECT_PATTERN='gh[[:space:]]+(issue[[:space:]]+(create|edit|comment)|pr[[:space:]]+(merge|create|edit)|gist[[:space:]]+(create|edit))|git[[:space:]]+push|--auto-merge|--add-label|--remove-label'
92
+
93
+ ALLOWLIST_TOKEN='allowlist:check-command-side-effects'
94
+
95
+ VIOLATIONS=0
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Class A: Scan for gh gist create/edit --public in code blocks (full corpus)
99
+ # This runs regardless of --full or diff mode.
100
+ # ---------------------------------------------------------------------------
101
+
102
+ class_a_scan() {
103
+ local file="$1"
104
+ local IN_CB=0
105
+ local HAS_GIST=0
106
+ local BLOCK_LINES=""
107
+ local BLOCK_START=0
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
+ }
135
+
136
+ while IFS= read -r line; do
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
158
+
159
+ if [ "$IN_CB" -eq 0 ]; then
160
+ if [ "$IS_FENCE" -eq 1 ]; then
161
+ IN_CB=1
162
+ HAS_GIST=0
163
+ BLOCK_LINES=""
164
+ BLOCK_START=$LN
165
+ fi
166
+ continue
167
+ fi
168
+
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.
182
+ BLOCK_LINES="${BLOCK_LINES}${line}
183
+ "
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
193
+ fi
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
201
+ }
202
+
203
+ while IFS= read -r file; do
204
+ [ -f "$file" ] || continue
205
+ class_a_scan "$file"
206
+ done < <(find "$COMMANDS_DIR" -name '*.md' | sort)
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # Class B: Diff-aware scan for unguarded side-effect verbs in added lines
210
+ # ---------------------------------------------------------------------------
211
+
212
+ # Determine base SHA for diff
213
+ BASE_SHA=""
214
+ if [ -n "${GITHUB_BASE_SHA:-}" ]; then
215
+ BASE_SHA="$GITHUB_BASE_SHA"
216
+ elif [ -n "${GITHUB_EVENT_PULL_REQUEST_BASE_SHA:-}" ]; then
217
+ BASE_SHA="$GITHUB_EVENT_PULL_REQUEST_BASE_SHA"
218
+ else
219
+ BASE_SHA="$(git rev-parse HEAD^ 2>/dev/null || echo '')"
220
+ fi
221
+
222
+ if [ -z "$BASE_SHA" ]; then
223
+ echo "INFO: No git base SHA available — Class B (diff-aware) check skipped" >&2
224
+ else
225
+ # Get list of changed command spec files
226
+ CHANGED_SPECS=$(git diff --name-only "$BASE_SHA"...HEAD -- "${COMMANDS_DIR}" 2>/dev/null \
227
+ | grep -E '\.md$' | grep -v '^$' || true)
228
+
229
+ if [ -z "$CHANGED_SPECS" ]; then
230
+ echo "OK (Class B): No commands/*.md files changed — diff check skipped"
231
+ else
232
+ echo "Class B: Checking changed spec files for unguarded side-effect verbs:"
233
+ echo "$CHANGED_SPECS"
234
+ echo ""
235
+
236
+ while IFS= read -r file; do
237
+ [ -f "$file" ] || continue
238
+
239
+ # Get added lines for this file
240
+ ADDED_CONTENT=$(git diff "$BASE_SHA"...HEAD -- "$file" 2>/dev/null \
241
+ | grep '^+' | grep -v '^+++' | sed 's/^+//' || true)
242
+
243
+ # Skip if no added lines contain side-effect verbs
244
+ if ! echo "$ADDED_CONTENT" | grep -qE "$SIDE_EFFECT_PATTERN"; then
245
+ continue
246
+ fi
247
+
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)
255
+ IN_CB=0
256
+ SECTION="(top)"
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
271
+ VIOLATIONS=$((VIOLATIONS + 1))
272
+ fi
273
+ BLOCK_HAS_GUARD=0
274
+ BLOCK_HAS_ADDED_SE=0
275
+ BLOCK_SE_LINE=0
276
+ BLOCK_SE_VERB=""
277
+ }
278
+
279
+ while IFS= read -r line; do
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
311
+
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
322
+ continue
323
+ fi
324
+
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
337
+ continue
338
+ fi
339
+
340
+ # Content line while inside (at any depth).
341
+ if echo "$line" | grep -qF "$ALLOWLIST_TOKEN"; then continue; fi
342
+
343
+ # Check for guard — scoped to THIS code block only (forge#2289)
344
+ echo "$line" | grep -qE "$GUARD_PATTERN" && BLOCK_HAS_GUARD=1
345
+
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")
353
+ fi
354
+ fi
355
+ done < "$file"
356
+
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
361
+
362
+ done <<< "$CHANGED_SPECS"
363
+ fi
364
+ fi
365
+
366
+ # ---------------------------------------------------------------------------
367
+ # Summary
368
+ # ---------------------------------------------------------------------------
369
+
370
+ if [ "$VIOLATIONS" -gt 0 ]; then
371
+ echo "check-command-side-effects: $VIOLATIONS violation(s) found. See stderr for details." >&2
372
+ exit 1
373
+ fi
374
+
375
+ echo "OK: No side-effect violations found"
376
+ exit 0
@@ -0,0 +1,96 @@
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
+ // Also verify every registry marker entry that has a `completionMarker` or
73
+ // `completionLabel` field is a non-empty string — catches an accidental
74
+ // undefined/null slipping in (e.g. a typo'd RESERVED_TYPES key whose
75
+ // completionSentinel is null) which would otherwise fail silently at
76
+ // has(blob, undefined) → substring "undefined" never matches, masking a
77
+ // real bug as "phase never completes".
78
+ const emptyMarkers = [];
79
+ for (const [phaseId, entry] of Object.entries(registryModule.PHASE_MARKERS || {})) {
80
+ const marker = entry.completionMarker ?? entry.completionLabel;
81
+ if (typeof marker !== "string" || marker.length === 0) {
82
+ emptyMarkers.push(phaseId);
83
+ }
84
+ }
85
+ if (emptyMarkers.length > 0) {
86
+ console.error(
87
+ `ERROR: PHASE_MARKERS entries with no usable completionMarker/completionLabel: ${emptyMarkers.join(", ")}`
88
+ );
89
+ process.exit(1);
90
+ }
91
+
92
+ console.log("OK: phase-id list and marker registry are consistent (no drift detected).");
93
+ process.exit(0);
94
+ }
95
+
96
+ main();