@onlooker-community/ecosystem 0.31.1 → 0.32.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.release-please-manifest.json +4 -4
- package/CHANGELOG.md +89 -0
- package/package.json +1 -1
- package/plugins/bursar/.claude-plugin/plugin.json +1 -1
- package/plugins/bursar/CHANGELOG.md +7 -0
- package/plugins/bursar/scripts/hooks/bursar-session-end.sh +17 -4
- package/plugins/bursar/scripts/lib/portable-lock.sh +27 -9
- package/plugins/historian/.claude-plugin/plugin.json +1 -1
- package/plugins/historian/CHANGELOG.md +7 -0
- package/plugins/historian/scripts/hooks/historian-prompt-submit.sh +2 -1
- package/plugins/historian/scripts/hooks/historian-session-end.sh +2 -1
- package/plugins/librarian/.claude-plugin/plugin.json +1 -1
- package/plugins/librarian/CHANGELOG.md +19 -0
- package/plugins/librarian/scripts/hooks/librarian-session-end.sh +70 -6
- package/plugins/librarian/scripts/lib/librarian-conflict-detector.sh +215 -0
- package/scripts/lib/portable-lock.sh +27 -9
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ecosystem",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.1",
|
|
4
4
|
"description": "Observability substrate for Claude Code. Provides the shared $ONLOOKER_DIR storage root (default $HOME/.onlooker), canonical schema-validated event emission, session and tool tracking hooks, and prompt rules. Required by all other Onlooker plugins.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Onlooker Community",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
".": "0.
|
|
2
|
+
".": "0.32.1",
|
|
3
3
|
"plugins/archivist": "0.3.1",
|
|
4
4
|
"plugins/tribunal": "1.1.1",
|
|
5
5
|
"plugins/echo": "0.3.1",
|
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
"plugins/scribe": "0.4.1",
|
|
10
10
|
"plugins/counsel": "0.5.1",
|
|
11
11
|
"plugins/warden": "0.3.1",
|
|
12
|
-
"plugins/librarian": "0.
|
|
12
|
+
"plugins/librarian": "0.6.0",
|
|
13
13
|
"plugins/curator": "0.2.1",
|
|
14
|
-
"plugins/historian": "0.3.
|
|
14
|
+
"plugins/historian": "0.3.2",
|
|
15
15
|
"plugins/assayer": "1.1.1",
|
|
16
|
-
"plugins/bursar": "0.2.
|
|
16
|
+
"plugins/bursar": "0.2.2",
|
|
17
17
|
"plugins/lineage": "0.2.1",
|
|
18
18
|
"plugins/inspector": "0.3.1"
|
|
19
19
|
}
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,94 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.32.1](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.32.0...ecosystem-v0.32.1) (2026-08-02)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* librarian conflict detection + bursar rollup lock timeout ([#117](https://github.com/onlooker-community/ecosystem/issues/117)) ([16180a2](https://github.com/onlooker-community/ecosystem/commit/16180a210a6f0561c09b77a2b102849f63f1e730))
|
|
9
|
+
|
|
10
|
+
## [0.32.0](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.31.2...ecosystem-v0.32.0) (2026-08-02)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* add configuration and hooks for agent spawn tracking ([3ef4590](https://github.com/onlooker-community/ecosystem/commit/3ef459006bbbda246604bdd1ffaf9af0a59f9740))
|
|
16
|
+
* add settings.json for plugin configuration ([67fbdfe](https://github.com/onlooker-community/ecosystem/commit/67fbdfe37f067a45801e7d0355c4a533b687f6b2))
|
|
17
|
+
* **archivist:** introduce structured session memory plugin :rocket: ([378fff3](https://github.com/onlooker-community/ecosystem/commit/378fff3c14b40644af45b1a2335992e7b0428160))
|
|
18
|
+
* **assayer:** introduce claim-verification plugin ([#70](https://github.com/onlooker-community/ecosystem/issues/70)) ([1d0500b](https://github.com/onlooker-community/ecosystem/commit/1d0500b64f8cd670d1cfa1ac070182d72696bdfd))
|
|
19
|
+
* **bursar:** introduce multi-session budget rollup plugin ([#81](https://github.com/onlooker-community/ecosystem/issues/81)) ([b11e687](https://github.com/onlooker-community/ecosystem/commit/b11e687744bab70a94025c46c4aaa58fb7ea97f4))
|
|
20
|
+
* **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))
|
|
21
|
+
* **compass:** pre-write intent clarity gate plugin :compass: ([#47](https://github.com/onlooker-community/ecosystem/issues/47)) ([144c2ef](https://github.com/onlooker-community/ecosystem/commit/144c2ef44d28bab3dcec14a9eace7ec76470d090))
|
|
22
|
+
* **counsel:** add /counsel on-demand weekly-review command :rocket: ([#76](https://github.com/onlooker-community/ecosystem/issues/76)) ([8ce951c](https://github.com/onlooker-community/ecosystem/commit/8ce951cd5cb7b173f194f86c2960a31fb0d6889d))
|
|
23
|
+
* **counsel:** weekly observability synthesis and coaching brief :robot: ([#51](https://github.com/onlooker-community/ecosystem/issues/51)) ([6364586](https://github.com/onlooker-community/ecosystem/commit/63645863cf3a1d7bbf0353aacb9b71e4f977dd56))
|
|
24
|
+
* **coverage:** report node + bash coverage on every PR :sparkles: ([cb5d122](https://github.com/onlooker-community/ecosystem/commit/cb5d1221ad20e6257d66b507897dae14549a870f))
|
|
25
|
+
* **curator:** introduce plugin with cheap-tier checks :microscope: ([#57](https://github.com/onlooker-community/ecosystem/issues/57)) ([7f9fa18](https://github.com/onlooker-community/ecosystem/commit/7f9fa18bbde29c8b5bd1eaad185bd4c5595a3762))
|
|
26
|
+
* **echo:** add prompt regression detection plugin ([#32](https://github.com/onlooker-community/ecosystem/issues/32)) ([65274d4](https://github.com/onlooker-community/ecosystem/commit/65274d4d8326950d6c998ca292fed13b1b8c493b))
|
|
27
|
+
* **ecosystem:** emit memory.recalled at SessionStart :link: ([#62](https://github.com/onlooker-community/ecosystem/issues/62)) ([d5876f9](https://github.com/onlooker-community/ecosystem/commit/d5876f9f819165cc07d691d733662b549863b7f5))
|
|
28
|
+
* **governor:** resource governance and budget enforcement plugin :rocket: ([#43](https://github.com/onlooker-community/ecosystem/issues/43)) ([04e6d70](https://github.com/onlooker-community/ecosystem/commit/04e6d7051f27db752bb121d389d65b4d8ade04ad))
|
|
29
|
+
* **historian:** introduce SessionEnd indexing :spiral_notepad: ([#59](https://github.com/onlooker-community/ecosystem/issues/59)) ([dd6c7f6](https://github.com/onlooker-community/ecosystem/commit/dd6c7f6ea872437cab6b16de50838dfc72750c7b))
|
|
30
|
+
* **historian:** retrieval pipeline + ollama embedder :telescope: ([#61](https://github.com/onlooker-community/ecosystem/issues/61)) ([7eae752](https://github.com/onlooker-community/ecosystem/commit/7eae752a288c4678ab093042469f2e65d428f0d9))
|
|
31
|
+
* **hooks:** add PreCompact and PostCompact context compaction trackers ([#15](https://github.com/onlooker-community/ecosystem/issues/15)) ([1ec5632](https://github.com/onlooker-community/ecosystem/commit/1ec5632404676ed8b35d324b79ad71a2e9093505))
|
|
32
|
+
* **hooks:** add SessionStart and SessionEnd session trackers ([#10](https://github.com/onlooker-community/ecosystem/issues/10)) ([a48d680](https://github.com/onlooker-community/ecosystem/commit/a48d680dd24c98e79ef1c0401b07483ecebf9e8b))
|
|
33
|
+
* **hooks:** add TaskCreated and TaskCompleted task lifecycle trackers ([#21](https://github.com/onlooker-community/ecosystem/issues/21)) ([986ffa8](https://github.com/onlooker-community/ecosystem/commit/986ffa84bdd857a464ca0d556671628190ed27bc))
|
|
34
|
+
* **hooks:** add UserPromptSubmit turn and session duration trackers ([#12](https://github.com/onlooker-community/ecosystem/issues/12)) ([cbb7657](https://github.com/onlooker-community/ecosystem/commit/cbb7657979ed144efce506e6b487e037679b9462))
|
|
35
|
+
* **hooks:** add WorktreeCreate and WorktreeRemove lifecycle trackers ([#24](https://github.com/onlooker-community/ecosystem/issues/24)) ([ff55e39](https://github.com/onlooker-community/ecosystem/commit/ff55e397a0c0adc3e76f66aba12c6b237149ad17))
|
|
36
|
+
* **hooks:** emit canonical schema events for tool history :sparkles: ([1e49a24](https://github.com/onlooker-community/ecosystem/commit/1e49a24bfb930942fa477b594395ef352618f574))
|
|
37
|
+
* **hooks:** enrich tool.file.read with read chunking observability ([#25](https://github.com/onlooker-community/ecosystem/issues/25)) ([8eb23c8](https://github.com/onlooker-community/ecosystem/commit/8eb23c8f4f03dfbeb701a30de1fa50c1c8ee48ac))
|
|
38
|
+
* **hooks:** track skill usage via skill.invoked events ([23fff0f](https://github.com/onlooker-community/ecosystem/commit/23fff0f0bfad8ab91788d8c45a0457d099d2e870))
|
|
39
|
+
* **hooks:** track tool call sequence on every PreToolUse :sparkles: ([0ad9546](https://github.com/onlooker-community/ecosystem/commit/0ad95465cc22a237e26115a67814a6e7b2951b1d))
|
|
40
|
+
* **inspector:** ship the per-edit lint/typecheck plugin ([#88](https://github.com/onlooker-community/ecosystem/issues/88)) ([2018243](https://github.com/onlooker-community/ecosystem/commit/201824384abd6a4fc5f4395266924aa413a2ffd1))
|
|
41
|
+
* **librarian:** /librarian review skill closes promotion loop :tada: ([#68](https://github.com/onlooker-community/ecosystem/issues/68)) ([8f3e3db](https://github.com/onlooker-community/ecosystem/commit/8f3e3dbdf6f08dceb0cf61d46281936a4f9954de))
|
|
42
|
+
* **librarian:** implement conflict detection for memory promotion scanning ([#115](https://github.com/onlooker-community/ecosystem/issues/115)) ([8699577](https://github.com/onlooker-community/ecosystem/commit/86995773cb44127d915b5aea53aed66c771d8bfb))
|
|
43
|
+
* **librarian:** land plugin end-to-end with memory layer designs :seedling: ([#55](https://github.com/onlooker-community/ecosystem/issues/55)) ([d4821ef](https://github.com/onlooker-community/ecosystem/commit/d4821efabfeb587e460e898d7db8f92fcc3f2c61))
|
|
44
|
+
* **lineage:** introduce per-change provenance plugin ([#83](https://github.com/onlooker-community/ecosystem/issues/83)) ([86b00d3](https://github.com/onlooker-community/ecosystem/commit/86b00d3d7393e2b63c5b04d60692fc89f202bf6c))
|
|
45
|
+
* **lint:** add marketplace cross-reference linter :nail_care: ([0f48817](https://github.com/onlooker-community/ecosystem/commit/0f488170326659ef1d0b8bd7ae4d207c78a43694))
|
|
46
|
+
* **lint:** add plugin manifest validator :nail_care: ([e12615f](https://github.com/onlooker-community/ecosystem/commit/e12615ff99d43caf59d5e215d882c0acb3352c01))
|
|
47
|
+
* **plugins:** installation enables plugins — remove per-plugin 'enabled' config key ([#108](https://github.com/onlooker-community/ecosystem/issues/108)) ([45e4e6b](https://github.com/onlooker-community/ecosystem/commit/45e4e6bd29a0a7545dbd5007bf3f09600e1be391))
|
|
48
|
+
* **plugins:** persist structured JSON and emit onlooker.artifact.ready :outbox_tray: ([#103](https://github.com/onlooker-community/ecosystem/issues/103)) ([9b689a4](https://github.com/onlooker-community/ecosystem/commit/9b689a41aa4bdb481fef93b484e6446da731e8f1))
|
|
49
|
+
* **prompt-rules:** deterministic regex-triggered guidance injection :relieved: ([#28](https://github.com/onlooker-community/ecosystem/issues/28)) ([662c811](https://github.com/onlooker-community/ecosystem/commit/662c8119657cebc350900f859c43dbaca97d6703))
|
|
50
|
+
* **scribe:** intent documentation from agent activity :pencil2: ([#50](https://github.com/onlooker-community/ecosystem/issues/50)) ([f0a95d1](https://github.com/onlooker-community/ecosystem/commit/f0a95d1058e36d1bb5f0f645964d9e88e8f98b66))
|
|
51
|
+
* **tribunal:** add multi-agent code review plugin :sparkles: ([#30](https://github.com/onlooker-community/ecosystem/issues/30)) ([893f24a](https://github.com/onlooker-community/ecosystem/commit/893f24a8876fdd6ccb5c7dcf2636a7c902e88949))
|
|
52
|
+
* **warden:** untrusted-content gate enforcing the Agents Rule of Two :shield: ([#53](https://github.com/onlooker-community/ecosystem/issues/53)) ([210aa51](https://github.com/onlooker-community/ecosystem/commit/210aa51bff66226a0eec1f17292a2af4ea4ef56a))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
### Bug Fixes
|
|
56
|
+
|
|
57
|
+
* **cartographer:** correct typo in release-please bump-patch key :face_with_spiral_eyes: ([#91](https://github.com/onlooker-community/ecosystem/issues/91)) ([dfab160](https://github.com/onlooker-community/ecosystem/commit/dfab1602afda2b6255a72b0975ebab9289d75b8e))
|
|
58
|
+
* **ci:** apply release-please extra-files for Claude plugin manifests ([#17](https://github.com/onlooker-community/ecosystem/issues/17)) ([da9913c](https://github.com/onlooker-community/ecosystem/commit/da9913ca4f7497280edc34f8c64baa903c1e6754))
|
|
59
|
+
* **ci:** checkout release tag before npm publish :relieved: ([bc7bbdc](https://github.com/onlooker-community/ecosystem/commit/bc7bbdc7a886a55ba8f04fe09bfa60043648c766))
|
|
60
|
+
* **ci:** grant id-token write for npm provenance on publish ([c78c9f0](https://github.com/onlooker-community/ecosystem/commit/c78c9f054c1d48ca8a83d0d26b76ce991fffe51b))
|
|
61
|
+
* **ci:** parse release-please paths_released JSON for npm publish ([749e1a0](https://github.com/onlooker-community/ecosystem/commit/749e1a02b563f37f81a8da21fc3f6e10e179314a))
|
|
62
|
+
* **ci:** stop upgrading npm globally before publish ([a7c7a0e](https://github.com/onlooker-community/ecosystem/commit/a7c7a0e1f25aee1bbb75bdd2af130dbc276480a6))
|
|
63
|
+
* **ci:** use HTTPS repository URL for npm provenance ([a7e8927](https://github.com/onlooker-community/ecosystem/commit/a7e89275c5a025a8afee009853265b717091f6ca))
|
|
64
|
+
* **ci:** use paths_released to gate npm publish :rage: ([#37](https://github.com/onlooker-community/ecosystem/issues/37)) ([c62b17f](https://github.com/onlooker-community/ecosystem/commit/c62b17f7e1352cfe260a23c8f48be30f72edbbed))
|
|
65
|
+
* **compass:** resolve MultiEdit file path from the top-level field ([#85](https://github.com/onlooker-community/ecosystem/issues/85)) ([468abaa](https://github.com/onlooker-community/ecosystem/commit/468abaaad4bef59fe4308bd8887dfcf6d633921a))
|
|
66
|
+
* **counsel:** drop unsupported --max-tokens flag from claude synthesis call :relieved: ([#79](https://github.com/onlooker-community/ecosystem/issues/79)) ([ade85ce](https://github.com/onlooker-community/ecosystem/commit/ade85cecb3243781f47e14fea4990ce31e69e8f4))
|
|
67
|
+
* **counsel:** stop pipefail from discarding all events on large logs :relieved: ([#78](https://github.com/onlooker-community/ecosystem/issues/78)) ([638347d](https://github.com/onlooker-community/ecosystem/commit/638347dec3b9df740b7a85c3e475fa2ffe5d054b))
|
|
68
|
+
* **emitter:** make emission dependency-free and fail-open :relieved: ([#99](https://github.com/onlooker-community/ecosystem/issues/99)) ([0dda7f8](https://github.com/onlooker-community/ecosystem/commit/0dda7f803ed2dfa28561bd8d9e4193b2b18e5bbf))
|
|
69
|
+
* **historian:** export CLAUDE_PLUGIN_ROOT so config loads correctly :bug: ([#112](https://github.com/onlooker-community/ecosystem/issues/112)) ([f24b0b4](https://github.com/onlooker-community/ecosystem/commit/f24b0b4375c64385fefd329cd07d8874785002e9))
|
|
70
|
+
* **historian:** repair CI broken by embedder check + document PR workflow :nail_care: ([#106](https://github.com/onlooker-community/ecosystem/issues/106)) ([02062a2](https://github.com/onlooker-community/ecosystem/commit/02062a21163e084705dae9cf421719b6e5a7b306))
|
|
71
|
+
* **hooks:** replace flock with portable mkdir mutex :bug: ([3dffa6f](https://github.com/onlooker-community/ecosystem/commit/3dffa6f5e43ef9f3c117f2406ddd03ce485df1cd))
|
|
72
|
+
* **package:** update repository URL format in package.json ([591ce9f](https://github.com/onlooker-community/ecosystem/commit/591ce9f54dd605ec04ceb77b9dcca40b3e08621e))
|
|
73
|
+
* **plugins:** glob-discover ecosystem root so sub-plugins work from cache :relieved: ([#110](https://github.com/onlooker-community/ecosystem/issues/110)) ([3639240](https://github.com/onlooker-community/ecosystem/commit/3639240383e1b820e5d8ea42639e0b863ef0d90e))
|
|
74
|
+
* **release:** sync marketplace.json out-of-band :relieved: ([0d2a0a3](https://github.com/onlooker-community/ecosystem/commit/0d2a0a38c0c4ee0e400b9a143a4be7904ea3f70a))
|
|
75
|
+
* **scribe:** mark hook scripts executable :relieved: ([#64](https://github.com/onlooker-community/ecosystem/issues/64)) ([05603e5](https://github.com/onlooker-community/ecosystem/commit/05603e56895c009c1435d1712592adbbc4c15e61))
|
|
76
|
+
* **tribunal:** persist all artifacts on every iteration including retries :relieved: ([#41](https://github.com/onlooker-community/ecosystem/issues/41)) ([1636105](https://github.com/onlooker-community/ecosystem/commit/163610535a4ce0fa73c8fb82dc5c6296d2d1065a))
|
|
77
|
+
* vendor portable-lock.sh into cartographer and governor ([#73](https://github.com/onlooker-community/ecosystem/issues/73)) ([ab2c354](https://github.com/onlooker-community/ecosystem/commit/ab2c354b131c26cc642ebb51e84a043dc43cbaa1))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
### Performance Improvements
|
|
81
|
+
|
|
82
|
+
* **bursar:** collapse process forks in SessionEnd hot path :relieved: ([#101](https://github.com/onlooker-community/ecosystem/issues/101)) ([7a426fe](https://github.com/onlooker-community/ecosystem/commit/7a426fe359785eca35ea1ad61523b05fda79e0da))
|
|
83
|
+
* **historian:** fix O(n²) chunk loop and embedder false-positive :relieved: ([4ef130a](https://github.com/onlooker-community/ecosystem/commit/4ef130a4c01ec378da5b4d8baeeb7b4fb6059272))
|
|
84
|
+
|
|
85
|
+
## [0.31.2](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.31.1...ecosystem-v0.31.2) (2026-08-01)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
### Bug Fixes
|
|
89
|
+
|
|
90
|
+
* **historian:** export CLAUDE_PLUGIN_ROOT so config loads correctly :bug: ([#112](https://github.com/onlooker-community/ecosystem/issues/112)) ([f24b0b4](https://github.com/onlooker-community/ecosystem/commit/f24b0b4375c64385fefd329cd07d8874785002e9))
|
|
91
|
+
|
|
3
92
|
## [0.31.1](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.31.0...ecosystem-v0.31.1) (2026-08-01)
|
|
4
93
|
|
|
5
94
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bursar",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Multi-session, per-project budget accounting for the Onlooker ecosystem. Rolls each session's spend into a per-project ledger on SessionEnd and surfaces \"this project burned $X this week\" at SessionStart. Where governor regulates a single session, bursar is the cross-session rollup: it reads governor.session.complete off the shared event bus and emits bursar.* events for audit. Named for the officer who keeps the accounts. Builds on the Onlooker ecosystem plugin.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Onlooker Community",
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.2](https://github.com/onlooker-community/ecosystem/compare/bursar-v0.2.1...bursar-v0.2.2) (2026-08-02)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* librarian conflict detection + bursar rollup lock timeout ([#117](https://github.com/onlooker-community/ecosystem/issues/117)) ([16180a2](https://github.com/onlooker-community/ecosystem/commit/16180a210a6f0561c09b77a2b102849f63f1e730))
|
|
9
|
+
|
|
3
10
|
## [0.2.1](https://github.com/onlooker-community/ecosystem/compare/bursar-v0.2.0...bursar-v0.2.1) (2026-08-01)
|
|
4
11
|
|
|
5
12
|
|
|
@@ -35,6 +35,11 @@ INPUT=$(cat)
|
|
|
35
35
|
|
|
36
36
|
_done() { exit 0; }
|
|
37
37
|
|
|
38
|
+
# Emit diagnostic errors to stderr when ledger recording fails.
|
|
39
|
+
_log_error() {
|
|
40
|
+
printf 'bursar-session-end: %s\n' "$1" >&2
|
|
41
|
+
}
|
|
42
|
+
|
|
38
43
|
# Parse session_id and cwd in a single jq pass (one fork, not two).
|
|
39
44
|
{ IFS= read -r SESSION_ID; IFS= read -r CWD; } < <(printf '%s' "$INPUT" | jq -r '.session_id // "", .cwd // ""' 2>/dev/null)
|
|
40
45
|
|
|
@@ -142,10 +147,18 @@ MODEL=""
|
|
|
142
147
|
# ledger upsert actually succeeds. A failed write (lock timeout, mv failure)
|
|
143
148
|
# must keep the breadcrumb so the session→project attribution survives for a
|
|
144
149
|
# later attempt rather than being lost behind a false "recorded" event.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
150
|
+
#
|
|
151
|
+
# Use a longer lock timeout (60s) to handle high concurrency: under typical
|
|
152
|
+
# usage, SessionEnd hooks from concurrent sessions compete for the per-project
|
|
153
|
+
# ledger lock. The default 5s timeout is too aggressive with 100+ concurrent
|
|
154
|
+
# sessions, leading to silent recording failures and breadcrumb accumulation.
|
|
155
|
+
if [[ -n "$RECORD" ]]; then
|
|
156
|
+
if bursar_ledger_record "$PROJECT_KEY" "$RECORD" 60; then
|
|
157
|
+
[[ -n "$EV" ]] && bursar_emit_event "bursar.session.recorded" "$EV" "$SESSION_ID" || true
|
|
158
|
+
rm -f "$BREADCRUMB" 2>/dev/null || true
|
|
159
|
+
else
|
|
160
|
+
_log_error "ledger_record failed for session $SESSION_ID project $PROJECT_KEY (lock timeout after 60s?). Breadcrumb retained for retry."
|
|
161
|
+
fi
|
|
149
162
|
fi
|
|
150
163
|
|
|
151
164
|
_done
|
|
@@ -29,23 +29,41 @@
|
|
|
29
29
|
# Avoid associative arrays so bash 3.2 (macOS default) keeps working.
|
|
30
30
|
|
|
31
31
|
# Acquire an exclusive lock at LOCKPATH. Returns 0 on success, 1 on timeout.
|
|
32
|
+
# Uses exponential backoff under high contention to reduce cache-line thrashing.
|
|
32
33
|
lock_acquire() {
|
|
33
34
|
local lockpath="${1:-}"
|
|
34
35
|
local timeout="${2:-5}"
|
|
35
36
|
[[ -z "$lockpath" ]] && return 1
|
|
36
37
|
|
|
37
38
|
local lockdir="${lockpath}.d"
|
|
38
|
-
local
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
local start_time
|
|
40
|
+
start_time=$(date +%s 2>/dev/null) || start_time=0
|
|
41
|
+
|
|
42
|
+
local backoff=10 # Start at 10ms (1/100s)
|
|
41
43
|
while ! mkdir "$lockdir" 2>/dev/null; do
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
# Check timeout using epoch comparison (works on all systems).
|
|
45
|
+
if ((start_time > 0)); then
|
|
46
|
+
local now
|
|
47
|
+
now=$(date +%s 2>/dev/null) || now=0
|
|
48
|
+
if (( (now - start_time) >= timeout )); then
|
|
49
|
+
return 1
|
|
50
|
+
fi
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
# Exponential backoff: 10ms → 20ms → 40ms, capped at 100ms to avoid
|
|
54
|
+
# sleeping too long under sustained high contention. Reduces per-second
|
|
55
|
+
# mkdir() attempts from 10/sec (10Hz polling) to ~10-30/sec under load.
|
|
56
|
+
local sleep_ms=$((backoff))
|
|
57
|
+
if ((sleep_ms > 100)); then
|
|
58
|
+
sleep_ms=100
|
|
44
59
|
fi
|
|
45
|
-
|
|
46
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
60
|
+
|
|
61
|
+
# Convert to seconds: `sleep 0.01` is 10ms. Fallback for shells
|
|
62
|
+
# that only accept integer seconds.
|
|
63
|
+
awk -v ms="$sleep_ms" 'BEGIN { printf "%.2f\n", ms/1000 }' | \
|
|
64
|
+
xargs -I {} sleep {} 2>/dev/null || sleep 1
|
|
65
|
+
|
|
66
|
+
backoff=$((backoff * 2))
|
|
49
67
|
done
|
|
50
68
|
return 0
|
|
51
69
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "historian",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Episodic memory layer. At SessionEnd, chunks and sanitizes the session transcript and stores chunks under $ONLOOKER_DIR/historian/<project-key>/sessions/ (default $HOME/.onlooker). On UserPromptSubmit, embeds the prompt and performs similarity retrieval over stored chunks to surface relevant past context. Builds on the Onlooker ecosystem plugin.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Onlooker Community",
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.2](https://github.com/onlooker-community/ecosystem/compare/historian-v0.3.1...historian-v0.3.2) (2026-08-01)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **historian:** export CLAUDE_PLUGIN_ROOT so config loads correctly :bug: ([#112](https://github.com/onlooker-community/ecosystem/issues/112)) ([f24b0b4](https://github.com/onlooker-community/ecosystem/commit/f24b0b4375c64385fefd329cd07d8874785002e9))
|
|
9
|
+
|
|
3
10
|
## [0.3.1](https://github.com/onlooker-community/ecosystem/compare/historian-v0.3.0...historian-v0.3.1) (2026-08-01)
|
|
4
11
|
|
|
5
12
|
|
|
@@ -50,7 +50,8 @@ if [[ -z "$_ECOSYSTEM_ROOT" ]]; then
|
|
|
50
50
|
fi
|
|
51
51
|
if [[ -n "$_ECOSYSTEM_ROOT" && -f "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh" ]]; then
|
|
52
52
|
# shellcheck disable=SC1091
|
|
53
|
-
CLAUDE_PLUGIN_ROOT="$
|
|
53
|
+
export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
|
|
54
|
+
source "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh"
|
|
54
55
|
fi
|
|
55
56
|
|
|
56
57
|
# shellcheck source=../lib/historian-config.sh
|
|
@@ -37,7 +37,8 @@ if [[ -z "$_ECOSYSTEM_ROOT" ]]; then
|
|
|
37
37
|
fi
|
|
38
38
|
if [[ -n "$_ECOSYSTEM_ROOT" && -f "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh" ]]; then
|
|
39
39
|
# shellcheck disable=SC1091
|
|
40
|
-
CLAUDE_PLUGIN_ROOT="$
|
|
40
|
+
export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
|
|
41
|
+
source "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh"
|
|
41
42
|
fi
|
|
42
43
|
|
|
43
44
|
# shellcheck source=../lib/historian-config.sh
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "librarian",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Consolidation layer between archivist's per-session artifacts and the user's durable typed memory store. Detects which session decisions, dead-ends, and open questions deserve to live across sessions, classifies them into the user/feedback/project/reference types, and queues them as proposals for explicit confirmation. Auto-promotion is opt-in. Builds on the Onlooker ecosystem plugin.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Onlooker Community",
|
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.0](https://github.com/onlooker-community/ecosystem/compare/librarian-v0.5.0...librarian-v0.6.0) (2026-08-02)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **librarian:** implement conflict detection for memory promotion scanning ([#115](https://github.com/onlooker-community/ecosystem/issues/115)) ([8699577](https://github.com/onlooker-community/ecosystem/commit/86995773cb44127d915b5aea53aed66c771d8bfb))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* librarian conflict detection + bursar rollup lock timeout ([#117](https://github.com/onlooker-community/ecosystem/issues/117)) ([16180a2](https://github.com/onlooker-community/ecosystem/commit/16180a210a6f0561c09b77a2b102849f63f1e730))
|
|
14
|
+
|
|
15
|
+
## [0.5.0](https://github.com/onlooker-community/ecosystem/compare/librarian-v0.4.1...librarian-v0.5.0) (2026-08-02)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* **librarian:** implement conflict detection for memory promotion scanning ([#115](https://github.com/onlooker-community/ecosystem/issues/115)) ([8699577](https://github.com/onlooker-community/ecosystem/commit/86995773cb44127d915b5aea53aed66c771d8bfb))
|
|
21
|
+
|
|
3
22
|
## [0.4.1](https://github.com/onlooker-community/ecosystem/compare/librarian-v0.4.0...librarian-v0.4.1) (2026-08-01)
|
|
4
23
|
|
|
5
24
|
|
|
@@ -57,6 +57,8 @@ source "${PLUGIN_ROOT}/scripts/lib/librarian-archivist-reader.sh"
|
|
|
57
57
|
source "${PLUGIN_ROOT}/scripts/lib/librarian-durability.sh"
|
|
58
58
|
# shellcheck source=../lib/librarian-classifier.sh
|
|
59
59
|
source "${PLUGIN_ROOT}/scripts/lib/librarian-classifier.sh"
|
|
60
|
+
# shellcheck source=../lib/librarian-conflict-detector.sh
|
|
61
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-conflict-detector.sh"
|
|
60
62
|
|
|
61
63
|
INPUT=$(cat 2>/dev/null || true)
|
|
62
64
|
CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) || CWD=""
|
|
@@ -232,14 +234,75 @@ for ((i = 0; i < KEPT_COUNT; i++)); do
|
|
|
232
234
|
continue
|
|
233
235
|
fi
|
|
234
236
|
|
|
235
|
-
# Build and persist the proposal.
|
|
236
|
-
# memory store
|
|
237
|
-
# conflict_state: "none" for now.
|
|
237
|
+
# Build and persist the proposal. Detect conflicts against the user's
|
|
238
|
+
# memory store before writing.
|
|
238
239
|
PROPOSAL_ID=$(librarian_ulid)
|
|
239
240
|
FILENAME=$(librarian_classifier_filename "$MEMORY_TYPE" "$TITLE")
|
|
240
241
|
ARTIFACT_ID=$(printf '%s' "$ARTIFACT" | jq -r '.id // ""')
|
|
241
242
|
ARTIFACT_SESSION=$(printf '%s' "$ARTIFACT" | jq -r '.session_id // ""')
|
|
242
243
|
|
|
244
|
+
# Resolve the typed memory store path for this project.
|
|
245
|
+
MEMORY_STORE_PATH=$(librarian_config_get '.librarian.memory_store_path')
|
|
246
|
+
[[ -z "$MEMORY_STORE_PATH" || "$MEMORY_STORE_PATH" == "null" ]] && \
|
|
247
|
+
MEMORY_STORE_PATH='${HOME}/.claude/projects/${CLAUDE_PROJECT_ENCODED}/memory'
|
|
248
|
+
|
|
249
|
+
# Expand env vars in the path.
|
|
250
|
+
MEMORY_STORE_PATH=$(eval echo "$MEMORY_STORE_PATH")
|
|
251
|
+
|
|
252
|
+
# Initialize conflict state; will scan if memory dir exists.
|
|
253
|
+
CONFLICT_STATE="none"
|
|
254
|
+
CONFLICT_WITH="[]"
|
|
255
|
+
|
|
256
|
+
# Detect conflicts against existing memories only if the directory exists.
|
|
257
|
+
if [[ -d "$MEMORY_STORE_PATH" ]]; then
|
|
258
|
+
# Build a temporary proposal for conflict detection.
|
|
259
|
+
TEMP_PROPOSAL=$(jq -n \
|
|
260
|
+
--arg id "$PROPOSAL_ID" \
|
|
261
|
+
--arg memory_type "$MEMORY_TYPE" \
|
|
262
|
+
--arg filename "$FILENAME" \
|
|
263
|
+
--arg title "$TITLE" \
|
|
264
|
+
--arg body "$BODY" \
|
|
265
|
+
--argjson classifier_confidence "$CONFIDENCE" \
|
|
266
|
+
'{
|
|
267
|
+
id: $id,
|
|
268
|
+
proposed: {
|
|
269
|
+
type: $memory_type,
|
|
270
|
+
filename: $filename,
|
|
271
|
+
title: $title,
|
|
272
|
+
body: $body,
|
|
273
|
+
classifier_confidence: $classifier_confidence
|
|
274
|
+
}
|
|
275
|
+
}')
|
|
276
|
+
|
|
277
|
+
# Detect conflicts against existing memories.
|
|
278
|
+
DUP_THRESHOLD=$(librarian_config_get '.librarian.conflict.duplicate_threshold')
|
|
279
|
+
[[ -z "$DUP_THRESHOLD" || "$DUP_THRESHOLD" == "null" ]] && DUP_THRESHOLD="0.7"
|
|
280
|
+
MERGE_THRESHOLD=$(librarian_config_get '.librarian.conflict.merge_candidate_threshold')
|
|
281
|
+
[[ -z "$MERGE_THRESHOLD" || "$MERGE_THRESHOLD" == "null" ]] && MERGE_THRESHOLD="0.45"
|
|
282
|
+
|
|
283
|
+
CONFLICT_RESULT=$(librarian_conflict_scan "$TEMP_PROPOSAL" "$MEMORY_STORE_PATH" \
|
|
284
|
+
"$DUP_THRESHOLD" "$MERGE_THRESHOLD" 2>/dev/null) || CONFLICT_RESULT=""
|
|
285
|
+
|
|
286
|
+
# Ensure we have valid JSON; fall back to "none" if scan fails.
|
|
287
|
+
if [[ -z "$CONFLICT_RESULT" ]] || ! printf '%s' "$CONFLICT_RESULT" | jq -e '.' >/dev/null 2>&1; then
|
|
288
|
+
CONFLICT_RESULT='{"conflict_state":"none","conflict_with":[]}'
|
|
289
|
+
fi
|
|
290
|
+
|
|
291
|
+
CONFLICT_STATE=$(printf '%s' "$CONFLICT_RESULT" | jq -r '.conflict_state // "none"' 2>/dev/null) || CONFLICT_STATE="none"
|
|
292
|
+
CONFLICT_WITH=$(printf '%s' "$CONFLICT_RESULT" | jq -c '.conflict_with // []' 2>/dev/null) || CONFLICT_WITH="[]"
|
|
293
|
+
|
|
294
|
+
# Silently drop duplicates — no proposal written.
|
|
295
|
+
if [[ "$CONFLICT_STATE" == "duplicate" ]]; then
|
|
296
|
+
POST_CLASSIFIER_DROPPED=$((POST_CLASSIFIER_DROPPED + 1))
|
|
297
|
+
librarian_emit "librarian.candidate.dropped" "$SESSION_ID" "$(jq -cn \
|
|
298
|
+
--arg reason "duplicate" \
|
|
299
|
+
--arg src "$ARTIFACT_ID" \
|
|
300
|
+
'{ reason: $reason, source_artifact_id: (if $src == "" then null else $src end) }
|
|
301
|
+
| with_entries(select(.value != null))')"
|
|
302
|
+
continue
|
|
303
|
+
fi
|
|
304
|
+
fi
|
|
305
|
+
|
|
243
306
|
PROPOSAL_JSON=$(jq -n \
|
|
244
307
|
--arg id "$PROPOSAL_ID" \
|
|
245
308
|
--arg created_at "$NOW_TS" \
|
|
@@ -248,7 +311,8 @@ for ((i = 0; i < KEPT_COUNT; i++)); do
|
|
|
248
311
|
--arg title "$TITLE" \
|
|
249
312
|
--arg body "$BODY" \
|
|
250
313
|
--argjson classifier_confidence "$CONFIDENCE" \
|
|
251
|
-
--arg conflict_state "
|
|
314
|
+
--arg conflict_state "$CONFLICT_STATE" \
|
|
315
|
+
--argjson conflict_with "$CONFLICT_WITH" \
|
|
252
316
|
--arg artifact_id "$ARTIFACT_ID" \
|
|
253
317
|
--arg artifact_session "$ARTIFACT_SESSION" \
|
|
254
318
|
'{
|
|
@@ -264,7 +328,7 @@ for ((i = 0; i < KEPT_COUNT; i++)); do
|
|
|
264
328
|
classifier_confidence: $classifier_confidence
|
|
265
329
|
},
|
|
266
330
|
conflict_state: $conflict_state,
|
|
267
|
-
conflict_with:
|
|
331
|
+
conflict_with: $conflict_with,
|
|
268
332
|
status: "pending"
|
|
269
333
|
}')
|
|
270
334
|
|
|
@@ -314,7 +378,7 @@ for ((i = 0; i < KEPT_COUNT; i++)); do
|
|
|
314
378
|
--arg proposal_id "$PROPOSAL_ID" \
|
|
315
379
|
--arg memory_type "$MEMORY_TYPE" \
|
|
316
380
|
--argjson classifier_confidence "$CONFIDENCE" \
|
|
317
|
-
--arg conflict_state "
|
|
381
|
+
--arg conflict_state "$CONFLICT_STATE" \
|
|
318
382
|
--arg src "$ARTIFACT_ID" \
|
|
319
383
|
'{
|
|
320
384
|
proposal_id: $proposal_id,
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Conflict and duplicate detection for librarian proposals.
|
|
3
|
+
#
|
|
4
|
+
# Compares proposed memories against existing memories in the user's
|
|
5
|
+
# typed memory store (~/.claude/projects/<encoded>/memory/) and classifies
|
|
6
|
+
# each proposal into one of:
|
|
7
|
+
# - none (no similarity, safe to propose)
|
|
8
|
+
# - duplicate (similarity >= duplicate_threshold; drop silently)
|
|
9
|
+
# - merge_candidate (merge_threshold <= similarity < duplicate_threshold)
|
|
10
|
+
# - conflict_candidate (opposing sentiment on overlapping content)
|
|
11
|
+
#
|
|
12
|
+
# Uses deterministic token-set Jaccard similarity (no LLM call) with
|
|
13
|
+
# configurable thresholds.
|
|
14
|
+
|
|
15
|
+
# Extract and normalize tokens from text for similarity comparison.
|
|
16
|
+
# Lowercases, removes stopwords, splits on non-alphanumeric.
|
|
17
|
+
_librarian_conflict_normalize() {
|
|
18
|
+
local text="$1"
|
|
19
|
+
printf '%s' "$text" \
|
|
20
|
+
| tr '[:upper:]' '[:lower:]' \
|
|
21
|
+
| tr -cs '[:alnum:]' '\n' \
|
|
22
|
+
| grep -v '^$' \
|
|
23
|
+
| sort -u \
|
|
24
|
+
| paste -sd ' ' -
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# Compute Jaccard similarity (intersection / union) of two token sets.
|
|
28
|
+
# Input: two space-separated token strings.
|
|
29
|
+
# Output: float 0-1.
|
|
30
|
+
_librarian_conflict_jaccard() {
|
|
31
|
+
local tokens1="$1"
|
|
32
|
+
local tokens2="$2"
|
|
33
|
+
|
|
34
|
+
[[ -z "$tokens1" || -z "$tokens2" ]] && { printf '0'; return; }
|
|
35
|
+
|
|
36
|
+
TOKENS1="$tokens1" TOKENS2="$tokens2" python3 -c '
|
|
37
|
+
import os
|
|
38
|
+
tokens1 = set(os.environ.get("TOKENS1", "").split())
|
|
39
|
+
tokens2 = set(os.environ.get("TOKENS2", "").split())
|
|
40
|
+
if not tokens1 and not tokens2:
|
|
41
|
+
print(0)
|
|
42
|
+
else:
|
|
43
|
+
intersection = len(tokens1 & tokens2)
|
|
44
|
+
union = len(tokens1 | tokens2)
|
|
45
|
+
similarity = intersection / union if union > 0 else 0
|
|
46
|
+
print(f"{similarity:.2f}")
|
|
47
|
+
'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Check for opposing sentiment markers (do/don't, always/never, etc.).
|
|
51
|
+
# Returns 0 if opposing markers found, 1 otherwise.
|
|
52
|
+
_librarian_conflict_opposing_sentiment() {
|
|
53
|
+
local text1="$1"
|
|
54
|
+
local text2="$2"
|
|
55
|
+
|
|
56
|
+
# Normalize to lowercase.
|
|
57
|
+
text1=$(printf '%s' "$text1" | tr '[:upper:]' '[:lower:]')
|
|
58
|
+
text2=$(printf '%s' "$text2" | tr '[:upper:]' '[:lower:]')
|
|
59
|
+
|
|
60
|
+
# Define opposing pairs.
|
|
61
|
+
local -a pairs=("always:never" "never:always" "do:don't" "don't:do" "prefer:avoid"
|
|
62
|
+
"avoid:prefer" "enable:disable" "disable:enable" "keep:remove"
|
|
63
|
+
"remove:keep" "use:avoid" "avoid:use")
|
|
64
|
+
|
|
65
|
+
for pair in "${pairs[@]}"; do
|
|
66
|
+
local a="${pair%%:*}"
|
|
67
|
+
local b="${pair##*:}"
|
|
68
|
+
|
|
69
|
+
# Check if text1 has 'a' and text2 has 'b', or vice versa.
|
|
70
|
+
if [[ "$text1" == *"$a"* && "$text2" == *"$b"* ]]; then
|
|
71
|
+
return 0 # opposing
|
|
72
|
+
fi
|
|
73
|
+
if [[ "$text1" == *"$b"* && "$text2" == *"$a"* ]]; then
|
|
74
|
+
return 0 # opposing
|
|
75
|
+
fi
|
|
76
|
+
done
|
|
77
|
+
|
|
78
|
+
return 1 # not opposing
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Detect conflict state for a single proposal against a single existing memory.
|
|
82
|
+
# Returns conflict classification and (optionally) the conflicting memory filename.
|
|
83
|
+
#
|
|
84
|
+
# Usage: librarian_conflict_check <proposal_body> <existing_memory_path> \
|
|
85
|
+
# <duplicate_threshold> <merge_threshold>
|
|
86
|
+
#
|
|
87
|
+
# Output: JSON object { conflict_state, conflicting_file (optional) }
|
|
88
|
+
librarian_conflict_check() {
|
|
89
|
+
local proposal_body="$1"
|
|
90
|
+
local memory_path="$2"
|
|
91
|
+
local dup_threshold="${3:-0.7}"
|
|
92
|
+
local merge_threshold="${4:-0.45}"
|
|
93
|
+
|
|
94
|
+
[[ -z "$proposal_body" || -z "$memory_path" ]] && {
|
|
95
|
+
printf '%s' '{"conflict_state":"none"}'
|
|
96
|
+
return 0
|
|
97
|
+
}
|
|
98
|
+
[[ ! -f "$memory_path" ]] && {
|
|
99
|
+
printf '%s' '{"conflict_state":"none"}'
|
|
100
|
+
return 0
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# Extract the body of the existing memory (skip frontmatter).
|
|
104
|
+
local existing_body
|
|
105
|
+
existing_body=$(awk '/^---$/{ if (++count == 2) { in_body = 1; next } }
|
|
106
|
+
in_body { print }' "$memory_path")
|
|
107
|
+
|
|
108
|
+
[[ -z "$existing_body" ]] && {
|
|
109
|
+
printf '%s' '{"conflict_state":"none"}'
|
|
110
|
+
return 0
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# Normalize both bodies to token sets.
|
|
114
|
+
local prop_tokens existing_tokens
|
|
115
|
+
prop_tokens=$(_librarian_conflict_normalize "$proposal_body")
|
|
116
|
+
existing_tokens=$(_librarian_conflict_normalize "$existing_body")
|
|
117
|
+
|
|
118
|
+
# Compute Jaccard similarity.
|
|
119
|
+
local similarity
|
|
120
|
+
similarity=$(_librarian_conflict_jaccard "$prop_tokens" "$existing_tokens")
|
|
121
|
+
|
|
122
|
+
# Classify based on similarity + optional sentiment check.
|
|
123
|
+
if awk "BEGIN { exit !($similarity >= $dup_threshold) }"; then
|
|
124
|
+
# Definitely a duplicate.
|
|
125
|
+
jq -cn --arg file "$(basename "$memory_path")" \
|
|
126
|
+
'{ conflict_state: "duplicate", conflicting_file: $file }'
|
|
127
|
+
elif awk "BEGIN { exit !($similarity >= $merge_threshold) }"; then
|
|
128
|
+
# Merge candidate — significant overlap but not identical.
|
|
129
|
+
jq -cn --arg file "$(basename "$memory_path")" \
|
|
130
|
+
'{ conflict_state: "merge_candidate", conflicting_file: $file }'
|
|
131
|
+
elif awk "BEGIN { exit !($similarity >= $merge_threshold * 0.8) }"; then
|
|
132
|
+
# Check for opposing sentiment in the overlap zone.
|
|
133
|
+
if _librarian_conflict_opposing_sentiment "$proposal_body" "$existing_body"; then
|
|
134
|
+
jq -cn --arg file "$(basename "$memory_path")" \
|
|
135
|
+
'{ conflict_state: "conflict_candidate", conflicting_file: $file }'
|
|
136
|
+
else
|
|
137
|
+
printf '%s' '{"conflict_state":"none"}'
|
|
138
|
+
fi
|
|
139
|
+
else
|
|
140
|
+
# No meaningful overlap.
|
|
141
|
+
printf '%s' '{"conflict_state":"none"}'
|
|
142
|
+
fi
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
# Scan a proposal against all existing memories in the project.
|
|
146
|
+
# Returns the highest-priority conflict found (duplicate > conflict > merge).
|
|
147
|
+
#
|
|
148
|
+
# Usage: librarian_conflict_scan <proposal_json> <memory_dir> \
|
|
149
|
+
# <duplicate_threshold> <merge_threshold>
|
|
150
|
+
#
|
|
151
|
+
# Output: JSON object { conflict_state, conflict_with: [...] }
|
|
152
|
+
librarian_conflict_scan() {
|
|
153
|
+
local proposal="$1"
|
|
154
|
+
local memory_dir="$2"
|
|
155
|
+
local dup_threshold="${3:-0.7}"
|
|
156
|
+
local merge_threshold="${4:-0.45}"
|
|
157
|
+
|
|
158
|
+
[[ -z "$proposal" || -z "$memory_dir" ]] && {
|
|
159
|
+
printf '%s' '{"conflict_state":"none","conflict_with":[]}'
|
|
160
|
+
return 0
|
|
161
|
+
}
|
|
162
|
+
[[ ! -d "$memory_dir" ]] && {
|
|
163
|
+
printf '%s' '{"conflict_state":"none","conflict_with":[]}'
|
|
164
|
+
return 0
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
local proposal_body
|
|
168
|
+
proposal_body=$(printf '%s' "$proposal" | jq -r '.proposed.body // ""')
|
|
169
|
+
[[ -z "$proposal_body" ]] && {
|
|
170
|
+
printf '%s' '{"conflict_state":"none","conflict_with":[]}'
|
|
171
|
+
return 0
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
local worst_state=""
|
|
175
|
+
local conflicts='[]'
|
|
176
|
+
|
|
177
|
+
# Scan all memory files in the project.
|
|
178
|
+
for memory_file in "$memory_dir"/*.md; do
|
|
179
|
+
[[ ! -f "$memory_file" ]] && continue
|
|
180
|
+
|
|
181
|
+
local check
|
|
182
|
+
check=$(librarian_conflict_check "$proposal_body" "$memory_file" \
|
|
183
|
+
"$dup_threshold" "$merge_threshold")
|
|
184
|
+
[[ -z "$check" ]] && continue
|
|
185
|
+
|
|
186
|
+
local state
|
|
187
|
+
state=$(printf '%s' "$check" | jq -r '.conflict_state // "none"')
|
|
188
|
+
local filename
|
|
189
|
+
filename=$(printf '%s' "$check" | jq -r '.conflicting_file // ""')
|
|
190
|
+
|
|
191
|
+
# Track conflicts; prioritize by severity: duplicate > conflict > merge.
|
|
192
|
+
case "$state" in
|
|
193
|
+
duplicate)
|
|
194
|
+
worst_state="duplicate"
|
|
195
|
+
conflicts=$(printf '%s' "$conflicts" | jq --arg f "$filename" '. + [$f]')
|
|
196
|
+
;;
|
|
197
|
+
conflict_candidate)
|
|
198
|
+
if [[ "$worst_state" != "duplicate" ]]; then
|
|
199
|
+
worst_state="conflict_candidate"
|
|
200
|
+
conflicts=$(printf '%s' "$conflicts" | jq --arg f "$filename" '. + [$f]')
|
|
201
|
+
fi
|
|
202
|
+
;;
|
|
203
|
+
merge_candidate)
|
|
204
|
+
if [[ -z "$worst_state" ]]; then
|
|
205
|
+
worst_state="merge_candidate"
|
|
206
|
+
conflicts=$(printf '%s' "$conflicts" | jq --arg f "$filename" '. + [$f]')
|
|
207
|
+
fi
|
|
208
|
+
;;
|
|
209
|
+
esac
|
|
210
|
+
done
|
|
211
|
+
|
|
212
|
+
[[ -z "$worst_state" ]] && worst_state="none"
|
|
213
|
+
printf '%s' "$(jq -cn --arg state "$worst_state" --argjson files "$conflicts" \
|
|
214
|
+
'{ conflict_state: $state, conflict_with: $files }')"
|
|
215
|
+
}
|
|
@@ -18,23 +18,41 @@
|
|
|
18
18
|
# Avoid associative arrays so bash 3.2 (macOS default) keeps working.
|
|
19
19
|
|
|
20
20
|
# Acquire an exclusive lock at LOCKPATH. Returns 0 on success, 1 on timeout.
|
|
21
|
+
# Uses exponential backoff under high contention to reduce cache-line thrashing.
|
|
21
22
|
lock_acquire() {
|
|
22
23
|
local lockpath="${1:-}"
|
|
23
24
|
local timeout="${2:-5}"
|
|
24
25
|
[[ -z "$lockpath" ]] && return 1
|
|
25
26
|
|
|
26
27
|
local lockdir="${lockpath}.d"
|
|
27
|
-
local
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
local start_time
|
|
29
|
+
start_time=$(date +%s 2>/dev/null) || start_time=0
|
|
30
|
+
|
|
31
|
+
local backoff=10 # Start at 10ms (1/100s)
|
|
30
32
|
while ! mkdir "$lockdir" 2>/dev/null; do
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
# Check timeout using epoch comparison (works on all systems).
|
|
34
|
+
if ((start_time > 0)); then
|
|
35
|
+
local now
|
|
36
|
+
now=$(date +%s 2>/dev/null) || now=0
|
|
37
|
+
if (( (now - start_time) >= timeout )); then
|
|
38
|
+
return 1
|
|
39
|
+
fi
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
# Exponential backoff: 10ms → 20ms → 40ms, capped at 100ms to avoid
|
|
43
|
+
# sleeping too long under sustained high contention. Reduces per-second
|
|
44
|
+
# mkdir() attempts from 10/sec (10Hz polling) to ~10-30/sec under load.
|
|
45
|
+
local sleep_ms=$((backoff))
|
|
46
|
+
if ((sleep_ms > 100)); then
|
|
47
|
+
sleep_ms=100
|
|
33
48
|
fi
|
|
34
|
-
|
|
35
|
-
#
|
|
36
|
-
|
|
37
|
-
|
|
49
|
+
|
|
50
|
+
# Convert to seconds: `sleep 0.01` is 10ms. Fallback for shells
|
|
51
|
+
# that only accept integer seconds.
|
|
52
|
+
awk -v ms="$sleep_ms" 'BEGIN { printf "%.2f\n", ms/1000 }' | \
|
|
53
|
+
xargs -I {} sleep {} 2>/dev/null || sleep 1
|
|
54
|
+
|
|
55
|
+
backoff=$((backoff * 2))
|
|
38
56
|
done
|
|
39
57
|
return 0
|
|
40
58
|
}
|