@onlooker-community/ecosystem 0.34.0 → 0.43.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 (112) hide show
  1. package/.claude/skills/writing-tests/SKILL.md +27 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.github/workflows/release.yml +14 -14
  4. package/.release-please-manifest.json +7 -7
  5. package/AGENTS.md +116 -0
  6. package/CHANGELOG.md +175 -0
  7. package/CLAUDE.md +5 -0
  8. package/docs/lesson-promotion-pipeline.md +6 -6
  9. package/docs/superpowers/plans/2026-08-10-lesson-confirmation.md +1273 -0
  10. package/docs/superpowers/plans/2026-08-11-lesson-judging.md +1232 -0
  11. package/docs/superpowers/plans/2026-08-11-lesson-unconfirm.md +458 -0
  12. package/docs/superpowers/plans/2026-08-12-author-key.md +567 -0
  13. package/docs/superpowers/plans/2026-08-13-approved-pool.md +747 -0
  14. package/docs/superpowers/plans/2026-08-13-librarian-cluster.md +392 -0
  15. package/docs/superpowers/plans/2026-08-14-criterion-scores-consumers.md +1374 -0
  16. package/docs/superpowers/plans/2026-08-14-criterion-scores-schema.md +277 -0
  17. package/docs/superpowers/plans/2026-08-16-cartographer-undocumented-entity.md +741 -0
  18. package/docs/superpowers/specs/2026-08-10-lesson-confirmation-design.md +213 -0
  19. package/docs/superpowers/specs/2026-08-11-lesson-judging-design.md +267 -0
  20. package/docs/superpowers/specs/2026-08-11-lesson-unconfirm-design.md +152 -0
  21. package/docs/superpowers/specs/2026-08-12-author-key-design.md +317 -0
  22. package/docs/superpowers/specs/2026-08-13-approved-pool-design.md +232 -0
  23. package/docs/superpowers/specs/2026-08-14-criterion-scores-design.md +254 -0
  24. package/docs/superpowers/specs/2026-08-16-cartographer-undocumented-entity-design.md +249 -0
  25. package/package.json +2 -2
  26. package/plugins/assayer/.claude-plugin/plugin.json +1 -1
  27. package/plugins/assayer/CHANGELOG.md +7 -0
  28. package/plugins/assayer/scripts/lib/assayer-config.sh +6 -0
  29. package/plugins/cartographer/.claude-plugin/plugin.json +1 -1
  30. package/plugins/cartographer/CHANGELOG.md +54 -0
  31. package/plugins/cartographer/README.md +56 -1
  32. package/plugins/cartographer/config.json +7 -1
  33. package/plugins/cartographer/scripts/hooks/cartographer-post-write.sh +4 -4
  34. package/plugins/cartographer/scripts/hooks/cartographer-session-start.sh +7 -4
  35. package/plugins/cartographer/scripts/lib/cartographer-collect.sh +8 -1
  36. package/plugins/cartographer/scripts/lib/cartographer-config.sh +26 -0
  37. package/plugins/cartographer/scripts/lib/cartographer-events.sh +90 -0
  38. package/plugins/cartographer/scripts/lib/cartographer-filter.sh +105 -0
  39. package/plugins/cartographer/scripts/lib/cartographer-omission.sh +130 -0
  40. package/plugins/cartographer/scripts/lib/cartographer-resolve.sh +132 -0
  41. package/plugins/cartographer/scripts/run-audit.sh +136 -53
  42. package/plugins/cartographer/skills/cartographer/SKILL.md +33 -9
  43. package/plugins/curator/.claude-plugin/plugin.json +1 -1
  44. package/plugins/curator/CHANGELOG.md +7 -0
  45. package/plugins/curator/scripts/lib/curator-emit.sh +2 -1
  46. package/plugins/historian/.claude-plugin/plugin.json +1 -1
  47. package/plugins/historian/CHANGELOG.md +7 -0
  48. package/plugins/historian/scripts/lib/historian-emit.sh +2 -1
  49. package/plugins/librarian/.claude-plugin/plugin.json +1 -1
  50. package/plugins/librarian/CHANGELOG.md +71 -0
  51. package/plugins/librarian/config.json +30 -1
  52. package/plugins/librarian/docs/adr/002-agent-definitions-are-shared-assets.md +82 -0
  53. package/plugins/librarian/scripts/hooks/librarian-session-end.sh +28 -0
  54. package/plugins/librarian/scripts/hooks/librarian-session-start.sh +42 -12
  55. package/plugins/librarian/scripts/lib/librarian-author-key.sh +279 -0
  56. package/plugins/librarian/scripts/lib/librarian-cli.sh +346 -2
  57. package/plugins/librarian/scripts/lib/librarian-emit.sh +2 -1
  58. package/plugins/librarian/scripts/lib/librarian-lesson-judge.sh +349 -0
  59. package/plugins/librarian/scripts/lib/librarian-lesson-promote.sh +225 -0
  60. package/plugins/librarian/scripts/lib/librarian-lesson-review.sh +312 -0
  61. package/plugins/librarian/scripts/lib/librarian-lesson-rubric.sh +56 -0
  62. package/plugins/librarian/scripts/lib/librarian-lesson-storage.sh +82 -13
  63. package/plugins/librarian/scripts/lib/librarian-lesson-transform.sh +6 -1
  64. package/plugins/librarian/scripts/lib/librarian-lesson-validate.sh +131 -62
  65. package/plugins/librarian/skills/librarian/SKILL.md +98 -1
  66. package/plugins/tribunal/.claude-plugin/plugin.json +1 -1
  67. package/plugins/tribunal/CHANGELOG.md +63 -0
  68. package/plugins/tribunal/agents/tribunal-judge-adversarial.md +34 -0
  69. package/plugins/tribunal/agents/tribunal-judge-security.md +33 -0
  70. package/plugins/tribunal/agents/tribunal-judge-standard.md +12 -0
  71. package/plugins/tribunal/config.json +1 -0
  72. package/plugins/tribunal/scripts/lib/tribunal-aggregate.sh +154 -9
  73. package/plugins/tribunal/scripts/lib/tribunal-gate.sh +195 -16
  74. package/plugins/tribunal/skills/tribunal/SKILL.md +5 -3
  75. package/scripts/lib/prompt-rules.sh +6 -1
  76. package/test/bats/archivist-inject.bats +1 -1
  77. package/test/bats/assayer-extract.bats +2 -2
  78. package/test/bats/bursar-session-start.bats +3 -3
  79. package/test/bats/cartographer-config.bats +43 -0
  80. package/test/bats/cartographer-events.bats +267 -0
  81. package/test/bats/cartographer-filter.bats +169 -0
  82. package/test/bats/cartographer-lock.bats +3 -3
  83. package/test/bats/cartographer-omission.bats +202 -0
  84. package/test/bats/cartographer-resolve.bats +287 -0
  85. package/test/bats/cartographer-run-audit.bats +325 -0
  86. package/test/bats/compass-sanitizer.bats +11 -11
  87. package/test/bats/compass-transcript.bats +2 -2
  88. package/test/bats/config.bats +15 -15
  89. package/test/bats/curator-session-start.bats +10 -3
  90. package/test/bats/emit-payload-default.bats +52 -0
  91. package/test/bats/governor-ledger.bats +1 -1
  92. package/test/bats/historian-prompt-submit.bats +1 -1
  93. package/test/bats/inspector-post-write-hook.bats +4 -4
  94. package/test/bats/librarian-author-key.bats +477 -0
  95. package/test/bats/librarian-cli.bats +16 -16
  96. package/test/bats/librarian-lesson-judge.bats +884 -0
  97. package/test/bats/librarian-lesson-promote.bats +552 -0
  98. package/test/bats/librarian-lesson-review.bats +900 -0
  99. package/test/bats/librarian-lesson-transform.bats +17 -3
  100. package/test/bats/librarian-session-end.bats +161 -0
  101. package/test/bats/librarian-session-start.bats +36 -2
  102. package/test/bats/lineage-config.bats +1 -1
  103. package/test/bats/lineage-redact.bats +5 -5
  104. package/test/bats/session-tracker.bats +4 -4
  105. package/test/bats/tribunal-aggregate.bats +354 -1
  106. package/test/bats/tribunal-gate.bats +631 -0
  107. package/test/bats/tribunal-judge-agents.bats +119 -0
  108. package/test/bats/tribunal-jury.bats +1 -1
  109. package/test/bats/turn-tracker.bats +1 -1
  110. package/test/bats/warden-sanitizer.bats +3 -3
  111. package/test/bats/worktree-tracker.bats +2 -2
  112. package/test/node/lesson-validate-agreement.test.mjs +35 -6
@@ -14,6 +14,7 @@ Every other Onlooker plugin is reactive. Cartographer is the exception.
14
14
  | `dead_rule` | A rule fully subsumed by a more specific rule elsewhere |
15
15
  | `stale_ref` | A reference to a file path, tool, or command that no longer exists |
16
16
  | `scope_collision` | A project rule that duplicates or silently overrides a global `~/.claude/CLAUDE.md` rule |
17
+ | `undocumented_entity` | Something that exists on disk — a plugin, a skill — that no instruction file mentions |
17
18
 
18
19
  ## Installation
19
20
 
@@ -42,7 +43,7 @@ Findings appear in the next `/cartographer` invocation or in any event log consu
42
43
  /cartographer --verbose # show all known findings (no bus events)
43
44
  /cartographer --status # running state + last completion time
44
45
  /cartographer --force # kill running audit and restart
45
- /cartographer --phase=contradiction # single-phase audit
46
+ /cartographer --type=stale_ref # one finding type; skips the other analyzers
46
47
  /cartographer --scope=src/ # scoped to a subdirectory
47
48
  ```
48
49
 
@@ -74,6 +75,43 @@ All options are optional. Defaults shown:
74
75
 
75
76
  **Note:** Overriding `exclude_paths` replaces the entire list. Repeat the defaults plus your additions if you want to extend rather than replace.
76
77
 
78
+ ### Detecting omissions
79
+
80
+ Every other check reads the instruction files and tests what it finds against
81
+ the filesystem. `undocumented_entity` runs the other way: it enumerates
82
+ entities on disk and flags any whose name appears in no instruction file. That
83
+ is the one kind of drift the other checks structurally cannot see — something
84
+ absent produces no reference to follow. The mention check is case-sensitive,
85
+ so `plugins/foo/` documented only as "Foo" is still flagged as undocumented.
86
+
87
+ This phase only runs as part of a full audit (SessionStart or manual
88
+ `/cartographer`) — it is skipped on the targeted post-write audit that runs
89
+ after you edit an instruction file, so editing `CLAUDE.md` never produces an
90
+ omission finding on its own.
91
+
92
+ ```json
93
+ {
94
+ "cartographer": {
95
+ "undocumented_entity": {
96
+ "enabled": true,
97
+ "globs": ["plugins/*/", "skills/*/"],
98
+ "exclude": [],
99
+ "max_findings": 20
100
+ }
101
+ }
102
+ }
103
+ ```
104
+
105
+ `globs` are relative to the repository root, and a glob matching nothing is
106
+ simply inert — the defaults do nothing in a repository without those
107
+ directories. The list is deliberately opt-in: most of a repository has no
108
+ business being named in `CLAUDE.md`, so only classes where you expect the
109
+ documentation to be *complete* belong here.
110
+
111
+ **Note:** as with `exclude_paths`, overriding `globs` or `exclude` replaces the
112
+ entire list rather than extending it. Repeat the defaults alongside your
113
+ additions if you mean to extend.
114
+
77
115
  ## Privacy
78
116
 
79
117
  - All analysis uses `claude -p` via your existing Claude Code session — no separate API key, no new data recipient.
@@ -92,10 +130,27 @@ All options are optional. Defaults shown:
92
130
  └── dedup/ # empty sentinel per emitted finding hash
93
131
  ```
94
132
 
133
+ ## Finding lifecycle
134
+
135
+ A finding is born `resolved: false` and refreshed on every audit that observes it again. A **full** audit that completes without a failed phase then retires anything it did not observe: absence is the evidence that the drift is gone, so those records get `resolved: true` and a `resolved_at` stamp, and `/cartographer` stops rendering them. `/cartographer --verbose` still lists them, tagged `[RESOLVED]`, so you can confirm a fix took.
136
+
137
+ Two runs deliberately retire nothing, because neither looked widely enough for absence to mean anything:
138
+
139
+ - **Targeted post-write audits** evaluate a single file, so nearly every stored finding is absent for reasons unrelated to being fixed.
140
+ - **Partial runs** (any phase timed out or errored) produce no findings for the phase that failed, which is indistinguishable from its findings being gone.
141
+
142
+ Resolution is not terminal. The dedup sentinel outlives it, so drift that is reintroduced returns as a *known* finding — that path reopens the record (`resolved: false`, `resolved_at` cleared) rather than filing a new one, keeping `first_seen_at` intact. Without that, a recurring finding would stay hidden forever.
143
+
95
144
  ## Event delivery
96
145
 
97
146
  `cartographer.issue.found` events are delivered at-least-once. If the audit process crashes between emitting an event and writing the dedup sentinel, the finding is re-emitted once on the next run. Downstream consumers must deduplicate on `payload.finding_hash`.
98
147
 
148
+ `cartographer.issue.resolved` carries the same `finding_hash`, so a consumer can close the finding it opened and hold open/closed state from the log alone. It is emitted only by a run that looked everywhere: a targeted audit sees a single file and a run with a failed phase sees an incomplete corpus, so neither can treat a finding's absence as evidence its drift is gone.
149
+
150
+ The payload carries no timestamp of its own. The envelope's required `timestamp` is the resolution time, since the event is emitted from the sweep that flips the record.
151
+
152
+ `cartographer.audit.complete` reports `resolved_finding_count` for the same reason and under the same condition — a run that skipped the sweep omits the field entirely rather than reporting `0`, which would read as "swept, retired nothing".
153
+
99
154
  ## Non-goals
100
155
 
101
156
  Cartographer will not:
@@ -15,6 +15,12 @@
15
15
  "model": "claude-haiku-4-5-20251001",
16
16
  "max_output_tokens": 2048
17
17
  },
18
- "exclude_paths": ["node_modules", ".git", "vendor", ".venv", "dist", ".next", ".nuxt", "build", "__pycache__"]
18
+ "exclude_paths": ["node_modules", ".git", "vendor", ".venv", "dist", ".next", ".nuxt", "build", "__pycache__"],
19
+ "undocumented_entity": {
20
+ "enabled": true,
21
+ "globs": ["plugins/*/", "skills/*/"],
22
+ "exclude": [],
23
+ "max_findings": 20
24
+ }
19
25
  }
20
26
  }
@@ -66,18 +66,18 @@ export CARTOGRAPHER_TARGET_FILE="$CANONICAL"
66
66
  export CARTOGRAPHER_REPO_ROOT="$REPO_ROOT"
67
67
  export ONLOOKER_DIR
68
68
 
69
+ # The launcher deliberately loads no config — see the matching note in
70
+ # cartographer-session-start.sh. It execs run-audit.sh, which loads config once
71
+ # for itself; anything loaded here is discarded by the exec, and could not have
72
+ # resolved in the first place because PLUGIN_ROOT is not exported.
69
73
  if command -v setsid &>/dev/null; then
70
74
  nohup setsid bash -c "
71
75
  trap 'source \"$PLUGIN_ROOT/scripts/lib/cartographer-lock.sh\"; cartographer_lock_release \"$LOCK_FILE\"' EXIT
72
- source \"$PLUGIN_ROOT/scripts/lib/cartographer-config.sh\"
73
- cartographer_config_load \"$REPO_ROOT\"
74
76
  exec \"$PLUGIN_ROOT/scripts/run-audit.sh\"
75
77
  " >>"$CARTOGRAPHER_DIR/audit.log" 2>&1 &
76
78
  else
77
79
  nohup bash -c "
78
80
  trap 'source \"$PLUGIN_ROOT/scripts/lib/cartographer-lock.sh\"; cartographer_lock_release \"$LOCK_FILE\"' EXIT
79
- source \"$PLUGIN_ROOT/scripts/lib/cartographer-config.sh\"
80
- cartographer_config_load \"$REPO_ROOT\"
81
81
  exec \"$PLUGIN_ROOT/scripts/run-audit.sh\"
82
82
  " >>"$CARTOGRAPHER_DIR/audit.log" 2>&1 &
83
83
  fi
@@ -68,18 +68,21 @@ export CARTOGRAPHER_TRIGGER="$TRIGGER"
68
68
  export CARTOGRAPHER_REPO_ROOT="$REPO_ROOT"
69
69
  export ONLOOKER_DIR
70
70
 
71
+ # The launcher deliberately loads no config. It execs run-audit.sh, which
72
+ # replaces the process, and the config lives in a plain shell variable that is
73
+ # never exported — so anything loaded here is discarded a line later. It also
74
+ # could not have worked: PLUGIN_ROOT is not exported (only CLAUDE_PLUGIN_ROOT
75
+ # is), so cartographer-config.sh resolved config-loader.sh against an empty
76
+ # prefix in this sub-shell and appended two lines of noise to audit.log on every
77
+ # audit. run-audit.sh loads config once, for itself.
71
78
  if command -v setsid &>/dev/null; then
72
79
  nohup setsid bash -c "
73
80
  trap 'source \"$PLUGIN_ROOT/scripts/lib/cartographer-lock.sh\"; cartographer_lock_release \"$LOCK_FILE\"' EXIT
74
- source \"$PLUGIN_ROOT/scripts/lib/cartographer-config.sh\"
75
- cartographer_config_load \"$REPO_ROOT\"
76
81
  exec \"$PLUGIN_ROOT/scripts/run-audit.sh\"
77
82
  " >>"$CARTOGRAPHER_DIR/audit.log" 2>&1 &
78
83
  else
79
84
  nohup bash -c "
80
85
  trap 'source \"$PLUGIN_ROOT/scripts/lib/cartographer-lock.sh\"; cartographer_lock_release \"$LOCK_FILE\"' EXIT
81
- source \"$PLUGIN_ROOT/scripts/lib/cartographer-config.sh\"
82
- cartographer_config_load \"$REPO_ROOT\"
83
86
  exec \"$PLUGIN_ROOT/scripts/run-audit.sh\"
84
87
  " >>"$CARTOGRAPHER_DIR/audit.log" 2>&1 &
85
88
  fi
@@ -25,9 +25,16 @@ cartographer_collect_files() {
25
25
  done < <(printf '%s' "$exclude_json" | jq -r '.[]' 2>/dev/null)
26
26
 
27
27
  # Discover CLAUDE.md and AGENTS.md
28
+ #
29
+ # ${find_excludes[@]+"${find_excludes[@]}"} rather than "${find_excludes[@]}":
30
+ # under bash 3.2 (macOS system bash) with `set -u`, expanding a genuinely
31
+ # empty array with the plain form is an unbound-variable error, not an
32
+ # empty expansion — that's the case whenever exclude_json has zero entries
33
+ # (e.g. it is unset upstream, or a caller deliberately passes "[]"). The
34
+ # `+` form is the standard portable guard for bash <4.4.
28
35
  find "$repo_root" -maxdepth "$max_depth" \
29
36
  \( -name "CLAUDE.md" -o -name "AGENTS.md" \) \
30
- "${find_excludes[@]}" \
37
+ ${find_excludes[@]+"${find_excludes[@]}"} \
31
38
  -type f 2>/dev/null
32
39
 
33
40
  # Discover .claude/rules/*.md at repo level
@@ -78,3 +78,29 @@ cartographer_config_max_output_tokens_synthesis() {
78
78
  v=$(cartographer_config_get '.cartographer.synthesis.max_output_tokens')
79
79
  printf '%s' "${v:-2048}"
80
80
  }
81
+
82
+ # ── undocumented_entity phase ──────────────────────────────────────────────────
83
+ # Disk → doc detection. Unlike the other phases these are read inside the
84
+ # analysis sub-shell rather than by the orchestrator; see run-audit.sh and
85
+ # ecosystem-88v for why.
86
+
87
+ cartographer_config_undocumented_enabled() {
88
+ local v
89
+ v=$(cartographer_config_get '.cartographer.undocumented_entity.enabled')
90
+ printf '%s' "${v:-true}"
91
+ }
92
+
93
+ cartographer_config_undocumented_globs() {
94
+ cartographer_config_get_json \
95
+ '.cartographer.undocumented_entity.globs // ["plugins/*/","skills/*/"]'
96
+ }
97
+
98
+ cartographer_config_undocumented_exclude() {
99
+ cartographer_config_get_json '.cartographer.undocumented_entity.exclude // []'
100
+ }
101
+
102
+ cartographer_config_undocumented_max_findings() {
103
+ local v
104
+ v=$(cartographer_config_get '.cartographer.undocumented_entity.max_findings')
105
+ printf '%s' "${v:-20}"
106
+ }
@@ -44,6 +44,96 @@ _cartographer_session_id() {
44
44
  printf 'unknown'
45
45
  }
46
46
 
47
+ # Build the cartographer.issue.found payload for one finding record.
48
+ #
49
+ # Lives here rather than inline in run_emit so the test suite drives the same
50
+ # construction production does. Two independent descriptions of one payload is
51
+ # precisely how the published schema came to document events no code had ever
52
+ # emitted; a single builder means a test that passes is evidence about the real
53
+ # emission path, not about a copy of it.
54
+ #
55
+ # Usage: cartographer_issue_found_payload <audit_id> <finding_hash> <finding_json>
56
+ cartographer_issue_found_payload() {
57
+ local audit_id="${1:-}" finding_hash="${2:-}" finding="${3:-}"
58
+ [[ -z "$audit_id" || -z "$finding_hash" || -z "$finding" ]] && return 1
59
+
60
+ local ftype fseverity ffile_a ffile_b fdesc
61
+ # A finding with no usable type is malformed — the analysis phase that
62
+ # produced it has a bug. This used to default to "unknown", a value no
63
+ # schema admits, so the payload was built, rejected at emit, and the
64
+ # rejection swallowed by the caller's emit_safe: the finding reached disk
65
+ # and nothing reached the bus. Failing here puts it in audit.log instead,
66
+ # where an operator can read it (ecosystem-ci0).
67
+ #
68
+ # `// ""` collapses absent, null, and empty into one check. jq's `//`
69
+ # treats "" as present, so an empty type would otherwise slip through and
70
+ # produce a payload just as unvalidatable as "unknown", by another route.
71
+ ftype=$(printf '%s' "$finding" | jq -r '.type // ""')
72
+ if [[ -z "$ftype" ]]; then
73
+ printf 'cartographer-events: finding %s carries no type; refusing to emit issue.found\n' \
74
+ "$finding_hash" >&2
75
+ return 1
76
+ fi
77
+ fseverity=$(printf '%s' "$finding" | jq -r '.severity // "warning"')
78
+ ffile_a=$(printf '%s' "$finding" | jq -r '.file_a // ""')
79
+ ffile_b=$(printf '%s' "$finding" | jq -r '.file_b // null')
80
+ fdesc=$(printf '%s' "$finding" | jq -r '.description // ""')
81
+
82
+ jq -n \
83
+ --arg audit_id "$audit_id" \
84
+ --arg finding_hash "$finding_hash" \
85
+ --arg finding_type "$ftype" \
86
+ --arg severity "$fseverity" \
87
+ --argjson affected_files "$(jq -n --arg a "$ffile_a" --arg b "$ffile_b" \
88
+ 'if $b == "null" or $b == "" then [$a] else [$a,$b] end')" \
89
+ --arg summary "$fdesc" \
90
+ '{"audit_id":$audit_id,"finding_hash":$finding_hash,"finding_type":$finding_type,"severity":$severity,"affected_files":$affected_files,"summary":$summary}'
91
+ }
92
+
93
+ # Build the cartographer.issue.resolved payload for a retired finding.
94
+ #
95
+ # Deliberately just the pair: the envelope already carries a required timestamp
96
+ # set at emit time, and this is emitted from the sweep that flips the record, so
97
+ # a payload-level resolved_at would duplicate it while being free to disagree —
98
+ # the record stores epoch seconds, the envelope ISO 8601. The schema rejects the
99
+ # field for that reason (onlooker-community/schema#50).
100
+ #
101
+ # Usage: cartographer_issue_resolved_payload <audit_id> <finding_hash>
102
+ cartographer_issue_resolved_payload() {
103
+ local audit_id="${1:-}" finding_hash="${2:-}"
104
+ [[ -z "$audit_id" || -z "$finding_hash" ]] && return 1
105
+
106
+ jq -n \
107
+ --arg audit_id "$audit_id" \
108
+ --arg finding_hash "$finding_hash" \
109
+ '{"audit_id":$audit_id,"finding_hash":$finding_hash}'
110
+ }
111
+
112
+ # Build the cartographer.audit.complete payload for a finished run.
113
+ #
114
+ # resolved_count is optional and omitted from the payload when empty. That is
115
+ # the difference between "swept and retired nothing" and "never swept": a
116
+ # targeted or partial run cannot treat a finding's absence as evidence, so it
117
+ # must report no count rather than a zero that reads as the former.
118
+ #
119
+ # Usage: cartographer_audit_complete_payload <audit_id> <trigger> <new_count> <total_count> <duration_ms> [resolved_count]
120
+ cartographer_audit_complete_payload() {
121
+ local audit_id="${1:-}" trigger="${2:-}"
122
+ local new_count="${3:-0}" total_count="${4:-0}" duration_ms="${5:-0}"
123
+ local resolved_count="${6:-}"
124
+ [[ -z "$audit_id" ]] && return 1
125
+
126
+ jq -n \
127
+ --arg audit_id "$audit_id" \
128
+ --arg trigger "$trigger" \
129
+ --argjson new_finding_count "$new_count" \
130
+ --argjson total_finding_count "$total_count" \
131
+ --argjson duration_ms "$duration_ms" \
132
+ --arg resolved "$resolved_count" \
133
+ '{"audit_id":$audit_id,"trigger":$trigger,"new_finding_count":$new_finding_count,"total_finding_count":$total_finding_count,"duration_ms":$duration_ms}
134
+ + (if $resolved == "" then {} else {"resolved_finding_count": ($resolved|tonumber)} end)'
135
+ }
136
+
47
137
  cartographer_emit_event() {
48
138
  local event_type="${1:-}"
49
139
  local payload="${2:-}"
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env bash
2
+ # cartographer-filter.sh — narrow an audit to one finding type or one subtree.
3
+ #
4
+ # SKILL.md documented a --phase flag and a --scope flag that run-audit.sh never
5
+ # read, so both silently ran a full audit (ecosystem-9og). The vocabulary was
6
+ # wrong too: --phase listed finding TYPES, while the pipeline's phases are
7
+ # discover/extract/relate/synthesize/emit. The flag is --type here for that
8
+ # reason — it narrows what the audit looks FOR, not which stage runs.
9
+ #
10
+ # The five types do not map one-to-one onto analyzer calls:
11
+ #
12
+ # contradiction \_ one LLM pass emits either type, so neither can be
13
+ # dead_rule / requested without running the other
14
+ # stale_ref — its own call
15
+ # scope_collision — its own call
16
+ # undocumented_entity — its own call
17
+ #
18
+ # So narrowing works in two steps: skip the analyzers that cannot produce the
19
+ # requested type, then drop any findings the surviving analyzers produced that
20
+ # were not asked for. Skipping is where the savings are — each analyzer skipped
21
+ # is an LLM call not made.
22
+
23
+ CARTOGRAPHER_FINDING_TYPES="contradiction dead_rule stale_ref scope_collision undocumented_entity"
24
+
25
+ # True when a type filter is set and valid.
26
+ #
27
+ # An unrecognized value is rejected rather than silently matching nothing: a
28
+ # typo that quietly produced an empty audit would look exactly like a clean
29
+ # repo, which is the failure this whole issue is about.
30
+ #
31
+ # Usage: cartographer_filter_valid_type <type>
32
+ cartographer_filter_valid_type() {
33
+ local want="${1:-}"
34
+ [[ -z "$want" ]] && return 1
35
+ local t
36
+ for t in $CARTOGRAPHER_FINDING_TYPES; do
37
+ [[ "$t" == "$want" ]] && return 0
38
+ done
39
+ return 1
40
+ }
41
+
42
+ # True when the analyzer should run under the active filter.
43
+ #
44
+ # An empty filter means no filter, so everything runs. The contradiction
45
+ # analyzer answers to both of the types it can emit.
46
+ #
47
+ # Usage: cartographer_filter_wants <analyzer> <type_filter>
48
+ # analyzer: contradiction | stale_ref | scope_collision | undocumented_entity
49
+ cartographer_filter_wants() {
50
+ local analyzer="${1:-}" filter="${2:-}"
51
+ [[ -z "$filter" ]] && return 0
52
+ [[ -z "$analyzer" ]] && return 1
53
+
54
+ if [[ "$analyzer" == "contradiction" ]]; then
55
+ [[ "$filter" == "contradiction" || "$filter" == "dead_rule" ]]
56
+ return
57
+ fi
58
+ [[ "$analyzer" == "$filter" ]]
59
+ }
60
+
61
+ # Drop findings whose type was not requested.
62
+ #
63
+ # Only the contradiction analyzer can return a type other than the one asked
64
+ # for, but filtering the whole set is simpler than special-casing it and stays
65
+ # correct if an analyzer gains a second output type later.
66
+ #
67
+ # Usage: cartographer_filter_findings <findings_json> <type_filter>
68
+ cartographer_filter_findings() {
69
+ local findings="${1:-[]}" filter="${2:-}"
70
+ if [[ -z "$filter" ]]; then
71
+ printf '%s' "$findings"
72
+ return 0
73
+ fi
74
+ printf '%s' "$findings" \
75
+ | jq -c --arg t "$filter" '[.[] | select(.type == $t)]' 2>/dev/null \
76
+ || printf '[]'
77
+ }
78
+
79
+ # Restrict a discovered-file list to those under a subtree.
80
+ #
81
+ # The scope narrows which instruction files are ANALYZED; it deliberately does
82
+ # not become the repo root, because stale_ref resolves path-like tokens against
83
+ # the real root and would otherwise report every path outside the scope as
84
+ # broken.
85
+ #
86
+ # A scope matching nothing yields an empty list, which is a legitimate answer —
87
+ # the caller decides whether that is worth reporting.
88
+ #
89
+ # Usage: cartographer_filter_scope <files_json> <repo_root> <scope_path>
90
+ cartographer_filter_scope() {
91
+ local files="${1:-[]}" repo_root="${2:-}" scope="${3:-}"
92
+ if [[ -z "$scope" ]]; then
93
+ printf '%s' "$files"
94
+ return 0
95
+ fi
96
+
97
+ # Accept a repo-relative scope or an absolute one.
98
+ local abs="$scope"
99
+ [[ "$abs" != /* ]] && abs="${repo_root%/}/${scope#./}"
100
+ abs="${abs%/}"
101
+
102
+ printf '%s' "$files" \
103
+ | jq -c --arg p "$abs" '[.[] | select(. == $p or startswith($p + "/"))]' 2>/dev/null \
104
+ || printf '[]'
105
+ }
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env bash
2
+ # cartographer-omission.sh — disk → doc detection.
3
+ #
4
+ # Every other analysis phase starts from the text of the instruction files and
5
+ # tests what it finds against the filesystem. This one runs the other way: it
6
+ # enumerates entities on disk and checks each is mentioned somewhere in the
7
+ # corpus. An entity nothing names produces no token for stale_ref to extract
8
+ # and no rule for contradiction to compare, which is why omissions were
9
+ # previously invisible to every phase.
10
+ #
11
+ # Detection is a grep — no model call. A model would only help judge whether an
12
+ # omission MATTERS, which is a sharper question than the drift that motivated
13
+ # this. See docs/superpowers/specs/2026-08-16-cartographer-undocumented-entity-design.md
14
+ #
15
+ # Usage:
16
+ # cartographer_analyze_undocumented_entity <files_json> <repo_root> \
17
+ # <globs_json> <exclude_json> <max_findings>
18
+ #
19
+ # Prints a JSON array of findings on stdout in the same shape the analyzers in
20
+ # cartographer-analyze.sh return, so run_synthesize merges it without special
21
+ # handling. Diagnostics go to stderr, which run-audit.sh appends to audit.log.
22
+
23
+ # Word-boundary mention test.
24
+ #
25
+ # Boundaries are hand-rolled rather than \b because entity names contain
26
+ # hyphens, and \b treats '-' as a non-word character: \blist-prompt-rules\b
27
+ # would also match inside "my-list-prompt-rules-thing". Bounding on
28
+ # [^A-Za-z0-9_-] instead means a hyphenated name matches only when genuinely
29
+ # standalone, while a name inside a path ("plugins/beta/") still counts.
30
+ _cartographer_name_mentioned() {
31
+ local name="$1"
32
+ local files_json="$2"
33
+
34
+ local escaped
35
+ escaped=$(printf '%s' "$name" | sed 's/[][\.*^$(){}?+|\\]/\\&/g')
36
+
37
+ local fpath
38
+ while IFS= read -r fpath; do
39
+ [[ -z "$fpath" || ! -f "$fpath" ]] && continue
40
+ if grep -qE "(^|[^A-Za-z0-9_-])${escaped}([^A-Za-z0-9_-]|\$)" "$fpath" 2>/dev/null; then
41
+ return 0
42
+ fi
43
+ done < <(printf '%s' "$files_json" | jq -r '.[]' 2>/dev/null)
44
+ return 1
45
+ }
46
+
47
+ cartographer_analyze_undocumented_entity() {
48
+ local files_json="${1:-[]}"
49
+ local repo_root="${2:?repo_root required}"
50
+ local globs_json="${3:-[]}"
51
+ local exclude_json="${4:-[]}"
52
+ local max_findings="${5:-20}"
53
+
54
+ # An empty corpus cannot document anything, so every entity would look
55
+ # undocumented. Refuse rather than emit a burst of false findings that the
56
+ # emit phase would dedup-sentinel and never re-evaluate.
57
+ local corpus_count
58
+ corpus_count=$(printf '%s' "$files_json" | jq 'length' 2>/dev/null || printf '0')
59
+ [[ "${corpus_count:-0}" -eq 0 ]] && { printf '[]'; return 0; }
60
+
61
+ local findings="[]"
62
+ local emitted=0 dropped=0
63
+ local root="${repo_root%/}"
64
+
65
+ # nullglob so an unmatched pattern expands to nothing rather than to itself.
66
+ # Restore the prior setting — this library is sourced, not run.
67
+ local had_nullglob=0
68
+ shopt -q nullglob && had_nullglob=1
69
+ shopt -s nullglob
70
+
71
+ local glob match
72
+ while IFS= read -r glob; do
73
+ [[ -z "$glob" ]] && continue
74
+ # shellcheck disable=SC2086 # $glob unquoted on purpose: this is the glob
75
+ # expansion. ${root} IS quoted — a repo path containing a space must not
76
+ # word-split before the glob expands, or the match silently finds nothing.
77
+ for match in "${root}"/$glob; do
78
+ [[ -e "$match" ]] || continue
79
+
80
+ local trimmed="${match%/}"
81
+ local name relpath
82
+ name=$(basename "$trimmed")
83
+ relpath="${trimmed#"${root}"/}"
84
+
85
+ local excluded=0 excl
86
+ while IFS= read -r excl; do
87
+ [[ -z "$excl" ]] && continue
88
+ [[ "$relpath" == *"$excl"* ]] && { excluded=1; break; }
89
+ done < <(printf '%s' "$exclude_json" | jq -r '.[]' 2>/dev/null)
90
+ [[ "$excluded" -eq 1 ]] && continue
91
+
92
+ _cartographer_name_mentioned "$name" "$files_json" && continue
93
+
94
+ if [[ "$emitted" -ge "$max_findings" ]]; then
95
+ dropped=$(( dropped + 1 ))
96
+ continue
97
+ fi
98
+
99
+ local finding
100
+ finding=$(jq -n \
101
+ --arg fa "$trimmed" \
102
+ --arg n "$name" \
103
+ --arg rp "$relpath" \
104
+ '{
105
+ type: "undocumented_entity",
106
+ severity: "warning",
107
+ file_a: $fa,
108
+ excerpt_a: $n,
109
+ file_b: null,
110
+ excerpt_b: null,
111
+ description: ($n + " exists at " + $rp
112
+ + " but is not mentioned in any instruction file."),
113
+ suggested_fix: ("Document " + $n
114
+ + " in CLAUDE.md, or exclude its path from cartographer.undocumented_entity.")
115
+ }')
116
+ findings=$(printf '%s' "$findings" | jq --argjson f "$finding" '. + [$f]')
117
+ emitted=$(( emitted + 1 ))
118
+ done
119
+ done < <(printf '%s' "$globs_json" | jq -r '.[]' 2>/dev/null)
120
+
121
+ [[ "$had_nullglob" -eq 0 ]] && shopt -u nullglob
122
+
123
+ # Say what was dropped. A silent truncation reads as "this is everything".
124
+ if [[ "$dropped" -gt 0 ]]; then
125
+ printf 'undocumented_entity: capped at %s findings, %s candidate(s) dropped\n' \
126
+ "$max_findings" "$dropped" >&2
127
+ fi
128
+
129
+ printf '%s' "$findings"
130
+ }
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env bash
2
+ # cartographer-resolve.sh — retire findings whose drift is gone.
3
+ #
4
+ # A finding is written once to findings/<hash>.json and re-observed on every
5
+ # later audit, which refreshes last_seen_at. Nothing ever marked one absent, so
6
+ # fixing the drift a finding reported did not retire the finding — it rendered
7
+ # forever. The record already carried resolved:false from birth; the field
8
+ # existed, the loop that flips it did not.
9
+ #
10
+ # Absence is the evidence: the audit knows every finding it observed this run,
11
+ # so anything in the store whose last_seen_at predates this run's start was not
12
+ # observed and its drift is gone.
13
+ #
14
+ # That inference is only sound when the run actually looked everywhere, which is
15
+ # what the two guards below protect.
16
+
17
+ # Refresh a finding that was observed again this run.
18
+ #
19
+ # Usage: cartographer_refresh_finding <finding_file> <now>
20
+ #
21
+ # Reopening matters as much as the timestamp. The dedup sentinel outlives
22
+ # resolution, so drift that is fixed and then reintroduced arrives here rather
23
+ # than down the new-finding path. Leaving resolved:true would keep a live
24
+ # finding hidden from the renderer forever — a silent false negative, and a
25
+ # worse failure than the stale findings this module exists to retire.
26
+ cartographer_refresh_finding() {
27
+ local finding_file="${1:-}" now="${2:-}"
28
+ [[ -z "$finding_file" || -z "$now" ]] && return 1
29
+ [[ -f "$finding_file" ]] || return 1
30
+
31
+ local updated
32
+ updated=$(jq --argjson ts "$now" \
33
+ '.last_seen_at = $ts | .resolved = false | del(.resolved_at)' \
34
+ "$finding_file" 2>/dev/null) || return 1
35
+ [[ -z "$updated" ]] && return 1
36
+
37
+ printf '%s\n' "$updated" >"${finding_file}.tmp" || return 1
38
+ mv -f "${finding_file}.tmp" "$finding_file"
39
+ }
40
+
41
+ # Whether this run looked at enough for absence to mean anything.
42
+ #
43
+ # Two ways it does not. A targeted post-write audit evaluates a single file, so
44
+ # almost every stored finding is "unobserved" for reasons that have nothing to
45
+ # do with being fixed — letting it resolve would wipe the store on every edit.
46
+ # And a phase that timed out or errored contributes no findings, which is
47
+ # indistinguishable from its findings being gone.
48
+ #
49
+ # Named rather than inlined because run_emit needs the same answer to decide
50
+ # whether cartographer.audit.complete may report a resolved count at all. Two
51
+ # copies of this condition is precisely the kind of divergence that has bitten
52
+ # this plugin before.
53
+ #
54
+ # Usage: cartographer_resolution_is_sound <target_file> <phases_failed>
55
+ cartographer_resolution_is_sound() {
56
+ local target_file="${1:-}" phases_failed="${2:-0}"
57
+ [[ -z "$target_file" && "$phases_failed" -eq 0 ]]
58
+ }
59
+
60
+ # Mark findings not observed by this audit as resolved.
61
+ #
62
+ # Usage:
63
+ # cartographer_resolve_absent_findings <findings_dir> <cutoff_ts> \
64
+ # <target_file> <phases_failed_count> \
65
+ # [now] [on_resolved]
66
+ #
67
+ # on_resolved, when given, is the name of a function called once per retired
68
+ # finding with its hash. Its stdout is discarded; see the call site below.
69
+ #
70
+ # cutoff_ts is the audit's start time: a finding observed this run had its
71
+ # last_seen_at refreshed to at-or-after it, so a record older than it was not
72
+ # observed. The comparison is strict (<), which errs toward leaving a finding
73
+ # open when the timestamps collide — a stale finding is visible and correctable,
74
+ # an incorrectly retired one is silent.
75
+ #
76
+ # Prints the number of findings newly resolved.
77
+ cartographer_resolve_absent_findings() {
78
+ local findings_dir="${1:-}" cutoff_ts="${2:-}"
79
+ local target_file="${3:-}" phases_failed="${4:-0}"
80
+ local now="${5:-}"
81
+ local on_resolved="${6:-}"
82
+ [[ -z "$now" ]] && now=$(date +%s)
83
+
84
+ [[ -z "$findings_dir" || -z "$cutoff_ts" ]] && return 1
85
+ if [[ ! -d "$findings_dir" ]]; then
86
+ printf '0'
87
+ return 0
88
+ fi
89
+
90
+ # Both guards live in the predicate. A run that fails either one retires
91
+ # nothing and announces nothing — announcing a resolution the run did not
92
+ # establish tells every consumer to close a finding that is still live.
93
+ if ! cartographer_resolution_is_sound "$target_file" "$phases_failed"; then
94
+ printf '0'
95
+ return 0
96
+ fi
97
+
98
+ local resolved=0 f updated
99
+ for f in "$findings_dir"/*.json; do
100
+ [[ -f "$f" ]] || continue
101
+ # Emits nothing when the record is already resolved or was seen this
102
+ # run, so an unchanged record is never rewritten and never counted.
103
+ updated=$(jq --argjson cutoff "$cutoff_ts" --argjson now "$now" '
104
+ if .resolved == true then empty
105
+ elif ((.last_seen_at // 0) < $cutoff) then . + {resolved: true, resolved_at: $now}
106
+ else empty end
107
+ ' "$f" 2>/dev/null) || continue
108
+ [[ -z "$updated" ]] && continue
109
+ printf '%s\n' "$updated" >"${f}.tmp" || continue
110
+ mv -f "${f}.tmp" "$f" || continue
111
+ resolved=$(( resolved + 1 ))
112
+
113
+ # Announce inside the guarded loop, so a run that must not resolve also
114
+ # cannot announce. The emitter is injected rather than sourced: this
115
+ # module stays pure data, and a test can pass a stub without dragging
116
+ # the event bus in behind it.
117
+ #
118
+ # stdout is the count channel — the caller reads it through a command
119
+ # substitution — so the emitter is redirected away from it. That is also
120
+ # why announcing works at all here despite the subshell: appending to
121
+ # the event log is a filesystem effect, and unlike a variable it
122
+ # survives.
123
+ if [[ -n "$on_resolved" ]]; then
124
+ local fhash
125
+ fhash=$(printf '%s' "$updated" | jq -r '.finding_hash // ""')
126
+ [[ -z "$fhash" ]] && fhash=$(basename "$f" .json)
127
+ "$on_resolved" "$fhash" >/dev/null || true
128
+ fi
129
+ done
130
+
131
+ printf '%d' "$resolved"
132
+ }