@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,309 @@
1
+ #!/usr/bin/env bash
2
+ # run-audit.sh — Cartographer audit pipeline (5 phases).
3
+ #
4
+ # Intended to run as a detached background process launched by the SessionStart
5
+ # hook. Also called directly by the /cartographer skill (foreground).
6
+ #
7
+ # Phases:
8
+ # 1. discover — collect all auditable files
9
+ # 2. extract — per-file content hash (incremental cache key)
10
+ # 3. relate — contradiction + dead_rule analysis (LLM)
11
+ # 4. synthesize — stale_ref + scope_collision + finding hash computation
12
+ # 5. emit — persist findings atomically, emit events for new findings
13
+ #
14
+ # Environment:
15
+ # CARTOGRAPHER_DIR — state directory (~/.onlooker/cartographer/<project_key>)
16
+ # CARTOGRAPHER_TRIGGER — "session_start_interval" | "session_start_first_run" | "post_tool_use" | "manual"
17
+ # CARTOGRAPHER_TARGET_FILE — (optional) single file for targeted post-write audit
18
+ # CLAUDE_PLUGIN_ROOT — plugin root directory
19
+ #
20
+ # Invariant: last_audit_at is written ONLY on full successful completion of all
21
+ # phases. Partial runs leave last_audit_at unchanged so the next session retries.
22
+
23
+ set -uo pipefail
24
+
25
+ PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
26
+
27
+ source "$PLUGIN_ROOT/scripts/lib/cartographer-config.sh"
28
+ source "$PLUGIN_ROOT/scripts/lib/cartographer-ulid.sh"
29
+ source "$PLUGIN_ROOT/scripts/lib/cartographer-project-key.sh"
30
+ source "$PLUGIN_ROOT/scripts/lib/cartographer-events.sh"
31
+ source "$PLUGIN_ROOT/scripts/lib/cartographer-collect.sh"
32
+ source "$PLUGIN_ROOT/scripts/lib/cartographer-analyze.sh"
33
+
34
+ CARTOGRAPHER_DIR="${CARTOGRAPHER_DIR:?CARTOGRAPHER_DIR must be set}"
35
+ TRIGGER="${CARTOGRAPHER_TRIGGER:-manual}"
36
+ TARGET_FILE="${CARTOGRAPHER_TARGET_FILE:-}"
37
+ REPO_ROOT="${CARTOGRAPHER_REPO_ROOT:-$(pwd)}"
38
+ AUDIT_ID=$(cartographer_ulid)
39
+ START_TS=$(date +%s)
40
+
41
+ _TIMEOUT_CMD=$(command -v gtimeout 2>/dev/null || command -v timeout 2>/dev/null || printf 'timeout')
42
+
43
+ FINDINGS_DIR="$CARTOGRAPHER_DIR/findings"
44
+ DEDUP_DIR="$CARTOGRAPHER_DIR/dedup"
45
+ RUNS_DIR="$CARTOGRAPHER_DIR/runs"
46
+ mkdir -p "$FINDINGS_DIR" "$DEDUP_DIR" "$RUNS_DIR"
47
+
48
+ PHASES_COMPLETED=()
49
+ PHASES_FAILED=()
50
+ ALL_FINDINGS="[]"
51
+
52
+ _phase_timeout=$(cartographer_config_phase_timeout)
53
+ _total_timeout=$(cartographer_config_total_timeout)
54
+ log() { printf '[cartographer] %s\n' "$*" >>"$CARTOGRAPHER_DIR/audit.log" 2>&1; }
55
+ [[ "$_total_timeout" -lt $(( _phase_timeout * 3 )) ]] && \
56
+ log "warning: total_timeout_seconds=${_total_timeout} is less than 3× phase_timeout_seconds=${_phase_timeout}; phases may be killed early"
57
+ _model_extraction=$(cartographer_config_model_extraction)
58
+ _model_synthesis=$(cartographer_config_model_synthesis)
59
+ _max_tokens_extraction=$(cartographer_config_max_output_tokens_extraction)
60
+ _max_tokens_synthesis=$(cartographer_config_max_output_tokens_synthesis)
61
+ _exclude_json=$(cartographer_config_exclude_paths)
62
+
63
+ emit_safe() {
64
+ cartographer_emit_event "$1" "$2" 2>>"$CARTOGRAPHER_DIR/audit.log" || true
65
+ }
66
+
67
+ # ── Phase 1: Discover ──────────────────────────────────────────────────────────
68
+ run_discover() {
69
+ log "phase=discover starting"
70
+ local repo_root="$REPO_ROOT"
71
+
72
+ if [[ -n "$TARGET_FILE" ]]; then
73
+ # Targeted post-write audit: only the modified file
74
+ DISCOVERED_FILES=$(jq -n --arg f "$TARGET_FILE" '[$f]')
75
+ GLOBAL_FILES="[]"
76
+ else
77
+ local raw_files
78
+ raw_files=$(cartographer_collect_files "$repo_root" "$_exclude_json" 5)
79
+ DISCOVERED_FILES=$(printf '%s\n' "$raw_files" | grep -v '^$' | jq -R . | jq -s .)
80
+ local raw_global
81
+ raw_global=$(cartographer_collect_global_files)
82
+ GLOBAL_FILES=$(printf '%s\n' "$raw_global" | grep -v '^$' | jq -R . | jq -s .)
83
+ fi
84
+
85
+ local file_count
86
+ file_count=$(printf '%s' "$DISCOVERED_FILES" | jq 'length')
87
+ log "phase=discover files=${file_count}"
88
+ PHASES_COMPLETED+=("discover")
89
+ }
90
+
91
+ # ── Phase 2: Extract ───────────────────────────────────────────────────────────
92
+ run_extract() {
93
+ log "phase=extract starting"
94
+ local cached=0 computed=0
95
+
96
+ EXTRACT_CACHE="{}"
97
+ while IFS= read -r fpath; do
98
+ [[ -z "$fpath" || ! -f "$fpath" ]] && continue
99
+ local fhash
100
+ fhash=$(cartographer_file_content_hash "$fpath") || continue
101
+ local cache_file="$CARTOGRAPHER_DIR/extracts/${fhash}.json"
102
+
103
+ if [[ -f "$cache_file" ]]; then
104
+ (( cached++ )) || true
105
+ else
106
+ mkdir -p "$CARTOGRAPHER_DIR/extracts"
107
+ printf '{"path":"%s","hash":"%s"}' "$fpath" "$fhash" >"${cache_file}.tmp"
108
+ mv -f "${cache_file}.tmp" "$cache_file"
109
+ (( computed++ )) || true
110
+ fi
111
+ EXTRACT_CACHE=$(printf '%s' "$EXTRACT_CACHE" \
112
+ | jq --arg p "$fpath" --arg h "$fhash" '.[$p]=$h')
113
+ done < <(printf '%s' "$DISCOVERED_FILES" | jq -r '.[]' 2>/dev/null)
114
+
115
+ log "phase=extract cached=${cached} computed=${computed}"
116
+ PHASES_COMPLETED+=("extract")
117
+ }
118
+
119
+ # ── Phase 3: Relate (contradiction + dead_rule) ────────────────────────────────
120
+ run_relate() {
121
+ log "phase=relate starting"
122
+ local findings
123
+ findings=$($_TIMEOUT_CMD "$_phase_timeout" bash -c \
124
+ "source '$PLUGIN_ROOT/scripts/lib/cartographer-config.sh'
125
+ source '$PLUGIN_ROOT/scripts/lib/cartographer-analyze.sh'
126
+ cartographer_config_load '$REPO_ROOT'
127
+ cartographer_analyze_contradiction '$DISCOVERED_FILES' \
128
+ '$_model_extraction' '$_max_tokens_extraction' '$_phase_timeout'" \
129
+ 2>>"$CARTOGRAPHER_DIR/audit.log") || {
130
+ log "phase=relate timeout or error"
131
+ PHASES_FAILED+=("relate")
132
+ return 1
133
+ }
134
+ RELATE_FINDINGS="${findings:-[]}"
135
+ local count
136
+ count=$(printf '%s' "$RELATE_FINDINGS" | jq 'length' 2>/dev/null || printf '0')
137
+ log "phase=relate findings=${count}"
138
+ PHASES_COMPLETED+=("relate")
139
+ }
140
+
141
+ # ── Phase 4: Synthesize (stale_ref + scope_collision + hash) ──────────────────
142
+ run_synthesize() {
143
+ log "phase=synthesize starting"
144
+
145
+ local stale_findings scope_findings
146
+ stale_findings=$($_TIMEOUT_CMD "$_phase_timeout" bash -c \
147
+ "source '$PLUGIN_ROOT/scripts/lib/cartographer-config.sh'
148
+ source '$PLUGIN_ROOT/scripts/lib/cartographer-analyze.sh'
149
+ cartographer_config_load '$REPO_ROOT'
150
+ cartographer_analyze_stale_ref '$DISCOVERED_FILES' '$REPO_ROOT' \
151
+ '$_model_synthesis' '$_max_tokens_synthesis' '$_phase_timeout'" \
152
+ 2>>"$CARTOGRAPHER_DIR/audit.log") || stale_findings="[]"
153
+
154
+ scope_findings=$($_TIMEOUT_CMD "$_phase_timeout" bash -c \
155
+ "source '$PLUGIN_ROOT/scripts/lib/cartographer-config.sh'
156
+ source '$PLUGIN_ROOT/scripts/lib/cartographer-analyze.sh'
157
+ cartographer_config_load '$REPO_ROOT'
158
+ cartographer_analyze_scope_collision '$GLOBAL_FILES' '$DISCOVERED_FILES' \
159
+ '$_model_synthesis' '$_max_tokens_synthesis' '$_phase_timeout'" \
160
+ 2>>"$CARTOGRAPHER_DIR/audit.log") || scope_findings="[]"
161
+
162
+ # Merge all raw findings
163
+ local raw_all
164
+ raw_all=$(jq -n \
165
+ --argjson relate "${RELATE_FINDINGS:-[]}" \
166
+ --argjson stale "${stale_findings:-[]}" \
167
+ --argjson scope "${scope_findings:-[]}" \
168
+ '$relate + $stale + $scope')
169
+
170
+ # Add finding_hash to each finding
171
+ ALL_FINDINGS="[]"
172
+ local idx=0
173
+ while IFS= read -r finding; do
174
+ [[ -z "$finding" ]] && continue
175
+ local ftype ffile_a fexcerpt_a ffile_b fexcerpt_b
176
+ ftype=$(printf '%s' "$finding" | jq -r '.type // "unknown"')
177
+ ffile_a=$(printf '%s' "$finding" | jq -r '.file_a // ""')
178
+ fexcerpt_a=$(printf '%s' "$finding" | jq -r '.excerpt_a // ""')
179
+ ffile_b=$(printf '%s' "$finding" | jq -r '.file_b // ""')
180
+ fexcerpt_b=$(printf '%s' "$finding" | jq -r '.excerpt_b // ""')
181
+
182
+ local fhash
183
+ fhash=$(cartographer_finding_hash \
184
+ "$ftype" "$ffile_a" "$fexcerpt_a" "$ffile_b" "$fexcerpt_b")
185
+
186
+ local enriched
187
+ enriched=$(printf '%s' "$finding" \
188
+ | jq --arg h "$fhash" --arg aid "$AUDIT_ID" \
189
+ '. + {"finding_hash":$h,"audit_id":$aid}')
190
+ ALL_FINDINGS=$(printf '%s' "$ALL_FINDINGS" \
191
+ | jq --argjson f "$enriched" '. + [$f]')
192
+ (( idx++ )) || true
193
+ done < <(printf '%s' "$raw_all" | jq -c '.[]' 2>/dev/null)
194
+
195
+ log "phase=synthesize total_findings=$(printf '%s' "$ALL_FINDINGS" | jq 'length')"
196
+ PHASES_COMPLETED+=("synthesize")
197
+ }
198
+
199
+ # ── Phase 5: Emit ──────────────────────────────────────────────────────────────
200
+ run_emit() {
201
+ log "phase=emit starting"
202
+ local new_count=0 known_count=0
203
+
204
+ while IFS= read -r finding; do
205
+ [[ -z "$finding" ]] && continue
206
+ local fhash
207
+ fhash=$(printf '%s' "$finding" | jq -r '.finding_hash')
208
+ [[ -z "$fhash" ]] && continue
209
+
210
+ local finding_file="$FINDINGS_DIR/${fhash}.json"
211
+ local dedup_sentinel="$DEDUP_DIR/${fhash}"
212
+ local now
213
+ now=$(date +%s)
214
+
215
+ if [[ -f "$dedup_sentinel" ]]; then
216
+ # Known finding — update last_seen_at atomically, no bus event
217
+ (( known_count++ )) || true
218
+ if [[ -f "$finding_file" ]]; then
219
+ local updated
220
+ updated=$(jq --argjson ts "$now" '.last_seen_at=$ts' "$finding_file" 2>/dev/null) || true
221
+ [[ -n "$updated" ]] && printf '%s\n' "$updated" >"${finding_file}.tmp" \
222
+ && mv -f "${finding_file}.tmp" "$finding_file"
223
+ fi
224
+ else
225
+ # New finding — write file atomically, emit bus event, then mark dedup
226
+ local with_ts
227
+ with_ts=$(printf '%s' "$finding" \
228
+ | jq --argjson ts "$now" '. + {"first_seen_at":$ts,"last_seen_at":$ts,"resolved":false}')
229
+ printf '%s\n' "$with_ts" >"${finding_file}.tmp"
230
+ mv -f "${finding_file}.tmp" "$finding_file"
231
+
232
+ local ftype fseverity ffile_a ffile_b fdesc
233
+ ftype=$(printf '%s' "$finding" | jq -r '.type // "unknown"')
234
+ fseverity=$(printf '%s' "$finding" | jq -r '.severity // "warning"')
235
+ ffile_a=$(printf '%s' "$finding" | jq -r '.file_a // ""')
236
+ ffile_b=$(printf '%s' "$finding" | jq -r '.file_b // null')
237
+ fdesc=$(printf '%s' "$finding" | jq -r '.description // ""')
238
+
239
+ emit_safe "cartographer.issue.found" "$(jq -n \
240
+ --arg audit_id "$AUDIT_ID" \
241
+ --arg finding_hash "$fhash" \
242
+ --arg finding_type "$ftype" \
243
+ --arg severity "$fseverity" \
244
+ --argjson affected_files "$(jq -n --arg a "$ffile_a" --arg b "$ffile_b" \
245
+ 'if $b == "null" or $b == "" then [$a] else [$a,$b] end')" \
246
+ --arg summary "$fdesc" \
247
+ '{"audit_id":$audit_id,"finding_hash":$finding_hash,"finding_type":$finding_type,"severity":$severity,"affected_files":$affected_files,"summary":$summary}')"
248
+
249
+ touch "$dedup_sentinel"
250
+ (( new_count++ )) || true
251
+ fi
252
+ done < <(printf '%s' "$ALL_FINDINGS" | jq -c '.[]' 2>/dev/null)
253
+
254
+ log "phase=emit new=${new_count} known=${known_count}"
255
+
256
+ local end_ts duration_ms total_count
257
+ end_ts=$(date +%s)
258
+ duration_ms=$(( (end_ts - START_TS) * 1000 ))
259
+ total_count=$(printf '%s' "$ALL_FINDINGS" | jq 'length')
260
+
261
+ # Write run record
262
+ local run_file="$RUNS_DIR/audit-${AUDIT_ID}.json"
263
+ jq -n \
264
+ --arg audit_id "$AUDIT_ID" \
265
+ --arg trigger "$TRIGGER" \
266
+ --argjson new_finding_count "$new_count" \
267
+ --argjson known_finding_count "$known_count" \
268
+ --argjson total_finding_count "$total_count" \
269
+ --argjson duration_ms "$duration_ms" \
270
+ --argjson phases_completed "$(printf '%s\n' "${PHASES_COMPLETED[@]}" | jq -R . | jq -s .)" \
271
+ --argjson phases_failed "$(printf '%s\n' "${PHASES_FAILED[@]:-}" | jq -R . | jq -s .)" \
272
+ '{"audit_id":$audit_id,"trigger":$trigger,"new_finding_count":$new_finding_count,"known_finding_count":$known_finding_count,"total_finding_count":$total_finding_count,"duration_ms":$duration_ms,"phases_completed":$phases_completed,"phases_failed":$phases_failed}' \
273
+ >"${run_file}.tmp" && mv -f "${run_file}.tmp" "$run_file"
274
+
275
+ emit_safe "cartographer.audit.complete" "$(jq -n \
276
+ --arg audit_id "$AUDIT_ID" \
277
+ --arg trigger "$TRIGGER" \
278
+ --argjson new_finding_count "$new_count" \
279
+ --argjson total_finding_count "$total_count" \
280
+ --argjson duration_ms "$duration_ms" \
281
+ '{"audit_id":$audit_id,"trigger":$trigger,"new_finding_count":$new_finding_count,"total_finding_count":$total_finding_count,"duration_ms":$duration_ms}')"
282
+
283
+ PHASES_COMPLETED+=("emit")
284
+ }
285
+
286
+ # ── Main ───────────────────────────────────────────────────────────────────────
287
+ main() {
288
+ log "audit_id=${AUDIT_ID} trigger=${TRIGGER} starting"
289
+
290
+ run_discover || { log "discover failed"; exit 1; }
291
+ run_extract || { log "extract failed (non-fatal, continuing)"; }
292
+ run_relate || { log "relate phase failed"; PHASES_FAILED+=("relate"); }
293
+ run_synthesize || { log "synthesize phase failed"; PHASES_FAILED+=("synthesize"); }
294
+ run_emit || { log "emit phase failed"; exit 1; }
295
+
296
+ # Only advance last_audit_at for full (non-targeted) audits with no failures.
297
+ # Targeted post-write audits cover only a single file and must not reset the
298
+ # interval gate — the next session start would otherwise skip a needed full run.
299
+ if [[ "${#PHASES_FAILED[@]}" -eq 0 && -z "$TARGET_FILE" ]]; then
300
+ printf '%d' "$(date +%s)" >"$CARTOGRAPHER_DIR/last_audit_at"
301
+ log "audit_id=${AUDIT_ID} completed successfully"
302
+ elif [[ "${#PHASES_FAILED[@]}" -gt 0 ]]; then
303
+ log "audit_id=${AUDIT_ID} partial — last_audit_at not advanced (failed: ${PHASES_FAILED[*]})"
304
+ else
305
+ log "audit_id=${AUDIT_ID} targeted audit complete — last_audit_at not advanced"
306
+ fi
307
+ }
308
+
309
+ main "$@"
@@ -0,0 +1,154 @@
1
+ ---
2
+ name: cartographer
3
+ description: Audit CLAUDE.md, AGENTS.md, and .claude/rules/ instruction files for contradictions, stale references, dead rules, and scope collisions. Runs a full audit in the foreground (with lock). Use when the user explicitly invokes /cartographer, or when they want immediate feedback after editing an instruction file. Supports --scope, --phase, --verbose, --status, and --force flags.
4
+ ---
5
+
6
+ # Cartographer Skill
7
+
8
+ Cartographer audits the persistent instruction layer of the current project and reports findings in the conversation. The automated SessionStart path handles periodic background audits; this skill is for on-demand use.
9
+
10
+ ## Setup
11
+
12
+ ```bash
13
+ set -uo pipefail
14
+ PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
15
+ source "$PLUGIN_ROOT/scripts/lib/cartographer-config.sh"
16
+ source "$PLUGIN_ROOT/scripts/lib/cartographer-project-key.sh"
17
+ source "$PLUGIN_ROOT/scripts/lib/cartographer-lock.sh"
18
+ source "$PLUGIN_ROOT/scripts/lib/cartographer-events.sh"
19
+ source "$PLUGIN_ROOT/scripts/lib/cartographer-collect.sh"
20
+ source "$PLUGIN_ROOT/scripts/lib/cartographer-analyze.sh"
21
+ ```
22
+
23
+ Load config and resolve project context:
24
+
25
+ ```bash
26
+ REPO_ROOT=$(cartographer_project_repo_root "$(pwd)")
27
+ cartographer_config_load "$REPO_ROOT"
28
+
29
+ if ! cartographer_config_enabled; then
30
+ echo "Cartographer is disabled. Set cartographer.enabled=true in .claude/settings.json to enable."
31
+ exit 0
32
+ fi
33
+
34
+ PROJECT_KEY=$(cartographer_project_key "$(pwd)")
35
+ CARTOGRAPHER_DIR="${ONLOOKER_DIR:-$HOME/.onlooker}/cartographer/$PROJECT_KEY"
36
+ mkdir -p "$CARTOGRAPHER_DIR"
37
+ ```
38
+
39
+ ## Invocation Modes
40
+
41
+ ### `/cartographer` — full audit
42
+
43
+ Runs all phases in the foreground. Acquires the audit lock (non-blocking).
44
+
45
+ ```bash
46
+ LOCK_FILE="$CARTOGRAPHER_DIR/audit.lock"
47
+
48
+ if ! cartographer_lock_acquire "$LOCK_FILE"; then
49
+ echo "An audit is already in progress."
50
+ echo "Run \`/cartographer --status\` to follow it, or \`/cartographer --force\` to restart."
51
+ exit 0
52
+ fi
53
+
54
+ export CARTOGRAPHER_DIR CARTOGRAPHER_TRIGGER="manual" ONLOOKER_DIR="${ONLOOKER_DIR:-$HOME/.onlooker}"
55
+ bash "$PLUGIN_ROOT/scripts/run-audit.sh"
56
+ cartographer_lock_release "$LOCK_FILE"
57
+ ```
58
+
59
+ After the audit completes, read findings and render them to the conversation grouped by severity (see Rendering section).
60
+
61
+ ### `/cartographer --verbose` — all findings
62
+
63
+ Shows ALL known findings (new + previously seen) from `$CARTOGRAPHER_DIR/findings/`. Does NOT re-emit bus events — renders to in-conversation output only.
64
+
65
+ ```bash
66
+ echo "## Known Cartographer Findings"
67
+ echo ""
68
+ for f in "$CARTOGRAPHER_DIR/findings/"*.json; do
69
+ [[ -f "$f" ]] || continue
70
+ jq -r '"**[\(.severity | ascii_upcase)]** \(.type) — \(.description)\n Files: \(.file_a // "n/a") / \(.file_b // "n/a")\n Fix: \(.suggested_fix // "n/a")\n"' "$f" 2>/dev/null
71
+ done
72
+ ```
73
+
74
+ ### `/cartographer --status` — audit status
75
+
76
+ Reports the running state and last completion time. No LLM calls.
77
+
78
+ ```bash
79
+ echo "## Cartographer Status"
80
+ if cartographer_lock_is_held "$CARTOGRAPHER_DIR/audit.lock"; then
81
+ pid=$(cat "$CARTOGRAPHER_DIR/audit.lock" 2>/dev/null)
82
+ echo "Audit running (PID $pid)"
83
+ else
84
+ echo "No audit in progress"
85
+ fi
86
+
87
+ if [[ -f "$CARTOGRAPHER_DIR/last_audit_at" ]]; then
88
+ ts=$(cat "$CARTOGRAPHER_DIR/last_audit_at")
89
+ echo "Last completed: $(date -r "$ts" 2>/dev/null || date -d "@$ts" 2>/dev/null || echo "$ts")"
90
+ fi
91
+
92
+ runs=$(ls "$CARTOGRAPHER_DIR/runs/" 2>/dev/null | wc -l | tr -d ' ')
93
+ total=$(ls "$CARTOGRAPHER_DIR/findings/" 2>/dev/null | wc -l | tr -d ' ')
94
+ echo "Total findings on disk: $total"
95
+ echo "Audit runs recorded: $runs"
96
+ ```
97
+
98
+ ### `/cartographer --force` — force restart
99
+
100
+ Kills the running audit (if any) and starts fresh.
101
+
102
+ ```bash
103
+ LOCK_FILE="$CARTOGRAPHER_DIR/audit.lock"
104
+ if cartographer_lock_is_held "$LOCK_FILE"; then
105
+ pid=$(cat "$LOCK_FILE" 2>/dev/null)
106
+ if [[ -n "$pid" ]]; then
107
+ kill "$pid" 2>/dev/null || true
108
+ sleep 1
109
+ fi
110
+ rm -f "$LOCK_FILE"
111
+ fi
112
+ # Then proceed as a normal full audit
113
+ ```
114
+
115
+ ### `/cartographer --phase=<phase>` — single phase
116
+
117
+ Runs only one analysis phase: `contradiction`, `stale_ref`, `dead_rule`, or `scope_collision`.
118
+ Pass `CARTOGRAPHER_PHASE_FILTER` to run-audit.sh (the script checks this env var and skips other phases).
119
+
120
+ ### `/cartographer --scope=<path>` — scoped audit
121
+
122
+ Limits discovery to files under `<path>`. Set `CARTOGRAPHER_SCOPE_PATH` before running.
123
+
124
+ ## Rendering Findings
125
+
126
+ After a manual audit completes, render findings grouped by severity:
127
+
128
+ ```bash
129
+ echo "## Cartographer Findings"
130
+ echo ""
131
+
132
+ for severity in error warning informational; do
133
+ count=0
134
+ output=""
135
+ for f in "$CARTOGRAPHER_DIR/findings/"*.json; do
136
+ [[ -f "$f" ]] || continue
137
+ fsev=$(jq -r '.severity // "warning"' "$f" 2>/dev/null)
138
+ [[ "$fsev" != "$severity" ]] && continue
139
+ (( count++ )) || true
140
+ output+=$(jq -r '"- **\(.type)**: \(.description)\n `\(.file_a // "n/a")`\n Fix: \(.suggested_fix // "n/a")\n"' "$f" 2>/dev/null)
141
+ output+=$'\n'
142
+ done
143
+ if [[ "$count" -gt 0 ]]; then
144
+ echo "### ${severity^} ($count)"
145
+ printf '%s\n' "$output"
146
+ fi
147
+ done
148
+ ```
149
+
150
+ ## Event Contract
151
+
152
+ - `cartographer.issue.found` — emitted for NEW findings only (not in dedup store). Downstream consumers must deduplicate on `payload.finding_hash`.
153
+ - `cartographer.audit.complete` — emitted when all phases complete (or partial with `status: "partial"`).
154
+ - Manual `--verbose` renders known findings as in-conversation output only — no bus events.
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "echo",
3
+ "version": "0.2.0",
4
+ "description": "Prompt-change regression detection. When a watched agent file is modified, Echo runs a single-judge quality pass via claude -p and compares the score against a stored baseline to report improved, degraded, or neutral. Builds on the Onlooker ecosystem plugin.",
5
+ "author": {
6
+ "name": "Onlooker Community",
7
+ "url": "https://onlooker.dev"
8
+ },
9
+ "homepage": "https://onlooker.dev",
10
+ "repository": "https://github.com/onlooker-community/ecosystem",
11
+ "license": "MIT",
12
+ "skills": [],
13
+ "agents": []
14
+ }
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ ## [0.2.0](https://github.com/onlooker-community/ecosystem/compare/echo-v0.1.0...echo-v0.2.0) (2026-05-25)
4
+
5
+
6
+ ### Features
7
+
8
+ * **echo:** add prompt regression detection plugin ([#32](https://github.com/onlooker-community/ecosystem/issues/32)) ([65274d4](https://github.com/onlooker-community/ecosystem/commit/65274d4d8326950d6c998ca292fed13b1b8c493b))
9
+
10
+ ## [Unreleased]
11
+
12
+ ### Added
13
+
14
+ - Initial plugin scaffold: `echo-stop-gate.sh` Stop hook
15
+ - Config schema (`config.json`) with `watch_paths`, `exclude_paths`, `drift_threshold`, and `evaluation` model settings
16
+ - `echo-config.sh`: config loading with `.claude/settings.json` override support
17
+ - `echo-events.sh`: canonical `echo.*` event emission via `onlooker-event.mjs`
18
+ - `echo-project-key.sh`: stable project key and test_id derivation
19
+ - `echo-ulid.sh`: ULID generator for suite and test identifiers
20
+ - Recursion guard (`ECHO_NESTED=1`) preventing subprocess re-entry
21
+ - Baseline storage under `~/.onlooker/echo/<project-key>/baselines/`
22
+ - Emits `echo.suite.started`, `echo.improvement.detected`, `echo.regression.detected`, `echo.suite.complete` against schema v2.2.0
23
+ - `merge_recommended` derived from absence of regressions
24
+ - `drift`, `baseline_score`, `score_after`, `drift_threshold` populated on `echo.suite.complete` when a prior baseline exists
@@ -0,0 +1,110 @@
1
+ # Echo
2
+
3
+ Prompt-change regression detection for the Onlooker ecosystem.
4
+
5
+ When a watched agent file is modified, Echo runs a single-judge quality pass on the file via `claude -p` and compares the score against a stored baseline. It reports whether the change **improved**, **degraded**, or had **no measurable effect** on prompt quality — giving every prompt edit a before/after signal instead of relying on intuition.
6
+
7
+ Echo is a sibling plugin to [`ecosystem`](../../) and assumes the Onlooker observability substrate (`~/.onlooker/`) is present.
8
+
9
+ ## How it works
10
+
11
+ Echo registers a **Stop hook** that fires at the end of every Claude Code session. When triggered:
12
+
13
+ 1. Detects which watched files changed (unstaged, staged, or untracked).
14
+ 2. Filters against configured `watch_paths` and `exclude_paths` patterns.
15
+ 3. For each matching file, builds a rubric prompt and calls `claude -p --max-turns 1` to score it on four criteria: role clarity, output format, criterion coverage, and internal consistency.
16
+ 4. Compares the score to a stored baseline (if one exists) and emits `echo.improvement.detected` or `echo.regression.detected`.
17
+ 5. Emits `echo.suite.complete` with aggregate drift, a `merge_recommended` flag, and duration.
18
+
19
+ The hook always exits 0 — it never blocks a session from ending.
20
+
21
+ ## Activation
22
+
23
+ Echo is **off by default**. Enable it per-project in `.claude/settings.json`:
24
+
25
+ ```json
26
+ {
27
+ "echo": {
28
+ "enabled": true
29
+ }
30
+ }
31
+ ```
32
+
33
+ ## Configuration
34
+
35
+ All keys are optional. Unset keys fall back to the plugin's `config.json` defaults.
36
+
37
+ ```json
38
+ {
39
+ "echo": {
40
+ "enabled": true,
41
+ "watch_paths": ["plugins/*/agents/*.md"],
42
+ "exclude_paths": [],
43
+ "drift_threshold": 0.05,
44
+ "evaluation": {
45
+ "model": "claude-haiku-4-5-20251001",
46
+ "timeout_seconds": 60
47
+ }
48
+ }
49
+ }
50
+ ```
51
+
52
+ | Key | Default | Description |
53
+ |-----|---------|-------------|
54
+ | `enabled` | `false` | Must be `true` for any evaluation to run. |
55
+ | `watch_paths` | `["plugins/*/agents/*.md"]` | Glob patterns (relative to repo root) of files to watch. Bash extended glob syntax. |
56
+ | `exclude_paths` | `[]` | Patterns to exclude. `plugins/echo/**` is always excluded regardless of this setting. |
57
+ | `drift_threshold` | `0.05` | Minimum absolute score delta to classify a change as improvement or regression. Deltas below this are reported as neutral. |
58
+ | `evaluation.model` | `claude-haiku-4-5-20251001` | Model used for the quality pass. Haiku is fast and cheap; upgrade to Sonnet for higher-stakes repos. |
59
+ | `evaluation.timeout_seconds` | `60` | Per-file wall-clock timeout passed to the `timeout` command. |
60
+
61
+ ## Scoring rubric
62
+
63
+ Each watched file is scored 0.0–1.0 on four equally-weighted criteria:
64
+
65
+ | Criterion | What it checks |
66
+ |-----------|---------------|
67
+ | **Role clarity** | Does the file clearly define what the agent is and what it must do? |
68
+ | **Output format** | Are output format and schema requirements unambiguous? |
69
+ | **Criterion coverage** | Are all evaluation dimensions specified with enough detail to apply consistently? |
70
+ | **Internal consistency** | No contradictory instructions; no undefined terms. |
71
+
72
+ A score ≥ 0.7 is considered "passed". A delta beyond `drift_threshold` in either direction is classified as improvement or regression.
73
+
74
+ ## Storage layout
75
+
76
+ ```text
77
+ ~/.onlooker/echo/<project-key>/
78
+ ├── baselines/
79
+ │ └── <test-id>.json # one per watched file (test-id = first 16 hex of SHA256 of path)
80
+ └── run-<session-id>.json # advisory summary written at end of each suite
81
+ ```
82
+
83
+ Project key: first 12 hex chars of SHA256 of `git remote get-url origin`, falling back to a hash of the repo root realpath. This makes the key stable across directory moves and clones of the same repo.
84
+
85
+ ## Events emitted
86
+
87
+ Echo emits the canonical `echo.*` event surface from [`@onlooker-community/schema`](https://github.com/onlooker-community/schema) v2.2.0+. All events land in `~/.onlooker/logs/onlooker-events.jsonl` and are validated against the schema before write.
88
+
89
+ | Event | When |
90
+ |-------|------|
91
+ | `echo.suite.started` | Before the evaluation loop begins. Includes `test_count` and `changed_file`. |
92
+ | `echo.improvement.detected` | A file's score increased beyond `drift_threshold`. |
93
+ | `echo.regression.detected` | A file's score decreased beyond `drift_threshold`. |
94
+ | `echo.suite.complete` | After all files are evaluated. Includes aggregate drift fields when a prior baseline exists. |
95
+
96
+ ## Requirements
97
+
98
+ - The `ecosystem` plugin installed (for the `~/.onlooker/` substrate and canonical event emission).
99
+ - `claude` CLI on `PATH` (the hook shells out to `claude -p` for evaluation passes).
100
+ - `jq` for JSON manipulation.
101
+ - `node` for canonical-event emission.
102
+ - `python3` for millisecond timestamps (standard on macOS and most Linux distributions).
103
+
104
+ ## Architecture decisions
105
+
106
+ Key decisions made during initial design are recorded in [`docs/adr/`](docs/adr/):
107
+
108
+ - [ADR-001](docs/adr/001-echo-as-separate-plugin.md) — Echo as a separate plugin, not an extension of Tribunal
109
+ - [ADR-002](docs/adr/002-direct-evaluation-vs-tribunal-pipeline.md) — Direct `claude -p` evaluation vs. routing through Tribunal's full pipeline
110
+ - [ADR-003](docs/adr/003-stop-hook-trigger.md) — Stop hook as the trigger mechanism
@@ -0,0 +1,15 @@
1
+ {
2
+ "plugin_name": "echo",
3
+ "storage_path": "~/.onlooker",
4
+ "echo": {
5
+ "enabled": false,
6
+ "watch_paths": ["plugins/*/agents/*.md"],
7
+ "exclude_paths": [],
8
+ "drift_threshold": 0.05,
9
+ "evaluation": {
10
+ "model": "claude-haiku-4-5-20251001",
11
+ "max_output_tokens": 512,
12
+ "timeout_seconds": 60
13
+ }
14
+ }
15
+ }
@@ -0,0 +1,33 @@
1
+ # ADR-001: Echo as a Separate Plugin, Not an Extension of Tribunal
2
+
3
+ **Status:** Accepted
4
+ **Date:** 2026-05-24
5
+
6
+ ## Context
7
+
8
+ Tribunal already exists as an evaluation engine in this ecosystem. When designing Echo's prompt-change regression detection, the first question was whether Echo should live inside Tribunal (as a sub-feature or mode) or stand alone as its own plugin.
9
+
10
+ The Tribunal team ran a formal evaluation of this question using Tribunal itself. The final score across three iterations was 0.79 (above the 0.75 acceptance threshold), with outcome `exhausted_iterations` — the adversarial judge never passed, which is expected behavior for that judge type. The substantive conclusion from all three iterations was: Echo is architecturally sound as a standalone plugin.
11
+
12
+ ## Decision
13
+
14
+ Echo is a separate, independent plugin under `plugins/echo/`.
15
+
16
+ ## Rationale
17
+
18
+ **Separate concerns.** Tribunal is an orchestrator for arbitrary tasks. Echo is a specialized harness for prompt quality regression testing. Bundling Echo into Tribunal would couple two distinct concerns: general task evaluation and change-detection/baselining.
19
+
20
+ **Different lifecycle.** Tribunal is always on (for `/tribunal` skill invocations) and opt-in for its Stop hook. Echo is opt-in by default (`"enabled": false`) and has no interactive skill surface — it only runs as a Stop hook. These are different activation patterns that would conflict if forced into the same plugin config namespace.
21
+
22
+ **Independent versioning.** Echo and Tribunal can release, iterate, and break/fix independently. Echo v0.2 does not need to drag along a Tribunal major bump.
23
+
24
+ **Composability.** Echo today calls `claude -p` directly. A future version could delegate to Tribunal for richer multi-judge evaluation (see ADR-002). That migration is easier when Echo is its own entry point.
25
+
26
+ **Self-exclusion.** Echo must never trigger on its own files changing. This is simpler to enforce as a first-class concern in a standalone plugin (`plugins/echo/**` is always in `exclude_paths`) than as a special case inside Tribunal.
27
+
28
+ ## Consequences
29
+
30
+ - Echo requires the ecosystem plugin but does **not** require Tribunal to be installed.
31
+ - Echo gets its own `config.json`, `hooks.json`, `.claude-plugin/plugin.json`, CHANGELOG, and release-please track.
32
+ - Any future Tribunal integration (e.g., Echo delegating multi-judge eval to Tribunal) will be an opt-in config option, not a hard dependency.
33
+ - Marketplace listing and docs must be careful not to imply Tribunal is a prerequisite (an early draft of the description made this mistake; corrected before merge).