@onlooker-community/ecosystem 0.14.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.
- package/.claude-plugin/plugin.json +1 -1
- package/.release-please-manifest.json +3 -2
- package/CHANGELOG.md +7 -0
- package/README.md +1 -0
- package/docs/architecture.md +28 -22
- package/package.json +1 -1
- package/plugins/cartographer/.claude-plugin/plugin.json +14 -0
- package/plugins/cartographer/CHANGELOG.md +27 -0
- package/plugins/cartographer/README.md +113 -0
- package/plugins/cartographer/config.json +21 -0
- package/plugins/cartographer/docs/adr/001-background-audit-launch.md +28 -0
- package/plugins/cartographer/docs/adr/002-flock-pid-file-fallback.md +30 -0
- package/plugins/cartographer/docs/adr/003-at-least-once-event-delivery.md +32 -0
- package/plugins/cartographer/docs/adr/004-exclude-paths-replace-semantics.md +27 -0
- package/plugins/cartographer/hooks/hooks.json +44 -0
- package/plugins/cartographer/scripts/hooks/cartographer-post-write.sh +87 -0
- package/plugins/cartographer/scripts/hooks/cartographer-session-start.sh +89 -0
- package/plugins/cartographer/scripts/lib/cartographer-analyze.sh +286 -0
- package/plugins/cartographer/scripts/lib/cartographer-collect.sh +59 -0
- package/plugins/cartographer/scripts/lib/cartographer-config.sh +105 -0
- package/plugins/cartographer/scripts/lib/cartographer-events.sh +82 -0
- package/plugins/cartographer/scripts/lib/cartographer-lock.sh +38 -0
- package/plugins/cartographer/scripts/lib/cartographer-project-key.sh +55 -0
- package/plugins/cartographer/scripts/lib/cartographer-ulid.sh +47 -0
- package/plugins/cartographer/scripts/run-audit.sh +309 -0
- package/plugins/cartographer/skills/cartographer/SKILL.md +154 -0
- package/release-please-config.json +16 -0
- package/test/bats/cartographer-config.bats +107 -0
- package/test/bats/cartographer-lock.bats +77 -0
- package/test/bats/cartographer-ulid.bats +56 -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.
|
|
@@ -62,6 +62,22 @@
|
|
|
62
62
|
"jsonpath": "$.version"
|
|
63
63
|
}
|
|
64
64
|
]
|
|
65
|
+
},
|
|
66
|
+
"plugins/cartographer": {
|
|
67
|
+
"changelog-path": "CHANGELOG.md",
|
|
68
|
+
"release-type": "simple",
|
|
69
|
+
"bump-minor-pre-major": true,
|
|
70
|
+
"bump-patch-for-bump-minor-pre-major": false,
|
|
71
|
+
"component": "cartographer",
|
|
72
|
+
"draft": false,
|
|
73
|
+
"prerelease": false,
|
|
74
|
+
"extra-files": [
|
|
75
|
+
{
|
|
76
|
+
"type": "json",
|
|
77
|
+
"path": ".claude-plugin/plugin.json",
|
|
78
|
+
"jsonpath": "$.version"
|
|
79
|
+
}
|
|
80
|
+
]
|
|
65
81
|
}
|
|
66
82
|
},
|
|
67
83
|
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
|
|
@@ -0,0 +1,107 @@
|
|
|
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/cartographer"
|
|
8
|
+
export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
|
|
9
|
+
# shellcheck disable=SC1091
|
|
10
|
+
source "${PLUGIN_ROOT}/scripts/lib/cartographer-config.sh"
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
@test "config defaults: disabled by default" {
|
|
14
|
+
cartographer_config_load ""
|
|
15
|
+
run cartographer_config_enabled
|
|
16
|
+
[ "$status" -ne 0 ]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@test "user-level settings.json can enable cartographer" {
|
|
20
|
+
mkdir -p "${HOME}/.claude"
|
|
21
|
+
printf '%s\n' '{"cartographer":{"enabled":true}}' > "${HOME}/.claude/settings.json"
|
|
22
|
+
cartographer_config_load ""
|
|
23
|
+
run cartographer_config_enabled
|
|
24
|
+
[ "$status" -eq 0 ]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@test "repo-level settings.json overrides user-level enabled" {
|
|
28
|
+
mkdir -p "${HOME}/.claude"
|
|
29
|
+
printf '%s\n' '{"cartographer":{"enabled":true}}' > "${HOME}/.claude/settings.json"
|
|
30
|
+
local repo="${BATS_TEST_TMPDIR}/repo"
|
|
31
|
+
mkdir -p "${repo}/.claude"
|
|
32
|
+
printf '%s\n' '{"cartographer":{"enabled":false}}' > "${repo}/.claude/settings.json"
|
|
33
|
+
cartographer_config_load "$repo"
|
|
34
|
+
run cartographer_config_enabled
|
|
35
|
+
[ "$status" -ne 0 ]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@test "deep merge: user-level partial override preserves plugin defaults" {
|
|
39
|
+
mkdir -p "${HOME}/.claude"
|
|
40
|
+
printf '%s\n' '{"cartographer":{"audit_interval_hours":12}}' > "${HOME}/.claude/settings.json"
|
|
41
|
+
cartographer_config_load ""
|
|
42
|
+
local interval model
|
|
43
|
+
interval=$(cartographer_config_audit_interval_hours)
|
|
44
|
+
model=$(cartographer_config_model_extraction)
|
|
45
|
+
[ "$interval" = "12" ]
|
|
46
|
+
[ "$model" = "claude-haiku-4-5-20251001" ]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@test "deep merge: repo overrides user but preserves user keys not in repo" {
|
|
50
|
+
mkdir -p "${HOME}/.claude"
|
|
51
|
+
printf '%s\n' '{"cartographer":{"audit_interval_hours":6,"phase_timeout_seconds":30}}' > "${HOME}/.claude/settings.json"
|
|
52
|
+
local repo="${BATS_TEST_TMPDIR}/repo2"
|
|
53
|
+
mkdir -p "${repo}/.claude"
|
|
54
|
+
printf '%s\n' '{"cartographer":{"audit_interval_hours":48}}' > "${repo}/.claude/settings.json"
|
|
55
|
+
cartographer_config_load "$repo"
|
|
56
|
+
local interval timeout_s
|
|
57
|
+
interval=$(cartographer_config_audit_interval_hours)
|
|
58
|
+
timeout_s=$(cartographer_config_phase_timeout)
|
|
59
|
+
[ "$interval" = "48" ]
|
|
60
|
+
[ "$timeout_s" = "30" ]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@test "model_extraction falls back to default when not configured" {
|
|
64
|
+
cartographer_config_load ""
|
|
65
|
+
local v
|
|
66
|
+
v=$(cartographer_config_model_extraction)
|
|
67
|
+
[ "$v" = "claude-haiku-4-5-20251001" ]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@test "model_synthesis falls back to default when not configured" {
|
|
71
|
+
cartographer_config_load ""
|
|
72
|
+
local v
|
|
73
|
+
v=$(cartographer_config_model_synthesis)
|
|
74
|
+
[ "$v" = "claude-haiku-4-5-20251001" ]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@test "model_extraction respects user-level override" {
|
|
78
|
+
mkdir -p "${HOME}/.claude"
|
|
79
|
+
printf '%s\n' '{"cartographer":{"extraction":{"model":"claude-sonnet-4-6"}}}' > "${HOME}/.claude/settings.json"
|
|
80
|
+
cartographer_config_load ""
|
|
81
|
+
local v
|
|
82
|
+
v=$(cartographer_config_model_extraction)
|
|
83
|
+
[ "$v" = "claude-sonnet-4-6" ]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
@test "phase_timeout_seconds defaults to 60" {
|
|
87
|
+
cartographer_config_load ""
|
|
88
|
+
local v
|
|
89
|
+
v=$(cartographer_config_phase_timeout)
|
|
90
|
+
[ "$v" = "60" ]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@test "audit_interval_hours defaults to 24" {
|
|
94
|
+
cartographer_config_load ""
|
|
95
|
+
local v
|
|
96
|
+
v=$(cartographer_config_audit_interval_hours)
|
|
97
|
+
[ "$v" = "24" ]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@test "exclude_paths defaults are non-empty JSON array" {
|
|
101
|
+
cartographer_config_load ""
|
|
102
|
+
local v
|
|
103
|
+
v=$(cartographer_config_exclude_paths)
|
|
104
|
+
# Must be a non-empty JSON array containing node_modules
|
|
105
|
+
echo "$v" | jq -e 'type == "array" and length > 0' >/dev/null
|
|
106
|
+
echo "$v" | jq -e 'any(. == "node_modules")' >/dev/null
|
|
107
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
setup() {
|
|
4
|
+
source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
|
|
5
|
+
setup_test_env
|
|
6
|
+
# shellcheck disable=SC1091
|
|
7
|
+
source "${REPO_ROOT}/plugins/cartographer/scripts/lib/cartographer-lock.sh"
|
|
8
|
+
|
|
9
|
+
LOCK_FILE="${BATS_TEST_TMPDIR}/test.lock"
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
teardown() {
|
|
13
|
+
cartographer_lock_release "$LOCK_FILE" 2>/dev/null || true
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@test "acquire succeeds when lock is free" {
|
|
17
|
+
run cartographer_lock_acquire "$LOCK_FILE"
|
|
18
|
+
[ "$status" -eq 0 ]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@test "acquire fails immediately when lock is already held" {
|
|
22
|
+
cartographer_lock_acquire "$LOCK_FILE"
|
|
23
|
+
run cartographer_lock_acquire "$LOCK_FILE"
|
|
24
|
+
[ "$status" -ne 0 ]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@test "release allows re-acquire" {
|
|
28
|
+
cartographer_lock_acquire "$LOCK_FILE"
|
|
29
|
+
cartographer_lock_release "$LOCK_FILE"
|
|
30
|
+
run cartographer_lock_acquire "$LOCK_FILE"
|
|
31
|
+
[ "$status" -eq 0 ]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@test "is_held returns true while lock is held" {
|
|
35
|
+
cartographer_lock_acquire "$LOCK_FILE"
|
|
36
|
+
run cartographer_lock_is_held "$LOCK_FILE"
|
|
37
|
+
[ "$status" -eq 0 ]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@test "is_held returns false after release" {
|
|
41
|
+
cartographer_lock_acquire "$LOCK_FILE"
|
|
42
|
+
cartographer_lock_release "$LOCK_FILE"
|
|
43
|
+
run cartographer_lock_is_held "$LOCK_FILE"
|
|
44
|
+
[ "$status" -ne 0 ]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@test "release is idempotent (safe to call when not held)" {
|
|
48
|
+
run cartographer_lock_release "$LOCK_FILE"
|
|
49
|
+
[ "$status" -eq 0 ]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@test "two independent lock paths do not interfere" {
|
|
53
|
+
local lock_a="${BATS_TEST_TMPDIR}/a.lock"
|
|
54
|
+
local lock_b="${BATS_TEST_TMPDIR}/b.lock"
|
|
55
|
+
cartographer_lock_acquire "$lock_a"
|
|
56
|
+
run cartographer_lock_acquire "$lock_b"
|
|
57
|
+
[ "$status" -eq 0 ]
|
|
58
|
+
cartographer_lock_release "$lock_a"
|
|
59
|
+
cartographer_lock_release "$lock_b"
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@test "background child holds lock after parent releases its reference" {
|
|
63
|
+
# Acquire in a background subprocess, then verify is_held from this process.
|
|
64
|
+
bash -c "
|
|
65
|
+
source '${REPO_ROOT}/plugins/cartographer/scripts/lib/cartographer-lock.sh'
|
|
66
|
+
cartographer_lock_acquire '${LOCK_FILE}'
|
|
67
|
+
sleep 2
|
|
68
|
+
cartographer_lock_release '${LOCK_FILE}'
|
|
69
|
+
" &
|
|
70
|
+
sleep 0.1
|
|
71
|
+
run cartographer_lock_is_held "$LOCK_FILE"
|
|
72
|
+
[ "$status" -eq 0 ]
|
|
73
|
+
# Second acquire from this process must fail while child holds it
|
|
74
|
+
run cartographer_lock_acquire "$LOCK_FILE"
|
|
75
|
+
[ "$status" -ne 0 ]
|
|
76
|
+
wait
|
|
77
|
+
}
|