qualia-framework 4.1.1 → 4.4.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 (43) hide show
  1. package/README.md +15 -11
  2. package/agents/builder.md +28 -0
  3. package/agents/research-synthesizer.md +7 -0
  4. package/bin/agent-runs.js +233 -0
  5. package/bin/cli.js +355 -16
  6. package/bin/install.js +87 -6
  7. package/bin/knowledge-flush.js +164 -0
  8. package/bin/knowledge.js +317 -0
  9. package/bin/plan-contract.js +220 -0
  10. package/bin/state.js +15 -9
  11. package/docs/agent-runs.md +273 -0
  12. package/docs/journey-demo.html +1008 -0
  13. package/docs/plan-contract.md +321 -0
  14. package/docs/reviews/v4.1.0-audit.html +1488 -0
  15. package/docs/reviews/v4.1.0-audit.md +263 -0
  16. package/hooks/auto-update.js +3 -7
  17. package/hooks/git-guardrails.js +167 -0
  18. package/hooks/pre-compact.js +22 -11
  19. package/hooks/pre-deploy-gate.js +16 -2
  20. package/hooks/pre-push.js +22 -2
  21. package/hooks/stop-session-log.js +180 -0
  22. package/package.json +8 -2
  23. package/skills/qualia-build/SKILL.md +5 -5
  24. package/skills/qualia-debug/SKILL.md +1 -1
  25. package/skills/qualia-design/SKILL.md +15 -0
  26. package/skills/qualia-flush/SKILL.md +200 -0
  27. package/skills/qualia-learn/SKILL.md +47 -37
  28. package/skills/qualia-new/SKILL.md +1 -1
  29. package/skills/qualia-plan/SKILL.md +3 -2
  30. package/skills/qualia-postmortem/SKILL.md +238 -0
  31. package/skills/qualia-quick/SKILL.md +1 -1
  32. package/skills/qualia-report/SKILL.md +1 -1
  33. package/skills/qualia-review/SKILL.md +3 -2
  34. package/skills/qualia-ship/SKILL.md +12 -10
  35. package/skills/qualia-verify/SKILL.md +60 -0
  36. package/templates/help.html +13 -7
  37. package/templates/knowledge/agents.md +71 -0
  38. package/templates/knowledge/index.md +47 -0
  39. package/tests/bin.test.sh +322 -12
  40. package/tests/hooks.test.sh +131 -20
  41. package/tests/lib.test.sh +217 -0
  42. package/tests/runner.js +103 -77
  43. package/tests/state.test.sh +4 -3
package/tests/bin.test.sh CHANGED
@@ -476,10 +476,11 @@ else
476
476
  fail_case "CLAUDE.md role substitution"
477
477
  fi
478
478
 
479
- # 31. All 8 hooks installed
479
+ # 31. All 9 hooks installed (block-env-edit removed in v3.2.0;
480
+ # git-guardrails + stop-session-log added in v4.2.0)
480
481
  HOOK_COUNT=$(ls "$TMP/.claude/hooks/"*.js 2>/dev/null | wc -l)
481
- if [ "$HOOK_COUNT" -eq 8 ]; then
482
- pass "8 hooks installed in hooks/"
482
+ if [ "$HOOK_COUNT" -eq 9 ]; then
483
+ pass "9 hooks installed in hooks/"
483
484
  else
484
485
  fail_case "hook count" "got $HOOK_COUNT"
485
486
  fi
@@ -488,22 +489,24 @@ fi
488
489
  if [ -f "$TMP/.claude/settings.json" ] \
489
490
  && grep -q '"SessionStart"' "$TMP/.claude/settings.json" \
490
491
  && grep -q '"PreToolUse"' "$TMP/.claude/settings.json" \
492
+ && grep -q '"Stop"' "$TMP/.claude/settings.json" \
491
493
  && grep -q '"statusLine"' "$TMP/.claude/settings.json"; then
492
- pass "settings.json has SessionStart, PreToolUse, statusLine"
494
+ pass "settings.json has SessionStart, PreToolUse, Stop, statusLine"
493
495
  else
494
496
  fail_case "settings.json contents"
495
497
  fi
496
498
 
497
- # 33. settings.json contains all 8 hooks wired correctly
498
- if grep -q 'block-env-edit.js' "$TMP/.claude/settings.json" \
499
- && grep -q 'branch-guard.js' "$TMP/.claude/settings.json" \
499
+ # 33. settings.json contains all 9 hooks wired correctly
500
+ if grep -q 'branch-guard.js' "$TMP/.claude/settings.json" \
500
501
  && grep -q 'migration-guard.js' "$TMP/.claude/settings.json" \
501
502
  && grep -q 'pre-push.js' "$TMP/.claude/settings.json" \
502
503
  && grep -q 'pre-deploy-gate.js' "$TMP/.claude/settings.json" \
503
504
  && grep -q 'auto-update.js' "$TMP/.claude/settings.json" \
504
505
  && grep -q 'session-start.js' "$TMP/.claude/settings.json" \
505
- && grep -q 'pre-compact.js' "$TMP/.claude/settings.json"; then
506
- pass "settings.json has all 8 hooks wired"
506
+ && grep -q 'pre-compact.js' "$TMP/.claude/settings.json" \
507
+ && grep -q 'git-guardrails.js' "$TMP/.claude/settings.json" \
508
+ && grep -q 'stop-session-log.js' "$TMP/.claude/settings.json"; then
509
+ pass "settings.json has all 9 hooks wired"
507
510
  else
508
511
  fail_case "settings.json missing hooks"
509
512
  fi
@@ -635,8 +638,11 @@ else
635
638
  fail_case "knowledge idempotency" "exit=$EXIT"
636
639
  fi
637
640
 
638
- # 43. ERP API key file created and not overwritten on re-install
639
- if [ -f "$TMP/.claude/.erp-api-key" ]; then
641
+ # 43. ERP API key is opt-in and preserved on re-install
642
+ CONFIG_ENABLED=$($NODE -e "const c=require('$TMP/.claude/.qualia-config.json'); console.log(c.erp && c.erp.enabled === false ? 'disabled' : 'enabled')")
643
+ if [ ! -f "$TMP/.claude/.erp-api-key" ] && [ "$CONFIG_ENABLED" = "disabled" ]; then
644
+ echo " ✓ ERP disabled when no API key is provided"
645
+ PASS=$((PASS + 1))
640
646
  echo "custom-erp-key" > "$TMP/.claude/.erp-api-key"
641
647
  echo "QS-FAWZI-01" | HOME="$TMP" $NODE "$INSTALL_JS" > "$TMP/out3.log" 2>&1
642
648
  if grep -q "custom-erp-key" "$TMP/.claude/.erp-api-key"; then
@@ -645,7 +651,7 @@ if [ -f "$TMP/.claude/.erp-api-key" ]; then
645
651
  fail_case ".erp-api-key preservation"
646
652
  fi
647
653
  else
648
- fail_case ".erp-api-key missing after install"
654
+ fail_case "ERP opt-in default" "key_exists=$(test -f "$TMP/.claude/.erp-api-key" && echo yes || echo no) config=$CONFIG_ENABLED"
649
655
  fi
650
656
 
651
657
  # 44. Templates copied to qualia-templates/
@@ -682,6 +688,310 @@ else
682
688
  fail_case "config version mismatch"
683
689
  fi
684
690
 
691
+ echo ""
692
+ echo "--- v4.2.0 phase 3 (flush + forks + model matrix) ---"
693
+
694
+ # 61. qualia-flush skill installs
695
+ TMP=$(mktmp)
696
+ echo "QS-FAWZI-01" | HOME="$TMP" $NODE "$INSTALL_JS" >/dev/null 2>&1
697
+ if [ -f "$TMP/.claude/skills/qualia-flush/SKILL.md" ]; then
698
+ pass "qualia-flush skill installs"
699
+ else
700
+ fail_case "qualia-flush skill missing after install"
701
+ fi
702
+
703
+ # 62. CLAUDE_AGENT_FORK_ENABLED=1 in settings.json
704
+ if grep -q '"CLAUDE_AGENT_FORK_ENABLED": "1"' "$TMP/.claude/settings.json"; then
705
+ pass "settings.env CLAUDE_AGENT_FORK_ENABLED=1 (forked subagents on by default)"
706
+ else
707
+ fail_case "CLAUDE_AGENT_FORK_ENABLED not set"
708
+ fi
709
+
710
+ # 63. research-synthesizer agent has model: haiku frontmatter
711
+ if grep -q '^model: haiku$' "$TMP/.claude/agents/research-synthesizer.md"; then
712
+ pass "research-synthesizer agent uses haiku (model matrix)"
713
+ else
714
+ fail_case "research-synthesizer missing model frontmatter"
715
+ fi
716
+
717
+ # 64. Other agents do NOT have model frontmatter (conservative matrix)
718
+ SAFE_AGENTS=("planner.md" "builder.md" "verifier.md" "plan-checker.md")
719
+ ALL_OK=1
720
+ for a in "${SAFE_AGENTS[@]}"; do
721
+ if grep -q '^model: ' "$TMP/.claude/agents/$a" 2>/dev/null; then
722
+ ALL_OK=0
723
+ fi
724
+ done
725
+ if [ "$ALL_OK" = "1" ]; then
726
+ pass "high-stakes agents (planner/builder/verifier/plan-checker) keep default model"
727
+ else
728
+ fail_case "high-stakes agent has unexpected model frontmatter"
729
+ fi
730
+
731
+ echo ""
732
+ echo "--- knowledge.js (memory-layer loader) ---"
733
+
734
+ KN="$FRAMEWORK_DIR/bin/knowledge.js"
735
+
736
+ # 48. Help
737
+ EXIT=0; OUT=$($NODE "$KN" help 2>&1) || EXIT=$?
738
+ if [ "$EXIT" -eq 0 ] && echo "$OUT" | grep -q "knowledge.js"; then
739
+ pass "help prints usage"
740
+ else
741
+ fail_case "help" "exit=$EXIT"
742
+ fi
743
+
744
+ # 49. Default (no args) → "no entries" stub on fresh install, exit 0
745
+ TMP=$(mktmp)
746
+ EXIT=0; OUT=$(HOME="$TMP" $NODE "$KN" 2>&1) || EXIT=$?
747
+ if [ "$EXIT" -eq 0 ] && echo "$OUT" | grep -q "no entries"; then
748
+ pass "default → exits 0 with stub on fresh install"
749
+ else
750
+ fail_case "default no init" "exit=$EXIT"
751
+ fi
752
+
753
+ # 50. With initialized index → returns content
754
+ TMP=$(mktmp)
755
+ mkdir -p "$TMP/.claude/knowledge"
756
+ echo "# Test Index" > "$TMP/.claude/knowledge/index.md"
757
+ EXIT=0; OUT=$(HOME="$TMP" $NODE "$KN" 2>&1) || EXIT=$?
758
+ if [ "$EXIT" -eq 0 ] && echo "$OUT" | grep -q "Test Index"; then
759
+ pass "default → prints index.md when present"
760
+ else
761
+ fail_case "default with index" "exit=$EXIT"
762
+ fi
763
+
764
+ # 51. load <alias> resolves to mapped filename
765
+ TMP=$(mktmp)
766
+ mkdir -p "$TMP/.claude/knowledge"
767
+ echo "# Patterns" > "$TMP/.claude/knowledge/learned-patterns.md"
768
+ EXIT=0; OUT=$(HOME="$TMP" $NODE "$KN" load patterns 2>&1) || EXIT=$?
769
+ if [ "$EXIT" -eq 0 ] && echo "$OUT" | grep -q "# Patterns"; then
770
+ pass "load patterns → learned-patterns.md"
771
+ else
772
+ fail_case "load alias" "exit=$EXIT"
773
+ fi
774
+
775
+ # 52. load <missing-file> → "no entries" stub, exit 0
776
+ TMP=$(mktmp)
777
+ mkdir -p "$TMP/.claude/knowledge"
778
+ EXIT=0; OUT=$(HOME="$TMP" $NODE "$KN" load fixes 2>&1) || EXIT=$?
779
+ if [ "$EXIT" -eq 0 ] && echo "$OUT" | grep -q "no entries"; then
780
+ pass "load missing → exit 0 with stub (skill-pipeable)"
781
+ else
782
+ fail_case "load missing" "exit=$EXIT"
783
+ fi
784
+
785
+ # 53. append a pattern → entry lands on disk
786
+ TMP=$(mktmp)
787
+ EXIT=0; OUT=$(HOME="$TMP" $NODE "$KN" append --type pattern --title "RLS rule" --body "Add RLS in same migration as table" 2>&1) || EXIT=$?
788
+ if [ "$EXIT" -eq 0 ] && echo "$OUT" | grep -q "appended"; then
789
+ pass "append pattern → exit 0 with confirmation"
790
+ else
791
+ fail_case "append pattern" "exit=$EXIT"
792
+ fi
793
+ if [ -f "$TMP/.claude/knowledge/learned-patterns.md" ] \
794
+ && grep -q "### RLS rule" "$TMP/.claude/knowledge/learned-patterns.md" \
795
+ && grep -q "Add RLS in same migration" "$TMP/.claude/knowledge/learned-patterns.md"; then
796
+ pass "appended entry has title + body"
797
+ else
798
+ fail_case "append content"
799
+ fi
800
+
801
+ # 54. append without --title → exit 1
802
+ EXIT=0; HOME="$TMP" $NODE "$KN" append --type pattern --body "x" >/dev/null 2>&1 || EXIT=$?
803
+ if [ "$EXIT" -eq 1 ]; then
804
+ pass "append missing --title → exit 1"
805
+ else
806
+ fail_case "append missing title" "exit=$EXIT"
807
+ fi
808
+
809
+ # 55. append with bad type → exit 1
810
+ EXIT=0; HOME="$TMP" $NODE "$KN" append --type bogus --title T --body B >/dev/null 2>&1 || EXIT=$?
811
+ if [ "$EXIT" -eq 1 ]; then
812
+ pass "append bad --type → exit 1"
813
+ else
814
+ fail_case "append bad type" "exit=$EXIT"
815
+ fi
816
+
817
+ # 56. search finds an appended entry
818
+ TMP=$(mktmp)
819
+ HOME="$TMP" $NODE "$KN" append --type fix --title "Vercel build crash" --body "use node 20.x in package.json engines" >/dev/null 2>&1
820
+ EXIT=0; OUT=$(HOME="$TMP" $NODE "$KN" search "Vercel" 2>&1) || EXIT=$?
821
+ if [ "$EXIT" -eq 0 ] && echo "$OUT" | grep -q "Vercel"; then
822
+ pass "search finds appended entries"
823
+ else
824
+ fail_case "search appended" "exit=$EXIT"
825
+ fi
826
+
827
+ # 57. search with no matches → "no matches" stub, exit 0
828
+ EXIT=0; OUT=$(HOME="$TMP" $NODE "$KN" search "xyzzy_nonexistent_12345" 2>&1) || EXIT=$?
829
+ if [ "$EXIT" -eq 0 ] && echo "$OUT" | grep -q "no matches"; then
830
+ pass "search no matches → exit 0 with stub"
831
+ else
832
+ fail_case "search no matches" "exit=$EXIT"
833
+ fi
834
+
835
+ # 58. list shows existing files
836
+ TMP=$(mktmp)
837
+ mkdir -p "$TMP/.claude/knowledge"
838
+ echo "x" > "$TMP/.claude/knowledge/foo.md"
839
+ EXIT=0; OUT=$(HOME="$TMP" $NODE "$KN" list 2>&1) || EXIT=$?
840
+ if [ "$EXIT" -eq 0 ] && echo "$OUT" | grep -q "foo.md"; then
841
+ pass "list shows existing files"
842
+ else
843
+ fail_case "list" "exit=$EXIT"
844
+ fi
845
+
846
+ # 59. unknown command falls through to load (`knowledge.js patterns` shorthand)
847
+ TMP=$(mktmp)
848
+ mkdir -p "$TMP/.claude/knowledge"
849
+ echo "# Patterns content" > "$TMP/.claude/knowledge/learned-patterns.md"
850
+ EXIT=0; OUT=$(HOME="$TMP" $NODE "$KN" patterns 2>&1) || EXIT=$?
851
+ if [ "$EXIT" -eq 0 ] && echo "$OUT" | grep -q "# Patterns content"; then
852
+ pass "unknown command → falls through to load"
853
+ else
854
+ fail_case "fallthrough" "exit=$EXIT"
855
+ fi
856
+
857
+ # 60. path command resolves alias to absolute path (no read)
858
+ EXIT=0; OUT=$($NODE "$KN" path patterns 2>&1) || EXIT=$?
859
+ if [ "$EXIT" -eq 0 ] && echo "$OUT" | grep -q "learned-patterns.md"; then
860
+ pass "path resolves alias to filename"
861
+ else
862
+ fail_case "path" "exit=$EXIT"
863
+ fi
864
+
865
+ echo ""
866
+ echo "--- v4.3.0 (loader subdirs + self-healing skills) ---"
867
+
868
+ # 65. Subdirectory-qualified path: knowledge.js load concepts/foo
869
+ TMP=$(mktmp)
870
+ mkdir -p "$TMP/.claude/knowledge/concepts"
871
+ echo "# Stripe checkout" > "$TMP/.claude/knowledge/concepts/stripe.md"
872
+ EXIT=0; OUT=$(HOME="$TMP" $NODE "$KN" load concepts/stripe 2>&1) || EXIT=$?
873
+ if [ "$EXIT" -eq 0 ] && echo "$OUT" | grep -q "Stripe checkout"; then
874
+ pass "load concepts/stripe → reads concepts/stripe.md"
875
+ else
876
+ fail_case "subdir-qualified load" "exit=$EXIT"
877
+ fi
878
+
879
+ # 66. Bare name auto-discovers in concepts/
880
+ TMP=$(mktmp)
881
+ mkdir -p "$TMP/.claude/knowledge/concepts"
882
+ echo "# Voice agent state" > "$TMP/.claude/knowledge/concepts/voice-agent-call-state.md"
883
+ EXIT=0; OUT=$(HOME="$TMP" $NODE "$KN" load voice-agent-call-state 2>&1) || EXIT=$?
884
+ if [ "$EXIT" -eq 0 ] && echo "$OUT" | grep -q "Voice agent state"; then
885
+ pass "load <bare> auto-discovers in concepts/"
886
+ else
887
+ fail_case "bare-name subdir discovery" "exit=$EXIT"
888
+ fi
889
+
890
+ # 67. Top-level wins when both top-level and subdir have same filename
891
+ TMP=$(mktmp)
892
+ mkdir -p "$TMP/.claude/knowledge/concepts"
893
+ echo "# Top level" > "$TMP/.claude/knowledge/foo.md"
894
+ echo "# Subdir version" > "$TMP/.claude/knowledge/concepts/foo.md"
895
+ EXIT=0; OUT=$(HOME="$TMP" $NODE "$KN" load foo 2>&1) || EXIT=$?
896
+ if [ "$EXIT" -eq 0 ] && echo "$OUT" | grep -q "Top level" && ! echo "$OUT" | grep -q "Subdir version"; then
897
+ pass "top-level wins over subdir on bare-name lookup"
898
+ else
899
+ fail_case "precedence" "exit=$EXIT"
900
+ fi
901
+
902
+ # 68. Subdir-qualified still works when top-level exists with same name
903
+ EXIT=0; OUT=$(HOME="$TMP" $NODE "$KN" load concepts/foo 2>&1) || EXIT=$?
904
+ if [ "$EXIT" -eq 0 ] && echo "$OUT" | grep -q "Subdir version"; then
905
+ pass "qualified path overrides top-level"
906
+ else
907
+ fail_case "qualified override" "exit=$EXIT"
908
+ fi
909
+
910
+ # 69. Search recurses into subdirs
911
+ TMP=$(mktmp)
912
+ mkdir -p "$TMP/.claude/knowledge/concepts"
913
+ echo "supabase RLS pattern" > "$TMP/.claude/knowledge/concepts/auth.md"
914
+ EXIT=0; OUT=$(HOME="$TMP" $NODE "$KN" search RLS 2>&1) || EXIT=$?
915
+ if [ "$EXIT" -eq 0 ] && echo "$OUT" | grep -q "concepts/auth.md"; then
916
+ pass "search recurses into concepts/ subdir"
917
+ else
918
+ fail_case "search subdir" "exit=$EXIT"
919
+ fi
920
+
921
+ # 70. qualia-postmortem skill installs
922
+ TMP=$(mktmp)
923
+ echo "QS-FAWZI-01" | HOME="$TMP" $NODE "$INSTALL_JS" >/dev/null 2>&1
924
+ if [ -f "$TMP/.claude/skills/qualia-postmortem/SKILL.md" ]; then
925
+ pass "qualia-postmortem skill installs"
926
+ else
927
+ fail_case "qualia-postmortem skill missing"
928
+ fi
929
+
930
+ # 71. /qualia-verify skill documents --adversarial flag
931
+ if grep -qF -- '--adversarial' "$TMP/.claude/skills/qualia-verify/SKILL.md"; then
932
+ pass "qualia-verify documents --adversarial flag"
933
+ else
934
+ fail_case "qualia-verify missing --adversarial"
935
+ fi
936
+
937
+ # 72. /qualia-verify wires /qualia-postmortem on FAIL
938
+ if grep -q 'qualia-postmortem' "$TMP/.claude/skills/qualia-verify/SKILL.md"; then
939
+ pass "qualia-verify wires /qualia-postmortem on FAIL"
940
+ else
941
+ fail_case "qualia-verify missing postmortem wiring"
942
+ fi
943
+
944
+ # 73. knowledge-flush.js installs at ~/.claude/bin/
945
+ if [ -f "$TMP/.claude/bin/knowledge-flush.js" ]; then
946
+ pass "knowledge-flush.js installs"
947
+ else
948
+ fail_case "knowledge-flush.js missing after install"
949
+ fi
950
+
951
+ # 74. knowledge-flush.js is syntactically valid Node
952
+ EXIT=0; $NODE -c "$TMP/.claude/bin/knowledge-flush.js" 2>/dev/null || EXIT=$?
953
+ if [ "$EXIT" -eq 0 ]; then
954
+ pass "knowledge-flush.js parses as valid Node"
955
+ else
956
+ fail_case "knowledge-flush.js parse error"
957
+ fi
958
+
959
+ # 75. knowledge-flush.js exits 0 with no daily-log (cron-friendly: no spam)
960
+ TMP_HOME=$(mktmp)
961
+ mkdir -p "$TMP_HOME/.claude/knowledge"
962
+ EXIT=0; HOME="$TMP_HOME" $NODE "$FRAMEWORK_DIR/bin/knowledge-flush.js" >/dev/null 2>&1 || EXIT=$?
963
+ if [ "$EXIT" -eq 0 ]; then
964
+ pass "knowledge-flush exits 0 with no daily-log (no cron spam)"
965
+ else
966
+ fail_case "knowledge-flush exited $EXIT with no daily-log"
967
+ fi
968
+
969
+ # 76. knowledge-flush.js writes structured JSONL log even on skip
970
+ TMP_HOME=$(mktmp)
971
+ HOME="$TMP_HOME" $NODE "$FRAMEWORK_DIR/bin/knowledge-flush.js" >/dev/null 2>&1
972
+ if [ -f "$TMP_HOME/.claude/.qualia-flush.log" ] && grep -q '"event":"skipped"' "$TMP_HOME/.claude/.qualia-flush.log"; then
973
+ pass "knowledge-flush writes JSONL audit log"
974
+ else
975
+ fail_case "knowledge-flush no audit log"
976
+ fi
977
+
978
+ # 77. CLI `qualia-framework flush` dispatches to the script (errors when not installed)
979
+ TMP_HOME=$(mktmp)
980
+ EXIT=0; OUT=$(HOME="$TMP_HOME" $NODE "$CLI_JS" flush 2>&1) || EXIT=$?
981
+ if [ "$EXIT" -eq 1 ] && echo "$OUT" | grep -q "knowledge-flush.js not installed"; then
982
+ pass "CLI flush command surfaces install error gracefully"
983
+ else
984
+ fail_case "CLI flush dispatch" "exit=$EXIT out=$OUT"
985
+ fi
986
+
987
+ # 78. CLI flush command appears in help text
988
+ EXIT=0; OUT=$($NODE "$CLI_JS" help 2>&1) || EXIT=$?
989
+ if [ "$EXIT" -eq 0 ] && echo "$OUT" | grep -q "flush"; then
990
+ pass "help text documents flush command"
991
+ else
992
+ fail_case "help missing flush" "exit=$EXIT"
993
+ fi
994
+
685
995
  echo ""
686
996
  echo "=== Results: $PASS passed, $FAIL failed ==="
687
997
  [ "$FAIL" -eq 0 ] && exit 0 || exit 1
@@ -34,28 +34,17 @@ for f in "$HOOKS_DIR"/*.js; do
34
34
  fi
35
35
  done
36
36
 
37
- # --- block-env-edit.js ---
37
+ # --- block-env-edit.js retired in v3.2.0 ---
38
38
  echo ""
39
39
  echo "block-env-edit:"
40
40
 
41
- echo '{"tool_input":{"file_path":".env.local"}}' | $NODE "$HOOKS_DIR/block-env-edit.js" > /dev/null 2>&1
42
- assert_exit "blocks .env.local" 2 $?
43
-
44
- echo '{"tool_input":{"file_path":".env.production"}}' | $NODE "$HOOKS_DIR/block-env-edit.js" > /dev/null 2>&1
45
- assert_exit "blocks .env.production" 2 $?
46
-
47
- echo '{"tool_input":{"file_path":".env"}}' | $NODE "$HOOKS_DIR/block-env-edit.js" > /dev/null 2>&1
48
- assert_exit "blocks .env" 2 $?
49
-
50
- # Windows-style path with backslashes (normalized by the hook)
51
- echo '{"tool_input":{"file_path":"C:\\project\\.env.local"}}' | $NODE "$HOOKS_DIR/block-env-edit.js" > /dev/null 2>&1
52
- assert_exit "blocks windows .env.local" 2 $?
53
-
54
- echo '{"tool_input":{"file_path":"src/app.tsx"}}' | $NODE "$HOOKS_DIR/block-env-edit.js" > /dev/null 2>&1
55
- assert_exit "allows src/app.tsx" 0 $?
56
-
57
- echo '{"tool_input":{"file_path":"components/Footer.tsx"}}' | $NODE "$HOOKS_DIR/block-env-edit.js" > /dev/null 2>&1
58
- assert_exit "allows components/Footer.tsx" 0 $?
41
+ if [ ! -f "$HOOKS_DIR/block-env-edit.js" ]; then
42
+ echo " retired hook is absent"
43
+ PASS=$((PASS + 1))
44
+ else
45
+ echo " retired hook still exists"
46
+ FAIL=$((FAIL + 1))
47
+ fi
59
48
 
60
49
  # --- migration-guard.js ---
61
50
  echo ""
@@ -329,7 +318,7 @@ mkdir -p "$TMP/app/admin"
329
318
  echo 'const key = "service_role"; export default function Page() { return <div>{key}</div>; }' > "$TMP/app/admin/page.tsx"
330
319
  OUT=$( (cd "$TMP" && $NODE "$HOOKS_DIR/pre-deploy-gate.js") 2>&1 )
331
320
  RC=$?
332
- assert_exit "regular page.tsx with service_role → blocked (exit 1)" 1 $RC
321
+ assert_exit "regular page.tsx with service_role → blocked (exit 2)" 2 $RC
333
322
  rm -rf "$TMP"
334
323
 
335
324
  # --- session-start.js — must exit 0 always ---
@@ -379,6 +368,128 @@ else
379
368
  fi
380
369
  rm -rf "$TMP"
381
370
 
371
+ # --- git-guardrails.js ---
372
+ echo ""
373
+ echo "git-guardrails:"
374
+
375
+ # No stdin / no command → allow (exit 0)
376
+ echo '{}' | $NODE "$HOOKS_DIR/git-guardrails.js" > /dev/null 2>&1
377
+ assert_exit "no command → allowed" 0 $?
378
+
379
+ # Force push to main → BLOCK
380
+ echo '{"tool_input":{"command":"git push --force origin main"}}' | $NODE "$HOOKS_DIR/git-guardrails.js" > /dev/null 2>&1
381
+ assert_exit "force-push to main → blocked" 2 $?
382
+
383
+ echo '{"tool_input":{"command":"git push -f origin master"}}' | $NODE "$HOOKS_DIR/git-guardrails.js" > /dev/null 2>&1
384
+ assert_exit "force-push (-f) to master → blocked" 2 $?
385
+
386
+ # --force-with-lease to main → ALLOW (the safe variant)
387
+ echo '{"tool_input":{"command":"git push --force-with-lease origin main"}}' | $NODE "$HOOKS_DIR/git-guardrails.js" > /dev/null 2>&1
388
+ assert_exit "--force-with-lease to main → allowed" 0 $?
389
+
390
+ # Regular push → ALLOW
391
+ echo '{"tool_input":{"command":"git push origin feature/x"}}' | $NODE "$HOOKS_DIR/git-guardrails.js" > /dev/null 2>&1
392
+ assert_exit "regular push → allowed" 0 $?
393
+
394
+ # git clean -fd → BLOCK
395
+ echo '{"tool_input":{"command":"git clean -fd"}}' | $NODE "$HOOKS_DIR/git-guardrails.js" > /dev/null 2>&1
396
+ assert_exit "git clean -fd → blocked" 2 $?
397
+
398
+ echo '{"tool_input":{"command":"git clean -fdx"}}' | $NODE "$HOOKS_DIR/git-guardrails.js" > /dev/null 2>&1
399
+ assert_exit "git clean -fdx → blocked" 2 $?
400
+
401
+ # git branch -D main → BLOCK
402
+ echo '{"tool_input":{"command":"git branch -D main"}}' | $NODE "$HOOKS_DIR/git-guardrails.js" > /dev/null 2>&1
403
+ assert_exit "git branch -D main → blocked" 2 $?
404
+
405
+ # rm -rf .git → BLOCK
406
+ echo '{"tool_input":{"command":"rm -rf .git"}}' | $NODE "$HOOKS_DIR/git-guardrails.js" > /dev/null 2>&1
407
+ assert_exit "rm -rf .git → blocked" 2 $?
408
+
409
+ # rm -rf src → ALLOW (only .git is special)
410
+ echo '{"tool_input":{"command":"rm -rf src/old"}}' | $NODE "$HOOKS_DIR/git-guardrails.js" > /dev/null 2>&1
411
+ assert_exit "rm -rf src/old → allowed" 0 $?
412
+
413
+ # QUALIA_ALLOW_DESTRUCTIVE=1 escape hatch → ALLOW even force push
414
+ echo '{"tool_input":{"command":"git push --force origin main"}}' | QUALIA_ALLOW_DESTRUCTIVE=1 $NODE "$HOOKS_DIR/git-guardrails.js" > /dev/null 2>&1
415
+ assert_exit "QUALIA_ALLOW_DESTRUCTIVE=1 → allowed despite force-push" 0 $?
416
+
417
+ # Block reason includes "BLOCKED" + "main" for force push to main
418
+ OUT=$(echo '{"tool_input":{"command":"git push --force origin main"}}' | $NODE "$HOOKS_DIR/git-guardrails.js" 2>&1)
419
+ if echo "$OUT" | grep -q "BLOCKED" && echo "$OUT" | grep -q "main"; then
420
+ echo " ✓ block reason includes BLOCKED + main"
421
+ PASS=$((PASS + 1))
422
+ else
423
+ echo " ✗ block reason missing BLOCKED or main: $OUT"
424
+ FAIL=$((FAIL + 1))
425
+ fi
426
+
427
+ # --- stop-session-log.js ---
428
+ echo ""
429
+ echo "stop-session-log:"
430
+
431
+ # Outside a git repo, with nothing to log → exit 0 silent
432
+ TMP=$(mktemp -d)
433
+ HOME="$TMP" $NODE "$HOOKS_DIR/stop-session-log.js" >/dev/null 2>&1
434
+ assert_exit "no activity → exit 0 silent" 0 $?
435
+ # Daily log dir might or might not exist; the hook must NOT crash either way.
436
+ rm -rf "$TMP"
437
+
438
+ # In a git repo with .planning/tracking.json → writes to daily log
439
+ TMP=$(mktemp -d)
440
+ mkdir -p "$TMP/proj/.planning"
441
+ (cd "$TMP/proj" && git init -q && git checkout -b feat/test -q 2>/dev/null && git config user.email t@t.com && git config user.name T)
442
+ echo "x" > "$TMP/proj/file.txt"
443
+ (cd "$TMP/proj" && git add file.txt && git commit -q -m "seed" 2>/dev/null)
444
+ cat > "$TMP/proj/.planning/tracking.json" <<EOF
445
+ {"phase":2,"phase_total":4,"tasks_done":3,"tasks_total":5}
446
+ EOF
447
+ # Touch a file so the diff has something
448
+ echo "y" >> "$TMP/proj/file.txt"
449
+ (cd "$TMP/proj" && HOME="$TMP" $NODE "$HOOKS_DIR/stop-session-log.js" >/dev/null 2>&1)
450
+ assert_exit "with activity → exit 0" 0 $?
451
+ TODAY=$(date -u +%Y-%m-%d)
452
+ LOG_FILE="$TMP/.claude/knowledge/daily-log/$TODAY.md"
453
+ if [ -f "$LOG_FILE" ] && grep -q "phase=2/4" "$LOG_FILE" && grep -q "tasks=3/5" "$LOG_FILE"; then
454
+ echo " ✓ daily-log contains phase + tasks"
455
+ PASS=$((PASS + 1))
456
+ else
457
+ echo " ✗ daily-log missing or malformed: $(cat "$LOG_FILE" 2>/dev/null)"
458
+ FAIL=$((FAIL + 1))
459
+ fi
460
+ # Header is project name (basename of repo root)
461
+ if [ -f "$LOG_FILE" ] && grep -q "^## proj$" "$LOG_FILE"; then
462
+ echo " ✓ daily-log has project header"
463
+ PASS=$((PASS + 1))
464
+ else
465
+ echo " ✗ daily-log missing project header"
466
+ FAIL=$((FAIL + 1))
467
+ fi
468
+ rm -rf "$TMP"
469
+
470
+ # Stop hook is idempotent within MIN_INTERVAL_MS — second run within 5min skips
471
+ TMP=$(mktemp -d)
472
+ mkdir -p "$TMP/proj/.planning" "$TMP/.claude"
473
+ (cd "$TMP/proj" && git init -q && git checkout -b feat/x -q 2>/dev/null && git config user.email t@t.com && git config user.name T)
474
+ echo "z" > "$TMP/proj/f.txt"
475
+ (cd "$TMP/proj" && git add f.txt && git commit -q -m "s" 2>/dev/null)
476
+ echo '{"phase":1,"phase_total":2,"tasks_done":1,"tasks_total":2}' > "$TMP/proj/.planning/tracking.json"
477
+ echo "a" >> "$TMP/proj/f.txt"
478
+ # First run — writes
479
+ (cd "$TMP/proj" && HOME="$TMP" $NODE "$HOOKS_DIR/stop-session-log.js" >/dev/null 2>&1)
480
+ LINES_BEFORE=$(wc -l < "$TMP/.claude/knowledge/daily-log/$(date -u +%Y-%m-%d).md" 2>/dev/null || echo 0)
481
+ # Second run within 5min — must skip
482
+ (cd "$TMP/proj" && HOME="$TMP" $NODE "$HOOKS_DIR/stop-session-log.js" >/dev/null 2>&1)
483
+ LINES_AFTER=$(wc -l < "$TMP/.claude/knowledge/daily-log/$(date -u +%Y-%m-%d).md" 2>/dev/null || echo 0)
484
+ if [ "$LINES_BEFORE" = "$LINES_AFTER" ]; then
485
+ echo " ✓ second run within MIN_INTERVAL skips (no double-write)"
486
+ PASS=$((PASS + 1))
487
+ else
488
+ echo " ✗ second run wrote anyway ($LINES_BEFORE → $LINES_AFTER)"
489
+ FAIL=$((FAIL + 1))
490
+ fi
491
+ rm -rf "$TMP"
492
+
382
493
  echo ""
383
494
  echo "=== Results: $PASS passed, $FAIL failed ==="
384
495
  [ "$FAIL" -eq 0 ] && exit 0 || exit 1