@onlooker-community/ecosystem 0.10.0 → 0.15.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 (129) hide show
  1. package/.claude-plugin/marketplace.json +39 -1
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/.github/copilot-instructions.md +46 -0
  4. package/.github/workflows/coverage.yml +78 -0
  5. package/.github/workflows/release.yml +24 -8
  6. package/.github/workflows/test.yml +3 -0
  7. package/.markdownlintignore +3 -0
  8. package/.release-please-manifest.json +5 -1
  9. package/CHANGELOG.md +44 -0
  10. package/README.md +58 -13
  11. package/config.json +6 -1
  12. package/docs/adr/001-claude-code-hooks-as-integration-surface.md +43 -0
  13. package/docs/adr/002-centralized-jsonl-event-log.md +39 -0
  14. package/docs/adr/003-ulid-over-uuid.md +40 -0
  15. package/docs/adr/004-plugin-config-with-settings-overlay.md +34 -0
  16. package/docs/architecture.md +123 -0
  17. package/hooks/hooks.json +4 -0
  18. package/package.json +13 -7
  19. package/plugins/archivist/.claude-plugin/plugin.json +14 -0
  20. package/plugins/archivist/CHANGELOG.md +8 -0
  21. package/plugins/archivist/README.md +105 -0
  22. package/plugins/archivist/config.json +18 -0
  23. package/plugins/archivist/hooks/hooks.json +35 -0
  24. package/plugins/archivist/scripts/hooks/archivist-extract.sh +238 -0
  25. package/plugins/archivist/scripts/hooks/archivist-inject.sh +159 -0
  26. package/plugins/archivist/scripts/lib/archivist-config.sh +66 -0
  27. package/plugins/archivist/scripts/lib/archivist-project-key.sh +91 -0
  28. package/plugins/archivist/scripts/lib/archivist-storage.sh +215 -0
  29. package/plugins/archivist/scripts/lib/archivist-ulid.sh +52 -0
  30. package/plugins/cartographer/.claude-plugin/plugin.json +14 -0
  31. package/plugins/cartographer/CHANGELOG.md +27 -0
  32. package/plugins/cartographer/README.md +113 -0
  33. package/plugins/cartographer/config.json +21 -0
  34. package/plugins/cartographer/docs/adr/001-background-audit-launch.md +28 -0
  35. package/plugins/cartographer/docs/adr/002-flock-pid-file-fallback.md +30 -0
  36. package/plugins/cartographer/docs/adr/003-at-least-once-event-delivery.md +32 -0
  37. package/plugins/cartographer/docs/adr/004-exclude-paths-replace-semantics.md +27 -0
  38. package/plugins/cartographer/hooks/hooks.json +44 -0
  39. package/plugins/cartographer/scripts/hooks/cartographer-post-write.sh +87 -0
  40. package/plugins/cartographer/scripts/hooks/cartographer-session-start.sh +89 -0
  41. package/plugins/cartographer/scripts/lib/cartographer-analyze.sh +286 -0
  42. package/plugins/cartographer/scripts/lib/cartographer-collect.sh +59 -0
  43. package/plugins/cartographer/scripts/lib/cartographer-config.sh +105 -0
  44. package/plugins/cartographer/scripts/lib/cartographer-events.sh +82 -0
  45. package/plugins/cartographer/scripts/lib/cartographer-lock.sh +38 -0
  46. package/plugins/cartographer/scripts/lib/cartographer-project-key.sh +55 -0
  47. package/plugins/cartographer/scripts/lib/cartographer-ulid.sh +47 -0
  48. package/plugins/cartographer/scripts/run-audit.sh +309 -0
  49. package/plugins/cartographer/skills/cartographer/SKILL.md +154 -0
  50. package/plugins/echo/.claude-plugin/plugin.json +14 -0
  51. package/plugins/echo/CHANGELOG.md +24 -0
  52. package/plugins/echo/README.md +110 -0
  53. package/plugins/echo/config.json +15 -0
  54. package/plugins/echo/docs/adr/001-echo-as-separate-plugin.md +33 -0
  55. package/plugins/echo/docs/adr/002-direct-evaluation-vs-tribunal-pipeline.md +35 -0
  56. package/plugins/echo/docs/adr/003-stop-hook-trigger.md +40 -0
  57. package/plugins/echo/hooks/hooks.json +15 -0
  58. package/plugins/echo/scripts/hooks/echo-stop-gate.sh +366 -0
  59. package/plugins/echo/scripts/lib/echo-config.sh +108 -0
  60. package/plugins/echo/scripts/lib/echo-events.sh +74 -0
  61. package/plugins/echo/scripts/lib/echo-project-key.sh +81 -0
  62. package/plugins/echo/scripts/lib/echo-ulid.sh +46 -0
  63. package/plugins/tribunal/.claude-plugin/plugin.json +20 -0
  64. package/plugins/tribunal/CHANGELOG.md +10 -0
  65. package/plugins/tribunal/README.md +134 -0
  66. package/plugins/tribunal/agents/tribunal-actor.md +35 -0
  67. package/plugins/tribunal/agents/tribunal-judge-adversarial.md +51 -0
  68. package/plugins/tribunal/agents/tribunal-judge-security.md +47 -0
  69. package/plugins/tribunal/agents/tribunal-judge-standard.md +47 -0
  70. package/plugins/tribunal/agents/tribunal-meta-judge.md +61 -0
  71. package/plugins/tribunal/config.json +50 -0
  72. package/plugins/tribunal/docs/adr/001-actor-jury-meta-gate-loop.md +40 -0
  73. package/plugins/tribunal/docs/adr/002-majority-gate-policy.md +48 -0
  74. package/plugins/tribunal/hooks/hooks.json +15 -0
  75. package/plugins/tribunal/scripts/hooks/tribunal-stop-gate.sh +267 -0
  76. package/plugins/tribunal/scripts/lib/tribunal-aggregate.sh +65 -0
  77. package/plugins/tribunal/scripts/lib/tribunal-config.sh +101 -0
  78. package/plugins/tribunal/scripts/lib/tribunal-events.sh +97 -0
  79. package/plugins/tribunal/scripts/lib/tribunal-gate.sh +111 -0
  80. package/plugins/tribunal/scripts/lib/tribunal-jury.sh +102 -0
  81. package/plugins/tribunal/scripts/lib/tribunal-project-key.sh +84 -0
  82. package/plugins/tribunal/scripts/lib/tribunal-rubric.sh +153 -0
  83. package/plugins/tribunal/scripts/lib/tribunal-ulid.sh +50 -0
  84. package/plugins/tribunal/scripts/lib/tribunal-verdict.sh +127 -0
  85. package/plugins/tribunal/skills/tribunal/SKILL.md +129 -0
  86. package/release-please-config.json +59 -5
  87. package/scripts/coverage/bash-coverage.mjs +169 -0
  88. package/scripts/coverage/format-comment.mjs +120 -0
  89. package/scripts/coverage/run-coverage.mjs +151 -0
  90. package/scripts/hooks/agent-spawn-tracker.sh +4 -4
  91. package/scripts/hooks/prompt-rule-injector.sh +122 -0
  92. package/scripts/lib/portable-lock.sh +48 -0
  93. package/scripts/lib/prompt-rules.sh +207 -0
  94. package/scripts/lib/tool-history.sh +7 -8
  95. package/scripts/lib/validate-path.sh +4 -0
  96. package/scripts/lint/check-manifests.mjs +314 -0
  97. package/scripts/lint/check-references.mjs +311 -0
  98. package/skills/list-prompt-rules/SKILL.md +15 -0
  99. package/test/bats/archivist-config-files.bats +60 -0
  100. package/test/bats/archivist-config.bats +54 -0
  101. package/test/bats/archivist-inject.bats +73 -0
  102. package/test/bats/archivist-project-key.bats +75 -0
  103. package/test/bats/archivist-storage.bats +119 -0
  104. package/test/bats/archivist-ulid.bats +36 -0
  105. package/test/bats/cartographer-config.bats +107 -0
  106. package/test/bats/cartographer-lock.bats +77 -0
  107. package/test/bats/cartographer-ulid.bats +56 -0
  108. package/test/bats/config.bats +10 -10
  109. package/test/bats/echo-config.bats +90 -0
  110. package/test/bats/echo-events.bats +121 -0
  111. package/test/bats/echo-project-key.bats +115 -0
  112. package/test/bats/echo-stop-hook.bats +101 -0
  113. package/test/bats/echo-ulid.bats +38 -0
  114. package/test/bats/portable-lock.bats +62 -0
  115. package/test/bats/prompt-rules.bats +269 -0
  116. package/test/bats/tribunal-aggregate.bats +77 -0
  117. package/test/bats/tribunal-config.bats +86 -0
  118. package/test/bats/tribunal-events.bats +209 -0
  119. package/test/bats/tribunal-gate.bats +95 -0
  120. package/test/bats/tribunal-jury.bats +80 -0
  121. package/test/bats/tribunal-rubric.bats +119 -0
  122. package/test/bats/tribunal-stop-hook.bats +73 -0
  123. package/test/bats/tribunal-verdict.bats +71 -0
  124. package/test/fixtures/hook-inputs/user-prompt-submit-rule-match.json +8 -0
  125. package/test/fixtures/hook-inputs/user-prompt-submit-rule-nomatch.json +8 -0
  126. package/test/helpers/setup.bash +9 -0
  127. package/test/node/check-manifests.test.mjs +173 -0
  128. package/test/node/check-references.test.mjs +279 -0
  129. package/test/node/coverage.test.mjs +143 -0
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
5
+ setup_test_env
6
+
7
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/tribunal"
8
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
9
+ # shellcheck disable=SC1091
10
+ source "${PLUGIN_ROOT}/scripts/lib/tribunal-gate.sh"
11
+ }
12
+
13
+ ALL_PASSED='[{"judge_id":"a","score":0.85,"passed":true},{"judge_id":"b","score":0.80,"passed":true}]'
14
+ ONE_FAILED='[{"judge_id":"a","score":0.85,"passed":true},{"judge_id":"b","score":0.40,"passed":false}]'
15
+ ALL_FAILED='[{"judge_id":"a","score":0.30,"passed":false},{"judge_id":"b","score":0.40,"passed":false}]'
16
+ NO_META='{}'
17
+
18
+ @test "strict: all judges pass + score >= threshold → passed" {
19
+ local out
20
+ out=$(tribunal_gate_decide "strict" "$ALL_PASSED" "0.82" "0.75" "$NO_META" "0.05" "0.25")
21
+ [ "$(printf '%s' "$out" | jq -r '.passed')" = "true" ]
22
+ }
23
+
24
+ @test "strict: one judge fails → blocked with dissent_unresolved or low_score" {
25
+ local out
26
+ out=$(tribunal_gate_decide "strict" "$ONE_FAILED" "0.62" "0.75" "$NO_META" "0.45" "0.25")
27
+ [ "$(printf '%s' "$out" | jq -r '.passed')" = "false" ]
28
+ # dissent + no meta override → dissent_unresolved
29
+ [ "$(printf '%s' "$out" | jq -r '.reason')" = "dissent_unresolved" ]
30
+ }
31
+
32
+ @test "majority: more than half pass + score clears → passed" {
33
+ local three='[{"judge_id":"a","score":0.9,"passed":true},{"judge_id":"b","score":0.8,"passed":true},{"judge_id":"c","score":0.4,"passed":false}]'
34
+ local out
35
+ out=$(tribunal_gate_decide "majority" "$three" "0.78" "0.75" "$NO_META" "0.20" "0.25")
36
+ [ "$(printf '%s' "$out" | jq -r '.passed')" = "true" ]
37
+ }
38
+
39
+ @test "majority: split 1-1 with low score → blocked low_score" {
40
+ local out
41
+ out=$(tribunal_gate_decide "majority" "$ONE_FAILED" "0.62" "0.75" "$NO_META" "0.20" "0.25")
42
+ [ "$(printf '%s' "$out" | jq -r '.passed')" = "false" ]
43
+ [ "$(printf '%s' "$out" | jq -r '.reason')" = "low_score" ]
44
+ }
45
+
46
+ @test "unanimous: identical to strict when count > 1" {
47
+ local out
48
+ out=$(tribunal_gate_decide "unanimous" "$ALL_PASSED" "0.82" "0.75" "$NO_META" "0.05" "0.25")
49
+ [ "$(printf '%s' "$out" | jq -r '.passed')" = "true" ]
50
+
51
+ out=$(tribunal_gate_decide "unanimous" "$ONE_FAILED" "0.62" "0.75" "$NO_META" "0.05" "0.25")
52
+ [ "$(printf '%s' "$out" | jq -r '.passed')" = "false" ]
53
+ }
54
+
55
+ @test "meta_override accept beats failing jury" {
56
+ local meta='{"override_recommendation":"accept","bias_detected":false}'
57
+ local out
58
+ out=$(tribunal_gate_decide "meta_override" "$ALL_FAILED" "0.30" "0.75" "$meta" "0.10" "0.25")
59
+ [ "$(printf '%s' "$out" | jq -r '.passed')" = "true" ]
60
+ }
61
+
62
+ @test "meta_override reject blocks even with passing jury" {
63
+ local meta='{"override_recommendation":"reject","bias_detected":false}'
64
+ local out
65
+ out=$(tribunal_gate_decide "meta_override" "$ALL_PASSED" "0.82" "0.75" "$meta" "0.05" "0.25")
66
+ [ "$(printf '%s' "$out" | jq -r '.passed')" = "false" ]
67
+ [ "$(printf '%s' "$out" | jq -r '.reason')" = "meta_override" ]
68
+ }
69
+
70
+ @test "bias_detected + meta says reject → bias_detected reason" {
71
+ local meta='{"override_recommendation":"reject","bias_detected":true,"bias_types":["verbosity"]}'
72
+ local out
73
+ out=$(tribunal_gate_decide "majority" "$ONE_FAILED" "0.60" "0.75" "$meta" "0.45" "0.25")
74
+ [ "$(printf '%s' "$out" | jq -r '.passed')" = "false" ]
75
+ [ "$(printf '%s' "$out" | jq -r '.reason')" = "bias_detected" ]
76
+ }
77
+
78
+ @test "dissent above threshold + no meta override → dissent_unresolved" {
79
+ local meta='{"bias_detected":false}'
80
+ local out
81
+ out=$(tribunal_gate_decide "majority" "$ONE_FAILED" "0.62" "0.50" "$meta" "0.45" "0.25")
82
+ [ "$(printf '%s' "$out" | jq -r '.passed')" = "false" ]
83
+ [ "$(printf '%s' "$out" | jq -r '.reason')" = "dissent_unresolved" ]
84
+ }
85
+
86
+ @test "score clears threshold but jury says no → meta_override or dissent reason" {
87
+ # All judges marked passed=false but aggregated_score is above threshold
88
+ # (contrived to exercise the "score_ok + jury_fail" branch).
89
+ local odd='[{"judge_id":"a","score":0.9,"passed":false},{"judge_id":"b","score":0.8,"passed":false}]'
90
+ local meta='{"override_recommendation":"reject","bias_detected":false}'
91
+ local out
92
+ out=$(tribunal_gate_decide "majority" "$odd" "0.85" "0.75" "$meta" "0.10" "0.25")
93
+ [ "$(printf '%s' "$out" | jq -r '.passed')" = "false" ]
94
+ [ "$(printf '%s' "$out" | jq -r '.reason')" = "meta_override" ]
95
+ }
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
5
+ setup_test_env
6
+
7
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/tribunal"
8
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
9
+ # shellcheck disable=SC1091
10
+ source "${PLUGIN_ROOT}/scripts/lib/tribunal-config.sh"
11
+ # shellcheck disable=SC1091
12
+ source "${PLUGIN_ROOT}/scripts/lib/tribunal-ulid.sh"
13
+ # shellcheck disable=SC1091
14
+ source "${PLUGIN_ROOT}/scripts/lib/tribunal-jury.sh"
15
+
16
+ tribunal_config_load ""
17
+ }
18
+
19
+ @test "default empanel produces standard + adversarial" {
20
+ local panel
21
+ panel=$(tribunal_jury_empanel '["standard","adversarial"]')
22
+ [ "$(printf '%s' "$panel" | jq 'length')" = "2" ]
23
+ [ "$(printf '%s' "$panel" | jq -r '.[0].judge_type')" = "standard" ]
24
+ [ "$(printf '%s' "$panel" | jq -r '.[1].judge_type')" = "adversarial" ]
25
+ }
26
+
27
+ @test "each panel member gets a distinct judge_id" {
28
+ local panel
29
+ panel=$(tribunal_jury_empanel '["standard","adversarial","security"]')
30
+ local distinct
31
+ distinct=$(printf '%s' "$panel" | jq -r '[.[].judge_id] | unique | length')
32
+ [ "$distinct" = "3" ]
33
+ }
34
+
35
+ @test "panel members get model from config" {
36
+ local panel m
37
+ panel=$(tribunal_jury_empanel '["standard"]')
38
+ m=$(printf '%s' "$panel" | jq -r '.[0].model')
39
+ [ "$m" = "claude-opus-4-7" ]
40
+ }
41
+
42
+ @test "maintainability degrades to standard with warning" {
43
+ run bash -c '
44
+ source "${REPO_ROOT}/plugins/tribunal/scripts/lib/tribunal-config.sh"
45
+ source "${REPO_ROOT}/plugins/tribunal/scripts/lib/tribunal-ulid.sh"
46
+ source "${REPO_ROOT}/plugins/tribunal/scripts/lib/tribunal-jury.sh"
47
+ CLAUDE_PLUGIN_ROOT="${REPO_ROOT}/plugins/tribunal" tribunal_config_load ""
48
+ tribunal_jury_empanel "[\"maintainability\"]" 2>&1
49
+ '
50
+ [ "$status" -eq 0 ]
51
+ [[ "$output" == *"degrading to standard"* ]]
52
+ [[ "$output" == *"standard"* ]]
53
+ }
54
+
55
+ @test "meta type is refused in jury panel" {
56
+ local panel
57
+ panel=$(tribunal_jury_empanel '["standard","meta"]' 2>/dev/null)
58
+ [ "$(printf '%s' "$panel" | jq 'length')" = "1" ]
59
+ [ "$(printf '%s' "$panel" | jq -r '.[0].judge_type')" = "standard" ]
60
+ }
61
+
62
+ @test "subagent mapping is canonical per judge_type" {
63
+ local panel
64
+ panel=$(tribunal_jury_empanel '["standard","security","adversarial"]')
65
+ [ "$(printf '%s' "$panel" | jq -r '.[0].subagent')" = "tribunal-judge-standard" ]
66
+ [ "$(printf '%s' "$panel" | jq -r '.[1].subagent')" = "tribunal-judge-security" ]
67
+ [ "$(printf '%s' "$panel" | jq -r '.[2].subagent')" = "tribunal-judge-adversarial" ]
68
+ }
69
+
70
+ @test "to_schema_judges strips internal subagent field" {
71
+ local panel schema
72
+ panel=$(tribunal_jury_empanel '["standard"]')
73
+ schema=$(tribunal_jury_to_schema_judges "$panel")
74
+ # subagent must NOT appear in the schema-shape output
75
+ [ "$(printf '%s' "$schema" | jq -r '.[0] | has("subagent")')" = "false" ]
76
+ # but judge_id, judge_type, model_id must
77
+ [ "$(printf '%s' "$schema" | jq -r '.[0] | has("judge_id")')" = "true" ]
78
+ [ "$(printf '%s' "$schema" | jq -r '.[0] | has("judge_type")')" = "true" ]
79
+ [ "$(printf '%s' "$schema" | jq -r '.[0] | has("model_id")')" = "true" ]
80
+ }
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
5
+ setup_test_env
6
+
7
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/tribunal"
8
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
9
+ # shellcheck disable=SC1091
10
+ source "${PLUGIN_ROOT}/scripts/lib/tribunal-config.sh"
11
+ # shellcheck disable=SC1091
12
+ source "${PLUGIN_ROOT}/scripts/lib/tribunal-rubric.sh"
13
+
14
+ tribunal_config_load ""
15
+ }
16
+
17
+ @test "default rubric loads from builtins" {
18
+ tribunal_rubric_load ""
19
+ local r id
20
+ r=$(tribunal_rubric_get "default")
21
+ [ -n "$r" ]
22
+ id=$(printf '%s' "$r" | jq -r '.id')
23
+ [ "$id" = "default" ]
24
+ }
25
+
26
+ @test "default rubric id resolves to 'default'" {
27
+ local id
28
+ id=$(tribunal_rubric_default_id)
29
+ [ "$id" = "default" ]
30
+ }
31
+
32
+ @test "default rubric passes validation" {
33
+ tribunal_rubric_load ""
34
+ local r
35
+ r=$(tribunal_rubric_get "default")
36
+ run tribunal_rubric_validate "$r"
37
+ [ "$status" -eq 0 ]
38
+ }
39
+
40
+ @test "project rubric override by id replaces builtin" {
41
+ local repo="${BATS_TEST_TMPDIR}/repo"
42
+ mkdir -p "${repo}/.claude"
43
+ cat > "${repo}/.claude/tribunal.json" <<'EOF'
44
+ {
45
+ "rubrics": [
46
+ {
47
+ "id": "default",
48
+ "criteria": [
49
+ { "name": "tests", "weight": 1.0, "min_pass": 0.9 }
50
+ ],
51
+ "score_threshold": 0.9,
52
+ "max_iterations": 5,
53
+ "judge_types": ["standard"],
54
+ "gate_policy": "strict",
55
+ "aggregation_method": "min"
56
+ }
57
+ ]
58
+ }
59
+ EOF
60
+ tribunal_rubric_load "$repo"
61
+ local r mi gp
62
+ r=$(tribunal_rubric_get "default")
63
+ mi=$(printf '%s' "$r" | jq -r '.max_iterations')
64
+ gp=$(printf '%s' "$r" | jq -r '.gate_policy')
65
+ [ "$mi" = "5" ]
66
+ [ "$gp" = "strict" ]
67
+ }
68
+
69
+ @test "named rubric from project file is reachable by id" {
70
+ local repo="${BATS_TEST_TMPDIR}/repo"
71
+ mkdir -p "${repo}/.claude"
72
+ cat > "${repo}/.claude/tribunal.json" <<'EOF'
73
+ {
74
+ "rubrics": [
75
+ {
76
+ "id": "security-tight",
77
+ "criteria": [
78
+ { "name": "security", "weight": 1.0, "min_pass": 0.95 }
79
+ ],
80
+ "score_threshold": 0.95,
81
+ "max_iterations": 3,
82
+ "judge_types": ["standard", "security"],
83
+ "gate_policy": "unanimous",
84
+ "aggregation_method": "min"
85
+ }
86
+ ]
87
+ }
88
+ EOF
89
+ tribunal_rubric_load "$repo"
90
+ local r
91
+ r=$(tribunal_rubric_get "security-tight")
92
+ [ -n "$r" ]
93
+ [ "$(printf '%s' "$r" | jq -r '.id')" = "security-tight" ]
94
+ }
95
+
96
+ @test "missing rubric id returns empty" {
97
+ tribunal_rubric_load ""
98
+ local r
99
+ r=$(tribunal_rubric_get "does-not-exist")
100
+ [ -z "$r" ]
101
+ }
102
+
103
+ @test "validate rejects weights summing != 1" {
104
+ local r='{"id":"bad","criteria":[{"name":"a","weight":0.4,"min_pass":0.5},{"name":"b","weight":0.4,"min_pass":0.5}],"score_threshold":0.75,"max_iterations":3,"judge_types":["standard"],"gate_policy":"majority","aggregation_method":"mean"}'
105
+ run tribunal_rubric_validate "$r"
106
+ [ "$status" -ne 0 ]
107
+ }
108
+
109
+ @test "validate rejects invalid gate_policy" {
110
+ local r='{"id":"bad","criteria":[{"name":"a","weight":1.0,"min_pass":0.5}],"score_threshold":0.75,"max_iterations":3,"judge_types":["standard"],"gate_policy":"democracy","aggregation_method":"mean"}'
111
+ run tribunal_rubric_validate "$r"
112
+ [ "$status" -ne 0 ]
113
+ }
114
+
115
+ @test "validate rejects out-of-range score_threshold" {
116
+ local r='{"id":"bad","criteria":[{"name":"a","weight":1.0,"min_pass":0.5}],"score_threshold":1.5,"max_iterations":3,"judge_types":["standard"],"gate_policy":"majority","aggregation_method":"mean"}'
117
+ run tribunal_rubric_validate "$r"
118
+ [ "$status" -ne 0 ]
119
+ }
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Exercises the Stop hook's gating behavior. Does not run `claude -p` (the
4
+ # script bails when claude is not on PATH or when conditions don't apply), so
5
+ # these tests verify the SHORT-CIRCUIT branches: disabled, no-git, no-changes.
6
+
7
+ setup() {
8
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
9
+ setup_test_env
10
+
11
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/tribunal"
12
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
13
+ HOOK="${PLUGIN_ROOT}/scripts/hooks/tribunal-stop-gate.sh"
14
+
15
+ REPO="${BATS_TEST_TMPDIR}/repo"
16
+ mkdir -p "$REPO"
17
+ git -C "$REPO" init -q
18
+ git -C "$REPO" config user.email test@example.com
19
+ git -C "$REPO" config user.name test
20
+ (cd "$REPO" && printf 'initial\n' > README.md && git add README.md && git commit -q -m init)
21
+
22
+ TRANSCRIPT="${BATS_TEST_TMPDIR}/transcript.jsonl"
23
+ printf '{"role":"user","content":"hi"}\n' > "$TRANSCRIPT"
24
+ }
25
+
26
+ _make_input() {
27
+ local cwd="$1" tp="$2" sid="${3:-test-session}"
28
+ jq -n --arg cwd "$cwd" --arg tp "$tp" --arg sid "$sid" \
29
+ '{cwd: $cwd, transcript_path: $tp, session_id: $sid}'
30
+ }
31
+
32
+ @test "hook exits 0 silently when stop_hook.enabled is false (default)" {
33
+ local input
34
+ input=$(_make_input "$REPO" "$TRANSCRIPT")
35
+ run bash -c "printf '%s' '$input' | '$HOOK'"
36
+ [ "$status" -eq 0 ]
37
+ [ -z "$output" ]
38
+ # No verdict files written
39
+ ! find "${ONLOOKER_DIR}/tribunal" -name 'stop-*.json' 2>/dev/null | grep -q .
40
+ }
41
+
42
+ @test "hook exits 0 when enabled but no git context" {
43
+ mkdir -p "${REPO}/.claude"
44
+ printf '%s\n' '{"tribunal":{"stop_hook":{"enabled":true,"skip_if_no_file_changes":false}}}' \
45
+ > "${REPO}/.claude/settings.json"
46
+ # cwd outside any repo
47
+ local non_repo="${BATS_TEST_TMPDIR}/not-a-repo"
48
+ mkdir -p "$non_repo"
49
+ local input
50
+ input=$(_make_input "$non_repo" "$TRANSCRIPT")
51
+ run bash -c "printf '%s' '$input' | '$HOOK'"
52
+ [ "$status" -eq 0 ]
53
+ }
54
+
55
+ @test "hook skips when enabled + skip_if_no_file_changes + clean tree" {
56
+ mkdir -p "${REPO}/.claude"
57
+ printf '%s\n' '{"tribunal":{"stop_hook":{"enabled":true,"skip_if_no_file_changes":true}}}' \
58
+ > "${REPO}/.claude/settings.json"
59
+ local input
60
+ input=$(_make_input "$REPO" "$TRANSCRIPT")
61
+ run bash -c "printf '%s' '$input' | '$HOOK'"
62
+ [ "$status" -eq 0 ]
63
+ # No verdict files written (no changes to evaluate)
64
+ ! find "${ONLOOKER_DIR}/tribunal" -name 'stop-*.json' 2>/dev/null | grep -q .
65
+ }
66
+
67
+ @test "hook never prints to stdout (Stop must not break the contract)" {
68
+ local input
69
+ input=$(_make_input "$REPO" "$TRANSCRIPT")
70
+ run bash -c "printf '%s' '$input' | '$HOOK'"
71
+ [ "$status" -eq 0 ]
72
+ [ -z "$output" ]
73
+ }
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
5
+ setup_test_env
6
+
7
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/tribunal"
8
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
9
+ # shellcheck disable=SC1091
10
+ source "${PLUGIN_ROOT}/scripts/lib/tribunal-ulid.sh"
11
+ # shellcheck disable=SC1091
12
+ source "${PLUGIN_ROOT}/scripts/lib/tribunal-verdict.sh"
13
+
14
+ KEY="abc123def456"
15
+ TASK_ID=$(tribunal_ulid)
16
+ ITER_ID=$(tribunal_ulid)
17
+ }
18
+
19
+ @test "ulid is 26 chars" {
20
+ local u
21
+ u=$(tribunal_ulid)
22
+ [ "${#u}" -eq 26 ]
23
+ }
24
+
25
+ @test "init_task creates task directory" {
26
+ tribunal_init_task "$KEY" "$TASK_ID"
27
+ [ -d "${ONLOOKER_DIR}/tribunal/${KEY}/${TASK_ID}" ]
28
+ }
29
+
30
+ @test "init_iteration creates iteration + verdicts dirs" {
31
+ tribunal_init_iteration "$KEY" "$TASK_ID" "$ITER_ID"
32
+ [ -d "${ONLOOKER_DIR}/tribunal/${KEY}/${TASK_ID}/iteration-${ITER_ID}/verdicts" ]
33
+ }
34
+
35
+ @test "write_project_manifest stores remote + repo_root" {
36
+ tribunal_write_project_manifest "$KEY" "https://example.com/r.git" "/tmp/repo"
37
+ local m
38
+ m=$(jq -r '.remote_url' "${ONLOOKER_DIR}/tribunal/${KEY}/manifest.json")
39
+ [ "$m" = "https://example.com/r.git" ]
40
+ [ "$(jq -r '.source' "${ONLOOKER_DIR}/tribunal/${KEY}/manifest.json")" = "local" ]
41
+ }
42
+
43
+ @test "write_task_manifest stores rubric snapshot" {
44
+ local rubric='{"id":"default","criteria":[{"name":"a","weight":1.0,"min_pass":0.5}],"score_threshold":0.75,"max_iterations":3,"judge_types":["standard"],"gate_policy":"majority","aggregation_method":"mean"}'
45
+ tribunal_write_task_manifest "$KEY" "$TASK_ID" "do the thing" "default" "$rubric"
46
+ local path="${ONLOOKER_DIR}/tribunal/${KEY}/${TASK_ID}/manifest.json"
47
+ [ -f "$path" ]
48
+ [ "$(jq -r '.task_summary' "$path")" = "do the thing" ]
49
+ [ "$(jq -r '.rubric.gate_policy' "$path")" = "majority" ]
50
+ }
51
+
52
+ @test "write_actor_output writes actor.md" {
53
+ tribunal_write_actor_output "$KEY" "$TASK_ID" "$ITER_ID" "# work"
54
+ local path="${ONLOOKER_DIR}/tribunal/${KEY}/${TASK_ID}/iteration-${ITER_ID}/actor.md"
55
+ [ -f "$path" ]
56
+ [[ "$(cat "$path")" == "# work" ]]
57
+ }
58
+
59
+ @test "write_judge_verdict writes one file per judge_id" {
60
+ local v='{"score":0.8,"passed":true,"judge_type":"standard"}'
61
+ tribunal_write_judge_verdict "$KEY" "$TASK_ID" "$ITER_ID" "judge-1" "$v"
62
+ tribunal_write_judge_verdict "$KEY" "$TASK_ID" "$ITER_ID" "judge-2" "$v"
63
+ local count
64
+ count=$(find "${ONLOOKER_DIR}/tribunal/${KEY}/${TASK_ID}/iteration-${ITER_ID}/verdicts" -name '*.json' -type f | wc -l | tr -d ' ')
65
+ [ "$count" = "2" ]
66
+ }
67
+
68
+ @test "write_iteration_artifact persists named JSON files" {
69
+ tribunal_write_iteration_artifact "$KEY" "$TASK_ID" "$ITER_ID" "consensus" '{"aggregated_score":0.8,"passed":true}'
70
+ [ -f "${ONLOOKER_DIR}/tribunal/${KEY}/${TASK_ID}/iteration-${ITER_ID}/consensus.json" ]
71
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "session_id": "rule-session-001",
3
+ "transcript_path": "/tmp/transcript.jsonl",
4
+ "cwd": "/project/repo",
5
+ "permission_mode": "default",
6
+ "hook_event_name": "UserPromptSubmit",
7
+ "prompt": "let's just run git commit -m 'wip' --no-verify and move on"
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "session_id": "rule-session-002",
3
+ "transcript_path": "/tmp/transcript.jsonl",
4
+ "cwd": "/project/repo",
5
+ "permission_mode": "default",
6
+ "hook_event_name": "UserPromptSubmit",
7
+ "prompt": "what does this function do?"
8
+ }
@@ -20,6 +20,15 @@ setup_test_env() {
20
20
  export ONLOOKER_DIR="${TEST_HOME}/.onlooker"
21
21
  export CLAUDE_HOME="${TEST_HOME}/.claude"
22
22
  export CLAUDE_PLUGIN_ROOT="${REPO_ROOT}"
23
+
24
+ # Sever git from the developer's global config. Otherwise XDG_CONFIG_HOME
25
+ # (which is exported by the parent shell and not affected by reassigning
26
+ # HOME) leaks `commit.gpgsign = true` and the per-test signingkey path
27
+ # into git-driven tests like worktree-tracker, where there's no SSH key
28
+ # in the isolated $TEST_HOME and `git worktree add` fails to sign.
29
+ export GIT_CONFIG_GLOBAL=/dev/null
30
+ export GIT_CONFIG_SYSTEM=/dev/null
31
+ unset XDG_CONFIG_HOME
23
32
  }
24
33
 
25
34
  # Source validate-path.sh with test env vars already set.
@@ -0,0 +1,173 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { describe, it } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const HERE = dirname(fileURLToPath(import.meta.url));
10
+ const REPO_ROOT = resolve(HERE, '..', '..');
11
+ const LINTER = join(REPO_ROOT, 'scripts', 'lint', 'check-manifests.mjs');
12
+
13
+ function scaffold() {
14
+ const root = mkdtempSync(join(tmpdir(), 'check-manifests-'));
15
+ mkdirSync(join(root, '.claude-plugin'), { recursive: true });
16
+ return root;
17
+ }
18
+
19
+ function writeJson(p, data) {
20
+ mkdirSync(dirname(p), { recursive: true });
21
+ writeFileSync(p, `${JSON.stringify(data, null, 2)}\n`);
22
+ }
23
+
24
+ function run(root, ...args) {
25
+ const r = spawnSync('node', [LINTER, '--root', root, ...args], { encoding: 'utf8' });
26
+ return { code: r.status, stdout: r.stdout, stderr: r.stderr };
27
+ }
28
+
29
+ const VALID_PLUGIN_JSON = (overrides = {}) => ({
30
+ name: 'sample',
31
+ version: '0.1.0',
32
+ description: 'A sample plugin used by tests.',
33
+ ...overrides,
34
+ });
35
+
36
+ const VALID_MARKETPLACE = (overrides = {}) => ({
37
+ name: 'tm',
38
+ owner: { name: 'Onlooker' },
39
+ plugins: [{ name: 'sample', source: './' }],
40
+ ...overrides,
41
+ });
42
+
43
+ describe('check-manifests', () => {
44
+ it('passes a minimally valid marketplace + plugin', () => {
45
+ const root = scaffold();
46
+ writeJson(join(root, '.claude-plugin', 'marketplace.json'), VALID_MARKETPLACE());
47
+ writeJson(join(root, '.claude-plugin', 'plugin.json'), VALID_PLUGIN_JSON());
48
+ const r = run(root);
49
+ assert.equal(r.code, 0, r.stderr);
50
+ assert.match(r.stdout, /ok \(1 plugin/);
51
+ });
52
+
53
+ it('errors if marketplace plugin entry carries a version field (drift hazard)', () => {
54
+ const root = scaffold();
55
+ writeJson(
56
+ join(root, '.claude-plugin', 'marketplace.json'),
57
+ VALID_MARKETPLACE({
58
+ plugins: [{ name: 'sample', source: './', version: '0.1.0' }],
59
+ }),
60
+ );
61
+ writeJson(join(root, '.claude-plugin', 'plugin.json'), VALID_PLUGIN_JSON());
62
+ const r = run(root);
63
+ assert.equal(r.code, 1);
64
+ assert.match(r.stderr, /MUST NOT be set in marketplace\.json/);
65
+ });
66
+
67
+ it('errors when plugin.json name does not match marketplace name', () => {
68
+ const root = scaffold();
69
+ writeJson(join(root, '.claude-plugin', 'marketplace.json'), VALID_MARKETPLACE());
70
+ writeJson(join(root, '.claude-plugin', 'plugin.json'), VALID_PLUGIN_JSON({ name: 'mismatched' }));
71
+ const r = run(root);
72
+ assert.equal(r.code, 1);
73
+ assert.match(r.stderr, /does not match marketplace entry name/);
74
+ });
75
+
76
+ it('errors on non-kebab-case names', () => {
77
+ const root = scaffold();
78
+ writeJson(
79
+ join(root, '.claude-plugin', 'marketplace.json'),
80
+ VALID_MARKETPLACE({ plugins: [{ name: 'Bad_Name', source: './' }] }),
81
+ );
82
+ writeJson(join(root, '.claude-plugin', 'plugin.json'), VALID_PLUGIN_JSON({ name: 'Bad_Name' }));
83
+ const r = run(root);
84
+ assert.equal(r.code, 1);
85
+ assert.match(r.stderr, /must be kebab-case/);
86
+ });
87
+
88
+ it('errors on a non-semver version', () => {
89
+ const root = scaffold();
90
+ writeJson(join(root, '.claude-plugin', 'marketplace.json'), VALID_MARKETPLACE());
91
+ writeJson(join(root, '.claude-plugin', 'plugin.json'), VALID_PLUGIN_JSON({ version: 'v1' }));
92
+ const r = run(root);
93
+ assert.equal(r.code, 1);
94
+ assert.match(r.stderr, /not semver-shaped/);
95
+ });
96
+
97
+ it('errors when required plugin.json fields are missing', () => {
98
+ const root = scaffold();
99
+ writeJson(join(root, '.claude-plugin', 'marketplace.json'), VALID_MARKETPLACE());
100
+ writeJson(join(root, '.claude-plugin', 'plugin.json'), { name: 'sample' });
101
+ const r = run(root);
102
+ assert.equal(r.code, 1);
103
+ assert.match(r.stderr, /`version` is required/);
104
+ assert.match(r.stderr, /`description` is required/);
105
+ });
106
+
107
+ it('warns on unknown fields (typo detection)', () => {
108
+ const root = scaffold();
109
+ writeJson(join(root, '.claude-plugin', 'marketplace.json'), VALID_MARKETPLACE());
110
+ writeJson(
111
+ join(root, '.claude-plugin', 'plugin.json'),
112
+ VALID_PLUGIN_JSON({ descripton: 'typo' }), // misspelled
113
+ );
114
+ const r = run(root);
115
+ // Warnings alone do not fail.
116
+ assert.equal(r.code, 0);
117
+ assert.match(r.stderr, /unknown field "descripton"/);
118
+ });
119
+
120
+ it('--strict turns warnings into errors', () => {
121
+ const root = scaffold();
122
+ writeJson(join(root, '.claude-plugin', 'marketplace.json'), VALID_MARKETPLACE());
123
+ writeJson(join(root, '.claude-plugin', 'plugin.json'), VALID_PLUGIN_JSON({ descripton: 'typo' }));
124
+ const r = run(root, '--strict');
125
+ assert.equal(r.code, 1);
126
+ });
127
+
128
+ it('errors when skills field is not an array', () => {
129
+ const root = scaffold();
130
+ writeJson(join(root, '.claude-plugin', 'marketplace.json'), VALID_MARKETPLACE());
131
+ writeJson(join(root, '.claude-plugin', 'plugin.json'), VALID_PLUGIN_JSON({ skills: 'oops' }));
132
+ const r = run(root);
133
+ assert.equal(r.code, 1);
134
+ assert.match(r.stderr, /`skills` must be an array/);
135
+ });
136
+
137
+ it('validates hooks.json shape when present', () => {
138
+ const root = scaffold();
139
+ writeJson(join(root, '.claude-plugin', 'marketplace.json'), VALID_MARKETPLACE());
140
+ writeJson(join(root, '.claude-plugin', 'plugin.json'), VALID_PLUGIN_JSON());
141
+ writeJson(join(root, 'hooks', 'hooks.json'), { wrong: true });
142
+ const r = run(root);
143
+ assert.equal(r.code, 1);
144
+ assert.match(r.stderr, /must contain a `hooks` object/);
145
+ });
146
+
147
+ it('warns on hook events not recognized by claude code', () => {
148
+ const root = scaffold();
149
+ writeJson(join(root, '.claude-plugin', 'marketplace.json'), VALID_MARKETPLACE());
150
+ writeJson(join(root, '.claude-plugin', 'plugin.json'), VALID_PLUGIN_JSON());
151
+ writeJson(join(root, 'hooks', 'hooks.json'), {
152
+ hooks: { GibberishEvent: [{ hooks: [] }] },
153
+ });
154
+ const r = run(root);
155
+ assert.equal(r.code, 0);
156
+ assert.match(r.stderr, /declares unknown event "GibberishEvent"/);
157
+ });
158
+
159
+ it('--plugin filters validation to a single plugin', () => {
160
+ const root = scaffold();
161
+ writeJson(join(root, '.claude-plugin', 'marketplace.json'), {
162
+ name: 'tm',
163
+ plugins: [
164
+ { name: 'good', source: './good' },
165
+ { name: 'bad', source: './bad' },
166
+ ],
167
+ });
168
+ writeJson(join(root, 'good', '.claude-plugin', 'plugin.json'), VALID_PLUGIN_JSON({ name: 'good' }));
169
+ writeJson(join(root, 'bad', '.claude-plugin', 'plugin.json'), { name: 'bad' }); // missing version + description
170
+ const r = run(root, '--plugin', 'good');
171
+ assert.equal(r.code, 0, r.stderr);
172
+ });
173
+ });