You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/09/20 14:21:39 UTC
(skywalking-ai-sessionizer) 09/23: Let a runtime that is not Claude Code drive the change recorder
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch langchain-harness
in repository https://gitbox.apache.org/repos/asf/skywalking-ai-sessionizer.git
commit 02a594f41ef4a4eec1800afccde58ea67f92f4a1
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Sun Sep 20 19:26:28 2026 +0800
Let a runtime that is not Claude Code drive the change recorder
The program records what a tool call changed, which is nobody's idea in
particular, but three things made it Claude Code's alone. Each was a wall found
by driving it from somewhere else, not by reading it.
A tool with no shell command was classified read-only and skipped. The
classifier returns true for an empty string, which is vacuously right and
practically wrong: every tool of every runtime whose tools take arguments
rather than a command line was being skipped. No command is not a read-only
command.
Its name could not be listed. A runtime with a fixed tool set can name its
shells, which is what the Claude Code entry does; a runtime whose tools the
application defines cannot, because nobody knows the names in advance. So an
entry is now an exact name, a regular expression when it begins "re:", or "*"
for every tool — with an exclusion list in the same three forms, which is what
makes the wildcard usable: the read-only classifier only understands shell
commands, so for any other runtime the exclusions are the only way to keep a
scan off a tool that reads. A regular expression that does not compile matches
nothing rather than everything, because a setting someone got wrong should
observe too little and be noticed.
And its data directory could only be named by CLAUDE_PLUGIN_DATA. ASZ_CHANGES_
DATA comes first now; the other still works, because Claude Code sets it for
every hook and a plugin installed there should need no configuration at all.
The events have neutral names too — tool.begin, tool.end, tool.failed — meaning
exactly what Claude Code's do and taking the same path. Another runtime should
not have to send "PreToolUse" to say a tool began.
---
plugins/claude-code/internal/hook/hook.go | 33 ++++-
plugins/claude-code/internal/readonly/readonly.go | 7 +
plugins/claude-code/internal/settings/settings.go | 38 +++++-
plugins/claude-code/main.go | 32 +++--
plugins/claude-code/neutral_test.go | 148 ++++++++++++++++++++++
5 files changed, 247 insertions(+), 11 deletions(-)
diff --git a/plugins/claude-code/internal/hook/hook.go b/plugins/claude-code/internal/hook/hook.go
index 2da704b..3d7b3fa 100644
--- a/plugins/claude-code/internal/hook/hook.go
+++ b/plugins/claude-code/internal/hook/hook.go
@@ -30,7 +30,11 @@ import (
"io"
)
-// Events this plugin acts on.
+// Events this plugin acts on, in its own vocabulary.
+//
+// The names are Claude Code's, because that is the runtime this was written
+// for and changing them would break every installed manifest for nothing. What
+// matters is that they are no longer the ONLY names: see neutral below.
const (
PreToolUse = "PreToolUse"
PostToolUse = "PostToolUse"
@@ -39,6 +43,30 @@ const (
SessionEnd = "SessionEnd"
)
+// neutral maps a runtime-neutral event name onto the one this acts on.
+//
+// This program records what a tool call changed, which is not a Claude Code
+// idea. Another runtime's integration should not have to send "PreToolUse" to
+// say a tool began, so it can say so plainly instead. The two vocabularies
+// mean the same thing and take the same path.
+var neutral = map[string]string{
+ "tool.begin": PreToolUse,
+ "tool.end": PostToolUse,
+ "tool.failed": PostToolUseFailure,
+ "session.begin": SessionStart,
+ "session.end": SessionEnd,
+}
+
+// Neutral returns the neutral name for an event, for a caller writing one.
+func Neutral(event string) string {
+ for name, mapped := range neutral {
+ if mapped == event {
+ return name
+ }
+ }
+ return event
+}
+
// Input is one hook invocation.
type Input struct {
Event string `json:"hook_event_name"`
@@ -68,6 +96,9 @@ func Read(r io.Reader) (*Input, error) {
if in.Event == "" {
return nil, fmt.Errorf("hook: input names no event")
}
+ if mapped, ok := neutral[in.Event]; ok {
+ in.Event = mapped
+ }
return &in, nil
}
diff --git a/plugins/claude-code/internal/readonly/readonly.go b/plugins/claude-code/internal/readonly/readonly.go
index cd63c56..1ed8631 100644
--- a/plugins/claude-code/internal/readonly/readonly.go
+++ b/plugins/claude-code/internal/readonly/readonly.go
@@ -88,6 +88,13 @@ var dupIn = regexp.MustCompile(`^<&(\d+|-)`)
// IsReadOnly reports whether the command cannot change the working tree.
func IsReadOnly(cmd string) bool {
+ // No command at all is not a read-only command: it is a tool that is not
+ // a shell. Returning true here would skip the scan for every tool of
+ // every runtime whose tools take arguments rather than a command line,
+ // which is all of them but this one.
+ if strings.TrimSpace(cmd) == "" {
+ return false
+ }
segs, redirect, subst := split(cmd)
if redirect || subst {
return false
diff --git a/plugins/claude-code/internal/settings/settings.go b/plugins/claude-code/internal/settings/settings.go
index 07810af..df73dc5 100644
--- a/plugins/claude-code/internal/settings/settings.go
+++ b/plugins/claude-code/internal/settings/settings.go
@@ -25,6 +25,8 @@ import (
"fmt"
"os"
"path/filepath"
+ "regexp"
+ "strings"
"time"
"gopkg.in/yaml.v3"
@@ -73,8 +75,20 @@ type ReadOnly struct {
func (r ReadOnly) On() bool { return r.Enabled == nil || *r.Enabled }
// Tools names the tools observed with a scan.
+//
+// A runtime with a fixed tool set can be listed by name, which is what Claude
+// Code's entry does. A runtime whose tools the application defines cannot be:
+// nobody knows their names in advance. So an entry takes three forms, and the
+// exclusions take the same three.
type Tools struct {
+ // Scope names the tools observed. An entry is an exact name, a regular
+ // expression when it begins "re:", or "*" alone for every tool.
Scope []string `yaml:"scope"`
+ // Exclude names tools never observed, in the same three forms, applied
+ // after Scope. It is what makes "*" usable: the read-only classifier
+ // only understands shell commands, so for any other runtime this is the
+ // only way to keep a scan off a tool that reads.
+ Exclude []string `yaml:"exclude"`
}
// Retention is how long things are kept.
@@ -143,8 +157,28 @@ func Load(dataDir string) (*Settings, error) {
// IsScopeTool reports whether a tool is observed with a scan.
func (s *Settings) IsScopeTool(name string) bool {
- for _, t := range s.Tools.Scope {
- if t == name {
+ if !matchesAny(s.Tools.Scope, name) {
+ return false
+ }
+ return !matchesAny(s.Tools.Exclude, name)
+}
+
+// matchesAny reads the three entry forms.
+//
+// A regular expression that does not compile matches nothing rather than
+// everything. A setting someone got wrong should observe too little and be
+// noticed, not observe everything and be expensive.
+func matchesAny(entries []string, name string) bool {
+ for _, entry := range entries {
+ switch {
+ case entry == "*":
+ return true
+ case strings.HasPrefix(entry, "re:"):
+ re, err := regexp.Compile(entry[len("re:"):])
+ if err == nil && re.MatchString(name) {
+ return true
+ }
+ case entry == name:
return true
}
}
diff --git a/plugins/claude-code/main.go b/plugins/claude-code/main.go
index 3b8d9a4..5b1471d 100644
--- a/plugins/claude-code/main.go
+++ b/plugins/claude-code/main.go
@@ -57,6 +57,21 @@ import (
"github.com/apache/skywalking-ai-sessionizer/plugins/claude-code/internal/settings"
)
+// dataDir is where this keeps its snapshots and its output.
+//
+// ASZ_CHANGES_DATA comes first because this program is not Claude Code's: it
+// records what a tool call changed for whatever runtime drives it, and a
+// runtime-neutral program reading a runtime's variable would be asking every
+// other one to pretend. CLAUDE_PLUGIN_DATA still works, because Claude Code
+// sets it for every hook and a plugin installed there should need no
+// configuration at all.
+func dataDir() string {
+ if dir := os.Getenv("ASZ_CHANGES_DATA"); dir != "" {
+ return dir
+ }
+ return os.Getenv("CLAUDE_PLUGIN_DATA")
+}
+
// version is set at build time from the tag or the commit.
var version = "dev"
@@ -71,14 +86,14 @@ Usage:
With no command, it runs as hook when standard input is a pipe, a socket, a
file, or anything else that is not a terminal or another character device.
-The plugin's data directory is CLAUDE_PLUGIN_DATA, which Claude Code sets for
-every hook. status and prune take it from the environment too.
+The data directory is ASZ_CHANGES_DATA, or CLAUDE_PLUGIN_DATA, which Claude
+Code sets for every hook. status and prune take it from the environment too.
`
func main() {
switch subcommand(os.Args[1:], os.Stdin) {
case "hook":
- os.Exit(runHook(os.Stdin, os.Getenv("CLAUDE_PLUGIN_DATA"), os.Getenv("CLAUDE_PROJECT_DIR"), time.Now()))
+ os.Exit(runHook(os.Stdin, dataDir(), os.Getenv("CLAUDE_PROJECT_DIR"), time.Now()))
case "version":
fmt.Printf("asz-claude-plugin %s (%s %s/%s)\n", version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
case "status":
@@ -582,10 +597,11 @@ func (p *plugin) maintain() {
// status prints what the plugin would run under.
func status() error {
- dataDir := os.Getenv("CLAUDE_PLUGIN_DATA")
- if dataDir == "" {
- return errors.New("CLAUDE_PLUGIN_DATA is not set; run under Claude Code, or set it to the plugin's data directory")
+ dir := dataDir()
+ if dir == "" {
+ return errors.New("no data directory: set ASZ_CHANGES_DATA, or run under Claude Code, which sets CLAUDE_PLUGIN_DATA")
}
+ dataDir := dir
st, err := settings.Load(dataDir)
if err != nil {
return err
@@ -616,9 +632,9 @@ func orDefault(s, d string) string {
// pruneNow runs the retention rules from the command line.
func pruneNow() error {
- dataDir := os.Getenv("CLAUDE_PLUGIN_DATA")
+ dataDir := dataDir()
if dataDir == "" {
- return errors.New("CLAUDE_PLUGIN_DATA is not set")
+ return errors.New("no data directory: set ASZ_CHANGES_DATA, or run under Claude Code, which sets CLAUDE_PLUGIN_DATA")
}
st, err := settings.Load(dataDir)
if err != nil {
diff --git a/plugins/claude-code/neutral_test.go b/plugins/claude-code/neutral_test.go
new file mode 100644
index 0000000..db7b3a6
--- /dev/null
+++ b/plugins/claude-code/neutral_test.go
@@ -0,0 +1,148 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package main
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/apache/skywalking-ai-sessionizer/pkg/changes"
+ "github.com/apache/skywalking-ai-sessionizer/plugins/claude-code/internal/hook"
+)
+
+// TestAnotherRuntimeCanDriveThis carries one tool call in the neutral
+// vocabulary, from a runtime that is not Claude Code: a tool with no shell
+// command, a name nobody could have listed in advance, and no
+// CLAUDE_PLUGIN_DATA anywhere.
+//
+// Each of the three things it needs was a wall before: a tool with no command
+// was classified read-only and skipped, its name was not in the scope list and
+// could not be, and the data directory could only be named by a Claude Code
+// variable.
+func TestAnotherRuntimeCanDriveThis(t *testing.T) {
+ data, work := t.TempDir(), t.TempDir()
+ if err := os.WriteFile(filepath.Join(data, "settings.yaml"),
+ []byte("tools:\n scope: [\"*\"]\n exclude: [\"re:^(read|get|list)_\"]\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(work, "notes.txt"), []byte("before\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("ASZ_CHANGES_DATA", data)
+
+ event := func(name, tool string) string {
+ payload, _ := json.Marshal(map[string]any{
+ "hook_event_name": name, "session_id": "ls-a-thread", "cwd": work,
+ "tool_name": tool, "tool_use_id": "call_write_report_5",
+ "tool_input": map[string]any{"cluster": "prod-1"},
+ })
+ return string(payload)
+ }
+ now := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)
+ if code := runHook(strings.NewReader(event("tool.begin", "write_report")), data, "", now); code != 0 {
+ t.Fatalf("tool.begin exited %d", code)
+ }
+ if err := os.WriteFile(filepath.Join(work, "findings.md"), []byte("# found\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if code := runHook(strings.NewReader(event("tool.end", "write_report")), data,
+ "", now.Add(time.Second)); code != 0 {
+ t.Fatalf("tool.end exited %d", code)
+ }
+
+ record := lastRecord(t, data)
+ if record.Basis != "tool_window" {
+ t.Fatalf("basis %q: a tool with no shell command was not observed", record.Basis)
+ }
+ if record.Tool != "call_write_report_5" {
+ t.Errorf("the record names %q, not the call it answers", record.Tool)
+ }
+ if record.ChangedFiles == nil || *record.ChangedFiles == 0 {
+ t.Fatal("nothing was recorded as changed")
+ }
+ var added bool
+ for _, c := range record.Changes {
+ if strings.HasSuffix(c.Path, "findings.md") {
+ added = true
+ }
+ }
+ if !added {
+ t.Error("the file the tool wrote is not in the record")
+ }
+
+ // And a tool the exclusions name is left alone, which is what makes the
+ // wildcard usable for a runtime whose tools nobody can list.
+ if code := runHook(strings.NewReader(event("tool.begin", "read_status")), data,
+ "", now.Add(2*time.Second)); code != 0 {
+ t.Fatalf("excluded tool.begin exited %d", code)
+ }
+ if err := os.WriteFile(filepath.Join(work, "ignored.txt"), []byte("x\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if code := runHook(strings.NewReader(event("tool.end", "read_status")), data,
+ "", now.Add(3*time.Second)); code != 0 {
+ t.Fatalf("excluded tool.end exited %d", code)
+ }
+ if got := lastRecord(t, data); got.Tool == "call_write_report_5" {
+ return // nothing new was written, which is the point
+ } else if got.Basis == "tool_window" && got.ToolName == "read_status" {
+ t.Error("an excluded tool was scanned")
+ }
+}
+
+// TestNeutralNamesAreTheSameEvents keeps the two vocabularies in step.
+func TestNeutralNamesAreTheSameEvents(t *testing.T) {
+ for neutral, want := range map[string]string{
+ "tool.begin": hook.PreToolUse, "tool.end": hook.PostToolUse,
+ "tool.failed": hook.PostToolUseFailure,
+ "session.begin": hook.SessionStart, "session.end": hook.SessionEnd,
+ } {
+ in, err := hook.Read(strings.NewReader(`{"hook_event_name":"` + neutral + `"}`))
+ if err != nil {
+ t.Fatalf("%s: %v", neutral, err)
+ }
+ if in.Event != want {
+ t.Errorf("%s read as %q, want %q", neutral, in.Event, want)
+ }
+ if got := hook.Neutral(want); got != neutral {
+ t.Errorf("%s renders back as %q", want, got)
+ }
+ }
+}
+
+func lastRecord(t *testing.T, data string) changes.Record {
+ t.Helper()
+ matches, err := filepath.Glob(filepath.Join(data, "output", "*", "*.jsonl"))
+ if err != nil || len(matches) == 0 {
+ t.Fatalf("no output under %s: %v", data, err)
+ }
+ body, err := os.ReadFile(matches[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ lines := strings.Split(strings.TrimSpace(string(body)), "\n")
+ var record changes.Record
+ if err := json.Unmarshal([]byte(lines[len(lines)-1]), &record); err != nil {
+ t.Fatal(err)
+ }
+ return record
+}