@onlooker-community/ecosystem 0.14.0 → 0.15.1

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 (31) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.github/workflows/release.yml +8 -4
  3. package/.release-please-manifest.json +3 -2
  4. package/CHANGELOG.md +14 -0
  5. package/README.md +1 -0
  6. package/docs/architecture.md +28 -22
  7. package/package.json +1 -1
  8. package/plugins/cartographer/.claude-plugin/plugin.json +14 -0
  9. package/plugins/cartographer/CHANGELOG.md +27 -0
  10. package/plugins/cartographer/README.md +113 -0
  11. package/plugins/cartographer/config.json +21 -0
  12. package/plugins/cartographer/docs/adr/001-background-audit-launch.md +28 -0
  13. package/plugins/cartographer/docs/adr/002-flock-pid-file-fallback.md +30 -0
  14. package/plugins/cartographer/docs/adr/003-at-least-once-event-delivery.md +32 -0
  15. package/plugins/cartographer/docs/adr/004-exclude-paths-replace-semantics.md +27 -0
  16. package/plugins/cartographer/hooks/hooks.json +44 -0
  17. package/plugins/cartographer/scripts/hooks/cartographer-post-write.sh +87 -0
  18. package/plugins/cartographer/scripts/hooks/cartographer-session-start.sh +89 -0
  19. package/plugins/cartographer/scripts/lib/cartographer-analyze.sh +286 -0
  20. package/plugins/cartographer/scripts/lib/cartographer-collect.sh +59 -0
  21. package/plugins/cartographer/scripts/lib/cartographer-config.sh +105 -0
  22. package/plugins/cartographer/scripts/lib/cartographer-events.sh +82 -0
  23. package/plugins/cartographer/scripts/lib/cartographer-lock.sh +38 -0
  24. package/plugins/cartographer/scripts/lib/cartographer-project-key.sh +55 -0
  25. package/plugins/cartographer/scripts/lib/cartographer-ulid.sh +47 -0
  26. package/plugins/cartographer/scripts/run-audit.sh +309 -0
  27. package/plugins/cartographer/skills/cartographer/SKILL.md +154 -0
  28. package/release-please-config.json +16 -0
  29. package/test/bats/cartographer-config.bats +107 -0
  30. package/test/bats/cartographer-lock.bats +77 -0
  31. package/test/bats/cartographer-ulid.bats +56 -0
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env bash
2
+ # cartographer-project-key.sh — stable 12-char hex project key.
3
+ #
4
+ # Derives a key that survives repo renames, clones, and worktrees.
5
+ # Algorithm:
6
+ # 1. git remote get-url origin → sha256("remote:" + url)[0:12]
7
+ # 2. Fallback: git rev-parse --show-toplevel → sha256("root:" + path)[0:12]
8
+ # 3. Non-git: sha256("cwd:" + pwd)[0:12]
9
+ #
10
+ # Usage:
11
+ # key=$(cartographer_project_key <cwd>)
12
+ # root=$(cartographer_project_repo_root <cwd>)
13
+
14
+ _cartographer_sha256_first12() {
15
+ local input="$1"
16
+ if command -v sha256sum &>/dev/null; then
17
+ printf '%s' "$input" | sha256sum | cut -c1-12
18
+ elif command -v shasum &>/dev/null; then
19
+ printf '%s' "$input" | shasum -a 256 | cut -c1-12
20
+ else
21
+ printf '%s' "$input" | python3 -c \
22
+ 'import sys,hashlib; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest()[:12])'
23
+ fi
24
+ }
25
+
26
+ cartographer_project_repo_root() {
27
+ local cwd="${1:-$(pwd)}"
28
+ local root
29
+ root=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null) && printf '%s' "$root" && return 0
30
+ printf '%s' "$cwd"
31
+ }
32
+
33
+ cartographer_project_remote_url() {
34
+ local cwd="${1:-$(pwd)}"
35
+ git -C "$cwd" remote get-url origin 2>/dev/null || true
36
+ }
37
+
38
+ cartographer_project_key() {
39
+ local cwd="${1:-$(pwd)}"
40
+ local remote
41
+ remote=$(cartographer_project_remote_url "$cwd")
42
+ if [[ -n "$remote" ]]; then
43
+ _cartographer_sha256_first12 "remote:${remote}"
44
+ return 0
45
+ fi
46
+
47
+ local root
48
+ root=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null)
49
+ if [[ -n "$root" ]]; then
50
+ _cartographer_sha256_first12 "root:${root}"
51
+ return 0
52
+ fi
53
+
54
+ _cartographer_sha256_first12 "cwd:${cwd}"
55
+ }
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env bash
2
+ # cartographer-ulid.sh — ULID generation for Cartographer.
3
+ #
4
+ # Generates Universally Unique Lexicographically Sortable Identifiers:
5
+ # 10-char Crockford Base32 timestamp + 16-char random component = 26 chars.
6
+ #
7
+ # Usage:
8
+ # id=$(cartographer_ulid)
9
+
10
+ _CARTOGRAPHER_ULID_ALPHABET="0123456789ABCDEFGHJKMNPQRSTVWXYZ"
11
+
12
+ _cartographer_ulid_encode() {
13
+ local n="${1:-0}"
14
+ local len="${2:-10}"
15
+ local result=""
16
+ local i
17
+ for (( i = 0; i < len; i++ )); do
18
+ result="${_CARTOGRAPHER_ULID_ALPHABET:$(( n & 31 )):1}${result}"
19
+ n=$(( n >> 5 ))
20
+ done
21
+ printf '%s' "$result"
22
+ }
23
+
24
+ cartographer_ulid() {
25
+ local ts_ms
26
+ if date +%s%3N &>/dev/null && [[ "$(date +%s%3N)" =~ ^[0-9]{13}$ ]]; then
27
+ ts_ms=$(date +%s%3N)
28
+ else
29
+ ts_ms=$(python3 -c 'import time; print(int(time.time() * 1000))')
30
+ fi
31
+
32
+ local ts_encoded
33
+ ts_encoded=$(_cartographer_ulid_encode "$ts_ms" 10)
34
+
35
+ local rand_hex
36
+ rand_hex=$(openssl rand -hex 10 2>/dev/null) \
37
+ || rand_hex=$(printf '%020x' $(( (RANDOM * RANDOM & 0xFFFFF) * 0x100000 + (RANDOM * RANDOM & 0xFFFFF) )))
38
+
39
+ # Bash integers are 63-bit signed, so split the 80-bit random across two 40-bit halves.
40
+ local rand_hi rand_lo
41
+ rand_hi=$(( 16#${rand_hex:0:10} ))
42
+ rand_lo=$(( 16#${rand_hex:10:10} ))
43
+ local rand_encoded
44
+ rand_encoded="$(_cartographer_ulid_encode "$rand_hi" 8)$(_cartographer_ulid_encode "$rand_lo" 8)"
45
+
46
+ printf '%s%s' "$ts_encoded" "$rand_encoded"
47
+ }
@@ -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"