forgedock 1.1.11 → 1.2.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 (73) hide show
  1. package/README.md +1 -1
  2. package/bin/cli-spawn-shared.mjs +25 -0
  3. package/bin/engine-cli.mjs +22 -1
  4. package/bin/engine.mjs +46 -4
  5. package/bin/forgedock.mjs +87 -16
  6. package/bin/hooks/pre-tool-use.mjs +331 -10
  7. package/bin/init-enrich-api.mjs +12 -4
  8. package/bin/init-enrich-cli.mjs +328 -0
  9. package/bin/init-enrich.mjs +137 -0
  10. package/bin/journey.mjs +13 -4
  11. package/bin/runner.mjs +653 -10
  12. package/commands/adopt.md +1 -1
  13. package/commands/analytics.md +10 -7
  14. package/commands/audit-agents.md +29 -6
  15. package/commands/audit.md +9 -20
  16. package/commands/autopilot.md +52 -78
  17. package/commands/changelog.md +1 -1
  18. package/commands/ci-audit.md +19 -10
  19. package/commands/cleanup.md +1 -1
  20. package/commands/compat-audit.md +1 -1
  21. package/commands/deploy-info.md +1 -1
  22. package/commands/deploy-pr.md +1 -1
  23. package/commands/design-bench.md +1 -1
  24. package/commands/design-render-critique-loop.md +1 -1
  25. package/commands/diagnose.md +1 -1
  26. package/commands/forge-stats.md +1 -1
  27. package/commands/forgedock-init.md +1 -1
  28. package/commands/geo-audit.md +9 -6
  29. package/commands/incident-response.md +15 -5
  30. package/commands/issue.md +298 -15
  31. package/commands/milestone.md +45 -18
  32. package/commands/optimize.md +1 -1
  33. package/commands/orchestrate/phase-1-resolve.md +20 -5
  34. package/commands/orchestrate/phase-4-execution.md +6 -1
  35. package/commands/orchestrate/phase-5-cleanup.md +43 -2
  36. package/commands/orchestrate/phase-6-report.md +1 -0
  37. package/commands/orchestrate.md +1 -1
  38. package/commands/pipeline-health/phase-4-proposals.md +40 -10
  39. package/commands/pipeline-health/phase-5-report.md +140 -13
  40. package/commands/pipeline-health.md +1 -1
  41. package/commands/pipeline-resume.md +1 -1
  42. package/commands/pipeline-status.md +1 -1
  43. package/commands/qa-sweep.md +10 -4
  44. package/commands/recover-orphans.md +1 -1
  45. package/commands/review-pr-agents/protocols.md +4 -0
  46. package/commands/review-pr-agents.md +7 -4
  47. package/commands/review-pr-staging.md +44 -15
  48. package/commands/review-pr.md +73 -28
  49. package/commands/rollback.md +15 -5
  50. package/commands/security-audit.md +14 -11
  51. package/commands/signal-planner.md +32 -12
  52. package/commands/spec-doctor.md +1 -1
  53. package/commands/sync-ecosystem.md +54 -11
  54. package/commands/test-gate.md +49 -11
  55. package/commands/validate.md +16 -5
  56. package/commands/work-on/build/architect.md +1 -1
  57. package/commands/work-on/build/context.md +1 -1
  58. package/commands/work-on/build/implement.md +2 -2
  59. package/commands/work-on/build/validate.md +3 -1
  60. package/commands/work-on/build.md +1 -1
  61. package/commands/work-on/close.md +1 -1
  62. package/commands/work-on/decompose.md +27 -24
  63. package/commands/work-on/investigate.md +2 -2
  64. package/commands/work-on/remediate.md +1 -1
  65. package/commands/work-on/review.md +5 -3
  66. package/commands/work-on-monolithic.md +1 -1
  67. package/commands/work-on.md +28 -7
  68. package/docs/CI.md +18 -0
  69. package/docs/CONFIG.md +5 -2
  70. package/docs/site/annotations-explained.md +2 -2
  71. package/docs/site/command-reference.md +21 -0
  72. package/docs/spec/review-protocol.md +6 -0
  73. package/package.json +1 -1
@@ -92,13 +92,43 @@ async function getInvariants() {
92
92
  * setting env vars via Bash tool calls, as this hook reads process.env which
93
93
  * is set at process start, not at tool-call time).
94
94
  *
95
- * Both rules only apply inside a ForgeDock-managed directory (a directory
95
+ * 5. Filesystem-root `find` guard (issue #2034)
96
+ * Intercepts any Bash command containing a `find` invocation whose search
97
+ * root is `/` or a bare Git-Bash drive mount (`/c`, `/d`, ...) and
98
+ * hard-blocks it. On Windows Git Bash, `/` spans every mounted drive, so a
99
+ * root-anchored `find` never terminates and accumulates as an orphaned,
100
+ * CPU-exhausting process once its parent sub-agent exits (issue #1984 was
101
+ * a narrow, single-call-site precursor fix — this rule is the systemic,
102
+ * deterministic follow-up). No override — a root-anchored `find` has no
103
+ * legitimate use in any ForgeDock pipeline.
104
+ *
105
+ * 6. Attribution guard
106
+ * Intercepts commit / PR / issue / comment creation commands (`git commit`,
107
+ * `gh pr create|edit|comment`, `gh issue create|edit|comment`) and
108
+ * hard-blocks any whose message or body carries the Claude Code harness
109
+ * default attribution — "🤖 Generated with [Claude Code]", "Co-Authored-By:
110
+ * Claude", or the `noreply@anthropic.com` co-author trailer. Pipeline output
111
+ * is ForgeDock-branded; the assistant-tool attribution must never leak into
112
+ * a repo's public commit/PR/issue history. The agent is told to remove the
113
+ * attribution and, where a footer is wanted, use the ForgeDock signature.
114
+ * Override: set FORGE_ALLOW_AI_ATTRIBUTION=1 in the shell environment before
115
+ * starting Claude Code (operator-set only — same process.env semantics as
116
+ * the gist guard above).
117
+ *
118
+ * Rules 1-4 and 6 only apply inside a ForgeDock-managed directory (a directory
96
119
  * with a `forge.yaml` or `.forgedock` marker) — see `isForgeDockManagedCwd()`
97
120
  * (issue #1591). The hook installs into the user's global
98
121
  * `~/.claude/settings.json`, so without this guard every Bash call in every
99
122
  * repo on the machine would be subject to these ForgeDock-specific rules,
100
123
  * including unrelated repos where `main` is a legitimate PR target.
101
124
  *
125
+ * Rule 5 is deliberately NOT gated by `isForgeDockManagedCwd()` — it runs
126
+ * before that check. A filesystem-root `find` is a universal footgun with no
127
+ * legitimate use anywhere (unlike Rules 1-4/6, which are ForgeDock-pipeline-
128
+ * specific), and gating it the same way would leave it silently disabled
129
+ * inside git worktrees, which typically carry no `forge.yaml`/`.forgedock`
130
+ * marker of their own — exactly where build/review sub-agents run.
131
+ *
102
132
  * === Fail-open contract ===
103
133
  *
104
134
  * Any uncaught error or parse failure exits 0 (allow) — this hook must
@@ -138,6 +168,84 @@ const FORBIDDEN_PR_BASES = ["main", "master"];
138
168
  /** Labels that are always valid (non-workflow labels are not constrained). */
139
169
  const WORKFLOW_LABEL_PREFIX = "workflow:";
140
170
 
171
+ // ---------------------------------------------------------------------------
172
+ // Rule 5 constants (issue #2034)
173
+ // Declared here — above the top-level `await main()` call — for the same
174
+ // reason the label-transition/PR-base constants above are: a top-level
175
+ // `await` suspends module evaluation at that statement, so any top-level
176
+ // `const` declared textually AFTER it (unlike hoisted `function` declarations)
177
+ // is still in its temporal dead zone when `main()` first runs, and would
178
+ // throw a ReferenceError the instant `checkFindRoot()` reads it — silently
179
+ // swallowed by main()'s fail-open catch, defeating the whole rule.
180
+ // ---------------------------------------------------------------------------
181
+
182
+ /**
183
+ * Match a `find` search-root token that is exactly the filesystem root (`/`)
184
+ * or a bare Git-Bash drive mount (`/c`, `/d`, `/C`, ... — a single letter,
185
+ * case-insensitive, optionally followed by nothing else). Deliberately does
186
+ * NOT match `/c/Users/...` or any other scoped absolute path — only the bare
187
+ * root/drive-mount token itself.
188
+ */
189
+ const FIND_ROOT_TOKEN_RE = /^\/(?:[a-zA-Z])?$/;
190
+
191
+ /**
192
+ * Shell metacharacters that can glue a command name to an adjacent token
193
+ * without whitespace (e.g. `cd /tmp;find /`, `$(find /...)`, `` `find /` ``,
194
+ * `echo x&&find /`). `tokenizeCommand()` only splits on whitespace and
195
+ * quotes, so without this extra split step a `find` immediately following
196
+ * one of these characters stays fused into the previous token (e.g. the
197
+ * single token `/tmp;find` or `$(find`) and is never recognized as a `find`
198
+ * invocation — a real, exploitable bypass of Rule 5 (issue #2034 review
199
+ * finding SEC-1). Declared here (above `try { await main(); }`) for the
200
+ * same temporal-dead-zone reason as `FIND_ROOT_TOKEN_RE` above.
201
+ */
202
+ const SHELL_METACHAR_SPLIT_RE = /[;&|()$`]+/;
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Attribution guard constants (Rule 6)
206
+ // Declared here — above the top-level `await main()` call — so they are
207
+ // initialized before the hook runs, for the same temporal-dead-zone reason
208
+ // documented for the Rule 5 constants above. `checkAttribution` (a hoisted
209
+ // function declaration) is defined further down, but the const values it
210
+ // closes over must exist at call time or the reference throws into the
211
+ // fail-open catch, silently defeating the rule.
212
+ // ---------------------------------------------------------------------------
213
+
214
+ /**
215
+ * Canonical ForgeDock signature line. The single source of truth for the
216
+ * pipeline's brand footer — command specs (work-on/review, review-pr,
217
+ * work-on/investigate) render this same wording. Kept here so the enforcement
218
+ * message can quote the exact replacement the agent should use.
219
+ */
220
+ const FORGEDOCK_SIGNATURE =
221
+ "⚒️ Orchestrated with [ForgeDock](https://github.com/RapierCraftStudios/ForgeDock) — state, scheduling, review, and memory on GitHub.";
222
+
223
+ /**
224
+ * Markers of the Claude Code harness default attribution. These are the exact
225
+ * signatures the assistant appends by default to commits and PR bodies; none
226
+ * of them belong in a repo's public commit/PR/issue history when the work is
227
+ * produced by the ForgeDock pipeline.
228
+ *
229
+ * Matching is case-insensitive and scoped (below) to commands that actually
230
+ * write a commit message or a GitHub body/comment, so mentions of "Claude
231
+ * Code" in unrelated commands (docs edits, greps) never trip this rule.
232
+ */
233
+ const AI_ATTRIBUTION_MARKERS = [
234
+ /generated\s+with\s+\[?claude\s+code/i, // "🤖 Generated with [Claude Code](...)" / "Generated with Claude Code"
235
+ /co-authored-by:\s*claude/i, // "Co-Authored-By: Claude ..."
236
+ /noreply@anthropic\.com/i, // the anthropic co-author trailer email
237
+ ];
238
+
239
+ /**
240
+ * Commands that carry a commit message or a GitHub body/comment — the only
241
+ * surfaces where an attribution footer can be persisted to history.
242
+ */
243
+ const ATTRIBUTION_SCOPED_COMMANDS = [
244
+ /git\s+commit\b/,
245
+ /gh\s+pr\s+(?:create|edit|comment)\b/,
246
+ /gh\s+issue\s+(?:create|edit|comment)\b/,
247
+ ];
248
+
141
249
  // ---------------------------------------------------------------------------
142
250
  // Main — fail-open wrapper
143
251
  // ---------------------------------------------------------------------------
@@ -168,13 +276,25 @@ async function main() {
168
276
  // Only intercept Bash tool calls.
169
277
  if (toolName !== "Bash") { process.exit(0); return; }
170
278
 
171
- // Only enforce inside a ForgeDock-managed project — this hook installs
172
- // globally (~/.claude/settings.json), so without this guard both rules
173
- // below would fire in every unrelated repo on the machine (issue #1591).
174
- if (!(await isForgeDockManagedCwd())) { process.exit(0); return; }
175
-
176
279
  const command = String(toolInput.command || "");
177
280
 
281
+ // --- Rule 5: Filesystem-root `find` guard ---
282
+ // Deliberately checked BEFORE the isForgeDockManagedCwd() gate below — a
283
+ // root-anchored `find` is a universal footgun with no legitimate use in
284
+ // any directory (managed or not), and must fire inside git worktrees,
285
+ // which typically carry no forge.yaml/.forgedock marker (issue #2034).
286
+ const findRootViolation = checkFindRoot(command);
287
+ if (findRootViolation) {
288
+ process.stderr.write(findRootViolation);
289
+ process.exit(2);
290
+ return;
291
+ }
292
+
293
+ // Only enforce Rules 1-4 and 6 inside a ForgeDock-managed project — this hook
294
+ // installs globally (~/.claude/settings.json), so without this guard those
295
+ // rules would fire in every unrelated repo on the machine (issue #1591).
296
+ if (!(await isForgeDockManagedCwd())) { process.exit(0); return; }
297
+
178
298
  // --- Rule 1: PR branch target validation ---
179
299
  const prViolation = checkPrTarget(command);
180
300
  if (prViolation) {
@@ -207,6 +327,14 @@ async function main() {
207
327
  return;
208
328
  }
209
329
 
330
+ // --- Rule 6: Attribution guard ---
331
+ const attributionViolation = checkAttribution(command);
332
+ if (attributionViolation) {
333
+ process.stderr.write(attributionViolation);
334
+ process.exit(2);
335
+ return;
336
+ }
337
+
210
338
  process.exit(0);
211
339
  }
212
340
 
@@ -508,6 +636,177 @@ function checkGistVisibility(command) {
508
636
  return null;
509
637
  }
510
638
 
639
+ // ---------------------------------------------------------------------------
640
+ // Rule 5: Filesystem-root `find` guard (issue #2034)
641
+ // ---------------------------------------------------------------------------
642
+
643
+ /**
644
+ * Break each unquoted token from `tokenizeCommand()` on shell metacharacters
645
+ * (`;`, `&`, `|`, `(`, `)`, `$`, `` ` ``) to recover `find` as its own
646
+ * logical token even when it was written with no separating whitespace from
647
+ * a command separator or a command-substitution opener (`$(find ...)`).
648
+ *
649
+ * A token is only passed through UNSPLIT (treated as inert argument text —
650
+ * e.g. a `--body` value that merely *mentions* `find /` in prose) when its
651
+ * quoting actually spans EMBEDDED WHITESPACE, proving the quotes were used
652
+ * to glue multiple real words into one argument. `quoted` alone is NOT a
653
+ * sufficient signal: `tokenizeCommand()` sets `quoted = true` the moment ANY
654
+ * quote character appears anywhere in a token, including a degenerate empty
655
+ * pair (`""`, `''`) glued onto otherwise-unquoted text. A command like
656
+ * `cd /tmp;""find /` tokenizes to a single token `/tmp;find` flagged
657
+ * `quoted: true` even though nothing was actually protected by the empty
658
+ * quotes — real shells treat `""find` as the plain word `find`. Passing
659
+ * that token through unsplit hid `find` inside `/tmp;find`, which never
660
+ * exact-matches `"find"` in `checkFindRoot()`, bypassing the guard (issue
661
+ * #2059). This mirrors the exact discriminator `extractFlag()` already uses
662
+ * for the same class of decoy (issues #1519, #1591): "was quoted at all" is
663
+ * the wrong test; "does the token contain embedded whitespace" is right.
664
+ *
665
+ * @param {string} command
666
+ * @returns {string[]} Flattened list of logical token strings, in order.
667
+ */
668
+ function extractLogicalTokens(command) {
669
+ const raw = tokenizeCommand(command);
670
+ const logical = [];
671
+ for (const { value, quoted } of raw) {
672
+ if (quoted && /\s/.test(value)) {
673
+ logical.push(value);
674
+ continue;
675
+ }
676
+ for (const piece of value.split(SHELL_METACHAR_SPLIT_RE)) {
677
+ if (piece.length > 0) logical.push(piece);
678
+ }
679
+ }
680
+ return logical;
681
+ }
682
+
683
+ /**
684
+ * Check whether a Bash command contains a `find` invocation whose search
685
+ * root is the filesystem root (`/`) or a bare Git-Bash drive mount (`/c`,
686
+ * `/d`, ...). On Windows Git Bash, `/` spans every mounted drive, so a
687
+ * root-anchored `find` never terminates and accumulates as an orphaned,
688
+ * CPU-exhausting process (issue #2034 — #1984 was a narrow precursor fix
689
+ * that only patched a single call site + added a prose guardrail; this is
690
+ * the systemic, deterministic follow-up).
691
+ *
692
+ * Scans ALL logical tokens for a `find` occurrence (not just the first word
693
+ * of the command), so it catches `find` appearing after `&&`, `;`, `|`, a
694
+ * command-substitution opener (`$(find ...)`), or backticks — not just a
695
+ * command that starts with `find`. Matching is case-insensitive on the
696
+ * command name (Windows resolves `Find`/`FIND` to the same binary as
697
+ * `find`) but NOT on the root-path token itself (POSIX paths are
698
+ * case-sensitive). Uses `extractLogicalTokens()` (quote-aware, and
699
+ * metacharacter-aware) so flag-shaped or path-shaped text embedded inside
700
+ * an unrelated quoted `--title`/`--body` value is never misread as a real
701
+ * `find` invocation (same class of decoy documented for `extractFlag()` —
702
+ * issues #1519, #1591), while a `find` glued to a shell metacharacter with
703
+ * no whitespace is still caught (issue #2034 review finding SEC-1/SEC-2).
704
+ *
705
+ * For each `find` token found, the search root is taken as the first
706
+ * following token that is not itself a `find` option (an option either
707
+ * starts with `-` or is `!` — `find`'s own root argument is always a bare
708
+ * positional path, never flag-shaped; the grouping operators `(`/`)` are
709
+ * shell metacharacters already stripped out by `extractLogicalTokens()`).
710
+ * Only an EXACT match against `FIND_ROOT_TOKEN_RE` blocks — `find
711
+ * /c/Users/.../repo -maxdepth 2 -name x` and `find "$REPO_PATH" ...` are
712
+ * legitimate, scoped searches and must be allowed.
713
+ *
714
+ * No operator override exists for this rule (unlike Rules 4/6-attribution)
715
+ * — a root-anchored `find` has no legitimate use in any ForgeDock pipeline.
716
+ *
717
+ * @param {string} command
718
+ * @returns {string|null} Error message to show, or null if allowed.
719
+ */
720
+ function checkFindRoot(command) {
721
+ if (!command) return null;
722
+ // Cheap pre-filter to skip tokenization for the common case of a command
723
+ // with no `find` anywhere. Quote characters AND backslashes are stripped
724
+ // first so a degenerate/empty quote pair (`"f"ind`) or a backslash-escape
725
+ // (`f\ind` — real bash for the plain word `find`) glued inside the word
726
+ // itself doesn't break the substring match and cause a false "no find
727
+ // here" short-circuit that skips tokenization entirely (issue #2059,
728
+ // including the backslash-escape variant found in that issue's review).
729
+ if (!/find/i.test(command.replace(/["'\\]/g, ""))) return null;
730
+
731
+ const tokens = extractLogicalTokens(command);
732
+
733
+ for (let i = 0; i < tokens.length; i++) {
734
+ if (tokens[i].toLowerCase() !== "find") continue;
735
+
736
+ // The search root is the first token after `find` that isn't a `find`
737
+ // option. `find` options always start with `-` (e.g. -maxdepth, -iname)
738
+ // or are `!` — the root path is always a bare positional token.
739
+ let rootToken = null;
740
+ for (let j = i + 1; j < tokens.length; j++) {
741
+ const val = tokens[j];
742
+ if (val.startsWith("-") || val === "!") continue;
743
+ rootToken = val;
744
+ break;
745
+ }
746
+
747
+ if (rootToken && FIND_ROOT_TOKEN_RE.test(rootToken)) {
748
+ return [
749
+ `[ForgeDock] BLOCKED: \`find\` rooted at filesystem root "${rootToken}".`,
750
+ ``,
751
+ `A \`find\` search starting at "${rootToken}" scans every mounted drive on`,
752
+ `Windows and never terminates — it becomes an orphaned, CPU-exhausting`,
753
+ `process once the parent sub-agent exits (issue #2034).`,
754
+ ``,
755
+ `Fix: scope the search to $REPO_PATH or a known path, e.g.:`,
756
+ ` find "$REPO_PATH" -iname "<pattern>"`,
757
+ ``,
758
+ `No override exists for this rule — a root-anchored \`find\` has no`,
759
+ `legitimate use in any ForgeDock pipeline.`,
760
+ ].join("\n");
761
+ }
762
+ }
763
+
764
+ return null;
765
+ }
766
+
767
+ // ---------------------------------------------------------------------------
768
+ // Rule 6: Attribution guard
769
+ // ---------------------------------------------------------------------------
770
+
771
+ /**
772
+ * Check whether a commit/PR/issue command carries Claude Code default
773
+ * attribution. Blocks it so ForgeDock-branded pipeline output never inherits
774
+ * the assistant-tool signature.
775
+ *
776
+ * Override: FORGE_ALLOW_AI_ATTRIBUTION=1 in the operator's shell environment
777
+ * (read at hook startup, not from the tool payload — agents cannot set it via
778
+ * a Bash tool call in the same session).
779
+ *
780
+ * @param {string} command
781
+ * @returns {string|null} Error message to show, or null if allowed.
782
+ */
783
+ function checkAttribution(command) {
784
+ // Operator override.
785
+ if (process.env.FORGE_ALLOW_AI_ATTRIBUTION === "1") return null;
786
+
787
+ // Only scan commands that persist a message/body to history.
788
+ if (!ATTRIBUTION_SCOPED_COMMANDS.some((re) => re.test(command))) return null;
789
+
790
+ const hit = AI_ATTRIBUTION_MARKERS.find((re) => re.test(command));
791
+ if (!hit) return null;
792
+
793
+ return [
794
+ `[ForgeDock] BLOCKED: Claude Code default attribution in a commit/PR/issue.`,
795
+ ``,
796
+ `The command carries the assistant-tool attribution ("🤖 Generated with`,
797
+ `Claude Code" / "Co-Authored-By: Claude" / noreply@anthropic.com). Pipeline`,
798
+ `output is ForgeDock-branded — this must never land in the repo's public`,
799
+ `commit, PR, or issue history.`,
800
+ ``,
801
+ `Fix: remove the attribution line/trailer. If you want a brand footer, use`,
802
+ `the ForgeDock signature instead:`,
803
+ ` ${FORGEDOCK_SIGNATURE}`,
804
+ ``,
805
+ `Exception: set FORGE_ALLOW_AI_ATTRIBUTION=1 in your shell environment BEFORE`,
806
+ `starting Claude Code if you intentionally need the assistant attribution.`,
807
+ ].join("\n");
808
+ }
809
+
511
810
  // ---------------------------------------------------------------------------
512
811
  // Git helpers
513
812
  // ---------------------------------------------------------------------------
@@ -552,10 +851,14 @@ function currentGitBranch() {
552
851
  * quoted" is the wrong discriminator.
553
852
  *
554
853
  * This is intentionally NOT a full POSIX shell parser — it doesn't handle
555
- * escapes, `$()`, backticks, or command chaining. It only needs to be
556
- * accurate enough to distinguish "a flag in argument position" from
557
- * "flag-shaped text embedded inside a different argument's value", which is
558
- * all `extractFlag` needs (issue #1519).
854
+ * `$()`, backticks, or command chaining. It DOES handle backslash-escapes
855
+ * outside quotes (`\X` collapses to the literal character `X`, matching real
856
+ * bash semantics) — added for issue #2059 to close a `find`-guard bypass
857
+ * where `f\ind` tokenized with a literal backslash byte and never
858
+ * exact-matched `"find"`. It only needs to be accurate enough to distinguish
859
+ * "a flag in argument position" from "flag-shaped text embedded inside a
860
+ * different argument's value", which is all `extractFlag` needs (issue
861
+ * #1519), and to recover `find` as its own token regardless of escaping.
559
862
  *
560
863
  * @param {string} command
561
864
  * @returns {CommandToken[]}
@@ -582,6 +885,24 @@ function tokenizeCommand(command) {
582
885
  continue;
583
886
  }
584
887
 
888
+ // Backslash-escape (outside quotes only — matches real bash semantics for
889
+ // `\X` when not already inside single/double quotes). A backslash strips
890
+ // the special meaning of the following character and the pair collapses
891
+ // to that character literally: `f\ind` is the plain word `find` to a real
892
+ // shell. Without this, `extractLogicalTokens()`'s exact-match check
893
+ // against `"find"` never fires because the token still contains a literal
894
+ // backslash byte (issue #2059 review finding — CONFIRMED HIGH). If the
895
+ // escaped character is whitespace, treat the token as `quoted` (glued
896
+ // words), mirroring the embedded-whitespace decoy-protection discriminator
897
+ // used elsewhere in this file for real quoting (issues #1519, #1591).
898
+ if (ch === "\\" && i + 1 < command.length) {
899
+ const next = command[i + 1];
900
+ current += next;
901
+ if (/\s/.test(next)) quoted = true;
902
+ i++; // consume the escaped character; the for-loop's own increment moves past it
903
+ continue;
904
+ }
905
+
585
906
  if (ch === "'") {
586
907
  inSingle = true;
587
908
  quoted = true;
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * init-enrich-api.mjs — Anthropic API enrichment backend for ForgeDock init.
3
3
  *
4
- * Implements the same enrich(ConfigDraft) contract as the skill backend so the
5
- * selection ladder in forgedock.mjs can treat both backends interchangeably.
4
+ * Implements the same enrich(ConfigDraft) contract as the cli backend
5
+ * (bin/init-enrich-cli.mjs) so the selection ladder in bin/init-enrich.mjs
6
+ * can treat both backends interchangeably.
6
7
  *
7
8
  * Uses Node.js built-in fetch (Node 18+) — no SDK dependency required.
8
9
  * Reads ANTHROPIC_API_KEY from the environment; cleanly skips enrichment with
@@ -153,10 +154,17 @@ export function parseEnrichedDraft(output, draft) {
153
154
  * baseline.
154
155
  *
155
156
  * @param {object} draft - ConfigDraft from detectConfig()
157
+ * @param {object} [opts]
158
+ * @param {object} [opts.env] - Environment to read ANTHROPIC_API_KEY from.
159
+ * Defaults to process.env. Injectable so callers (bin/init-enrich.mjs's
160
+ * dispatcher, which itself receives it from bin/journey.mjs's ctx.env) can
161
+ * exercise this backend deterministically in tests without depending on
162
+ * the real process environment.
156
163
  * @returns {Promise<object>} Enriched ConfigDraft, or the original draft on failure
157
164
  */
158
- export async function enrich(draft) {
159
- const apiKey = process.env.ANTHROPIC_API_KEY;
165
+ export async function enrich(draft, opts = {}) {
166
+ const { env = process.env } = opts;
167
+ const apiKey = env.ANTHROPIC_API_KEY;
160
168
 
161
169
  if (!apiKey) {
162
170
  console.error(