@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,54 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
5
+ setup_test_env
6
+
7
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/archivist"
8
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
9
+ # shellcheck disable=SC1091
10
+ source "${PLUGIN_ROOT}/scripts/lib/archivist-config.sh"
11
+ }
12
+
13
+ @test "config defaults from plugin config.json: disabled" {
14
+ archivist_config_load ""
15
+ run archivist_config_enabled
16
+ [ "$status" -ne 0 ]
17
+ }
18
+
19
+ @test "user-level settings.json overlay can enable archivist" {
20
+ mkdir -p "${HOME}/.claude"
21
+ printf '%s\n' '{"archivist":{"enabled":true}}' > "${HOME}/.claude/settings.json"
22
+ archivist_config_load ""
23
+ run archivist_config_enabled
24
+ [ "$status" -eq 0 ]
25
+ }
26
+
27
+ @test "repo-level settings.json overrides user-level" {
28
+ mkdir -p "${HOME}/.claude"
29
+ printf '%s\n' '{"archivist":{"enabled":true}}' > "${HOME}/.claude/settings.json"
30
+ local repo="${BATS_TEST_TMPDIR}/repo"
31
+ mkdir -p "${repo}/.claude"
32
+ printf '%s\n' '{"archivist":{"enabled":false}}' > "${repo}/.claude/settings.json"
33
+ archivist_config_load "$repo"
34
+ run archivist_config_enabled
35
+ [ "$status" -ne 0 ]
36
+ }
37
+
38
+ @test "injection.max_items defaults from config.json" {
39
+ archivist_config_load ""
40
+ local v
41
+ v=$(archivist_config_get '.archivist.injection.max_items')
42
+ [ "$v" = "8" ]
43
+ }
44
+
45
+ @test "settings overlay merges deeply (preserves unset defaults)" {
46
+ mkdir -p "${HOME}/.claude"
47
+ printf '%s\n' '{"archivist":{"injection":{"max_items":3}}}' > "${HOME}/.claude/settings.json"
48
+ archivist_config_load ""
49
+ local overridden default_model
50
+ overridden=$(archivist_config_get '.archivist.injection.max_items')
51
+ default_model=$(archivist_config_get '.archivist.extraction.model')
52
+ [ "$overridden" = "3" ]
53
+ [ -n "$default_model" ]
54
+ }
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
5
+ setup_test_env
6
+
7
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/archivist"
8
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
9
+ export ONLOOKER_ECOSYSTEM_ROOT="$REPO_ROOT"
10
+
11
+ # Stand up a fake project repo so project-key resolution succeeds.
12
+ PROJECT_REPO="${BATS_TEST_TMPDIR}/repo"
13
+ mkdir -p "$PROJECT_REPO"
14
+ git -C "$PROJECT_REPO" init -q
15
+ git -C "$PROJECT_REPO" config user.email t@example.com
16
+ git -C "$PROJECT_REPO" config user.name "Test"
17
+ git -C "$PROJECT_REPO" remote add origin git@github.com:org/archivist-inject-test.git
18
+
19
+ # Compute the project key the hook will use.
20
+ # shellcheck disable=SC1091
21
+ source "${PLUGIN_ROOT}/scripts/lib/archivist-project-key.sh"
22
+ PROJECT_KEY=$(archivist_project_key "$PROJECT_REPO")
23
+ [ -n "$PROJECT_KEY" ]
24
+
25
+ # Seed an artifact on disk for this project.
26
+ local kind_dir="${ONLOOKER_DIR}/archivist/${PROJECT_KEY}/decisions"
27
+ mkdir -p "$kind_dir"
28
+ printf '%s\n' '{
29
+ "id": "01TESTTESTTESTTESTTESTTEST",
30
+ "kind": "decision",
31
+ "summary": "use git remote SHA256 as project key",
32
+ "detail": "remote URL is stable across machines; falls back to repo path",
33
+ "files": [],
34
+ "created_at": "2026-05-22T10:00:00Z",
35
+ "updated_at": "2026-05-22T10:00:00Z"
36
+ }' > "${kind_dir}/01TESTTESTTESTTESTTESTTEST.json"
37
+
38
+ # Project-scoped settings.json that enables archivist.
39
+ mkdir -p "${PROJECT_REPO}/.claude"
40
+ printf '%s\n' '{"archivist":{"enabled":true}}' > "${PROJECT_REPO}/.claude/settings.json"
41
+ }
42
+
43
+ @test "inject hook is a no-op when archivist is disabled" {
44
+ rm -f "${PROJECT_REPO}/.claude/settings.json"
45
+ local input
46
+ input=$(jq -n --arg cwd "$PROJECT_REPO" '{cwd: $cwd, source: "startup", session_id: "s"}')
47
+ run bash -c "printf '%s' '$input' | '${PLUGIN_ROOT}/scripts/hooks/archivist-inject.sh'"
48
+ [ "$status" -eq 0 ]
49
+ echo "$output" | jq -e '.hookSpecificOutput.additionalContext == ""' >/dev/null
50
+ }
51
+
52
+ @test "inject hook emits seeded artifact when enabled" {
53
+ local input
54
+ input=$(jq -n --arg cwd "$PROJECT_REPO" '{cwd: $cwd, source: "startup", session_id: "s"}')
55
+ run bash -c "printf '%s' '$input' | '${PLUGIN_ROOT}/scripts/hooks/archivist-inject.sh'"
56
+ [ "$status" -eq 0 ]
57
+
58
+ echo "$output" | jq -e '.hookSpecificOutput.hookEventName == "SessionStart"' >/dev/null
59
+ local ctx
60
+ ctx=$(echo "$output" | jq -r '.hookSpecificOutput.additionalContext')
61
+ [[ "$ctx" == *"use git remote SHA256 as project key"* ]]
62
+ [[ "$ctx" == *"Archivist injected 1"* ]]
63
+ }
64
+
65
+ @test "inject hook skips when there is no git context" {
66
+ local non_git="${BATS_TEST_TMPDIR}/no-git"
67
+ mkdir -p "$non_git"
68
+ local input
69
+ input=$(jq -n --arg cwd "$non_git" '{cwd: $cwd, source: "startup", session_id: "s"}')
70
+ run bash -c "printf '%s' '$input' | '${PLUGIN_ROOT}/scripts/hooks/archivist-inject.sh'"
71
+ [ "$status" -eq 0 ]
72
+ echo "$output" | jq -e '.hookSpecificOutput.additionalContext == ""' >/dev/null
73
+ }
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ # shellcheck source=../helpers/setup.bash
5
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
6
+ setup_test_env
7
+
8
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/archivist"
9
+ # shellcheck disable=SC1091
10
+ source "${PLUGIN_ROOT}/scripts/lib/archivist-project-key.sh"
11
+ }
12
+
13
+ @test "non-git directory returns empty key" {
14
+ local d="${BATS_TEST_TMPDIR}/non-git"
15
+ mkdir -p "$d"
16
+ run archivist_project_key "$d"
17
+ [ "$status" -eq 0 ]
18
+ [ -z "$output" ]
19
+ }
20
+
21
+ @test "git repo without remote falls back to repo-root hash" {
22
+ local d="${BATS_TEST_TMPDIR}/local-only-repo"
23
+ mkdir -p "$d"
24
+ git -C "$d" init -q
25
+ git -C "$d" config user.email t@example.com
26
+ git -C "$d" config user.name "Test"
27
+
28
+ local k1
29
+ k1=$(archivist_project_key "$d")
30
+ [ -n "$k1" ]
31
+ [ "${#k1}" -eq 12 ]
32
+
33
+ # Stability: a second call returns the same key.
34
+ local k2
35
+ k2=$(archivist_project_key "$d")
36
+ [ "$k1" = "$k2" ]
37
+ }
38
+
39
+ @test "git repo with remote uses remote hash and ignores local path" {
40
+ local a="${BATS_TEST_TMPDIR}/clone-a"
41
+ local b="${BATS_TEST_TMPDIR}/clone-b"
42
+ mkdir -p "$a" "$b"
43
+ for d in "$a" "$b"; do
44
+ git -C "$d" init -q
45
+ git -C "$d" config user.email t@example.com
46
+ git -C "$d" config user.name "Test"
47
+ git -C "$d" remote add origin git@github.com:org/proj.git
48
+ done
49
+
50
+ local ka kb
51
+ ka=$(archivist_project_key "$a")
52
+ kb=$(archivist_project_key "$b")
53
+ [ -n "$ka" ]
54
+ [ "$ka" = "$kb" ]
55
+ }
56
+
57
+ @test "different remotes yield different keys" {
58
+ local a="${BATS_TEST_TMPDIR}/proj-a"
59
+ local b="${BATS_TEST_TMPDIR}/proj-b"
60
+ mkdir -p "$a" "$b"
61
+ for d in "$a" "$b"; do
62
+ git -C "$d" init -q
63
+ git -C "$d" config user.email t@example.com
64
+ git -C "$d" config user.name "Test"
65
+ done
66
+ git -C "$a" remote add origin git@github.com:org/proj-a.git
67
+ git -C "$b" remote add origin git@github.com:org/proj-b.git
68
+
69
+ local ka kb
70
+ ka=$(archivist_project_key "$a")
71
+ kb=$(archivist_project_key "$b")
72
+ [ -n "$ka" ]
73
+ [ -n "$kb" ]
74
+ [ "$ka" != "$kb" ]
75
+ }
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ # shellcheck source=../helpers/setup.bash
5
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
6
+ setup_test_env
7
+
8
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/archivist"
9
+ # shellcheck disable=SC1091
10
+ source "${PLUGIN_ROOT}/scripts/lib/archivist-storage.sh"
11
+ # shellcheck disable=SC1091
12
+ source "${PLUGIN_ROOT}/scripts/lib/archivist-ulid.sh"
13
+
14
+ REPO="${BATS_TEST_TMPDIR}/repo"
15
+ mkdir -p "$REPO/src"
16
+ : > "$REPO/src/known.ts"
17
+ : > "$REPO/README.md"
18
+ }
19
+
20
+ @test "validate accepts an existing repo-relative path" {
21
+ run archivist_validate_repo_path "$REPO" "src/known.ts"
22
+ [ "$status" -eq 0 ]
23
+ [ "$output" = "src/known.ts" ]
24
+ }
25
+
26
+ @test "validate accepts an absolute path inside the repo" {
27
+ run archivist_validate_repo_path "$REPO" "${REPO}/src/known.ts"
28
+ [ "$status" -eq 0 ]
29
+ [ "$output" = "src/known.ts" ]
30
+ }
31
+
32
+ @test "validate rejects a path outside the repo" {
33
+ local outside="${BATS_TEST_TMPDIR}/outside.ts"
34
+ : > "$outside"
35
+ run archivist_validate_repo_path "$REPO" "$outside"
36
+ [ "$status" -eq 0 ]
37
+ [ -z "$output" ]
38
+ }
39
+
40
+ @test "validate rejects a ../ escape" {
41
+ run archivist_validate_repo_path "$REPO" "../escaped.ts"
42
+ [ "$status" -eq 0 ]
43
+ [ -z "$output" ]
44
+ }
45
+
46
+ @test "validate rejects a path that does not exist" {
47
+ run archivist_validate_repo_path "$REPO" "src/missing.ts"
48
+ [ "$status" -eq 0 ]
49
+ [ -z "$output" ]
50
+ }
51
+
52
+ @test "validate_paths_array strips invalid entries" {
53
+ local input='["src/known.ts","../escape.ts","src/missing.ts","README.md"]'
54
+ local cleaned compact
55
+ cleaned=$(archivist_validate_paths_array "$REPO" "$input")
56
+ compact=$(printf '%s' "$cleaned" | jq -c .)
57
+ [ "$compact" = '["src/known.ts","README.md"]' ]
58
+ }
59
+
60
+ @test "storage_init creates kind directories" {
61
+ local key="abc123def456"
62
+ archivist_storage_init "$key"
63
+ [ -d "${ONLOOKER_DIR}/archivist/${key}/decisions" ]
64
+ [ -d "${ONLOOKER_DIR}/archivist/${key}/dead_ends" ]
65
+ [ -d "${ONLOOKER_DIR}/archivist/${key}/open_questions" ]
66
+ }
67
+
68
+ @test "write_artifact creates a ULID-keyed file" {
69
+ local key="abc123def456"
70
+ local id
71
+ id=$(archivist_ulid)
72
+ local json='{"id":"'"$id"'","kind":"decision","summary":"hello"}'
73
+ run archivist_storage_write_artifact "$key" "decisions" "$id" "$json"
74
+ [ "$status" -eq 0 ]
75
+ [ -f "${ONLOOKER_DIR}/archivist/${key}/decisions/${id}.json" ]
76
+ }
77
+
78
+ @test "write_artifact rejects unknown kind" {
79
+ local key="abc123def456"
80
+ run archivist_storage_write_artifact "$key" "bogus_kind" "01J" '{}'
81
+ [ "$status" -ne 0 ]
82
+ }
83
+
84
+ @test "load_ranked sorts pinned items first" {
85
+ local key="abc123def456"
86
+ archivist_storage_init "$key"
87
+
88
+ # Write two decisions; pin the older one.
89
+ local older_id="01AAAAAAAAAAAAAAAAAAAAAAAA"
90
+ local newer_id="01ZZZZZZZZZZZZZZZZZZZZZZZZ"
91
+ printf '{"id":"%s","summary":"older","created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-01T00:00:00Z"}\n' "$older_id" \
92
+ > "${ONLOOKER_DIR}/archivist/${key}/decisions/${older_id}.json"
93
+ printf '{"id":"%s","summary":"newer","created_at":"2026-05-22T00:00:00Z","updated_at":"2026-05-22T00:00:00Z"}\n' "$newer_id" \
94
+ > "${ONLOOKER_DIR}/archivist/${key}/decisions/${newer_id}.json"
95
+ printf '{"ids":["%s"]}\n' "$older_id" > "${ONLOOKER_DIR}/archivist/${key}/pinned.json"
96
+
97
+ local ranked
98
+ ranked=$(archivist_storage_load_ranked "$key")
99
+ local first_id
100
+ first_id=$(printf '%s' "$ranked" | jq -r '.[0].id')
101
+ [ "$first_id" = "$older_id" ]
102
+ }
103
+
104
+ @test "load_ranked sorts non-pinned by recency desc" {
105
+ local key="abc123def456"
106
+ archivist_storage_init "$key"
107
+ local older_id="01AAAAAAAAAAAAAAAAAAAAAAAA"
108
+ local newer_id="01ZZZZZZZZZZZZZZZZZZZZZZZZ"
109
+ printf '{"id":"%s","summary":"older","created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-01T00:00:00Z"}\n' "$older_id" \
110
+ > "${ONLOOKER_DIR}/archivist/${key}/decisions/${older_id}.json"
111
+ printf '{"id":"%s","summary":"newer","created_at":"2026-05-22T00:00:00Z","updated_at":"2026-05-22T00:00:00Z"}\n' "$newer_id" \
112
+ > "${ONLOOKER_DIR}/archivist/${key}/decisions/${newer_id}.json"
113
+
114
+ local ranked
115
+ ranked=$(archivist_storage_load_ranked "$key")
116
+ local first_id
117
+ first_id=$(printf '%s' "$ranked" | jq -r '.[0].id')
118
+ [ "$first_id" = "$newer_id" ]
119
+ }
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
5
+ setup_test_env
6
+ # shellcheck disable=SC1091
7
+ source "${REPO_ROOT}/plugins/archivist/scripts/lib/archivist-ulid.sh"
8
+ }
9
+
10
+ @test "archivist_ulid returns a 26-char crockford base32 string" {
11
+ local id
12
+ id=$(archivist_ulid)
13
+ [ "${#id}" -eq 26 ]
14
+ [[ "$id" =~ ^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$ ]]
15
+ }
16
+
17
+ @test "two ULIDs minted apart are lexicographically ordered" {
18
+ local a b
19
+ a=$(archivist_ulid)
20
+ sleep 0.01
21
+ b=$(archivist_ulid)
22
+ [[ "$a" < "$b" ]] || [ "$a" = "$b" ]
23
+ }
24
+
25
+ @test "many ULIDs are unique" {
26
+ local seen="${BATS_TEST_TMPDIR}/ulids.txt"
27
+ : > "$seen"
28
+ local i
29
+ for ((i = 0; i < 50; i++)); do
30
+ printf '%s\n' "$(archivist_ulid)" >> "$seen"
31
+ done
32
+ local total unique
33
+ total=$(wc -l < "$seen" | tr -d ' ')
34
+ unique=$(sort -u "$seen" | wc -l | tr -d ' ')
35
+ [ "$total" = "$unique" ]
36
+ }
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
5
+ setup_test_env
6
+
7
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/cartographer"
8
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
9
+ # shellcheck disable=SC1091
10
+ source "${PLUGIN_ROOT}/scripts/lib/cartographer-config.sh"
11
+ }
12
+
13
+ @test "config defaults: disabled by default" {
14
+ cartographer_config_load ""
15
+ run cartographer_config_enabled
16
+ [ "$status" -ne 0 ]
17
+ }
18
+
19
+ @test "user-level settings.json can enable cartographer" {
20
+ mkdir -p "${HOME}/.claude"
21
+ printf '%s\n' '{"cartographer":{"enabled":true}}' > "${HOME}/.claude/settings.json"
22
+ cartographer_config_load ""
23
+ run cartographer_config_enabled
24
+ [ "$status" -eq 0 ]
25
+ }
26
+
27
+ @test "repo-level settings.json overrides user-level enabled" {
28
+ mkdir -p "${HOME}/.claude"
29
+ printf '%s\n' '{"cartographer":{"enabled":true}}' > "${HOME}/.claude/settings.json"
30
+ local repo="${BATS_TEST_TMPDIR}/repo"
31
+ mkdir -p "${repo}/.claude"
32
+ printf '%s\n' '{"cartographer":{"enabled":false}}' > "${repo}/.claude/settings.json"
33
+ cartographer_config_load "$repo"
34
+ run cartographer_config_enabled
35
+ [ "$status" -ne 0 ]
36
+ }
37
+
38
+ @test "deep merge: user-level partial override preserves plugin defaults" {
39
+ mkdir -p "${HOME}/.claude"
40
+ printf '%s\n' '{"cartographer":{"audit_interval_hours":12}}' > "${HOME}/.claude/settings.json"
41
+ cartographer_config_load ""
42
+ local interval model
43
+ interval=$(cartographer_config_audit_interval_hours)
44
+ model=$(cartographer_config_model_extraction)
45
+ [ "$interval" = "12" ]
46
+ [ "$model" = "claude-haiku-4-5-20251001" ]
47
+ }
48
+
49
+ @test "deep merge: repo overrides user but preserves user keys not in repo" {
50
+ mkdir -p "${HOME}/.claude"
51
+ printf '%s\n' '{"cartographer":{"audit_interval_hours":6,"phase_timeout_seconds":30}}' > "${HOME}/.claude/settings.json"
52
+ local repo="${BATS_TEST_TMPDIR}/repo2"
53
+ mkdir -p "${repo}/.claude"
54
+ printf '%s\n' '{"cartographer":{"audit_interval_hours":48}}' > "${repo}/.claude/settings.json"
55
+ cartographer_config_load "$repo"
56
+ local interval timeout_s
57
+ interval=$(cartographer_config_audit_interval_hours)
58
+ timeout_s=$(cartographer_config_phase_timeout)
59
+ [ "$interval" = "48" ]
60
+ [ "$timeout_s" = "30" ]
61
+ }
62
+
63
+ @test "model_extraction falls back to default when not configured" {
64
+ cartographer_config_load ""
65
+ local v
66
+ v=$(cartographer_config_model_extraction)
67
+ [ "$v" = "claude-haiku-4-5-20251001" ]
68
+ }
69
+
70
+ @test "model_synthesis falls back to default when not configured" {
71
+ cartographer_config_load ""
72
+ local v
73
+ v=$(cartographer_config_model_synthesis)
74
+ [ "$v" = "claude-haiku-4-5-20251001" ]
75
+ }
76
+
77
+ @test "model_extraction respects user-level override" {
78
+ mkdir -p "${HOME}/.claude"
79
+ printf '%s\n' '{"cartographer":{"extraction":{"model":"claude-sonnet-4-6"}}}' > "${HOME}/.claude/settings.json"
80
+ cartographer_config_load ""
81
+ local v
82
+ v=$(cartographer_config_model_extraction)
83
+ [ "$v" = "claude-sonnet-4-6" ]
84
+ }
85
+
86
+ @test "phase_timeout_seconds defaults to 60" {
87
+ cartographer_config_load ""
88
+ local v
89
+ v=$(cartographer_config_phase_timeout)
90
+ [ "$v" = "60" ]
91
+ }
92
+
93
+ @test "audit_interval_hours defaults to 24" {
94
+ cartographer_config_load ""
95
+ local v
96
+ v=$(cartographer_config_audit_interval_hours)
97
+ [ "$v" = "24" ]
98
+ }
99
+
100
+ @test "exclude_paths defaults are non-empty JSON array" {
101
+ cartographer_config_load ""
102
+ local v
103
+ v=$(cartographer_config_exclude_paths)
104
+ # Must be a non-empty JSON array containing node_modules
105
+ echo "$v" | jq -e 'type == "array" and length > 0' >/dev/null
106
+ echo "$v" | jq -e 'any(. == "node_modules")' >/dev/null
107
+ }
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
5
+ setup_test_env
6
+ # shellcheck disable=SC1091
7
+ source "${REPO_ROOT}/plugins/cartographer/scripts/lib/cartographer-lock.sh"
8
+
9
+ LOCK_FILE="${BATS_TEST_TMPDIR}/test.lock"
10
+ }
11
+
12
+ teardown() {
13
+ cartographer_lock_release "$LOCK_FILE" 2>/dev/null || true
14
+ }
15
+
16
+ @test "acquire succeeds when lock is free" {
17
+ run cartographer_lock_acquire "$LOCK_FILE"
18
+ [ "$status" -eq 0 ]
19
+ }
20
+
21
+ @test "acquire fails immediately when lock is already held" {
22
+ cartographer_lock_acquire "$LOCK_FILE"
23
+ run cartographer_lock_acquire "$LOCK_FILE"
24
+ [ "$status" -ne 0 ]
25
+ }
26
+
27
+ @test "release allows re-acquire" {
28
+ cartographer_lock_acquire "$LOCK_FILE"
29
+ cartographer_lock_release "$LOCK_FILE"
30
+ run cartographer_lock_acquire "$LOCK_FILE"
31
+ [ "$status" -eq 0 ]
32
+ }
33
+
34
+ @test "is_held returns true while lock is held" {
35
+ cartographer_lock_acquire "$LOCK_FILE"
36
+ run cartographer_lock_is_held "$LOCK_FILE"
37
+ [ "$status" -eq 0 ]
38
+ }
39
+
40
+ @test "is_held returns false after release" {
41
+ cartographer_lock_acquire "$LOCK_FILE"
42
+ cartographer_lock_release "$LOCK_FILE"
43
+ run cartographer_lock_is_held "$LOCK_FILE"
44
+ [ "$status" -ne 0 ]
45
+ }
46
+
47
+ @test "release is idempotent (safe to call when not held)" {
48
+ run cartographer_lock_release "$LOCK_FILE"
49
+ [ "$status" -eq 0 ]
50
+ }
51
+
52
+ @test "two independent lock paths do not interfere" {
53
+ local lock_a="${BATS_TEST_TMPDIR}/a.lock"
54
+ local lock_b="${BATS_TEST_TMPDIR}/b.lock"
55
+ cartographer_lock_acquire "$lock_a"
56
+ run cartographer_lock_acquire "$lock_b"
57
+ [ "$status" -eq 0 ]
58
+ cartographer_lock_release "$lock_a"
59
+ cartographer_lock_release "$lock_b"
60
+ }
61
+
62
+ @test "background child holds lock after parent releases its reference" {
63
+ # Acquire in a background subprocess, then verify is_held from this process.
64
+ bash -c "
65
+ source '${REPO_ROOT}/plugins/cartographer/scripts/lib/cartographer-lock.sh'
66
+ cartographer_lock_acquire '${LOCK_FILE}'
67
+ sleep 2
68
+ cartographer_lock_release '${LOCK_FILE}'
69
+ " &
70
+ sleep 0.1
71
+ run cartographer_lock_is_held "$LOCK_FILE"
72
+ [ "$status" -eq 0 ]
73
+ # Second acquire from this process must fail while child holds it
74
+ run cartographer_lock_acquire "$LOCK_FILE"
75
+ [ "$status" -ne 0 ]
76
+ wait
77
+ }
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
5
+ setup_test_env
6
+ # shellcheck disable=SC1091
7
+ source "${REPO_ROOT}/plugins/cartographer/scripts/lib/cartographer-ulid.sh"
8
+ }
9
+
10
+ @test "cartographer_ulid returns a 26-char Crockford Base32 string" {
11
+ local id
12
+ id=$(cartographer_ulid)
13
+ [ "${#id}" -eq 26 ]
14
+ [[ "$id" =~ ^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$ ]]
15
+ }
16
+
17
+ @test "two ULIDs minted apart are lexicographically ordered" {
18
+ local a b
19
+ a=$(cartographer_ulid)
20
+ sleep 0.01
21
+ b=$(cartographer_ulid)
22
+ [[ "$a" < "$b" ]] || [ "$a" = "$b" ]
23
+ }
24
+
25
+ @test "many ULIDs are unique" {
26
+ local seen="${BATS_TEST_TMPDIR}/ulids.txt"
27
+ : > "$seen"
28
+ local i
29
+ for ((i = 0; i < 50; i++)); do
30
+ printf '%s\n' "$(cartographer_ulid)" >> "$seen"
31
+ done
32
+ local total unique
33
+ total=$(wc -l < "$seen" | tr -d ' ')
34
+ unique=$(sort -u "$seen" | wc -l | tr -d ' ')
35
+ [ "$total" = "$unique" ]
36
+ }
37
+
38
+ @test "random component is 16 chars (full 80-bit coverage)" {
39
+ local id
40
+ id=$(cartographer_ulid)
41
+ # Characters 10-25 (0-indexed) are the random component
42
+ local rand_part="${id:10:16}"
43
+ [ "${#rand_part}" -eq 16 ]
44
+ }
45
+
46
+ @test "timestamp component sorts ULIDs correctly across 10+ calls" {
47
+ local prev=""
48
+ local id
49
+ for _ in 1 2 3 4 5 6 7 8 9 10; do
50
+ id=$(cartographer_ulid)
51
+ if [[ -n "$prev" ]]; then
52
+ [[ "$prev" < "$id" ]] || [ "$prev" = "$id" ]
53
+ fi
54
+ prev="$id"
55
+ done
56
+ }
@@ -10,17 +10,15 @@ setup_file() {
10
10
  [ "$status" -eq 0 ]
11
11
  }
12
12
 
13
- @test "claude plugin versions match package.json" {
13
+ @test "ecosystem plugin.json version matches package.json" {
14
14
  local pkg_ver
15
15
  pkg_ver=$(jq -r '.version' "${REPO_ROOT}/package.json")
16
16
 
17
+ # Claude Code reads version from plugin.json. marketplace.json should NOT
18
+ # carry plugins[].version (see plugins-reference: setting both is a drift
19
+ # hazard since plugin.json silently wins).
17
20
  run jq -e --arg v "$pkg_ver" '.version == $v' "${REPO_ROOT}/.claude-plugin/plugin.json"
18
21
  [ "$status" -eq 0 ]
19
-
20
- run jq -e --arg v "$pkg_ver" \
21
- '[.plugins[].version] | unique | length == 1 and .[0] == $v' \
22
- "${REPO_ROOT}/.claude-plugin/marketplace.json"
23
- [ "$status" -eq 0 ]
24
22
  }
25
23
 
26
24
  @test "hooks.json wildcard matcher references tool-sequence-tracker" {
@@ -97,17 +95,19 @@ setup_file() {
97
95
  [[ "$hook_cmd" == *tool-history-tracker.sh ]]
98
96
  }
99
97
 
100
- @test "hooks.json UserPromptSubmit references turn and session-duration trackers" {
101
- run jq -e '.hooks.UserPromptSubmit[0].hooks | length == 2' "${REPO_ROOT}/hooks/hooks.json"
98
+ @test "hooks.json UserPromptSubmit references turn, session-duration, and prompt-rule trackers" {
99
+ run jq -e '.hooks.UserPromptSubmit[0].hooks | length == 3' "${REPO_ROOT}/hooks/hooks.json"
102
100
  [ "$status" -eq 0 ]
103
101
 
104
- local turn_cmd duration_cmd
102
+ local turn_cmd duration_cmd rule_cmd
105
103
  turn_cmd=$(jq -r '.hooks.UserPromptSubmit[0].hooks[0].command' "${REPO_ROOT}/hooks/hooks.json")
106
104
  duration_cmd=$(jq -r '.hooks.UserPromptSubmit[0].hooks[1].command' "${REPO_ROOT}/hooks/hooks.json")
105
+ rule_cmd=$(jq -r '.hooks.UserPromptSubmit[0].hooks[2].command' "${REPO_ROOT}/hooks/hooks.json")
107
106
  [[ "$turn_cmd" == *turn-tracker.sh ]]
108
107
  [[ "$duration_cmd" == *session-duration-tracker.sh ]]
108
+ [[ "$rule_cmd" == *prompt-rule-injector.sh ]]
109
109
 
110
- for hook_cmd in "$turn_cmd" "$duration_cmd"; do
110
+ for hook_cmd in "$turn_cmd" "$duration_cmd" "$rule_cmd"; do
111
111
  local script_path="${hook_cmd//\$CLAUDE_PLUGIN_ROOT/$REPO_ROOT}"
112
112
  script_path="${script_path//\"/}"
113
113
  run test -x "$script_path"