@warpgogol/forge 2.21.8 → 3.0.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.
@@ -144,6 +144,321 @@ workspace:
144
144
  - path: .gitattributes
145
145
  content: |
146
146
  # No LFS patterns for this stack
147
+ - path: hooks/pre-commit
148
+ content: |
149
+ #!/bin/sh
150
+ # Platform-scope pre-commit guard
151
+ # Runs forge validation before commits
152
+ echo "Pre-commit: running platform checks..."
153
+ pnpm exec forge run forge.validate || exit 1
154
+ - path: scripts/git-guard.sh
155
+ content: |
156
+ #!/bin/bash
157
+ # git-guard.sh — shell function that intercepts destructive git commands.
158
+ # Sourced by ~/.zshenv (via setup-git-guards.sh).
159
+ # Guards (only active in repos containing scripts/git-guard.sh):
160
+ # git stash, git reset --hard, git checkout --, git checkout -f,
161
+ # git switch -f, git clean -f, git restore
162
+ # Override: ALLOW_DESTRUCTIVE_GIT=1 git stash
163
+
164
+ export FORGE_GIT_GUARD=1
165
+
166
+ _git_guard_find_root() {
167
+ local _repo_root _dir
168
+ _repo_root="$(command git rev-parse --show-toplevel 2>/dev/null || echo "")"
169
+ if [ -z "$_repo_root" ]; then echo ""; return; fi
170
+ if [ -f "$_repo_root/scripts/git-guard.sh" ]; then echo "$_repo_root"; return; fi
171
+ _dir="$(dirname "$_repo_root")"
172
+ while [ -n "$_dir" ] && [ "$_dir" != "/" ]; do
173
+ if [ -f "$_dir/scripts/git-guard.sh" ]; then echo "$_dir"; return; fi
174
+ _dir="$(dirname "$_dir")"
175
+ done
176
+ echo ""
177
+ }
178
+
179
+ git() {
180
+ local _guard_root
181
+ _guard_root="$(_git_guard_find_root)"
182
+ if [ -z "$_guard_root" ] || [ -n "${ALLOW_DESTRUCTIVE_GIT:-}" ]; then
183
+ command git "$@"; return $?
184
+ fi
185
+ local _all_args=("$@") _i=0 _cmd="" _cmd_index=0
186
+ while [ "$_i" -lt "${#_all_args[@]}" ]; do
187
+ local _arg="${_all_args[$_i]}"
188
+ case "$_arg" in
189
+ -C|-c|--git-dir|--work-tree|--namespace) _i=$((_i + 2)); continue ;;
190
+ -*) _i=$((_i + 1)); continue ;;
191
+ esac
192
+ _cmd="$_arg"; _cmd_index="$_i"; break
193
+ done
194
+ local _sub_args=() _j=$((_cmd_index + 1))
195
+ while [ "$_j" -lt "${#_all_args[@]}" ]; do
196
+ _sub_args+=("${_all_args[$_j]}"); _j=$((_j + 1))
197
+ done
198
+ case "$_cmd" in
199
+ stash)
200
+ echo "BLOCKED: git stash is disabled in agent sessions." >&2
201
+ echo "Set ALLOW_DESTRUCTIVE_GIT=1 to override." >&2; return 1 ;;
202
+ reset)
203
+ for _arg in "${_sub_args[@]}"; do
204
+ if [ "$_arg" = "--hard" ] || [[ "$_arg" == --hard=* ]]; then
205
+ echo "BLOCKED: git reset --hard is disabled." >&2; return 1
206
+ fi
207
+ done; command git "$@"; return $? ;;
208
+ checkout)
209
+ for _arg in "${_sub_args[@]}"; do
210
+ if [ "$_arg" = "--" ]; then
211
+ echo "BLOCKED: git checkout -- is disabled." >&2; return 1
212
+ fi
213
+ case "$_arg" in -f*|--force*)
214
+ echo "BLOCKED: git checkout -f is disabled." >&2; return 1 ;;
215
+ esac
216
+ done; command git "$@"; return $? ;;
217
+ switch)
218
+ for _arg in "${_sub_args[@]}"; do
219
+ case "$_arg" in -f*|--force*)
220
+ echo "BLOCKED: git switch -f is disabled." >&2; return 1 ;;
221
+ esac
222
+ done; command git "$@"; return $? ;;
223
+ restore)
224
+ echo "BLOCKED: git restore is disabled." >&2; return 1 ;;
225
+ clean)
226
+ for _arg in "${_sub_args[@]}"; do
227
+ case "$_arg" in -f*|--force*)
228
+ echo "BLOCKED: git clean -f is disabled." >&2; return 1 ;;
229
+ esac
230
+ done; command git "$@"; return $? ;;
231
+ *) command git "$@"; return $? ;;
232
+ esac
233
+ }
234
+ - path: scripts/setup-git-guards.sh
235
+ content: |
236
+ #!/bin/bash
237
+ # setup-git-guards.sh — install shell function guards for destructive git ops.
238
+ # Auto-detects shell: zsh → ~/.zshenv, bash → ~/.bashrc (Git Bash on Windows).
239
+ set -euo pipefail
240
+ repo_root="$(git rev-parse --show-toplevel 2>/dev/null || echo "")"
241
+ if [ -z "$repo_root" ]; then echo "ERROR: not inside a git repository" >&2; exit 1; fi
242
+ guard_script="$repo_root/scripts/git-guard.sh"
243
+ marker="# forge-git-guard"
244
+ # Auto-detect target profile file based on current shell
245
+ case "${SHELL:-}" in
246
+ *zsh*) profile_file="${HOME}/.zshenv" ;;
247
+ *bash*) profile_file="${HOME}/.bashrc" ;;
248
+ *) profile_file="${HOME}/.bashrc" ;;
249
+ esac
250
+ mode="${1:-install}"
251
+ case "$mode" in
252
+ install)
253
+ if [ ! -f "$guard_script" ]; then echo "ERROR: $guard_script not found" >&2; exit 1; fi
254
+ chmod +x "$guard_script" 2>/dev/null || true
255
+ if [ -f "$profile_file" ]; then
256
+ tmp="$(mktemp)"
257
+ awk -v marker="$marker" '$0 == marker { skip=2; next } skip > 0 { skip--; next } { print }' "$profile_file" > "$tmp" || true
258
+ mv "$tmp" "$profile_file" 2>/dev/null || true
259
+ fi
260
+ echo "" >> "$profile_file"
261
+ echo "$marker" >> "$profile_file"
262
+ echo "[ -f \"$guard_script\" ] && source \"$guard_script\"" >> "$profile_file"
263
+ echo "Git guards installed into $profile_file. Restart your shell or run: source $guard_script" ;;
264
+ --verify)
265
+ if [ -z "${FORGE_GIT_GUARD:-}" ]; then echo "MISSING: git guard not loaded" >&2; exit 1; fi
266
+ if ! grep -qF "$marker" "$profile_file" 2>/dev/null; then echo "MISSING: guard not in $profile_file" >&2; exit 1; fi
267
+ echo "OK: git guards installed and loaded" ;;
268
+ --remove)
269
+ if [ -f "$profile_file" ]; then
270
+ tmp="$(mktemp)"
271
+ awk -v marker="$marker" '$0 == marker { skip=2; next } skip > 0 { skip--; next } { print }' "$profile_file" > "$tmp" || true
272
+ mv "$tmp" "$profile_file" 2>/dev/null || true
273
+ fi
274
+ echo "Git guards removed from $profile_file" ;;
275
+ *) echo "Usage: bash scripts/setup-git-guards.sh [install|--verify|--remove]" >&2; exit 1 ;;
276
+ esac
277
+ - path: scripts/clean-stale-stashes.sh
278
+ content: |
279
+ #!/bin/bash
280
+ # clean-stale-stashes.sh — detect and optionally drop stale git stash entries.
281
+ # Usage: bash scripts/clean-stale-stashes.sh [--drop|--drop-all]
282
+ set -euo pipefail
283
+ repo_root="$(git rev-parse --show-toplevel 2>/dev/null || echo "")"
284
+ if [ -z "$repo_root" ]; then echo "ERROR: not inside a git repository" >&2; exit 1; fi
285
+ mode="report"
286
+ if [ "${1:-}" = "--drop" ]; then mode="drop"; elif [ "${1:-}" = "--drop-all" ]; then mode="drop-all"; fi
287
+ stash_count="$(git -C "$repo_root" stash list 2>/dev/null | wc -l)"
288
+ if [ "$stash_count" -eq 0 ]; then exit 0; fi
289
+ stale_indices=() total="$stash_count"
290
+ for ((i = 0; i < total; i++)); do
291
+ ref="stash@{$i}"
292
+ if [ "$mode" = "drop-all" ]; then stale_indices+=("$i"); continue; fi
293
+ tracked_changes="$(git -C "$repo_root" stash show "$ref" --stat 2>/dev/null || true)"
294
+ untracked_files="$(git -C "$repo_root" stash show --include-untracked "$ref" --stat 2>/dev/null || true)"
295
+ if [ -z "$tracked_changes" ] && [ -z "$untracked_files" ]; then stale_indices+=("$i"); continue; fi
296
+ if [ -z "$tracked_changes" ] && [ -n "$untracked_files" ]; then
297
+ all_committed=true
298
+ while IFS= read -r filepath; do
299
+ [ -z "$filepath" ] && continue
300
+ if ! git -C "$repo_root" cat-file -e "HEAD:$filepath" 2>/dev/null; then all_committed=false; break; fi
301
+ done < <(git -C "$repo_root" stash show --include-untracked "$ref" --name-only 2>/dev/null || true)
302
+ if [ "$all_committed" = true ]; then stale_indices+=("$i"); fi
303
+ fi
304
+ done
305
+ if [ ${#stale_indices[@]} -eq 0 ]; then exit 0; fi
306
+ if [ "$mode" = "report" ]; then
307
+ echo "STALE STASH ENTRIES DETECTED (${#stale_indices[@]} of $stash_count):"
308
+ for idx in "${stale_indices[@]}"; do
309
+ echo " stash@{$idx}: $(git -C "$repo_root" stash list | sed -n "$((idx + 1))p")"
310
+ done
311
+ echo "Clean with: bash scripts/clean-stale-stashes.sh --drop"; exit 1
312
+ fi
313
+ if [ "$mode" = "drop-all" ]; then git -C "$repo_root" stash clear; echo "Dropped all $stash_count entries."; exit 0; fi
314
+ for ((i = ${#stale_indices[@]} - 1; i >= 0; i--)); do
315
+ idx="${stale_indices[$i]}"
316
+ git -C "$repo_root" stash drop "stash@{$idx}" 2>/dev/null || true
317
+ done
318
+ echo "Dropped ${#stale_indices[@]} stale entry/entries."; exit 0
319
+ - path: scripts/check-clean-trees.sh
320
+ content: |
321
+ #!/bin/bash
322
+ # check-clean-trees.sh — verify all git trees are clean.
323
+ # Checks repo root + all nested .git directories up to 3 levels deep.
324
+ set -euo pipefail
325
+ repo_root="$(git rev-parse --show-toplevel 2>/dev/null || echo "")"
326
+ if [ -z "$repo_root" ]; then echo "ERROR: not inside a git repository" >&2; exit 1; fi
327
+ dirty=0 output=""
328
+ main_status="$(git -C "$repo_root" status --short 2>/dev/null || true)"
329
+ if [ -n "$main_status" ]; then
330
+ output+="DIRTY: $repo_root\n$(printf '%s\n' "$main_status" | sed 's/^/ /')\n"
331
+ dirty=1
332
+ fi
333
+ while IFS= read -r gitdir; do
334
+ nested_root="$(dirname "$gitdir")"
335
+ [ "$nested_root" = "$repo_root" ] && continue
336
+ nested_status="$(git -C "$nested_root" status --short 2>/dev/null || true)"
337
+ if [ -n "$nested_status" ]; then
338
+ output+="DIRTY: $nested_root\n$(printf '%s\n' "$nested_status" | sed 's/^/ /')\n"
339
+ dirty=1
340
+ fi
341
+ done < <(find "$repo_root" -maxdepth 3 -name ".git" -type d 2>/dev/null | head -20)
342
+ if [ "$dirty" -eq 1 ]; then
343
+ echo -e "$output" | head -40
344
+ echo "---"
345
+ echo "Uncommitted changes detected. Commit before session end."
346
+ exit 1
347
+ fi
348
+ exit 0
349
+ - path: hooks/pre-user-prompt.sh
350
+ content: |
351
+ #!/bin/bash
352
+ # Pre-user-prompt hook: session-end protocol + stale-stash + git-guard checks.
353
+ # Requires jq for JSON payload parsing.
354
+ set -euo pipefail
355
+ payload="$(cat)"
356
+ user_prompt="$(printf '%s' "$payload" | jq -r '.tool_info.user_prompt // empty' 2>/dev/null || true)"
357
+ if [ -z "$user_prompt" ]; then exit 0; fi
358
+ repo_root="$(git rev-parse --show-toplevel 2>/dev/null || echo "")"
359
+ if [ -n "$repo_root" ] && [ -x "$repo_root/scripts/clean-stale-stashes.sh" ]; then
360
+ stash_output="$("$repo_root/scripts/clean-stale-stashes.sh" 2>&1 || true)"
361
+ if [ -n "$stash_output" ]; then
362
+ echo "⚠️ STALE GIT STASH DETECTED" >&2
363
+ echo "$stash_output" >&2
364
+ echo "Clean with: bash scripts/clean-stale-stashes.sh --drop" >&2
365
+ echo "" >&2
366
+ fi
367
+ fi
368
+ if [ -n "$repo_root" ] && [ -f "$repo_root/scripts/git-guard.sh" ]; then
369
+ if [ -z "${FORGE_GIT_GUARD:-}" ]; then
370
+ echo "⚠️ GIT GUARD NOT LOADED — destructive git operations are NOT blocked." >&2
371
+ echo "Run: source $repo_root/scripts/git-guard.sh" >&2
372
+ echo "" >&2
373
+ fi
374
+ fi
375
+ session_end_phrases=("Завершаем эту сессию" "Завершаем сессию" "Заканчиваем сессию" "Завершить сессию" "End session" "Wrap up" "Session end" "/session-end")
376
+ matched=""
377
+ for phrase in "${session_end_phrases[@]}"; do
378
+ if printf '%s' "$user_prompt" | grep -qiF "$phrase"; then matched="$phrase"; break; fi
379
+ done
380
+ if [ -z "$matched" ]; then exit 0; fi
381
+ cat >&2 <<EOF
382
+ SESSION-END PROTOCOL TRIGGERED (matched: "$matched")
383
+ The user's original message was:
384
+ > $user_prompt
385
+ You MUST invoke the fo-session-retro skill via the skill tool BEFORE producing
386
+ any other output. This is a NON-NEGOTIABLE BLOCKED GATE per PREFERENCES.md.
387
+ DO NOT produce a closing summary or ad-hoc output. The closing block must come
388
+ from fo-session-retro's report.
389
+ Protocol steps:
390
+ 1. Verify clean working trees — run: bash scripts/check-clean-trees.sh
391
+ 2. Invoke fo-session-retro via the skill tool
392
+ 3. The retro skill's report IS the session-end output
393
+ EOF
394
+ if [ -n "$repo_root" ] && [ -x "$repo_root/scripts/check-clean-trees.sh" ]; then
395
+ tree_output="$("$repo_root/scripts/check-clean-trees.sh" 2>&1 || true)"
396
+ if [ -n "$tree_output" ]; then echo "" >&2; echo "$tree_output" >&2; fi
397
+ fi
398
+ exit 2
399
+ - path: hooks/pre-user-prompt-wrapper.mjs
400
+ content: |
401
+ #!/usr/bin/env node
402
+ // pre-user-prompt-wrapper.mjs — cross-platform wrapper for pre-user-prompt.sh.
403
+ // Detects bash availability, prints one-time warning if bash not found,
404
+ // then delegates to bash hooks/pre-user-prompt.sh with stdin piped.
405
+ import { execFileSync, spawn } from "node:child_process";
406
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
407
+ import { dirname, join } from "node:path";
408
+ import { fileURLToPath } from "node:url";
409
+
410
+ const __dirname = dirname(fileURLToPath(import.meta.url));
411
+ const scriptPath = join(__dirname, "pre-user-prompt.sh");
412
+ const cacheDir = join(__dirname, "..", ".cache");
413
+ const warningFlag = join(cacheDir, "git-guard-warning-sent");
414
+
415
+ // If pre-user-prompt.sh doesn't exist, silently exit
416
+ if (!existsSync(scriptPath)) {
417
+ process.exit(0);
418
+ }
419
+
420
+ // Check if bash is available
421
+ let bashAvailable = false;
422
+ try {
423
+ execFileSync("bash", ["--version"], { stdio: "ignore" });
424
+ bashAvailable = true;
425
+ } catch {
426
+ bashAvailable = false;
427
+ }
428
+
429
+ if (!bashAvailable) {
430
+ // Print one-time warning if not already sent
431
+ if (!existsSync(warningFlag)) {
432
+ console.error("⚠️ bash not found — pre-user-prompt hook is disabled.");
433
+ console.error("Install Git Bash (Git for Windows) or WSL to enable agent-safety hooks.");
434
+ console.error("");
435
+ try {
436
+ mkdirSync(cacheDir, { recursive: true });
437
+ writeFileSync(warningFlag, new Date().toISOString(), "utf8");
438
+ } catch {
439
+ // Best-effort — don't crash if cache dir is not writable
440
+ }
441
+ }
442
+ process.exit(0);
443
+ }
444
+
445
+ // Delegate to bash script with stdin piped
446
+ const child = spawn("bash", [scriptPath], { stdio: ["inherit", "inherit", "inherit"] });
447
+ child.on("exit", (code) => {
448
+ process.exit(code ?? 0);
449
+ });
450
+ - path: .windsurf/hooks.json
451
+ content: |
452
+ {
453
+ "hooks": {
454
+ "pre_user_prompt": [
455
+ {
456
+ "command": "node $ROOT_WORKSPACE_PATH/hooks/pre-user-prompt-wrapper.mjs",
457
+ "show_output": true
458
+ }
459
+ ]
460
+ }
461
+ }
147
462
  - path: tools/kernel.config.ts
148
463
  content: |
149
464
  import { defineKernelConfig } from "@warpgogol/werkstatt-engine/kernel/types";
@@ -183,6 +498,14 @@ workspace:
183
498
  mode: protect
184
499
  - path: tsconfig.base.json
185
500
  mode: protect
501
+ - path: scripts/git-guard.sh
502
+ mode: protect
503
+ - path: scripts/setup-git-guards.sh
504
+ mode: protect
505
+ - path: hooks/pre-user-prompt.sh
506
+ mode: protect
507
+ - path: hooks/pre-user-prompt-wrapper.mjs
508
+ mode: protect
186
509
  - path: README.md
187
510
  content: |
188
511
  # __PROJECT_NAME__
@@ -193,6 +516,8 @@ workspace:
193
516
 
194
517
  - Node.js 24+
195
518
  - pnpm 10+
519
+ - jq (for pre-user-prompt hook)
520
+ - On Windows: Git Bash or WSL (for shell scripts)
196
521
 
197
522
  ## Setup
198
523
 
@@ -206,7 +531,19 @@ workspace:
206
531
  pnpm install
207
532
  ```
208
533
 
209
- ### 3. Verify the workshop
534
+ ### 3. Install git guards (recommended)
535
+
536
+ Git guards prevent AI agents from running destructive git commands (`git stash`, `git reset --hard`, `git checkout --`, `git restore`, `git clean -f`):
537
+
538
+ ```sh
539
+ bash scripts/setup-git-guards.sh
540
+ ```
541
+
542
+ This installs a shell function into your shell profile (`~/.zshenv` for zsh, `~/.bashrc` for bash/Git Bash). Restart your shell or run `source scripts/git-guard.sh`.
543
+
544
+ > **Windows**: Git guards require Git Bash (included with Git for Windows). Run `bash scripts/setup-git-guards.sh` from Git Bash. If bash is not in PATH, the pre-user-prompt hook wrapper prints a one-time warning. Install `jq` via `winget install jqlang.jq` or `choco install jq`.
545
+
546
+ ### 4. Verify the workshop
210
547
 
211
548
  ```sh
212
549
  pnpm exec forge run forge.doctor
@@ -21,6 +21,7 @@
21
21
  <item>RFC-0643: accept optional profileId and write it to forge.yaml as the `profile` field.</item>
22
22
  <item>RFC-0663: added syncSharedKnowledge step to sync shared knowledge layer to .agents/skills/shared-knowledge/.</item>
23
23
  <item>RFC-0941: create forge.plugin.yaml manifests for skill packs that lack them before calling discoverPackSkills.</item>
24
+ <item>RFC-1019: extend PREFERENCES.md with formOfAddress, session-end protocol, skill invocation tracking, plan confirmation vs implementation, commit granularity rules.</item>
24
25
  </CHANGE_SUMMARY>
25
26
  */
26
27
 
@@ -186,14 +187,63 @@ export function runInit(
186
187
  }
187
188
  }
188
189
 
189
- // 2. Create PREFERENCES.md if missing
190
+ // 2. Create PREFERENCES.md if missing (RFC-1019: extended with formOfAddress and operational rules)
190
191
  const prefsPath = path.join(workspaceRoot, "PREFERENCES.md");
191
192
  const register = domainFields?.register ?? "business";
193
+ const formOfAddress = "formal";
192
194
  if (fs.existsSync(prefsPath)) {
193
195
  skipped.push("PREFERENCES.md (already exists)");
194
196
  } else {
195
- const prefsContent = `---\naiLanguage: ${aiLang}\ndocumentationLanguage: ${docLang}\nregister: ${register}\n---\n\n# Operator Preferences\n\n- \`aiLanguage\`: ${aiLang} — AI uses this language for all communication with the operator.\n- \`documentationLanguage\`: ${docLang} — generated documentation uses this language.\n- \`register\`: ${register} — communication register (business or creative).\n`;
196
- fs.writeFileSync(prefsPath, prefsContent, "utf8");
197
+ const prefsContent = [
198
+ "---",
199
+ `aiLanguage: ${aiLang}`,
200
+ `documentationLanguage: ${docLang}`,
201
+ `register: ${register}`,
202
+ `formOfAddress: ${formOfAddress}`,
203
+ "---",
204
+ "",
205
+ "# Operator Preferences",
206
+ "",
207
+ "This file stores operator-level preferences that AI agents read at the start of a session.",
208
+ "",
209
+ "## Current preferences",
210
+ "",
211
+ `- \`aiLanguage\` — ${aiLang} — AI uses this language for all communication with the operator.`,
212
+ `- \`documentationLanguage\` — ${docLang} — generated documentation uses this language.`,
213
+ `- \`register\` — ${register} — communication register (business or creative).`,
214
+ `- \`formOfAddress\` — ${formOfAddress} — form of address (formal or informal).`,
215
+ "",
216
+ "## Plan confirmation vs implementation command (NON-NEGOTIABLE)",
217
+ "",
218
+ "Confirming a plan or design during grilling/discussion means the operator agrees with the approach — it is NOT a command to start implementation. The agent MUST wait for an explicit implementation command (\"implement\", \"go ahead\", \"реализуй\", \"начинай\") before writing any code beyond the RFC/ADR document itself.",
219
+ "",
220
+ "- **RFC creation** → agent creates the RFC file in `draft` status. No code changes.",
221
+ "- **Plan confirmation** → agent stops. No status change, no implementation, no todo list items moved to `in_progress`.",
222
+ "- **Implementation command** → only now does the agent begin coding, register commands, write tests, etc.",
223
+ "",
224
+ "If the agent is unsure whether the operator's message is a confirmation or a command to implement, the agent MUST ask for clarification before proceeding.",
225
+ "",
226
+ "## Skill invocation tracking",
227
+ "",
228
+ "When a skill is invoked (e.g. `fo-idea-i-just-want-to-see-the-result`), the agent MUST complete the full skill pipeline. Stopping halfway (e.g. after `fo-idea-create-rfc` when the operator asked for the full result) is a protocol violation. If a step fails, the agent reports the failure and asks for guidance — it does not silently skip remaining steps.",
229
+ "",
230
+ "## Commit granularity",
231
+ "",
232
+ "One logical fix = one commit, regardless of how many iterations were needed to produce it. Do not split a single logical change into multiple commits just because multiple edit rounds were needed. Conversely, do not combine unrelated changes into a single commit.",
233
+ "",
234
+ "## Session-end protocol",
235
+ "",
236
+ "When the operator signals session end (phrases like \"End session\", \"Wrap up\", \"Завершаем сессию\", \"/session-end\"), the agent MUST:",
237
+ "",
238
+ "1. Verify clean working trees — run: `bash scripts/check-clean-trees.sh`",
239
+ "2. Commit any uncommitted changes before proceeding",
240
+ "3. Invoke the `fo-session-retro` skill via the skill tool",
241
+ "4. The retro skill's report IS the session-end output — do not produce a separate closing summary",
242
+ "",
243
+ "This protocol is enforced by the `hooks/pre-user-prompt.sh` hook as a blocked gate.",
244
+ "",
245
+ ].join("\n");
246
+ fs.writeFileSync(prefsPath, prefsContent + "\n", "utf8");
197
247
  created.push("PREFERENCES.md");
198
248
  }
199
249
 
@@ -5,6 +5,7 @@
5
5
  <CHANGE_SUMMARY>
6
6
  <item>RFC-0544: initial forge.create tests.</item>
7
7
  <item>RFC-0877: rewrite tests for --in-place mode, add strict empty-directory check and name-derivation tests.</item>
8
+ <item>RFC-1019: add tests for pre-user-prompt-wrapper.mjs, shell auto-detection, hooks.json wrapper, pinned.yaml.</item>
8
9
  </CHANGE_SUMMARY>
9
10
  */
10
11
 
@@ -62,6 +63,7 @@ test("forge create --in-place scaffolds in cwd with forge.yaml and docs dirs", a
62
63
  expect(existsSync(join(tempDir, "PREFERENCES.md"))).toBe(true);
63
64
  expect(existsSync(join(tempDir, "package.json"))).toBe(true);
64
65
  expect(existsSync(join(tempDir, "scripts", "clean.mjs"))).toBe(true);
66
+ expect(existsSync(join(tempDir, "hooks", "pre-user-prompt-wrapper.mjs"))).toBe(true);
65
67
  }, 30000);
66
68
 
67
69
  test("forge create --in-place refuses when forge artifacts already exist", async () => {
@@ -305,6 +307,45 @@ test("forge create --profile godot-csharp writes compass.fileExtensions into for
305
307
  expect(forgeYaml).toContain(".gd");
306
308
  }, 30000);
307
309
 
310
+ test("forge create --in-place setup-git-guards.sh contains shell auto-detection (RFC-1019)", async () => {
311
+ const result = await runCreate(
312
+ { argv: [], flags: { "in-place": true, profile: "forge-shell", name: "my-project" } },
313
+ makeContext(tempDir),
314
+ );
315
+ expect(result.exitCode).toBe(0);
316
+
317
+ const { readFile: readFileAsync } = await import("node:fs/promises");
318
+ const setupScript = await readFileAsync(join(tempDir, "scripts", "setup-git-guards.sh"), "utf8");
319
+ expect(setupScript).toContain(".zshenv");
320
+ expect(setupScript).toContain(".bashrc");
321
+ expect(setupScript).toContain("profile_file");
322
+ }, 30000);
323
+
324
+ test("forge create --in-place .windsurf/hooks.json calls wrapper not bash directly (RFC-1019)", async () => {
325
+ const result = await runCreate(
326
+ { argv: [], flags: { "in-place": true, profile: "forge-shell", name: "my-project" } },
327
+ makeContext(tempDir),
328
+ );
329
+ expect(result.exitCode).toBe(0);
330
+
331
+ const { readFile: readFileAsync } = await import("node:fs/promises");
332
+ const hooksJson = await readFileAsync(join(tempDir, ".windsurf", "hooks.json"), "utf8");
333
+ expect(hooksJson).toContain("pre-user-prompt-wrapper.mjs");
334
+ expect(hooksJson).not.toContain("2>/dev/null || true");
335
+ }, 30000);
336
+
337
+ test("forge create --in-place pins pre-user-prompt-wrapper.mjs in pinned.yaml (RFC-1019)", async () => {
338
+ const result = await runCreate(
339
+ { argv: [], flags: { "in-place": true, profile: "forge-shell", name: "my-project" } },
340
+ makeContext(tempDir),
341
+ );
342
+ expect(result.exitCode).toBe(0);
343
+
344
+ const { readFile: readFileAsync } = await import("node:fs/promises");
345
+ const pinnedYaml = await readFileAsync(join(tempDir, ".forge", "pinned.yaml"), "utf8");
346
+ expect(pinnedYaml).toContain("pre-user-prompt-wrapper.mjs");
347
+ }, 30000);
348
+
308
349
  test("forge create --profile phaser-turborepo writes compass.fileExtensions into forge.yaml", async () => {
309
350
  const result = await runCreate(
310
351
  { argv: [], flags: { "in-place": true, profile: "phaser-turborepo", name: "my-phaser-game" } },