@onlooker-community/ecosystem 0.10.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/.claude-plugin/marketplace.json +39 -1
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/.github/copilot-instructions.md +46 -0
  4. package/.github/workflows/coverage.yml +78 -0
  5. package/.github/workflows/release.yml +24 -8
  6. package/.github/workflows/test.yml +3 -0
  7. package/.markdownlintignore +3 -0
  8. package/.release-please-manifest.json +5 -1
  9. package/CHANGELOG.md +44 -0
  10. package/README.md +58 -13
  11. package/config.json +6 -1
  12. package/docs/adr/001-claude-code-hooks-as-integration-surface.md +43 -0
  13. package/docs/adr/002-centralized-jsonl-event-log.md +39 -0
  14. package/docs/adr/003-ulid-over-uuid.md +40 -0
  15. package/docs/adr/004-plugin-config-with-settings-overlay.md +34 -0
  16. package/docs/architecture.md +123 -0
  17. package/hooks/hooks.json +4 -0
  18. package/package.json +13 -7
  19. package/plugins/archivist/.claude-plugin/plugin.json +14 -0
  20. package/plugins/archivist/CHANGELOG.md +8 -0
  21. package/plugins/archivist/README.md +105 -0
  22. package/plugins/archivist/config.json +18 -0
  23. package/plugins/archivist/hooks/hooks.json +35 -0
  24. package/plugins/archivist/scripts/hooks/archivist-extract.sh +238 -0
  25. package/plugins/archivist/scripts/hooks/archivist-inject.sh +159 -0
  26. package/plugins/archivist/scripts/lib/archivist-config.sh +66 -0
  27. package/plugins/archivist/scripts/lib/archivist-project-key.sh +91 -0
  28. package/plugins/archivist/scripts/lib/archivist-storage.sh +215 -0
  29. package/plugins/archivist/scripts/lib/archivist-ulid.sh +52 -0
  30. package/plugins/cartographer/.claude-plugin/plugin.json +14 -0
  31. package/plugins/cartographer/CHANGELOG.md +27 -0
  32. package/plugins/cartographer/README.md +113 -0
  33. package/plugins/cartographer/config.json +21 -0
  34. package/plugins/cartographer/docs/adr/001-background-audit-launch.md +28 -0
  35. package/plugins/cartographer/docs/adr/002-flock-pid-file-fallback.md +30 -0
  36. package/plugins/cartographer/docs/adr/003-at-least-once-event-delivery.md +32 -0
  37. package/plugins/cartographer/docs/adr/004-exclude-paths-replace-semantics.md +27 -0
  38. package/plugins/cartographer/hooks/hooks.json +44 -0
  39. package/plugins/cartographer/scripts/hooks/cartographer-post-write.sh +87 -0
  40. package/plugins/cartographer/scripts/hooks/cartographer-session-start.sh +89 -0
  41. package/plugins/cartographer/scripts/lib/cartographer-analyze.sh +286 -0
  42. package/plugins/cartographer/scripts/lib/cartographer-collect.sh +59 -0
  43. package/plugins/cartographer/scripts/lib/cartographer-config.sh +105 -0
  44. package/plugins/cartographer/scripts/lib/cartographer-events.sh +82 -0
  45. package/plugins/cartographer/scripts/lib/cartographer-lock.sh +38 -0
  46. package/plugins/cartographer/scripts/lib/cartographer-project-key.sh +55 -0
  47. package/plugins/cartographer/scripts/lib/cartographer-ulid.sh +47 -0
  48. package/plugins/cartographer/scripts/run-audit.sh +309 -0
  49. package/plugins/cartographer/skills/cartographer/SKILL.md +154 -0
  50. package/plugins/echo/.claude-plugin/plugin.json +14 -0
  51. package/plugins/echo/CHANGELOG.md +24 -0
  52. package/plugins/echo/README.md +110 -0
  53. package/plugins/echo/config.json +15 -0
  54. package/plugins/echo/docs/adr/001-echo-as-separate-plugin.md +33 -0
  55. package/plugins/echo/docs/adr/002-direct-evaluation-vs-tribunal-pipeline.md +35 -0
  56. package/plugins/echo/docs/adr/003-stop-hook-trigger.md +40 -0
  57. package/plugins/echo/hooks/hooks.json +15 -0
  58. package/plugins/echo/scripts/hooks/echo-stop-gate.sh +366 -0
  59. package/plugins/echo/scripts/lib/echo-config.sh +108 -0
  60. package/plugins/echo/scripts/lib/echo-events.sh +74 -0
  61. package/plugins/echo/scripts/lib/echo-project-key.sh +81 -0
  62. package/plugins/echo/scripts/lib/echo-ulid.sh +46 -0
  63. package/plugins/tribunal/.claude-plugin/plugin.json +20 -0
  64. package/plugins/tribunal/CHANGELOG.md +10 -0
  65. package/plugins/tribunal/README.md +134 -0
  66. package/plugins/tribunal/agents/tribunal-actor.md +35 -0
  67. package/plugins/tribunal/agents/tribunal-judge-adversarial.md +51 -0
  68. package/plugins/tribunal/agents/tribunal-judge-security.md +47 -0
  69. package/plugins/tribunal/agents/tribunal-judge-standard.md +47 -0
  70. package/plugins/tribunal/agents/tribunal-meta-judge.md +61 -0
  71. package/plugins/tribunal/config.json +50 -0
  72. package/plugins/tribunal/docs/adr/001-actor-jury-meta-gate-loop.md +40 -0
  73. package/plugins/tribunal/docs/adr/002-majority-gate-policy.md +48 -0
  74. package/plugins/tribunal/hooks/hooks.json +15 -0
  75. package/plugins/tribunal/scripts/hooks/tribunal-stop-gate.sh +267 -0
  76. package/plugins/tribunal/scripts/lib/tribunal-aggregate.sh +65 -0
  77. package/plugins/tribunal/scripts/lib/tribunal-config.sh +101 -0
  78. package/plugins/tribunal/scripts/lib/tribunal-events.sh +97 -0
  79. package/plugins/tribunal/scripts/lib/tribunal-gate.sh +111 -0
  80. package/plugins/tribunal/scripts/lib/tribunal-jury.sh +102 -0
  81. package/plugins/tribunal/scripts/lib/tribunal-project-key.sh +84 -0
  82. package/plugins/tribunal/scripts/lib/tribunal-rubric.sh +153 -0
  83. package/plugins/tribunal/scripts/lib/tribunal-ulid.sh +50 -0
  84. package/plugins/tribunal/scripts/lib/tribunal-verdict.sh +127 -0
  85. package/plugins/tribunal/skills/tribunal/SKILL.md +129 -0
  86. package/release-please-config.json +59 -5
  87. package/scripts/coverage/bash-coverage.mjs +169 -0
  88. package/scripts/coverage/format-comment.mjs +120 -0
  89. package/scripts/coverage/run-coverage.mjs +151 -0
  90. package/scripts/hooks/agent-spawn-tracker.sh +4 -4
  91. package/scripts/hooks/prompt-rule-injector.sh +122 -0
  92. package/scripts/lib/portable-lock.sh +48 -0
  93. package/scripts/lib/prompt-rules.sh +207 -0
  94. package/scripts/lib/tool-history.sh +7 -8
  95. package/scripts/lib/validate-path.sh +4 -0
  96. package/scripts/lint/check-manifests.mjs +314 -0
  97. package/scripts/lint/check-references.mjs +311 -0
  98. package/skills/list-prompt-rules/SKILL.md +15 -0
  99. package/test/bats/archivist-config-files.bats +60 -0
  100. package/test/bats/archivist-config.bats +54 -0
  101. package/test/bats/archivist-inject.bats +73 -0
  102. package/test/bats/archivist-project-key.bats +75 -0
  103. package/test/bats/archivist-storage.bats +119 -0
  104. package/test/bats/archivist-ulid.bats +36 -0
  105. package/test/bats/cartographer-config.bats +107 -0
  106. package/test/bats/cartographer-lock.bats +77 -0
  107. package/test/bats/cartographer-ulid.bats +56 -0
  108. package/test/bats/config.bats +10 -10
  109. package/test/bats/echo-config.bats +90 -0
  110. package/test/bats/echo-events.bats +121 -0
  111. package/test/bats/echo-project-key.bats +115 -0
  112. package/test/bats/echo-stop-hook.bats +101 -0
  113. package/test/bats/echo-ulid.bats +38 -0
  114. package/test/bats/portable-lock.bats +62 -0
  115. package/test/bats/prompt-rules.bats +269 -0
  116. package/test/bats/tribunal-aggregate.bats +77 -0
  117. package/test/bats/tribunal-config.bats +86 -0
  118. package/test/bats/tribunal-events.bats +209 -0
  119. package/test/bats/tribunal-gate.bats +95 -0
  120. package/test/bats/tribunal-jury.bats +80 -0
  121. package/test/bats/tribunal-rubric.bats +119 -0
  122. package/test/bats/tribunal-stop-hook.bats +73 -0
  123. package/test/bats/tribunal-verdict.bats +71 -0
  124. package/test/fixtures/hook-inputs/user-prompt-submit-rule-match.json +8 -0
  125. package/test/fixtures/hook-inputs/user-prompt-submit-rule-nomatch.json +8 -0
  126. package/test/helpers/setup.bash +9 -0
  127. package/test/node/check-manifests.test.mjs +173 -0
  128. package/test/node/check-references.test.mjs +279 -0
  129. package/test/node/coverage.test.mjs +143 -0
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env bash
2
+ # Archivist SessionStart injection hook.
3
+ #
4
+ # Triggered by SessionStart (matcher: *). Loads ranked artifacts for the
5
+ # current project key and emits them as invisible `additionalContext` in the
6
+ # hook output, within configured budgets.
7
+ #
8
+ # Ranking: pinned items first (in their pinned order), then everything else by
9
+ # updated_at descending. Items are dropped from the bottom of the list once
10
+ # either max_items or max_chars is exhausted.
11
+ #
12
+ # Hook contract:
13
+ # - Always exits 0. Never blocks session start.
14
+ # - Emits valid hookSpecificOutput JSON, even if there's nothing to inject.
15
+ # - Skips work if archivist.enabled is not true.
16
+
17
+ set -uo pipefail
18
+
19
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
20
+ PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
21
+
22
+ _ECOSYSTEM_ROOT="${ONLOOKER_ECOSYSTEM_ROOT:-}"
23
+ if [[ -z "$_ECOSYSTEM_ROOT" ]]; then
24
+ _candidate="$(cd "${PLUGIN_ROOT}/../.." 2>/dev/null && pwd)"
25
+ if [[ -f "${_candidate}/scripts/lib/validate-path.sh" ]]; then
26
+ _ECOSYSTEM_ROOT="$_candidate"
27
+ fi
28
+ fi
29
+ if [[ -n "$_ECOSYSTEM_ROOT" && -f "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh" ]]; then
30
+ # shellcheck disable=SC1091
31
+ CLAUDE_PLUGIN_ROOT="$_ECOSYSTEM_ROOT" source "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh"
32
+ fi
33
+
34
+ # shellcheck source=../lib/archivist-project-key.sh
35
+ source "${PLUGIN_ROOT}/scripts/lib/archivist-project-key.sh"
36
+ # shellcheck source=../lib/archivist-storage.sh
37
+ source "${PLUGIN_ROOT}/scripts/lib/archivist-storage.sh"
38
+ # shellcheck source=../lib/archivist-config.sh
39
+ source "${PLUGIN_ROOT}/scripts/lib/archivist-config.sh"
40
+
41
+ # Emit a hookSpecificOutput JSON object with the given additionalContext string.
42
+ # Empty string is fine — it just means "nothing to add".
43
+ _emit() {
44
+ local context="${1:-}"
45
+ jq -cn --arg ctx "$context" '
46
+ {
47
+ hookSpecificOutput: {
48
+ hookEventName: "SessionStart",
49
+ additionalContext: $ctx
50
+ }
51
+ }
52
+ '
53
+ }
54
+
55
+ INPUT=$(cat)
56
+
57
+ CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) || CWD=""
58
+ SOURCE=$(printf '%s' "$INPUT" | jq -r '.source // "startup"' 2>/dev/null) || SOURCE="startup"
59
+
60
+ REPO_ROOT=$(archivist_project_repo_root "$CWD")
61
+ PROJECT_KEY=$(archivist_project_key "$CWD")
62
+
63
+ archivist_config_load "$REPO_ROOT"
64
+
65
+ if ! archivist_config_enabled; then
66
+ _emit ""
67
+ exit 0
68
+ fi
69
+
70
+ if [[ -z "$PROJECT_KEY" ]]; then
71
+ _emit ""
72
+ exit 0
73
+ fi
74
+
75
+ MAX_ITEMS=$(archivist_config_get '.archivist.injection.max_items')
76
+ [[ -z "$MAX_ITEMS" || "$MAX_ITEMS" == "null" ]] && MAX_ITEMS=8
77
+
78
+ MAX_CHARS=$(archivist_config_get '.archivist.injection.max_chars')
79
+ [[ -z "$MAX_CHARS" || "$MAX_CHARS" == "null" ]] && MAX_CHARS=2400
80
+
81
+ INCLUDE_DEAD_ENDS=$(archivist_config_get '.archivist.injection.include_dead_ends')
82
+ INCLUDE_OPEN_QUESTIONS=$(archivist_config_get '.archivist.injection.include_open_questions')
83
+
84
+ RANKED=$(archivist_storage_load_ranked "$PROJECT_KEY")
85
+ TOTAL_ITEMS=$(printf '%s' "$RANKED" | jq 'length' 2>/dev/null) || TOTAL_ITEMS=0
86
+
87
+ if [[ "$TOTAL_ITEMS" -eq 0 ]]; then
88
+ _emit ""
89
+ exit 0
90
+ fi
91
+
92
+ # Filter out kinds the user disabled.
93
+ if [[ "$INCLUDE_DEAD_ENDS" != "true" ]]; then
94
+ RANKED=$(printf '%s' "$RANKED" | jq 'map(select(.kind != "dead_ends"))')
95
+ fi
96
+ if [[ "$INCLUDE_OPEN_QUESTIONS" != "true" ]]; then
97
+ RANKED=$(printf '%s' "$RANKED" | jq 'map(select(.kind != "open_questions"))')
98
+ fi
99
+
100
+ # Build the rendered context one item at a time, respecting both budgets.
101
+ HEADER="Archivist — carried over from prior sessions in this repo (kind | summary):"
102
+ RENDERED="$HEADER"
103
+ RUNNING_CHARS=${#HEADER}
104
+ EMITTED=0
105
+
106
+ COUNT=$(printf '%s' "$RANKED" | jq 'length')
107
+ for ((i = 0; i < COUNT; i++)); do
108
+ [[ "$EMITTED" -ge "$MAX_ITEMS" ]] && break
109
+
110
+ ITEM=$(printf '%s' "$RANKED" | jq ".[$i]")
111
+ KIND=$(printf '%s' "$ITEM" | jq -r '.kind // ""')
112
+ SUMMARY=$(printf '%s' "$ITEM" | jq -r '.summary // ""')
113
+ DETAIL=$(printf '%s' "$ITEM" | jq -r '.detail // ""')
114
+ PINNED=$(printf '%s' "$ITEM" | jq -r '.pinned // false')
115
+ FILES=$(printf '%s' "$ITEM" | jq -r '(.files // []) | join(", ")')
116
+
117
+ [[ -z "$SUMMARY" ]] && continue
118
+
119
+ # Map directory-style kinds to human-readable labels.
120
+ LABEL="$KIND"
121
+ case "$KIND" in
122
+ decisions) LABEL="decision" ;;
123
+ dead_ends) LABEL="dead-end" ;;
124
+ open_questions) LABEL="open-question" ;;
125
+ esac
126
+
127
+ LINE=""
128
+ if [[ "$PINNED" == "true" ]]; then
129
+ LINE="- [pinned ${LABEL}] ${SUMMARY}"
130
+ else
131
+ LINE="- [${LABEL}] ${SUMMARY}"
132
+ fi
133
+ if [[ -n "$DETAIL" && "$DETAIL" != "null" ]]; then
134
+ LINE="${LINE}"$'\n'" ${DETAIL}"
135
+ fi
136
+ if [[ -n "$FILES" ]]; then
137
+ LINE="${LINE}"$'\n'" files: ${FILES}"
138
+ fi
139
+
140
+ LINE_LEN=$((${#LINE} + 1))
141
+ if (( RUNNING_CHARS + LINE_LEN > MAX_CHARS )); then
142
+ break
143
+ fi
144
+
145
+ RENDERED="${RENDERED}"$'\n'"${LINE}"
146
+ RUNNING_CHARS=$((RUNNING_CHARS + LINE_LEN))
147
+ EMITTED=$((EMITTED + 1))
148
+ done
149
+
150
+ if [[ "$EMITTED" -eq 0 ]]; then
151
+ _emit ""
152
+ exit 0
153
+ fi
154
+
155
+ # Trailer with provenance + how to disable (helps future-you debug).
156
+ RENDERED="${RENDERED}"$'\n\n'"(Archivist injected ${EMITTED}/${TOTAL_ITEMS} items for project key ${PROJECT_KEY}. Set archivist.enabled=false to disable. Source: ${SOURCE}.)"
157
+
158
+ _emit "$RENDERED"
159
+ exit 0
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env bash
2
+ # Config resolution for Archivist.
3
+ #
4
+ # Reads three layers, latest wins:
5
+ # 1. plugins/archivist/config.json (defaults shipped with the plugin)
6
+ # 2. ~/.claude/settings.json
7
+ # 3. <repo>/.claude/settings.json
8
+ #
9
+ # Exposes:
10
+ # archivist_config_load <repo_root> # populates _ARCHIVIST_CONFIG (JSON)
11
+ # archivist_config_get <jq-path> # echoes string value (empty if unset)
12
+ # archivist_config_enabled # 0 if archivist.enabled is true
13
+ #
14
+ # Settings overlay only touches the `archivist.*` subtree of settings.json so it
15
+ # coexists with other plugins' configuration.
16
+
17
+ _ARCHIVIST_CONFIG="{}"
18
+
19
+ archivist_config_load() {
20
+ local repo_root="${1:-}"
21
+ local plugin_root="${CLAUDE_PLUGIN_ROOT:-}"
22
+ local home_dir="${HOME:-}"
23
+
24
+ local merged="{}"
25
+ local file
26
+
27
+ file="${plugin_root}/config.json"
28
+ if [[ -f "$file" ]]; then
29
+ local defaults
30
+ defaults=$(jq '.' "$file" 2>/dev/null) || defaults="{}"
31
+ merged=$(jq -n --argjson a "$merged" --argjson b "$defaults" '$a * $b' 2>/dev/null) \
32
+ || merged="$defaults"
33
+ fi
34
+
35
+ for file in "${home_dir}/.claude/settings.json" "${repo_root}/.claude/settings.json"; do
36
+ [[ -n "$file" && -f "$file" ]] || continue
37
+ local overlay
38
+ overlay=$(jq '{ archivist: (.archivist // {}) }' "$file" 2>/dev/null) || continue
39
+ [[ -z "$overlay" ]] && continue
40
+ merged=$(jq -n --argjson a "$merged" --argjson b "$overlay" '
41
+ def deepmerge($a; $b):
42
+ if ($a|type) == "object" and ($b|type) == "object" then
43
+ reduce (($a|keys) + ($b|keys) | unique)[] as $k
44
+ ({}; .[$k] = deepmerge($a[$k]; $b[$k]))
45
+ elif $b == null then $a
46
+ else $b end;
47
+ deepmerge($a; $b)
48
+ ' 2>/dev/null) || true
49
+ done
50
+
51
+ _ARCHIVIST_CONFIG="$merged"
52
+ }
53
+
54
+ # Read a value from the loaded config. Usage:
55
+ # archivist_config_get '.archivist.injection.max_items'
56
+ archivist_config_get() {
57
+ local path="$1"
58
+ printf '%s' "$_ARCHIVIST_CONFIG" | jq -r "${path} // empty" 2>/dev/null
59
+ }
60
+
61
+ # Returns 0 if archivist.enabled is true, 1 otherwise.
62
+ archivist_config_enabled() {
63
+ local v
64
+ v=$(archivist_config_get '.archivist.enabled')
65
+ [[ "$v" == "true" ]]
66
+ }
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env bash
2
+ # Project key derivation for Archivist.
3
+ #
4
+ # A project key is a stable 12-char hex identifier that survives:
5
+ # - local rename of the repo directory
6
+ # - cloning the same repo to a different path on the same machine
7
+ # - moving the repo between machines (as long as the git remote is preserved)
8
+ # - worktrees (a worktree shares its parent repo's key)
9
+ #
10
+ # Resolution order:
11
+ # 1. SHA256(`git remote get-url origin`) — preferred, machine-portable
12
+ # 2. SHA256(realpath of `git rev-parse --show-toplevel`) — fallback for repos
13
+ # without an origin remote (greenfield local-only work)
14
+ #
15
+ # Returns the first 12 hex chars. Returns empty string if neither resolution
16
+ # path works (caller decides whether to skip or treat as a non-repo session).
17
+
18
+ # Portable SHA256 helper. Prefers shasum (BSD/macOS), falls back to sha256sum.
19
+ _archivist_sha256_first12() {
20
+ local input="$1"
21
+ if command -v shasum >/dev/null 2>&1; then
22
+ printf '%s' "$input" | shasum -a 256 2>/dev/null | cut -c1-12
23
+ elif command -v sha256sum >/dev/null 2>&1; then
24
+ printf '%s' "$input" | sha256sum 2>/dev/null | cut -c1-12
25
+ else
26
+ return 1
27
+ fi
28
+ }
29
+
30
+ # Resolve git remote URL for the given cwd. Empty string if no remote.
31
+ archivist_project_remote_url() {
32
+ local cwd="${1:-}"
33
+ [[ -z "$cwd" || ! -d "$cwd" ]] && return 0
34
+ git -C "$cwd" remote get-url origin 2>/dev/null || true
35
+ }
36
+
37
+ # Resolve repository toplevel (worktree-aware: uses common-dir to get the main
38
+ # repo path so worktrees share a key with their parent).
39
+ archivist_project_repo_root() {
40
+ local cwd="${1:-}"
41
+ [[ -z "$cwd" || ! -d "$cwd" ]] && return 0
42
+
43
+ if ! git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
44
+ return 0
45
+ fi
46
+
47
+ local common_dir toplevel
48
+ common_dir=$(git -C "$cwd" rev-parse --git-common-dir 2>/dev/null) || return 0
49
+
50
+ # git-common-dir may be relative; resolve relative to cwd.
51
+ if [[ -n "$common_dir" && "$common_dir" != /* ]]; then
52
+ common_dir="$(cd "$cwd" && cd "$common_dir" 2>/dev/null && pwd -P)" || common_dir=""
53
+ fi
54
+
55
+ if [[ -n "$common_dir" && -d "$common_dir" ]]; then
56
+ # common_dir is typically the .git dir of the main repo; its parent is
57
+ # the repo root.
58
+ toplevel="$(cd "$common_dir/.." 2>/dev/null && pwd -P)" || toplevel=""
59
+ fi
60
+
61
+ if [[ -z "$toplevel" ]]; then
62
+ toplevel=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null || true)
63
+ [[ -n "$toplevel" ]] && toplevel="$(cd "$toplevel" 2>/dev/null && pwd -P)"
64
+ fi
65
+
66
+ printf '%s' "$toplevel"
67
+ }
68
+
69
+ # Compute the project key for the given cwd. Prints the key or empty string.
70
+ # Usage: key=$(archivist_project_key "$CWD")
71
+ archivist_project_key() {
72
+ local cwd="${1:-}"
73
+ [[ -z "$cwd" ]] && cwd="$(pwd)"
74
+
75
+ local remote
76
+ remote=$(archivist_project_remote_url "$cwd")
77
+ if [[ -n "$remote" ]]; then
78
+ _archivist_sha256_first12 "remote:$remote"
79
+ return 0
80
+ fi
81
+
82
+ local root
83
+ root=$(archivist_project_repo_root "$cwd")
84
+ if [[ -n "$root" ]]; then
85
+ _archivist_sha256_first12 "root:$root"
86
+ return 0
87
+ fi
88
+
89
+ # No git context at all — return empty so the caller skips archivist work.
90
+ return 0
91
+ }
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env bash
2
+ # Storage layout and path-validation helpers for Archivist.
3
+ #
4
+ # Layout (under $ONLOOKER_DIR/archivist/<project-key>/):
5
+ # manifest.json project metadata: remote_url, repo_root, last_compact_at
6
+ # decisions/<ulid>.json one decision per file
7
+ # dead_ends/<ulid>.json one dead-end per file
8
+ # open_questions/<ulid>.json one question per file
9
+ # pinned.json { "ids": ["01J...", ...] } -- always reinject first
10
+ #
11
+ # All paths inside artifacts are stored RELATIVE to the repo root. Validation
12
+ # happens before write: anything resolving outside the repo (or that does not
13
+ # exist) is dropped. This is the second line of defense against cross-project
14
+ # contamination after project-key isolation.
15
+
16
+ # ============================================================================
17
+ # Path helpers
18
+ # ============================================================================
19
+
20
+ # Root directory for archivist artifacts. Honors $ONLOOKER_DIR if set so tests
21
+ # can isolate writes.
22
+ archivist_storage_root() {
23
+ local base="${ONLOOKER_DIR:-$HOME/.onlooker}"
24
+ printf '%s/archivist' "$base"
25
+ }
26
+
27
+ archivist_project_dir() {
28
+ local key="$1"
29
+ printf '%s/%s' "$(archivist_storage_root)" "$key"
30
+ }
31
+
32
+ archivist_kind_dir() {
33
+ local key="$1"
34
+ local kind="$2"
35
+ printf '%s/%s' "$(archivist_project_dir "$key")" "$kind"
36
+ }
37
+
38
+ # Ensure the directory tree exists for a given project key.
39
+ archivist_storage_init() {
40
+ local key="$1"
41
+ [[ -z "$key" ]] && return 1
42
+ local project_dir
43
+ project_dir=$(archivist_project_dir "$key")
44
+ mkdir -p \
45
+ "$project_dir/decisions" \
46
+ "$project_dir/dead_ends" \
47
+ "$project_dir/open_questions" 2>/dev/null
48
+ }
49
+
50
+ # Write or update the project manifest.
51
+ # Usage: archivist_storage_write_manifest <key> <remote_url> <repo_root>
52
+ archivist_storage_write_manifest() {
53
+ local key="$1"
54
+ local remote_url="$2"
55
+ local repo_root="$3"
56
+ [[ -z "$key" ]] && return 1
57
+
58
+ archivist_storage_init "$key" || return 1
59
+
60
+ local manifest_path
61
+ manifest_path="$(archivist_project_dir "$key")/manifest.json"
62
+ local now
63
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
64
+
65
+ jq -n \
66
+ --arg key "$key" \
67
+ --arg remote "$remote_url" \
68
+ --arg root "$repo_root" \
69
+ --arg now "$now" \
70
+ '{
71
+ project_key: $key,
72
+ remote_url: (if $remote == "" then null else $remote end),
73
+ repo_root: (if $root == "" then null else $root end),
74
+ last_compact_at: $now,
75
+ source: "local"
76
+ }' > "$manifest_path" 2>/dev/null
77
+ }
78
+
79
+ # ============================================================================
80
+ # Path validation (drop entries pointing outside the repo)
81
+ # ============================================================================
82
+
83
+ # Given a repo root and a path string (possibly absolute, possibly relative,
84
+ # possibly with ../), echo the path relativized to the repo root iff it resolves
85
+ # inside the repo AND the file exists. Otherwise echo nothing.
86
+ #
87
+ # Worktrees: an absolute path that lives in a checked-out worktree of the same
88
+ # repo is considered "in repo" — we resolve against the worktree's toplevel.
89
+ archivist_validate_repo_path() {
90
+ local repo_root="$1"
91
+ local candidate="$2"
92
+ [[ -z "$repo_root" || -z "$candidate" ]] && return 0
93
+ [[ ! -d "$repo_root" ]] && return 0
94
+
95
+ local abs_root
96
+ abs_root=$(cd "$repo_root" 2>/dev/null && pwd -P) || return 0
97
+
98
+ local resolved
99
+ if [[ "$candidate" == /* ]]; then
100
+ resolved="$candidate"
101
+ else
102
+ resolved="$repo_root/$candidate"
103
+ fi
104
+
105
+ # Resolve to a physical path so symlinks (e.g. macOS /var -> /private/var)
106
+ # don't cause a false-negative against abs_root. realpath handles
107
+ # nonexistent leaf names via strict=False so we can still emit the
108
+ # canonical form and let the -e check below decide.
109
+ resolved=$(python3 -c '
110
+ import os, sys
111
+ print(os.path.realpath(sys.argv[1]))
112
+ ' "$resolved" 2>/dev/null) || return 0
113
+
114
+ # Must live under repo root and exist on disk.
115
+ case "$resolved" in
116
+ "$abs_root"|"$abs_root"/*) : ;;
117
+ *) return 0 ;;
118
+ esac
119
+
120
+ [[ -e "$resolved" ]] || return 0
121
+
122
+ # Echo relative form (strip "$abs_root/" prefix).
123
+ if [[ "$resolved" == "$abs_root" ]]; then
124
+ printf '.'
125
+ else
126
+ printf '%s' "${resolved#"$abs_root/"}"
127
+ fi
128
+ }
129
+
130
+ # Given a JSON array of file path strings, return a JSON array containing only
131
+ # the paths that pass validation, relativized to the repo root.
132
+ # Usage: cleaned=$(archivist_validate_paths_array "$repo_root" "$paths_json")
133
+ archivist_validate_paths_array() {
134
+ local repo_root="$1"
135
+ local paths_json="$2"
136
+ [[ -z "$paths_json" || "$paths_json" == "null" ]] && { echo '[]'; return 0; }
137
+
138
+ local out='[]'
139
+ local count i candidate cleaned
140
+ count=$(printf '%s' "$paths_json" | jq 'length' 2>/dev/null) || count=0
141
+ for ((i = 0; i < count; i++)); do
142
+ candidate=$(printf '%s' "$paths_json" | jq -r ".[$i]" 2>/dev/null) || continue
143
+ [[ -z "$candidate" || "$candidate" == "null" ]] && continue
144
+ cleaned=$(archivist_validate_repo_path "$repo_root" "$candidate")
145
+ [[ -z "$cleaned" ]] && continue
146
+ out=$(printf '%s' "$out" | jq --arg p "$cleaned" '. + [$p]')
147
+ done
148
+ printf '%s' "$out"
149
+ }
150
+
151
+ # ============================================================================
152
+ # Artifact write
153
+ # ============================================================================
154
+
155
+ # Write a single artifact file. Returns the path written on success.
156
+ # Usage: archivist_storage_write_artifact <key> <kind> <id> <json>
157
+ archivist_storage_write_artifact() {
158
+ local key="$1"
159
+ local kind="$2"
160
+ local id="$3"
161
+ local json="$4"
162
+ [[ -z "$key" || -z "$kind" || -z "$id" || -z "$json" ]] && return 1
163
+
164
+ case "$kind" in
165
+ decisions|dead_ends|open_questions) : ;;
166
+ *) return 1 ;;
167
+ esac
168
+
169
+ archivist_storage_init "$key" || return 1
170
+ local out_path
171
+ out_path="$(archivist_kind_dir "$key" "$kind")/${id}.json"
172
+ printf '%s\n' "$json" > "$out_path" 2>/dev/null && printf '%s' "$out_path"
173
+ }
174
+
175
+ # ============================================================================
176
+ # Artifact read (for injection)
177
+ # ============================================================================
178
+
179
+ # Read all artifacts for a given project key as a single JSON array, with each
180
+ # entry augmented with a `kind` field. Output is sorted by `updated_at`
181
+ # descending. Pinned IDs (from pinned.json) come first regardless of recency.
182
+ #
183
+ # Usage: items=$(archivist_storage_load_ranked <key>)
184
+ archivist_storage_load_ranked() {
185
+ local key="$1"
186
+ [[ -z "$key" ]] && { echo '[]'; return 0; }
187
+
188
+ local project_dir
189
+ project_dir=$(archivist_project_dir "$key")
190
+ [[ ! -d "$project_dir" ]] && { echo '[]'; return 0; }
191
+
192
+ local pinned_file="$project_dir/pinned.json"
193
+ local pinned_json='{"ids":[]}'
194
+ [[ -f "$pinned_file" ]] && pinned_json=$(cat "$pinned_file" 2>/dev/null) || true
195
+
196
+ local kind file all='[]'
197
+ for kind in decisions dead_ends open_questions; do
198
+ [[ -d "$project_dir/$kind" ]] || continue
199
+ for file in "$project_dir/$kind"/*.json; do
200
+ [[ -f "$file" ]] || continue
201
+ local item
202
+ item=$(jq --arg k "$kind" '. + {kind: $k}' "$file" 2>/dev/null) || continue
203
+ all=$(printf '%s' "$all" | jq --argjson item "$item" '. + [$item]')
204
+ done
205
+ done
206
+
207
+ printf '%s' "$all" | jq --argjson pinned "$pinned_json" '
208
+ ($pinned.ids // []) as $pids
209
+ | map(. + { pinned: (.id as $id | $pids | index($id) != null) })
210
+ | sort_by([
211
+ (if .pinned then 0 else 1 end),
212
+ -((.updated_at // .created_at // "") | sub("[^0-9]"; ""; "g") | tonumber? // 0)
213
+ ])
214
+ '
215
+ }
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env bash
2
+ # Minimal ULID generator for Archivist artifact IDs.
3
+ #
4
+ # Spec: https://github.com/ulid/spec
5
+ # - 48-bit timestamp (ms since epoch) → 10 chars Crockford Base32
6
+ # - 80-bit randomness → 16 chars Crockford Base32
7
+ # - lexicographically sortable, time-ordered
8
+ #
9
+ # We do not need monotonicity across rapid bursts inside a single ms (the spec's
10
+ # optional monotonic mode), since archivist artifacts are written infrequently.
11
+
12
+ _ARCHIVIST_ULID_ALPHABET="0123456789ABCDEFGHJKMNPQRSTVWXYZ"
13
+
14
+ # Encode a decimal integer to a fixed-length Crockford Base32 string (uppercase).
15
+ # Usage: _archivist_ulid_encode <integer> <length>
16
+ _archivist_ulid_encode() {
17
+ local n="$1"
18
+ local len="$2"
19
+ local out=""
20
+ local i
21
+ for ((i = 0; i < len; i++)); do
22
+ out="${_ARCHIVIST_ULID_ALPHABET:$((n % 32)):1}${out}"
23
+ n=$((n / 32))
24
+ done
25
+ printf '%s' "$out"
26
+ }
27
+
28
+ # Generate one ULID. Prints 26 chars (timestamp + randomness).
29
+ archivist_ulid() {
30
+ local now_ms
31
+ if [[ "$(uname)" == "Darwin" ]]; then
32
+ now_ms=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null) \
33
+ || now_ms=$(($(date +%s) * 1000))
34
+ else
35
+ now_ms=$(date +%s%3N 2>/dev/null) || now_ms=$(($(date +%s) * 1000))
36
+ fi
37
+
38
+ # 80 bits of randomness, split into two 40-bit halves so we stay inside
39
+ # bash arithmetic (signed 64-bit).
40
+ local rand_hi rand_lo
41
+ rand_hi=$((RANDOM * 32768 + RANDOM))
42
+ rand_lo=$((RANDOM * 32768 + RANDOM))
43
+ rand_hi=$(((rand_hi * 256 + RANDOM % 256) & ((1 << 40) - 1)))
44
+ rand_lo=$(((rand_lo * 256 + RANDOM % 256) & ((1 << 40) - 1)))
45
+
46
+ local ts_part hi_part lo_part
47
+ ts_part=$(_archivist_ulid_encode "$now_ms" 10)
48
+ hi_part=$(_archivist_ulid_encode "$rand_hi" 8)
49
+ lo_part=$(_archivist_ulid_encode "$rand_lo" 8)
50
+
51
+ printf '%s%s%s' "$ts_part" "$hi_part" "$lo_part"
52
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "cartographer",
3
+ "version": "0.2.0",
4
+ "description": "Proactive periodic auditor of the persistent instruction layer (CLAUDE.md, AGENTS.md, .claude/rules/). Discovers all instruction files in the repo, extracts semantic maps, and surfaces contradictions, shadowing, gaps, and drift before they cause expensive agent misbehavior. Builds on the Onlooker ecosystem plugin.",
5
+ "author": {
6
+ "name": "Onlooker Community",
7
+ "url": "https://onlooker.dev"
8
+ },
9
+ "homepage": "https://onlooker.dev",
10
+ "repository": "https://github.com/onlooker-community/ecosystem",
11
+ "license": "MIT",
12
+ "skills": ["./skills/cartographer"],
13
+ "agents": []
14
+ }
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ All notable changes to the Cartographer plugin are documented here.
4
+
5
+ ## [0.2.0](https://github.com/onlooker-community/ecosystem/compare/cartographer-v0.1.0...cartographer-v0.2.0) (2026-05-25)
6
+
7
+
8
+ ### Features
9
+
10
+ * **cartographer:** add proactive instruction-file audit plugin :mag: ([#35](https://github.com/onlooker-community/ecosystem/issues/35)) ([387d00a](https://github.com/onlooker-community/ecosystem/commit/387d00ad04da5aae91048254ad0526bb674ed498))
11
+
12
+ ## [0.1.0](https://github.com/onlooker-community/ecosystem/releases/tag/cartographer-v0.1.0) (2026-05-25)
13
+
14
+ ### Added
15
+
16
+ - SessionStart hook with interval gate and non-blocking background audit launch (`nohup setsid`)
17
+ - PostToolUse hook on Write/Edit/MultiEdit with exact `basename(realpath(...))` matching for CLAUDE.md files
18
+ - Five-phase audit pipeline: discover → extract → relate → synthesize → emit
19
+ - LLM-assisted analysis for contradictions, stale references, dead rules, and scope collisions
20
+ - `flock`-based cross-session audit lock with PID-file fallback for macOS
21
+ - Commutative `finding_hash` (SHA256) for stable finding identity across audit runs
22
+ - Atomic finding writes (`*.tmp` + `mv -f`) and `dedup/<hash>` sentinel store
23
+ - At-least-once `cartographer.issue.found` event delivery; documented dedup contract
24
+ - `/cartographer` skill with `--verbose`, `--status`, `--force`, `--scope`, and `--phase` flags
25
+ - Four ADRs documenting key design decisions
26
+ - Default `exclude_paths` covering `node_modules`, `.git`, `vendor`, `.venv`, and common build dirs
27
+ - `enabled: false` default — opt-in activation via `.claude/settings.json`