@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.
- package/.claude-plugin/marketplace.json +39 -1
- package/.claude-plugin/plugin.json +2 -2
- package/.github/copilot-instructions.md +46 -0
- package/.github/workflows/coverage.yml +78 -0
- package/.github/workflows/release.yml +24 -8
- package/.github/workflows/test.yml +3 -0
- package/.markdownlintignore +3 -0
- package/.release-please-manifest.json +5 -1
- package/CHANGELOG.md +44 -0
- package/README.md +58 -13
- package/config.json +6 -1
- package/docs/adr/001-claude-code-hooks-as-integration-surface.md +43 -0
- package/docs/adr/002-centralized-jsonl-event-log.md +39 -0
- package/docs/adr/003-ulid-over-uuid.md +40 -0
- package/docs/adr/004-plugin-config-with-settings-overlay.md +34 -0
- package/docs/architecture.md +123 -0
- package/hooks/hooks.json +4 -0
- package/package.json +13 -7
- package/plugins/archivist/.claude-plugin/plugin.json +14 -0
- package/plugins/archivist/CHANGELOG.md +8 -0
- package/plugins/archivist/README.md +105 -0
- package/plugins/archivist/config.json +18 -0
- package/plugins/archivist/hooks/hooks.json +35 -0
- package/plugins/archivist/scripts/hooks/archivist-extract.sh +238 -0
- package/plugins/archivist/scripts/hooks/archivist-inject.sh +159 -0
- package/plugins/archivist/scripts/lib/archivist-config.sh +66 -0
- package/plugins/archivist/scripts/lib/archivist-project-key.sh +91 -0
- package/plugins/archivist/scripts/lib/archivist-storage.sh +215 -0
- package/plugins/archivist/scripts/lib/archivist-ulid.sh +52 -0
- package/plugins/cartographer/.claude-plugin/plugin.json +14 -0
- package/plugins/cartographer/CHANGELOG.md +27 -0
- package/plugins/cartographer/README.md +113 -0
- package/plugins/cartographer/config.json +21 -0
- package/plugins/cartographer/docs/adr/001-background-audit-launch.md +28 -0
- package/plugins/cartographer/docs/adr/002-flock-pid-file-fallback.md +30 -0
- package/plugins/cartographer/docs/adr/003-at-least-once-event-delivery.md +32 -0
- package/plugins/cartographer/docs/adr/004-exclude-paths-replace-semantics.md +27 -0
- package/plugins/cartographer/hooks/hooks.json +44 -0
- package/plugins/cartographer/scripts/hooks/cartographer-post-write.sh +87 -0
- package/plugins/cartographer/scripts/hooks/cartographer-session-start.sh +89 -0
- package/plugins/cartographer/scripts/lib/cartographer-analyze.sh +286 -0
- package/plugins/cartographer/scripts/lib/cartographer-collect.sh +59 -0
- package/plugins/cartographer/scripts/lib/cartographer-config.sh +105 -0
- package/plugins/cartographer/scripts/lib/cartographer-events.sh +82 -0
- package/plugins/cartographer/scripts/lib/cartographer-lock.sh +38 -0
- package/plugins/cartographer/scripts/lib/cartographer-project-key.sh +55 -0
- package/plugins/cartographer/scripts/lib/cartographer-ulid.sh +47 -0
- package/plugins/cartographer/scripts/run-audit.sh +309 -0
- package/plugins/cartographer/skills/cartographer/SKILL.md +154 -0
- package/plugins/echo/.claude-plugin/plugin.json +14 -0
- package/plugins/echo/CHANGELOG.md +24 -0
- package/plugins/echo/README.md +110 -0
- package/plugins/echo/config.json +15 -0
- package/plugins/echo/docs/adr/001-echo-as-separate-plugin.md +33 -0
- package/plugins/echo/docs/adr/002-direct-evaluation-vs-tribunal-pipeline.md +35 -0
- package/plugins/echo/docs/adr/003-stop-hook-trigger.md +40 -0
- package/plugins/echo/hooks/hooks.json +15 -0
- package/plugins/echo/scripts/hooks/echo-stop-gate.sh +366 -0
- package/plugins/echo/scripts/lib/echo-config.sh +108 -0
- package/plugins/echo/scripts/lib/echo-events.sh +74 -0
- package/plugins/echo/scripts/lib/echo-project-key.sh +81 -0
- package/plugins/echo/scripts/lib/echo-ulid.sh +46 -0
- package/plugins/tribunal/.claude-plugin/plugin.json +20 -0
- package/plugins/tribunal/CHANGELOG.md +10 -0
- package/plugins/tribunal/README.md +134 -0
- package/plugins/tribunal/agents/tribunal-actor.md +35 -0
- package/plugins/tribunal/agents/tribunal-judge-adversarial.md +51 -0
- package/plugins/tribunal/agents/tribunal-judge-security.md +47 -0
- package/plugins/tribunal/agents/tribunal-judge-standard.md +47 -0
- package/plugins/tribunal/agents/tribunal-meta-judge.md +61 -0
- package/plugins/tribunal/config.json +50 -0
- package/plugins/tribunal/docs/adr/001-actor-jury-meta-gate-loop.md +40 -0
- package/plugins/tribunal/docs/adr/002-majority-gate-policy.md +48 -0
- package/plugins/tribunal/hooks/hooks.json +15 -0
- package/plugins/tribunal/scripts/hooks/tribunal-stop-gate.sh +267 -0
- package/plugins/tribunal/scripts/lib/tribunal-aggregate.sh +65 -0
- package/plugins/tribunal/scripts/lib/tribunal-config.sh +101 -0
- package/plugins/tribunal/scripts/lib/tribunal-events.sh +97 -0
- package/plugins/tribunal/scripts/lib/tribunal-gate.sh +111 -0
- package/plugins/tribunal/scripts/lib/tribunal-jury.sh +102 -0
- package/plugins/tribunal/scripts/lib/tribunal-project-key.sh +84 -0
- package/plugins/tribunal/scripts/lib/tribunal-rubric.sh +153 -0
- package/plugins/tribunal/scripts/lib/tribunal-ulid.sh +50 -0
- package/plugins/tribunal/scripts/lib/tribunal-verdict.sh +127 -0
- package/plugins/tribunal/skills/tribunal/SKILL.md +129 -0
- package/release-please-config.json +59 -5
- package/scripts/coverage/bash-coverage.mjs +169 -0
- package/scripts/coverage/format-comment.mjs +120 -0
- package/scripts/coverage/run-coverage.mjs +151 -0
- package/scripts/hooks/agent-spawn-tracker.sh +4 -4
- package/scripts/hooks/prompt-rule-injector.sh +122 -0
- package/scripts/lib/portable-lock.sh +48 -0
- package/scripts/lib/prompt-rules.sh +207 -0
- package/scripts/lib/tool-history.sh +7 -8
- package/scripts/lib/validate-path.sh +4 -0
- package/scripts/lint/check-manifests.mjs +314 -0
- package/scripts/lint/check-references.mjs +311 -0
- package/skills/list-prompt-rules/SKILL.md +15 -0
- package/test/bats/archivist-config-files.bats +60 -0
- package/test/bats/archivist-config.bats +54 -0
- package/test/bats/archivist-inject.bats +73 -0
- package/test/bats/archivist-project-key.bats +75 -0
- package/test/bats/archivist-storage.bats +119 -0
- package/test/bats/archivist-ulid.bats +36 -0
- package/test/bats/cartographer-config.bats +107 -0
- package/test/bats/cartographer-lock.bats +77 -0
- package/test/bats/cartographer-ulid.bats +56 -0
- package/test/bats/config.bats +10 -10
- package/test/bats/echo-config.bats +90 -0
- package/test/bats/echo-events.bats +121 -0
- package/test/bats/echo-project-key.bats +115 -0
- package/test/bats/echo-stop-hook.bats +101 -0
- package/test/bats/echo-ulid.bats +38 -0
- package/test/bats/portable-lock.bats +62 -0
- package/test/bats/prompt-rules.bats +269 -0
- package/test/bats/tribunal-aggregate.bats +77 -0
- package/test/bats/tribunal-config.bats +86 -0
- package/test/bats/tribunal-events.bats +209 -0
- package/test/bats/tribunal-gate.bats +95 -0
- package/test/bats/tribunal-jury.bats +80 -0
- package/test/bats/tribunal-rubric.bats +119 -0
- package/test/bats/tribunal-stop-hook.bats +73 -0
- package/test/bats/tribunal-verdict.bats +71 -0
- package/test/fixtures/hook-inputs/user-prompt-submit-rule-match.json +8 -0
- package/test/fixtures/hook-inputs/user-prompt-submit-rule-nomatch.json +8 -0
- package/test/helpers/setup.bash +9 -0
- package/test/node/check-manifests.test.mjs +173 -0
- package/test/node/check-references.test.mjs +279 -0
- 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`
|