forgedock 1.1.11 → 1.3.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 (109) hide show
  1. package/README.md +9 -3
  2. package/bin/cli-spawn-shared.mjs +25 -0
  3. package/bin/engine/phases.mjs +178 -28
  4. package/bin/engine/state.mjs +39 -7
  5. package/bin/engine-cli.mjs +85 -3
  6. package/bin/engine.mjs +71 -8
  7. package/bin/forgedock.mjs +322 -20
  8. package/bin/hooks/pre-tool-use.mjs +345 -10
  9. package/bin/init-enrich-api.mjs +12 -4
  10. package/bin/init-enrich-cli.mjs +328 -0
  11. package/bin/init-enrich.mjs +137 -0
  12. package/bin/journey.mjs +116 -15
  13. package/bin/runner.mjs +653 -10
  14. package/commands/adopt.md +1 -1
  15. package/commands/analytics.md +10 -7
  16. package/commands/audit-agents.md +29 -6
  17. package/commands/audit.md +9 -20
  18. package/commands/autopilot.md +52 -78
  19. package/commands/changelog.md +1 -1
  20. package/commands/ci-audit.md +19 -10
  21. package/commands/cleanup.md +1 -1
  22. package/commands/compat-audit.md +1 -1
  23. package/commands/deploy-info.md +1 -1
  24. package/commands/deploy-pr.md +1 -1
  25. package/commands/design-bench.md +1 -1
  26. package/commands/design-render-critique-loop.md +1 -1
  27. package/commands/diagnose.md +1 -1
  28. package/commands/forge-stats.md +6 -5
  29. package/commands/forgedock-init.md +1 -1
  30. package/commands/geo-audit.md +9 -6
  31. package/commands/incident-response.md +15 -5
  32. package/commands/issue.md +302 -15
  33. package/commands/milestone.md +45 -18
  34. package/commands/optimize.md +1 -1
  35. package/commands/orchestrate/phase-1-resolve.md +20 -5
  36. package/commands/orchestrate/phase-4-execution.md +8 -1
  37. package/commands/orchestrate/phase-5-cleanup.md +43 -2
  38. package/commands/orchestrate/phase-6-report.md +1 -0
  39. package/commands/orchestrate.md +1 -1
  40. package/commands/pipeline-health/phase-4-proposals.md +40 -10
  41. package/commands/pipeline-health/phase-5-report.md +140 -13
  42. package/commands/pipeline-health.md +1 -1
  43. package/commands/pipeline-resume.md +1 -1
  44. package/commands/pipeline-status.md +1 -1
  45. package/commands/qa-sweep.md +27 -4
  46. package/commands/recover-orphans.md +1 -1
  47. package/commands/replay.md +16 -2
  48. package/commands/review-pr-agents/protocols.md +4 -0
  49. package/commands/review-pr-agents/spec-cli.md +179 -0
  50. package/commands/review-pr-agents.md +7 -4
  51. package/commands/review-pr-staging.md +44 -15
  52. package/commands/review-pr.md +122 -28
  53. package/commands/rollback.md +15 -5
  54. package/commands/security-audit.md +14 -11
  55. package/commands/signal-planner.md +32 -12
  56. package/commands/spec-doctor.md +1 -1
  57. package/commands/sync-ecosystem.md +54 -11
  58. package/commands/test-gate.md +49 -11
  59. package/commands/upgrade-deps.md +518 -0
  60. package/commands/validate.md +16 -5
  61. package/commands/work-on/build/architect.md +1 -1
  62. package/commands/work-on/build/context.md +1 -1
  63. package/commands/work-on/build/implement.md +2 -2
  64. package/commands/work-on/build/validate.md +64 -2
  65. package/commands/work-on/build.md +10 -2
  66. package/commands/work-on/close.md +1 -1
  67. package/commands/work-on/decompose.md +35 -25
  68. package/commands/work-on/investigate.md +2 -2
  69. package/commands/work-on/remediate.md +1 -1
  70. package/commands/work-on/review.md +5 -3
  71. package/commands/work-on-monolithic.md +1 -1
  72. package/commands/work-on.md +28 -7
  73. package/docs/AIDER.md +264 -0
  74. package/docs/AUTOPILOT-CRON.md +130 -0
  75. package/docs/CI.md +18 -0
  76. package/docs/CONFIG.md +41 -2
  77. package/docs/CURSOR.md +135 -0
  78. package/docs/OPENCODE.md +312 -0
  79. package/docs/demo.gif +0 -0
  80. package/docs/eval/README.md +4 -4
  81. package/docs/launch/show-hn.md +3 -3
  82. package/docs/site/annotations-explained.md +2 -2
  83. package/docs/site/architecture.md +180 -0
  84. package/docs/site/command-reference.md +38 -0
  85. package/docs/site/configuration.md +243 -0
  86. package/docs/site/for-companies.md +4 -4
  87. package/docs/site/forge-annotation-protocol.md +1 -1
  88. package/docs/site/github-is-the-memory.md +2 -2
  89. package/docs/site/how-it-works.md +1 -1
  90. package/docs/site/public/llms-full.txt +380 -0
  91. package/docs/site/public/llms.txt +35 -0
  92. package/docs/spec/review-protocol.md +6 -0
  93. package/docs/spec-graph-schema.md +3 -3
  94. package/package.json +2 -2
  95. package/packages/protocol/README.md +75 -0
  96. package/packages/protocol/src/card.js +114 -0
  97. package/packages/protocol/src/cli.js +4 -77
  98. package/packages/protocol/src/emit.js +14 -3
  99. package/packages/protocol/src/index.js +2 -1
  100. package/packages/protocol/src/parse.js +17 -8
  101. package/packages/protocol/src/types.js +1 -1
  102. package/packages/protocol/src/validate.js +11 -0
  103. package/scripts/check-command-side-effects.sh +261 -0
  104. package/scripts/check-spec-markers.sh +273 -0
  105. package/scripts/classify-lane.sh +29 -7
  106. package/scripts/forge-run.sh +322 -0
  107. package/templates/cross-repo-trigger.yml +282 -0
  108. package/templates/cursor/forge.mdc +148 -0
  109. package/templates/github-actions/forgedock-autopilot.yml +113 -0
@@ -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,94 @@ 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 reaches the exact same whole-drive,
184
+ * non-terminating scan on Git Bash as the bare filesystem root (`/`) or a
185
+ * bare drive mount (`/c`, `/d`, ...). This includes trailing-slash drive
186
+ * mounts (`/c/`) — a natural way to write the same root, since `/c` and
187
+ * `/c/` are the identical directory — and the dot/double-slash root
188
+ * variants `/.`, `/..`, `//`, which likewise resolve to (or immediately
189
+ * above, which Git Bash still normalizes to) the same whole-drive tree
190
+ * (issue #2113 — PR #2112 review finding SEC-1: `find /c/` was silently
191
+ * ALLOWED because the prior regex had no trailing-slash/dot alternative).
192
+ *
193
+ * Deliberately does NOT match `/c/Users/...`, `/./foo`, `/../foo`, or any
194
+ * other scoped absolute path — only the bare root/drive-mount token itself,
195
+ * optionally followed by exactly one trailing slash (drive-letter case) or
196
+ * standing alone (dot/double-slash case). Case-insensitive on the drive
197
+ * letter only (POSIX paths are otherwise case-sensitive).
198
+ */
199
+ const FIND_ROOT_TOKEN_RE = /^\/(?:[a-zA-Z]\/?|\.{1,2}|\/)?$/;
200
+
201
+ /**
202
+ * Shell metacharacters that can glue a command name to an adjacent token
203
+ * without whitespace (e.g. `cd /tmp;find /`, `$(find /...)`, `` `find /` ``,
204
+ * `echo x&&find /`). `tokenizeCommand()` only splits on whitespace and
205
+ * quotes, so without this extra split step a `find` immediately following
206
+ * one of these characters stays fused into the previous token (e.g. the
207
+ * single token `/tmp;find` or `$(find`) and is never recognized as a `find`
208
+ * invocation — a real, exploitable bypass of Rule 5 (issue #2034 review
209
+ * finding SEC-1). Declared here (above `try { await main(); }`) for the
210
+ * same temporal-dead-zone reason as `FIND_ROOT_TOKEN_RE` above.
211
+ */
212
+ const SHELL_METACHAR_SPLIT_RE = /[;&|()$`]+/;
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Attribution guard constants (Rule 6)
216
+ // Declared here — above the top-level `await main()` call — so they are
217
+ // initialized before the hook runs, for the same temporal-dead-zone reason
218
+ // documented for the Rule 5 constants above. `checkAttribution` (a hoisted
219
+ // function declaration) is defined further down, but the const values it
220
+ // closes over must exist at call time or the reference throws into the
221
+ // fail-open catch, silently defeating the rule.
222
+ // ---------------------------------------------------------------------------
223
+
224
+ /**
225
+ * Canonical ForgeDock signature line. The single source of truth for the
226
+ * pipeline's brand footer — command specs (work-on/review, review-pr,
227
+ * work-on/investigate) render this same wording. Kept here so the enforcement
228
+ * message can quote the exact replacement the agent should use.
229
+ */
230
+ const FORGEDOCK_SIGNATURE =
231
+ "⚒️ Orchestrated with [ForgeDock](https://github.com/RapierCraftStudios/ForgeDock) — state, scheduling, review, and memory on GitHub.";
232
+
233
+ /**
234
+ * Markers of the Claude Code harness default attribution. These are the exact
235
+ * signatures the assistant appends by default to commits and PR bodies; none
236
+ * of them belong in a repo's public commit/PR/issue history when the work is
237
+ * produced by the ForgeDock pipeline.
238
+ *
239
+ * Matching is case-insensitive and scoped (below) to commands that actually
240
+ * write a commit message or a GitHub body/comment, so mentions of "Claude
241
+ * Code" in unrelated commands (docs edits, greps) never trip this rule.
242
+ */
243
+ const AI_ATTRIBUTION_MARKERS = [
244
+ /generated\s+with\s+\[?claude\s+code/i, // "🤖 Generated with [Claude Code](...)" / "Generated with Claude Code"
245
+ /co-authored-by:\s*claude/i, // "Co-Authored-By: Claude ..."
246
+ /noreply@anthropic\.com/i, // the anthropic co-author trailer email
247
+ ];
248
+
249
+ /**
250
+ * Commands that carry a commit message or a GitHub body/comment — the only
251
+ * surfaces where an attribution footer can be persisted to history.
252
+ */
253
+ const ATTRIBUTION_SCOPED_COMMANDS = [
254
+ /git\s+commit\b/,
255
+ /gh\s+pr\s+(?:create|edit|comment)\b/,
256
+ /gh\s+issue\s+(?:create|edit|comment)\b/,
257
+ ];
258
+
141
259
  // ---------------------------------------------------------------------------
142
260
  // Main — fail-open wrapper
143
261
  // ---------------------------------------------------------------------------
@@ -168,13 +286,25 @@ async function main() {
168
286
  // Only intercept Bash tool calls.
169
287
  if (toolName !== "Bash") { process.exit(0); return; }
170
288
 
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
289
  const command = String(toolInput.command || "");
177
290
 
291
+ // --- Rule 5: Filesystem-root `find` guard ---
292
+ // Deliberately checked BEFORE the isForgeDockManagedCwd() gate below — a
293
+ // root-anchored `find` is a universal footgun with no legitimate use in
294
+ // any directory (managed or not), and must fire inside git worktrees,
295
+ // which typically carry no forge.yaml/.forgedock marker (issue #2034).
296
+ const findRootViolation = checkFindRoot(command);
297
+ if (findRootViolation) {
298
+ process.stderr.write(findRootViolation);
299
+ process.exit(2);
300
+ return;
301
+ }
302
+
303
+ // Only enforce Rules 1-4 and 6 inside a ForgeDock-managed project — this hook
304
+ // installs globally (~/.claude/settings.json), so without this guard those
305
+ // rules would fire in every unrelated repo on the machine (issue #1591).
306
+ if (!(await isForgeDockManagedCwd())) { process.exit(0); return; }
307
+
178
308
  // --- Rule 1: PR branch target validation ---
179
309
  const prViolation = checkPrTarget(command);
180
310
  if (prViolation) {
@@ -207,6 +337,14 @@ async function main() {
207
337
  return;
208
338
  }
209
339
 
340
+ // --- Rule 6: Attribution guard ---
341
+ const attributionViolation = checkAttribution(command);
342
+ if (attributionViolation) {
343
+ process.stderr.write(attributionViolation);
344
+ process.exit(2);
345
+ return;
346
+ }
347
+
210
348
  process.exit(0);
211
349
  }
212
350
 
@@ -508,6 +646,181 @@ function checkGistVisibility(command) {
508
646
  return null;
509
647
  }
510
648
 
649
+ // ---------------------------------------------------------------------------
650
+ // Rule 5: Filesystem-root `find` guard (issue #2034)
651
+ // ---------------------------------------------------------------------------
652
+
653
+ /**
654
+ * Break each unquoted token from `tokenizeCommand()` on shell metacharacters
655
+ * (`;`, `&`, `|`, `(`, `)`, `$`, `` ` ``) to recover `find` as its own
656
+ * logical token even when it was written with no separating whitespace from
657
+ * a command separator or a command-substitution opener (`$(find ...)`).
658
+ *
659
+ * A token is only passed through UNSPLIT (treated as inert argument text —
660
+ * e.g. a `--body` value that merely *mentions* `find /` in prose) when its
661
+ * quoting actually spans EMBEDDED WHITESPACE, proving the quotes were used
662
+ * to glue multiple real words into one argument. `quoted` alone is NOT a
663
+ * sufficient signal: `tokenizeCommand()` sets `quoted = true` the moment ANY
664
+ * quote character appears anywhere in a token, including a degenerate empty
665
+ * pair (`""`, `''`) glued onto otherwise-unquoted text. A command like
666
+ * `cd /tmp;""find /` tokenizes to a single token `/tmp;find` flagged
667
+ * `quoted: true` even though nothing was actually protected by the empty
668
+ * quotes — real shells treat `""find` as the plain word `find`. Passing
669
+ * that token through unsplit hid `find` inside `/tmp;find`, which never
670
+ * exact-matches `"find"` in `checkFindRoot()`, bypassing the guard (issue
671
+ * #2059). This mirrors the exact discriminator `extractFlag()` already uses
672
+ * for the same class of decoy (issues #1519, #1591): "was quoted at all" is
673
+ * the wrong test; "does the token contain embedded whitespace" is right.
674
+ *
675
+ * @param {string} command
676
+ * @returns {string[]} Flattened list of logical token strings, in order.
677
+ */
678
+ function extractLogicalTokens(command) {
679
+ const raw = tokenizeCommand(command);
680
+ const logical = [];
681
+ for (const { value, quoted } of raw) {
682
+ if (quoted && /\s/.test(value)) {
683
+ logical.push(value);
684
+ continue;
685
+ }
686
+ for (const piece of value.split(SHELL_METACHAR_SPLIT_RE)) {
687
+ if (piece.length > 0) logical.push(piece);
688
+ }
689
+ }
690
+ return logical;
691
+ }
692
+
693
+ /**
694
+ * Check whether a Bash command contains a `find` invocation whose search
695
+ * root is the filesystem root (`/`), a bare Git-Bash drive mount (`/c`,
696
+ * `/d`, ...), a trailing-slash drive mount (`/c/`), or a dot/double-slash
697
+ * root variant (`/.`, `/..`, `//`). On Windows Git Bash, `/` spans every
698
+ * mounted drive, so a root-anchored `find` never terminates and accumulates
699
+ * as an orphaned, CPU-exhausting process (issue #2034 — #1984 was a narrow
700
+ * precursor fix that only patched a single call site + added a prose
701
+ * guardrail; this is the systemic, deterministic follow-up. Issue #2113
702
+ * widened the root-token match to also cover the trailing-slash/dot
703
+ * variants, which reach the identical whole-drive scan but were not
704
+ * originally matched by `FIND_ROOT_TOKEN_RE`).
705
+ *
706
+ * Scans ALL logical tokens for a `find` occurrence (not just the first word
707
+ * of the command), so it catches `find` appearing after `&&`, `;`, `|`, a
708
+ * command-substitution opener (`$(find ...)`), or backticks — not just a
709
+ * command that starts with `find`. Matching is case-insensitive on the
710
+ * command name (Windows resolves `Find`/`FIND` to the same binary as
711
+ * `find`) but NOT on the root-path token itself (POSIX paths are
712
+ * case-sensitive). Uses `extractLogicalTokens()` (quote-aware, and
713
+ * metacharacter-aware) so flag-shaped or path-shaped text embedded inside
714
+ * an unrelated quoted `--title`/`--body` value is never misread as a real
715
+ * `find` invocation (same class of decoy documented for `extractFlag()` —
716
+ * issues #1519, #1591), while a `find` glued to a shell metacharacter with
717
+ * no whitespace is still caught (issue #2034 review finding SEC-1/SEC-2).
718
+ *
719
+ * For each `find` token found, the search root is taken as the first
720
+ * following token that is not itself a `find` option (an option either
721
+ * starts with `-` or is `!` — `find`'s own root argument is always a bare
722
+ * positional path, never flag-shaped; the grouping operators `(`/`)` are
723
+ * shell metacharacters already stripped out by `extractLogicalTokens()`).
724
+ * Only an EXACT match against `FIND_ROOT_TOKEN_RE` blocks — `find
725
+ * /c/Users/.../repo -maxdepth 2 -name x` and `find "$REPO_PATH" ...` are
726
+ * legitimate, scoped searches and must be allowed.
727
+ *
728
+ * No operator override exists for this rule (unlike Rules 4/6-attribution)
729
+ * — a root-anchored `find` has no legitimate use in any ForgeDock pipeline.
730
+ *
731
+ * @param {string} command
732
+ * @returns {string|null} Error message to show, or null if allowed.
733
+ */
734
+ function checkFindRoot(command) {
735
+ if (!command) return null;
736
+ // Cheap pre-filter to skip tokenization for the common case of a command
737
+ // with no `find` anywhere. Quote characters AND backslashes are stripped
738
+ // first so a degenerate/empty quote pair (`"f"ind`) or a backslash-escape
739
+ // (`f\ind` — real bash for the plain word `find`) glued inside the word
740
+ // itself doesn't break the substring match and cause a false "no find
741
+ // here" short-circuit that skips tokenization entirely (issue #2059,
742
+ // including the backslash-escape variant found in that issue's review).
743
+ if (!/find/i.test(command.replace(/["'\\]/g, ""))) return null;
744
+
745
+ const tokens = extractLogicalTokens(command);
746
+
747
+ for (let i = 0; i < tokens.length; i++) {
748
+ if (tokens[i].toLowerCase() !== "find") continue;
749
+
750
+ // The search root is the first token after `find` that isn't a `find`
751
+ // option. `find` options always start with `-` (e.g. -maxdepth, -iname)
752
+ // or are `!` — the root path is always a bare positional token.
753
+ let rootToken = null;
754
+ for (let j = i + 1; j < tokens.length; j++) {
755
+ const val = tokens[j];
756
+ if (val.startsWith("-") || val === "!") continue;
757
+ rootToken = val;
758
+ break;
759
+ }
760
+
761
+ if (rootToken && FIND_ROOT_TOKEN_RE.test(rootToken)) {
762
+ return [
763
+ `[ForgeDock] BLOCKED: \`find\` rooted at filesystem root "${rootToken}".`,
764
+ ``,
765
+ `A \`find\` search starting at "${rootToken}" scans every mounted drive on`,
766
+ `Windows and never terminates — it becomes an orphaned, CPU-exhausting`,
767
+ `process once the parent sub-agent exits (issue #2034).`,
768
+ ``,
769
+ `Fix: scope the search to $REPO_PATH or a known path, e.g.:`,
770
+ ` find "$REPO_PATH" -iname "<pattern>"`,
771
+ ``,
772
+ `No override exists for this rule — a root-anchored \`find\` has no`,
773
+ `legitimate use in any ForgeDock pipeline.`,
774
+ ].join("\n");
775
+ }
776
+ }
777
+
778
+ return null;
779
+ }
780
+
781
+ // ---------------------------------------------------------------------------
782
+ // Rule 6: Attribution guard
783
+ // ---------------------------------------------------------------------------
784
+
785
+ /**
786
+ * Check whether a commit/PR/issue command carries Claude Code default
787
+ * attribution. Blocks it so ForgeDock-branded pipeline output never inherits
788
+ * the assistant-tool signature.
789
+ *
790
+ * Override: FORGE_ALLOW_AI_ATTRIBUTION=1 in the operator's shell environment
791
+ * (read at hook startup, not from the tool payload — agents cannot set it via
792
+ * a Bash tool call in the same session).
793
+ *
794
+ * @param {string} command
795
+ * @returns {string|null} Error message to show, or null if allowed.
796
+ */
797
+ function checkAttribution(command) {
798
+ // Operator override.
799
+ if (process.env.FORGE_ALLOW_AI_ATTRIBUTION === "1") return null;
800
+
801
+ // Only scan commands that persist a message/body to history.
802
+ if (!ATTRIBUTION_SCOPED_COMMANDS.some((re) => re.test(command))) return null;
803
+
804
+ const hit = AI_ATTRIBUTION_MARKERS.find((re) => re.test(command));
805
+ if (!hit) return null;
806
+
807
+ return [
808
+ `[ForgeDock] BLOCKED: Claude Code default attribution in a commit/PR/issue.`,
809
+ ``,
810
+ `The command carries the assistant-tool attribution ("🤖 Generated with`,
811
+ `Claude Code" / "Co-Authored-By: Claude" / noreply@anthropic.com). Pipeline`,
812
+ `output is ForgeDock-branded — this must never land in the repo's public`,
813
+ `commit, PR, or issue history.`,
814
+ ``,
815
+ `Fix: remove the attribution line/trailer. If you want a brand footer, use`,
816
+ `the ForgeDock signature instead:`,
817
+ ` ${FORGEDOCK_SIGNATURE}`,
818
+ ``,
819
+ `Exception: set FORGE_ALLOW_AI_ATTRIBUTION=1 in your shell environment BEFORE`,
820
+ `starting Claude Code if you intentionally need the assistant attribution.`,
821
+ ].join("\n");
822
+ }
823
+
511
824
  // ---------------------------------------------------------------------------
512
825
  // Git helpers
513
826
  // ---------------------------------------------------------------------------
@@ -552,10 +865,14 @@ function currentGitBranch() {
552
865
  * quoted" is the wrong discriminator.
553
866
  *
554
867
  * 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).
868
+ * `$()`, backticks, or command chaining. It DOES handle backslash-escapes
869
+ * outside quotes (`\X` collapses to the literal character `X`, matching real
870
+ * bash semantics) — added for issue #2059 to close a `find`-guard bypass
871
+ * where `f\ind` tokenized with a literal backslash byte and never
872
+ * exact-matched `"find"`. It only needs to be accurate enough to distinguish
873
+ * "a flag in argument position" from "flag-shaped text embedded inside a
874
+ * different argument's value", which is all `extractFlag` needs (issue
875
+ * #1519), and to recover `find` as its own token regardless of escaping.
559
876
  *
560
877
  * @param {string} command
561
878
  * @returns {CommandToken[]}
@@ -582,6 +899,24 @@ function tokenizeCommand(command) {
582
899
  continue;
583
900
  }
584
901
 
902
+ // Backslash-escape (outside quotes only — matches real bash semantics for
903
+ // `\X` when not already inside single/double quotes). A backslash strips
904
+ // the special meaning of the following character and the pair collapses
905
+ // to that character literally: `f\ind` is the plain word `find` to a real
906
+ // shell. Without this, `extractLogicalTokens()`'s exact-match check
907
+ // against `"find"` never fires because the token still contains a literal
908
+ // backslash byte (issue #2059 review finding — CONFIRMED HIGH). If the
909
+ // escaped character is whitespace, treat the token as `quoted` (glued
910
+ // words), mirroring the embedded-whitespace decoy-protection discriminator
911
+ // used elsewhere in this file for real quoting (issues #1519, #1591).
912
+ if (ch === "\\" && i + 1 < command.length) {
913
+ const next = command[i + 1];
914
+ current += next;
915
+ if (/\s/.test(next)) quoted = true;
916
+ i++; // consume the escaped character; the for-loop's own increment moves past it
917
+ continue;
918
+ }
919
+
585
920
  if (ch === "'") {
586
921
  inSingle = true;
587
922
  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(