@warpgogol/forge 2.21.7 → 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.
@@ -697,6 +697,314 @@ workspace:
697
697
  pnpm exec werkstatt run werkstatt.autonomy.validate || exit 1
698
698
  pnpm exec werkstatt run werkstatt.plugin.validate || exit 1
699
699
  pnpm exec werkstatt run compass.validate || exit 1
700
+ - path: scripts/git-guard.sh
701
+ content: |
702
+ #!/bin/bash
703
+ # git-guard.sh — shell function that intercepts destructive git commands.
704
+ # Sourced by ~/.zshenv (via setup-git-guards.sh).
705
+ # Guards (only active in repos containing scripts/git-guard.sh):
706
+ # git stash, git reset --hard, git checkout --, git checkout -f,
707
+ # git switch -f, git clean -f, git restore
708
+ # Override: ALLOW_DESTRUCTIVE_GIT=1 git stash
709
+
710
+ export FORGE_GIT_GUARD=1
711
+
712
+ _git_guard_find_root() {
713
+ local _repo_root _dir
714
+ _repo_root="$(command git rev-parse --show-toplevel 2>/dev/null || echo "")"
715
+ if [ -z "$_repo_root" ]; then echo ""; return; fi
716
+ if [ -f "$_repo_root/scripts/git-guard.sh" ]; then echo "$_repo_root"; return; fi
717
+ _dir="$(dirname "$_repo_root")"
718
+ while [ -n "$_dir" ] && [ "$_dir" != "/" ]; do
719
+ if [ -f "$_dir/scripts/git-guard.sh" ]; then echo "$_dir"; return; fi
720
+ _dir="$(dirname "$_dir")"
721
+ done
722
+ echo ""
723
+ }
724
+
725
+ git() {
726
+ local _guard_root
727
+ _guard_root="$(_git_guard_find_root)"
728
+ if [ -z "$_guard_root" ] || [ -n "${ALLOW_DESTRUCTIVE_GIT:-}" ]; then
729
+ command git "$@"; return $?
730
+ fi
731
+ local _all_args=("$@") _i=0 _cmd="" _cmd_index=0
732
+ while [ "$_i" -lt "${#_all_args[@]}" ]; do
733
+ local _arg="${_all_args[$_i]}"
734
+ case "$_arg" in
735
+ -C|-c|--git-dir|--work-tree|--namespace) _i=$((_i + 2)); continue ;;
736
+ -*) _i=$((_i + 1)); continue ;;
737
+ esac
738
+ _cmd="$_arg"; _cmd_index="$_i"; break
739
+ done
740
+ local _sub_args=() _j=$((_cmd_index + 1))
741
+ while [ "$_j" -lt "${#_all_args[@]}" ]; do
742
+ _sub_args+=("${_all_args[$_j]}"); _j=$((_j + 1))
743
+ done
744
+ case "$_cmd" in
745
+ stash)
746
+ echo "BLOCKED: git stash is disabled in agent sessions." >&2
747
+ echo "Set ALLOW_DESTRUCTIVE_GIT=1 to override." >&2; return 1 ;;
748
+ reset)
749
+ for _arg in "${_sub_args[@]}"; do
750
+ if [ "$_arg" = "--hard" ] || [[ "$_arg" == --hard=* ]]; then
751
+ echo "BLOCKED: git reset --hard is disabled." >&2; return 1
752
+ fi
753
+ done; command git "$@"; return $? ;;
754
+ checkout)
755
+ for _arg in "${_sub_args[@]}"; do
756
+ if [ "$_arg" = "--" ]; then
757
+ echo "BLOCKED: git checkout -- is disabled." >&2; return 1
758
+ fi
759
+ case "$_arg" in -f*|--force*)
760
+ echo "BLOCKED: git checkout -f is disabled." >&2; return 1 ;;
761
+ esac
762
+ done; command git "$@"; return $? ;;
763
+ switch)
764
+ for _arg in "${_sub_args[@]}"; do
765
+ case "$_arg" in -f*|--force*)
766
+ echo "BLOCKED: git switch -f is disabled." >&2; return 1 ;;
767
+ esac
768
+ done; command git "$@"; return $? ;;
769
+ restore)
770
+ echo "BLOCKED: git restore is disabled." >&2; return 1 ;;
771
+ clean)
772
+ for _arg in "${_sub_args[@]}"; do
773
+ case "$_arg" in -f*|--force*)
774
+ echo "BLOCKED: git clean -f is disabled." >&2; return 1 ;;
775
+ esac
776
+ done; command git "$@"; return $? ;;
777
+ *) command git "$@"; return $? ;;
778
+ esac
779
+ }
780
+ - path: scripts/setup-git-guards.sh
781
+ content: |
782
+ #!/bin/bash
783
+ # setup-git-guards.sh — install shell function guards for destructive git ops.
784
+ # Auto-detects shell: zsh → ~/.zshenv, bash → ~/.bashrc (Git Bash on Windows).
785
+ set -euo pipefail
786
+ repo_root="$(git rev-parse --show-toplevel 2>/dev/null || echo "")"
787
+ if [ -z "$repo_root" ]; then echo "ERROR: not inside a git repository" >&2; exit 1; fi
788
+ guard_script="$repo_root/scripts/git-guard.sh"
789
+ marker="# forge-git-guard"
790
+ # Auto-detect target profile file based on current shell
791
+ case "${SHELL:-}" in
792
+ *zsh*) profile_file="${HOME}/.zshenv" ;;
793
+ *bash*) profile_file="${HOME}/.bashrc" ;;
794
+ *) profile_file="${HOME}/.bashrc" ;;
795
+ esac
796
+ mode="${1:-install}"
797
+ case "$mode" in
798
+ install)
799
+ if [ ! -f "$guard_script" ]; then echo "ERROR: $guard_script not found" >&2; exit 1; fi
800
+ chmod +x "$guard_script" 2>/dev/null || true
801
+ if [ -f "$profile_file" ]; then
802
+ tmp="$(mktemp)"
803
+ awk -v marker="$marker" '$0 == marker { skip=2; next } skip > 0 { skip--; next } { print }' "$profile_file" > "$tmp" || true
804
+ mv "$tmp" "$profile_file" 2>/dev/null || true
805
+ fi
806
+ echo "" >> "$profile_file"
807
+ echo "$marker" >> "$profile_file"
808
+ echo "[ -f \"$guard_script\" ] && source \"$guard_script\"" >> "$profile_file"
809
+ echo "Git guards installed into $profile_file. Restart your shell or run: source $guard_script" ;;
810
+ --verify)
811
+ if [ -z "${FORGE_GIT_GUARD:-}" ]; then echo "MISSING: git guard not loaded" >&2; exit 1; fi
812
+ if ! grep -qF "$marker" "$profile_file" 2>/dev/null; then echo "MISSING: guard not in $profile_file" >&2; exit 1; fi
813
+ echo "OK: git guards installed and loaded" ;;
814
+ --remove)
815
+ if [ -f "$profile_file" ]; then
816
+ tmp="$(mktemp)"
817
+ awk -v marker="$marker" '$0 == marker { skip=2; next } skip > 0 { skip--; next } { print }' "$profile_file" > "$tmp" || true
818
+ mv "$tmp" "$profile_file" 2>/dev/null || true
819
+ fi
820
+ echo "Git guards removed from $profile_file" ;;
821
+ *) echo "Usage: bash scripts/setup-git-guards.sh [install|--verify|--remove]" >&2; exit 1 ;;
822
+ esac
823
+ - path: scripts/clean-stale-stashes.sh
824
+ content: |
825
+ #!/bin/bash
826
+ # clean-stale-stashes.sh — detect and optionally drop stale git stash entries.
827
+ # Usage: bash scripts/clean-stale-stashes.sh [--drop|--drop-all]
828
+ set -euo pipefail
829
+ repo_root="$(git rev-parse --show-toplevel 2>/dev/null || echo "")"
830
+ if [ -z "$repo_root" ]; then echo "ERROR: not inside a git repository" >&2; exit 1; fi
831
+ mode="report"
832
+ if [ "${1:-}" = "--drop" ]; then mode="drop"; elif [ "${1:-}" = "--drop-all" ]; then mode="drop-all"; fi
833
+ stash_count="$(git -C "$repo_root" stash list 2>/dev/null | wc -l)"
834
+ if [ "$stash_count" -eq 0 ]; then exit 0; fi
835
+ stale_indices=() total="$stash_count"
836
+ for ((i = 0; i < total; i++)); do
837
+ ref="stash@{$i}"
838
+ if [ "$mode" = "drop-all" ]; then stale_indices+=("$i"); continue; fi
839
+ tracked_changes="$(git -C "$repo_root" stash show "$ref" --stat 2>/dev/null || true)"
840
+ untracked_files="$(git -C "$repo_root" stash show --include-untracked "$ref" --stat 2>/dev/null || true)"
841
+ if [ -z "$tracked_changes" ] && [ -z "$untracked_files" ]; then stale_indices+=("$i"); continue; fi
842
+ if [ -z "$tracked_changes" ] && [ -n "$untracked_files" ]; then
843
+ all_committed=true
844
+ while IFS= read -r filepath; do
845
+ [ -z "$filepath" ] && continue
846
+ if ! git -C "$repo_root" cat-file -e "HEAD:$filepath" 2>/dev/null; then all_committed=false; break; fi
847
+ done < <(git -C "$repo_root" stash show --include-untracked "$ref" --name-only 2>/dev/null || true)
848
+ if [ "$all_committed" = true ]; then stale_indices+=("$i"); fi
849
+ fi
850
+ done
851
+ if [ ${#stale_indices[@]} -eq 0 ]; then exit 0; fi
852
+ if [ "$mode" = "report" ]; then
853
+ echo "STALE STASH ENTRIES DETECTED (${#stale_indices[@]} of $stash_count):"
854
+ for idx in "${stale_indices[@]}"; do
855
+ echo " stash@{$idx}: $(git -C "$repo_root" stash list | sed -n "$((idx + 1))p")"
856
+ done
857
+ echo "Clean with: bash scripts/clean-stale-stashes.sh --drop"; exit 1
858
+ fi
859
+ if [ "$mode" = "drop-all" ]; then git -C "$repo_root" stash clear; echo "Dropped all $stash_count entries."; exit 0; fi
860
+ for ((i = ${#stale_indices[@]} - 1; i >= 0; i--)); do
861
+ idx="${stale_indices[$i]}"
862
+ git -C "$repo_root" stash drop "stash@{$idx}" 2>/dev/null || true
863
+ done
864
+ echo "Dropped ${#stale_indices[@]} stale entry/entries."; exit 0
865
+ - path: scripts/check-clean-trees.sh
866
+ content: |
867
+ #!/bin/bash
868
+ # check-clean-trees.sh — verify all git trees are clean.
869
+ # Checks repo root + all nested .git directories up to 3 levels deep.
870
+ set -euo pipefail
871
+ repo_root="$(git rev-parse --show-toplevel 2>/dev/null || echo "")"
872
+ if [ -z "$repo_root" ]; then echo "ERROR: not inside a git repository" >&2; exit 1; fi
873
+ dirty=0 output=""
874
+ main_status="$(git -C "$repo_root" status --short 2>/dev/null || true)"
875
+ if [ -n "$main_status" ]; then
876
+ output+="DIRTY: $repo_root\n$(printf '%s\n' "$main_status" | sed 's/^/ /')\n"
877
+ dirty=1
878
+ fi
879
+ while IFS= read -r gitdir; do
880
+ nested_root="$(dirname "$gitdir")"
881
+ [ "$nested_root" = "$repo_root" ] && continue
882
+ nested_status="$(git -C "$nested_root" status --short 2>/dev/null || true)"
883
+ if [ -n "$nested_status" ]; then
884
+ output+="DIRTY: $nested_root\n$(printf '%s\n' "$nested_status" | sed 's/^/ /')\n"
885
+ dirty=1
886
+ fi
887
+ done < <(find "$repo_root" -maxdepth 3 -name ".git" -type d 2>/dev/null | head -20)
888
+ if [ "$dirty" -eq 1 ]; then
889
+ echo -e "$output" | head -40
890
+ echo "---"
891
+ echo "Uncommitted changes detected. Commit before session end."
892
+ exit 1
893
+ fi
894
+ exit 0
895
+ - path: hooks/pre-user-prompt.sh
896
+ content: |
897
+ #!/bin/bash
898
+ # Pre-user-prompt hook: session-end protocol + stale-stash + git-guard checks.
899
+ # Requires jq for JSON payload parsing.
900
+ set -euo pipefail
901
+ payload="$(cat)"
902
+ user_prompt="$(printf '%s' "$payload" | jq -r '.tool_info.user_prompt // empty' 2>/dev/null || true)"
903
+ if [ -z "$user_prompt" ]; then exit 0; fi
904
+ repo_root="$(git rev-parse --show-toplevel 2>/dev/null || echo "")"
905
+ if [ -n "$repo_root" ] && [ -x "$repo_root/scripts/clean-stale-stashes.sh" ]; then
906
+ stash_output="$("$repo_root/scripts/clean-stale-stashes.sh" 2>&1 || true)"
907
+ if [ -n "$stash_output" ]; then
908
+ echo "⚠️ STALE GIT STASH DETECTED" >&2
909
+ echo "$stash_output" >&2
910
+ echo "Clean with: bash scripts/clean-stale-stashes.sh --drop" >&2
911
+ echo "" >&2
912
+ fi
913
+ fi
914
+ if [ -n "$repo_root" ] && [ -f "$repo_root/scripts/git-guard.sh" ]; then
915
+ if [ -z "${FORGE_GIT_GUARD:-}" ]; then
916
+ echo "⚠️ GIT GUARD NOT LOADED — destructive git operations are NOT blocked." >&2
917
+ echo "Run: source $repo_root/scripts/git-guard.sh" >&2
918
+ echo "" >&2
919
+ fi
920
+ fi
921
+ session_end_phrases=("Завершаем эту сессию" "Завершаем сессию" "Заканчиваем сессию" "Завершить сессию" "End session" "Wrap up" "Session end" "/session-end")
922
+ matched=""
923
+ for phrase in "${session_end_phrases[@]}"; do
924
+ if printf '%s' "$user_prompt" | grep -qiF "$phrase"; then matched="$phrase"; break; fi
925
+ done
926
+ if [ -z "$matched" ]; then exit 0; fi
927
+ cat >&2 <<EOF
928
+ SESSION-END PROTOCOL TRIGGERED (matched: "$matched")
929
+ The user's original message was:
930
+ > $user_prompt
931
+ You MUST invoke the fo-session-retro skill via the skill tool BEFORE producing
932
+ any other output. This is a NON-NEGOTIABLE BLOCKED GATE per PREFERENCES.md.
933
+ DO NOT produce a closing summary or ad-hoc output. The closing block must come
934
+ from fo-session-retro's report.
935
+ Protocol steps:
936
+ 1. Verify clean working trees — run: bash scripts/check-clean-trees.sh
937
+ 2. Invoke fo-session-retro via the skill tool
938
+ 3. The retro skill's report IS the session-end output
939
+ EOF
940
+ if [ -n "$repo_root" ] && [ -x "$repo_root/scripts/check-clean-trees.sh" ]; then
941
+ tree_output="$("$repo_root/scripts/check-clean-trees.sh" 2>&1 || true)"
942
+ if [ -n "$tree_output" ]; then echo "" >&2; echo "$tree_output" >&2; fi
943
+ fi
944
+ exit 2
945
+ - path: hooks/pre-user-prompt-wrapper.mjs
946
+ content: |
947
+ #!/usr/bin/env node
948
+ // pre-user-prompt-wrapper.mjs — cross-platform wrapper for pre-user-prompt.sh.
949
+ // Detects bash availability, prints one-time warning if bash not found,
950
+ // then delegates to bash hooks/pre-user-prompt.sh with stdin piped.
951
+ import { execFileSync, spawn } from "node:child_process";
952
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
953
+ import { dirname, join } from "node:path";
954
+ import { fileURLToPath } from "node:url";
955
+
956
+ const __dirname = dirname(fileURLToPath(import.meta.url));
957
+ const scriptPath = join(__dirname, "pre-user-prompt.sh");
958
+ const cacheDir = join(__dirname, "..", ".cache");
959
+ const warningFlag = join(cacheDir, "git-guard-warning-sent");
960
+
961
+ // If pre-user-prompt.sh doesn't exist, silently exit
962
+ if (!existsSync(scriptPath)) {
963
+ process.exit(0);
964
+ }
965
+
966
+ // Check if bash is available
967
+ let bashAvailable = false;
968
+ try {
969
+ execFileSync("bash", ["--version"], { stdio: "ignore" });
970
+ bashAvailable = true;
971
+ } catch {
972
+ bashAvailable = false;
973
+ }
974
+
975
+ if (!bashAvailable) {
976
+ // Print one-time warning if not already sent
977
+ if (!existsSync(warningFlag)) {
978
+ console.error("⚠️ bash not found — pre-user-prompt hook is disabled.");
979
+ console.error("Install Git Bash (Git for Windows) or WSL to enable agent-safety hooks.");
980
+ console.error("");
981
+ try {
982
+ mkdirSync(cacheDir, { recursive: true });
983
+ writeFileSync(warningFlag, new Date().toISOString(), "utf8");
984
+ } catch {
985
+ // Best-effort — don't crash if cache dir is not writable
986
+ }
987
+ }
988
+ process.exit(0);
989
+ }
990
+
991
+ // Delegate to bash script with stdin piped
992
+ const child = spawn("bash", [scriptPath], { stdio: ["inherit", "inherit", "inherit"] });
993
+ child.on("exit", (code) => {
994
+ process.exit(code ?? 0);
995
+ });
996
+ - path: .windsurf/hooks.json
997
+ content: |
998
+ {
999
+ "hooks": {
1000
+ "pre_user_prompt": [
1001
+ {
1002
+ "command": "node $ROOT_WORKSPACE_PATH/hooks/pre-user-prompt-wrapper.mjs",
1003
+ "show_output": true
1004
+ }
1005
+ ]
1006
+ }
1007
+ }
700
1008
  - path: systems-cache/.gitkeep
701
1009
  content: |
702
1010
  # Sternsystem cache directory (RFC-0790)
@@ -722,6 +1030,14 @@ workspace:
722
1030
  mode: protect
723
1031
  - path: godot.version
724
1032
  mode: protect
1033
+ - path: scripts/git-guard.sh
1034
+ mode: protect
1035
+ - path: scripts/setup-git-guards.sh
1036
+ mode: protect
1037
+ - path: hooks/pre-user-prompt.sh
1038
+ mode: protect
1039
+ - path: hooks/pre-user-prompt-wrapper.mjs
1040
+ mode: protect
725
1041
  - path: README.md
726
1042
  content: |
727
1043
  # __PROJECT_NAME__
@@ -735,6 +1051,7 @@ workspace:
735
1051
  - .NET SDK 10+
736
1052
  - Godot 4.x (with .NET support)
737
1053
  - On Windows: Git Bash or WSL (for shell scripts)
1054
+ - jq (for pre-user-prompt hook)
738
1055
 
739
1056
  ## Setup
740
1057
 
@@ -752,7 +1069,19 @@ workspace:
752
1069
  pnpm install
753
1070
  ```
754
1071
 
755
- ### 3. Verify the workshop
1072
+ ### 3. Install git guards (recommended)
1073
+
1074
+ Git guards prevent AI agents from running destructive git commands (`git stash`, `git reset --hard`, `git checkout --`, `git restore`, `git clean -f`):
1075
+
1076
+ ```sh
1077
+ bash scripts/setup-git-guards.sh
1078
+ ```
1079
+
1080
+ 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`.
1081
+
1082
+ > **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`.
1083
+
1084
+ ### 4. Verify the workshop
756
1085
 
757
1086
  ```sh
758
1087
  pnpm exec werkstatt run forge.doctor