@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.
@@ -300,6 +300,314 @@ workspace:
300
300
  pnpm exec werkstatt run werkstatt.autonomy.validate || exit 1
301
301
  pnpm exec werkstatt run werkstatt.plugin.validate || exit 1
302
302
  pnpm exec werkstatt run compass.validate || exit 1
303
+ - path: scripts/git-guard.sh
304
+ content: |
305
+ #!/bin/bash
306
+ # git-guard.sh — shell function that intercepts destructive git commands.
307
+ # Sourced by ~/.zshenv (via setup-git-guards.sh).
308
+ # Guards (only active in repos containing scripts/git-guard.sh):
309
+ # git stash, git reset --hard, git checkout --, git checkout -f,
310
+ # git switch -f, git clean -f, git restore
311
+ # Override: ALLOW_DESTRUCTIVE_GIT=1 git stash
312
+
313
+ export FORGE_GIT_GUARD=1
314
+
315
+ _git_guard_find_root() {
316
+ local _repo_root _dir
317
+ _repo_root="$(command git rev-parse --show-toplevel 2>/dev/null || echo "")"
318
+ if [ -z "$_repo_root" ]; then echo ""; return; fi
319
+ if [ -f "$_repo_root/scripts/git-guard.sh" ]; then echo "$_repo_root"; return; fi
320
+ _dir="$(dirname "$_repo_root")"
321
+ while [ -n "$_dir" ] && [ "$_dir" != "/" ]; do
322
+ if [ -f "$_dir/scripts/git-guard.sh" ]; then echo "$_dir"; return; fi
323
+ _dir="$(dirname "$_dir")"
324
+ done
325
+ echo ""
326
+ }
327
+
328
+ git() {
329
+ local _guard_root
330
+ _guard_root="$(_git_guard_find_root)"
331
+ if [ -z "$_guard_root" ] || [ -n "${ALLOW_DESTRUCTIVE_GIT:-}" ]; then
332
+ command git "$@"; return $?
333
+ fi
334
+ local _all_args=("$@") _i=0 _cmd="" _cmd_index=0
335
+ while [ "$_i" -lt "${#_all_args[@]}" ]; do
336
+ local _arg="${_all_args[$_i]}"
337
+ case "$_arg" in
338
+ -C|-c|--git-dir|--work-tree|--namespace) _i=$((_i + 2)); continue ;;
339
+ -*) _i=$((_i + 1)); continue ;;
340
+ esac
341
+ _cmd="$_arg"; _cmd_index="$_i"; break
342
+ done
343
+ local _sub_args=() _j=$((_cmd_index + 1))
344
+ while [ "$_j" -lt "${#_all_args[@]}" ]; do
345
+ _sub_args+=("${_all_args[$_j]}"); _j=$((_j + 1))
346
+ done
347
+ case "$_cmd" in
348
+ stash)
349
+ echo "BLOCKED: git stash is disabled in agent sessions." >&2
350
+ echo "Set ALLOW_DESTRUCTIVE_GIT=1 to override." >&2; return 1 ;;
351
+ reset)
352
+ for _arg in "${_sub_args[@]}"; do
353
+ if [ "$_arg" = "--hard" ] || [[ "$_arg" == --hard=* ]]; then
354
+ echo "BLOCKED: git reset --hard is disabled." >&2; return 1
355
+ fi
356
+ done; command git "$@"; return $? ;;
357
+ checkout)
358
+ for _arg in "${_sub_args[@]}"; do
359
+ if [ "$_arg" = "--" ]; then
360
+ echo "BLOCKED: git checkout -- is disabled." >&2; return 1
361
+ fi
362
+ case "$_arg" in -f*|--force*)
363
+ echo "BLOCKED: git checkout -f is disabled." >&2; return 1 ;;
364
+ esac
365
+ done; command git "$@"; return $? ;;
366
+ switch)
367
+ for _arg in "${_sub_args[@]}"; do
368
+ case "$_arg" in -f*|--force*)
369
+ echo "BLOCKED: git switch -f is disabled." >&2; return 1 ;;
370
+ esac
371
+ done; command git "$@"; return $? ;;
372
+ restore)
373
+ echo "BLOCKED: git restore is disabled." >&2; return 1 ;;
374
+ clean)
375
+ for _arg in "${_sub_args[@]}"; do
376
+ case "$_arg" in -f*|--force*)
377
+ echo "BLOCKED: git clean -f is disabled." >&2; return 1 ;;
378
+ esac
379
+ done; command git "$@"; return $? ;;
380
+ *) command git "$@"; return $? ;;
381
+ esac
382
+ }
383
+ - path: scripts/setup-git-guards.sh
384
+ content: |
385
+ #!/bin/bash
386
+ # setup-git-guards.sh — install shell function guards for destructive git ops.
387
+ # Auto-detects shell: zsh → ~/.zshenv, bash → ~/.bashrc (Git Bash on Windows).
388
+ set -euo pipefail
389
+ repo_root="$(git rev-parse --show-toplevel 2>/dev/null || echo "")"
390
+ if [ -z "$repo_root" ]; then echo "ERROR: not inside a git repository" >&2; exit 1; fi
391
+ guard_script="$repo_root/scripts/git-guard.sh"
392
+ marker="# forge-git-guard"
393
+ # Auto-detect target profile file based on current shell
394
+ case "${SHELL:-}" in
395
+ *zsh*) profile_file="${HOME}/.zshenv" ;;
396
+ *bash*) profile_file="${HOME}/.bashrc" ;;
397
+ *) profile_file="${HOME}/.bashrc" ;;
398
+ esac
399
+ mode="${1:-install}"
400
+ case "$mode" in
401
+ install)
402
+ if [ ! -f "$guard_script" ]; then echo "ERROR: $guard_script not found" >&2; exit 1; fi
403
+ chmod +x "$guard_script" 2>/dev/null || true
404
+ if [ -f "$profile_file" ]; then
405
+ tmp="$(mktemp)"
406
+ awk -v marker="$marker" '$0 == marker { skip=2; next } skip > 0 { skip--; next } { print }' "$profile_file" > "$tmp" || true
407
+ mv "$tmp" "$profile_file" 2>/dev/null || true
408
+ fi
409
+ echo "" >> "$profile_file"
410
+ echo "$marker" >> "$profile_file"
411
+ echo "[ -f \"$guard_script\" ] && source \"$guard_script\"" >> "$profile_file"
412
+ echo "Git guards installed into $profile_file. Restart your shell or run: source $guard_script" ;;
413
+ --verify)
414
+ if [ -z "${FORGE_GIT_GUARD:-}" ]; then echo "MISSING: git guard not loaded" >&2; exit 1; fi
415
+ if ! grep -qF "$marker" "$profile_file" 2>/dev/null; then echo "MISSING: guard not in $profile_file" >&2; exit 1; fi
416
+ echo "OK: git guards installed and loaded" ;;
417
+ --remove)
418
+ if [ -f "$profile_file" ]; then
419
+ tmp="$(mktemp)"
420
+ awk -v marker="$marker" '$0 == marker { skip=2; next } skip > 0 { skip--; next } { print }' "$profile_file" > "$tmp" || true
421
+ mv "$tmp" "$profile_file" 2>/dev/null || true
422
+ fi
423
+ echo "Git guards removed from $profile_file" ;;
424
+ *) echo "Usage: bash scripts/setup-git-guards.sh [install|--verify|--remove]" >&2; exit 1 ;;
425
+ esac
426
+ - path: scripts/clean-stale-stashes.sh
427
+ content: |
428
+ #!/bin/bash
429
+ # clean-stale-stashes.sh — detect and optionally drop stale git stash entries.
430
+ # Usage: bash scripts/clean-stale-stashes.sh [--drop|--drop-all]
431
+ set -euo pipefail
432
+ repo_root="$(git rev-parse --show-toplevel 2>/dev/null || echo "")"
433
+ if [ -z "$repo_root" ]; then echo "ERROR: not inside a git repository" >&2; exit 1; fi
434
+ mode="report"
435
+ if [ "${1:-}" = "--drop" ]; then mode="drop"; elif [ "${1:-}" = "--drop-all" ]; then mode="drop-all"; fi
436
+ stash_count="$(git -C "$repo_root" stash list 2>/dev/null | wc -l)"
437
+ if [ "$stash_count" -eq 0 ]; then exit 0; fi
438
+ stale_indices=() total="$stash_count"
439
+ for ((i = 0; i < total; i++)); do
440
+ ref="stash@{$i}"
441
+ if [ "$mode" = "drop-all" ]; then stale_indices+=("$i"); continue; fi
442
+ tracked_changes="$(git -C "$repo_root" stash show "$ref" --stat 2>/dev/null || true)"
443
+ untracked_files="$(git -C "$repo_root" stash show --include-untracked "$ref" --stat 2>/dev/null || true)"
444
+ if [ -z "$tracked_changes" ] && [ -z "$untracked_files" ]; then stale_indices+=("$i"); continue; fi
445
+ if [ -z "$tracked_changes" ] && [ -n "$untracked_files" ]; then
446
+ all_committed=true
447
+ while IFS= read -r filepath; do
448
+ [ -z "$filepath" ] && continue
449
+ if ! git -C "$repo_root" cat-file -e "HEAD:$filepath" 2>/dev/null; then all_committed=false; break; fi
450
+ done < <(git -C "$repo_root" stash show --include-untracked "$ref" --name-only 2>/dev/null || true)
451
+ if [ "$all_committed" = true ]; then stale_indices+=("$i"); fi
452
+ fi
453
+ done
454
+ if [ ${#stale_indices[@]} -eq 0 ]; then exit 0; fi
455
+ if [ "$mode" = "report" ]; then
456
+ echo "STALE STASH ENTRIES DETECTED (${#stale_indices[@]} of $stash_count):"
457
+ for idx in "${stale_indices[@]}"; do
458
+ echo " stash@{$idx}: $(git -C "$repo_root" stash list | sed -n "$((idx + 1))p")"
459
+ done
460
+ echo "Clean with: bash scripts/clean-stale-stashes.sh --drop"; exit 1
461
+ fi
462
+ if [ "$mode" = "drop-all" ]; then git -C "$repo_root" stash clear; echo "Dropped all $stash_count entries."; exit 0; fi
463
+ for ((i = ${#stale_indices[@]} - 1; i >= 0; i--)); do
464
+ idx="${stale_indices[$i]}"
465
+ git -C "$repo_root" stash drop "stash@{$idx}" 2>/dev/null || true
466
+ done
467
+ echo "Dropped ${#stale_indices[@]} stale entry/entries."; exit 0
468
+ - path: scripts/check-clean-trees.sh
469
+ content: |
470
+ #!/bin/bash
471
+ # check-clean-trees.sh — verify all git trees are clean.
472
+ # Checks repo root + all nested .git directories up to 3 levels deep.
473
+ set -euo pipefail
474
+ repo_root="$(git rev-parse --show-toplevel 2>/dev/null || echo "")"
475
+ if [ -z "$repo_root" ]; then echo "ERROR: not inside a git repository" >&2; exit 1; fi
476
+ dirty=0 output=""
477
+ main_status="$(git -C "$repo_root" status --short 2>/dev/null || true)"
478
+ if [ -n "$main_status" ]; then
479
+ output+="DIRTY: $repo_root\n$(printf '%s\n' "$main_status" | sed 's/^/ /')\n"
480
+ dirty=1
481
+ fi
482
+ while IFS= read -r gitdir; do
483
+ nested_root="$(dirname "$gitdir")"
484
+ [ "$nested_root" = "$repo_root" ] && continue
485
+ nested_status="$(git -C "$nested_root" status --short 2>/dev/null || true)"
486
+ if [ -n "$nested_status" ]; then
487
+ output+="DIRTY: $nested_root\n$(printf '%s\n' "$nested_status" | sed 's/^/ /')\n"
488
+ dirty=1
489
+ fi
490
+ done < <(find "$repo_root" -maxdepth 3 -name ".git" -type d 2>/dev/null | head -20)
491
+ if [ "$dirty" -eq 1 ]; then
492
+ echo -e "$output" | head -40
493
+ echo "---"
494
+ echo "Uncommitted changes detected. Commit before session end."
495
+ exit 1
496
+ fi
497
+ exit 0
498
+ - path: hooks/pre-user-prompt.sh
499
+ content: |
500
+ #!/bin/bash
501
+ # Pre-user-prompt hook: session-end protocol + stale-stash + git-guard checks.
502
+ # Requires jq for JSON payload parsing.
503
+ set -euo pipefail
504
+ payload="$(cat)"
505
+ user_prompt="$(printf '%s' "$payload" | jq -r '.tool_info.user_prompt // empty' 2>/dev/null || true)"
506
+ if [ -z "$user_prompt" ]; then exit 0; fi
507
+ repo_root="$(git rev-parse --show-toplevel 2>/dev/null || echo "")"
508
+ if [ -n "$repo_root" ] && [ -x "$repo_root/scripts/clean-stale-stashes.sh" ]; then
509
+ stash_output="$("$repo_root/scripts/clean-stale-stashes.sh" 2>&1 || true)"
510
+ if [ -n "$stash_output" ]; then
511
+ echo "⚠️ STALE GIT STASH DETECTED" >&2
512
+ echo "$stash_output" >&2
513
+ echo "Clean with: bash scripts/clean-stale-stashes.sh --drop" >&2
514
+ echo "" >&2
515
+ fi
516
+ fi
517
+ if [ -n "$repo_root" ] && [ -f "$repo_root/scripts/git-guard.sh" ]; then
518
+ if [ -z "${FORGE_GIT_GUARD:-}" ]; then
519
+ echo "⚠️ GIT GUARD NOT LOADED — destructive git operations are NOT blocked." >&2
520
+ echo "Run: source $repo_root/scripts/git-guard.sh" >&2
521
+ echo "" >&2
522
+ fi
523
+ fi
524
+ session_end_phrases=("Завершаем эту сессию" "Завершаем сессию" "Заканчиваем сессию" "Завершить сессию" "End session" "Wrap up" "Session end" "/session-end")
525
+ matched=""
526
+ for phrase in "${session_end_phrases[@]}"; do
527
+ if printf '%s' "$user_prompt" | grep -qiF "$phrase"; then matched="$phrase"; break; fi
528
+ done
529
+ if [ -z "$matched" ]; then exit 0; fi
530
+ cat >&2 <<EOF
531
+ SESSION-END PROTOCOL TRIGGERED (matched: "$matched")
532
+ The user's original message was:
533
+ > $user_prompt
534
+ You MUST invoke the fo-session-retro skill via the skill tool BEFORE producing
535
+ any other output. This is a NON-NEGOTIABLE BLOCKED GATE per PREFERENCES.md.
536
+ DO NOT produce a closing summary or ad-hoc output. The closing block must come
537
+ from fo-session-retro's report.
538
+ Protocol steps:
539
+ 1. Verify clean working trees — run: bash scripts/check-clean-trees.sh
540
+ 2. Invoke fo-session-retro via the skill tool
541
+ 3. The retro skill's report IS the session-end output
542
+ EOF
543
+ if [ -n "$repo_root" ] && [ -x "$repo_root/scripts/check-clean-trees.sh" ]; then
544
+ tree_output="$("$repo_root/scripts/check-clean-trees.sh" 2>&1 || true)"
545
+ if [ -n "$tree_output" ]; then echo "" >&2; echo "$tree_output" >&2; fi
546
+ fi
547
+ exit 2
548
+ - path: hooks/pre-user-prompt-wrapper.mjs
549
+ content: |
550
+ #!/usr/bin/env node
551
+ // pre-user-prompt-wrapper.mjs — cross-platform wrapper for pre-user-prompt.sh.
552
+ // Detects bash availability, prints one-time warning if bash not found,
553
+ // then delegates to bash hooks/pre-user-prompt.sh with stdin piped.
554
+ import { execFileSync, spawn } from "node:child_process";
555
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
556
+ import { dirname, join } from "node:path";
557
+ import { fileURLToPath } from "node:url";
558
+
559
+ const __dirname = dirname(fileURLToPath(import.meta.url));
560
+ const scriptPath = join(__dirname, "pre-user-prompt.sh");
561
+ const cacheDir = join(__dirname, "..", ".cache");
562
+ const warningFlag = join(cacheDir, "git-guard-warning-sent");
563
+
564
+ // If pre-user-prompt.sh doesn't exist, silently exit
565
+ if (!existsSync(scriptPath)) {
566
+ process.exit(0);
567
+ }
568
+
569
+ // Check if bash is available
570
+ let bashAvailable = false;
571
+ try {
572
+ execFileSync("bash", ["--version"], { stdio: "ignore" });
573
+ bashAvailable = true;
574
+ } catch {
575
+ bashAvailable = false;
576
+ }
577
+
578
+ if (!bashAvailable) {
579
+ // Print one-time warning if not already sent
580
+ if (!existsSync(warningFlag)) {
581
+ console.error("⚠️ bash not found — pre-user-prompt hook is disabled.");
582
+ console.error("Install Git Bash (Git for Windows) or WSL to enable agent-safety hooks.");
583
+ console.error("");
584
+ try {
585
+ mkdirSync(cacheDir, { recursive: true });
586
+ writeFileSync(warningFlag, new Date().toISOString(), "utf8");
587
+ } catch {
588
+ // Best-effort — don't crash if cache dir is not writable
589
+ }
590
+ }
591
+ process.exit(0);
592
+ }
593
+
594
+ // Delegate to bash script with stdin piped
595
+ const child = spawn("bash", [scriptPath], { stdio: ["inherit", "inherit", "inherit"] });
596
+ child.on("exit", (code) => {
597
+ process.exit(code ?? 0);
598
+ });
599
+ - path: .windsurf/hooks.json
600
+ content: |
601
+ {
602
+ "hooks": {
603
+ "pre_user_prompt": [
604
+ {
605
+ "command": "node $ROOT_WORKSPACE_PATH/hooks/pre-user-prompt-wrapper.mjs",
606
+ "show_output": true
607
+ }
608
+ ]
609
+ }
610
+ }
303
611
  - path: systems-cache/.gitkeep
304
612
  content: |
305
613
  # Sternsystem cache directory (RFC-0790)
@@ -323,6 +631,14 @@ workspace:
323
631
  mode: protect
324
632
  - path: systems-cache/.gitkeep
325
633
  mode: protect
634
+ - path: scripts/git-guard.sh
635
+ mode: protect
636
+ - path: scripts/setup-git-guards.sh
637
+ mode: protect
638
+ - path: hooks/pre-user-prompt.sh
639
+ mode: protect
640
+ - path: hooks/pre-user-prompt-wrapper.mjs
641
+ mode: protect
326
642
  - path: README.md
327
643
  content: |
328
644
  # __PROJECT_NAME__
@@ -333,6 +649,8 @@ workspace:
333
649
 
334
650
  - Node.js 24+
335
651
  - pnpm 10+
652
+ - jq (for pre-user-prompt hook)
653
+ - On Windows: Git Bash or WSL (for shell scripts)
336
654
 
337
655
  ## Setup
338
656
 
@@ -350,7 +668,19 @@ workspace:
350
668
  pnpm install
351
669
  ```
352
670
 
353
- ### 3. Verify the workshop
671
+ ### 3. Install git guards (recommended)
672
+
673
+ Git guards prevent AI agents from running destructive git commands (`git stash`, `git reset --hard`, `git checkout --`, `git restore`, `git clean -f`):
674
+
675
+ ```sh
676
+ bash scripts/setup-git-guards.sh
677
+ ```
678
+
679
+ 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`.
680
+
681
+ > **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`.
682
+
683
+ ### 4. Verify the workshop
354
684
 
355
685
  ```sh
356
686
  pnpm exec werkstatt run forge.doctor