From 6ac5a9c2d52b69a5566ebd11bbcaa7b86853acc4 Mon Sep 17 00:00:00 2001 From: Reed Date: Mon, 2 Mar 2026 01:42:08 +0100 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=94=B4=20tools/trace/record=5Fread=20?= =?UTF-8?q?=E2=80=94=20failing=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RED phase: tests for tools.gleam extraction, trace.gleam scaffolding, and record_read HEAD advancement bug. All fail because modules don't exist yet. Co-Authored-By: Claude Opus 4.6 --- test/gall_record_read_test.gleam | 46 +++++++++++ test/gall_tools_test.gleam | 136 +++++++++++++++++++++++++++++++ test/gall_trace_test.gleam | 59 ++++++++++++++ 3 files changed, 241 insertions(+) create mode 100644 test/gall_record_read_test.gleam create mode 100644 test/gall_tools_test.gleam create mode 100644 test/gall_trace_test.gleam diff --git a/test/gall_record_read_test.gleam b/test/gall_record_read_test.gleam new file mode 100644 index 0000000..e3c0a0f --- /dev/null +++ b/test/gall_record_read_test.gleam @@ -0,0 +1,46 @@ +import gall/session +import gleeunit/should + +// --------------------------------------------------------------------------- +// record_read HEAD advancement +// --------------------------------------------------------------------------- + +/// After an act (which is what record_read uses internally), session HEAD +/// must advance. This test catches the bug where s2 was discarded. +pub fn act_advances_head_test() { + let config = + session.SessionConfig( + author: "reed@systemic.engineering", + name: "read-test", + ) + let s = session.new(config) + + // Fresh session has empty head + session.head(s) |> should.equal("") + + // After act, head should be non-empty (the SHA of the new fragment) + let #(s2, ref) = session.act(s, "@read", "file: src/gall/tools.gleam") + let sha = session.ref_sha(ref) + session.head(s2) |> should.equal(sha) + session.head(s2) |> should.not_equal("") +} + +/// Two sequential acts should produce different HEADs. +pub fn sequential_acts_advance_head_test() { + let config = + session.SessionConfig( + author: "reed@systemic.engineering", + name: "read-test", + ) + let s = session.new(config) + + let #(s2, _) = session.act(s, "@read", "file: src/a.gleam") + let head1 = session.head(s2) + + let #(s3, _) = session.act(s2, "@read", "file: src/b.gleam") + let head2 = session.head(s3) + + head1 |> should.not_equal("") + head2 |> should.not_equal("") + head1 |> should.not_equal(head2) +} diff --git a/test/gall_tools_test.gleam b/test/gall_tools_test.gleam new file mode 100644 index 0000000..c523e29 --- /dev/null +++ b/test/gall_tools_test.gleam @@ -0,0 +1,136 @@ +import gall/tools +import gleam/string +import gleeunit/should + +// --------------------------------------------------------------------------- +// Tool schema names +// --------------------------------------------------------------------------- + +pub fn tool_names_include_ado_tools_test() { + let names = tools.tool_names() + names + |> should.equal([ + "observe", "decide", "act", "commit", "git_status", "git_diff", "git_log", + "git_blame", "git_show_file", + ]) +} + +pub fn ado_tool_names_test() { + let names = tools.ado_tool_names() + names |> should.equal(["observe", "decide", "act", "commit"]) +} + +pub fn git_tool_names_test() { + let names = tools.git_tool_names() + names + |> should.equal([ + "git_status", "git_diff", "git_log", "git_blame", "git_show_file", + ]) +} + +// --------------------------------------------------------------------------- +// Schema content — observe +// --------------------------------------------------------------------------- + +pub fn observe_schema_has_name_test() { + let schema = tools.observe_schema() + schema |> string.contains("\"name\":\"observe\"") |> should.be_true() +} + +pub fn observe_schema_has_required_fields_test() { + let schema = tools.observe_schema() + schema |> string.contains("\"ref\"") |> should.be_true() + schema |> string.contains("\"data\"") |> should.be_true() + schema + |> string.contains("\"required\":[\"ref\",\"data\"]") + |> should.be_true() +} + +// --------------------------------------------------------------------------- +// Schema content — decide +// --------------------------------------------------------------------------- + +pub fn decide_schema_has_name_test() { + let schema = tools.decide_schema() + schema |> string.contains("\"name\":\"decide\"") |> should.be_true() +} + +pub fn decide_schema_has_required_fields_test() { + let schema = tools.decide_schema() + schema |> string.contains("\"rule\"") |> should.be_true() + schema |> string.contains("\"required\":[\"rule\"]") |> should.be_true() +} + +// --------------------------------------------------------------------------- +// Schema content — act +// --------------------------------------------------------------------------- + +pub fn act_schema_has_name_test() { + let schema = tools.act_schema() + schema |> string.contains("\"name\":\"act\"") |> should.be_true() +} + +pub fn act_schema_has_required_fields_test() { + let schema = tools.act_schema() + schema |> string.contains("\"annotation\"") |> should.be_true() + schema + |> string.contains("\"required\":[\"annotation\"]") + |> should.be_true() +} + +// --------------------------------------------------------------------------- +// Schema content — commit +// --------------------------------------------------------------------------- + +pub fn commit_schema_has_name_test() { + let schema = tools.commit_schema() + schema |> string.contains("\"name\":\"commit\"") |> should.be_true() +} + +// --------------------------------------------------------------------------- +// Schema content — git tools +// --------------------------------------------------------------------------- + +pub fn git_status_schema_has_name_test() { + let schema = tools.git_status_schema() + schema |> string.contains("\"name\":\"git_status\"") |> should.be_true() +} + +pub fn git_diff_schema_has_name_test() { + let schema = tools.git_diff_schema() + schema |> string.contains("\"name\":\"git_diff\"") |> should.be_true() +} + +pub fn git_log_schema_has_name_test() { + let schema = tools.git_log_schema() + schema |> string.contains("\"name\":\"git_log\"") |> should.be_true() +} + +pub fn git_blame_schema_has_name_test() { + let schema = tools.git_blame_schema() + schema |> string.contains("\"name\":\"git_blame\"") |> should.be_true() +} + +pub fn git_show_file_schema_has_name_test() { + let schema = tools.git_show_file_schema() + schema |> string.contains("\"name\":\"git_show_file\"") |> should.be_true() +} + +// --------------------------------------------------------------------------- +// Composed tool lists +// --------------------------------------------------------------------------- + +pub fn daemon_tools_json_contains_all_tools_test() { + let json = tools.daemon_tools_json() + json |> string.contains("\"observe\"") |> should.be_true() + json |> string.contains("\"git_status\"") |> should.be_true() + json |> string.contains("\"git_show_file\"") |> should.be_true() +} + +pub fn mcp_tools_json_contains_ado_only_test() { + let json = tools.mcp_tools_json() + json |> string.contains("\"observe\"") |> should.be_true() + json |> string.contains("\"commit\"") |> should.be_true() + // MCP tools should NOT contain git tools + json |> string.contains("\"git_status\"") |> should.be_false() +} diff --git a/test/gall_trace_test.gleam b/test/gall_trace_test.gleam new file mode 100644 index 0000000..f24a71e --- /dev/null +++ b/test/gall_trace_test.gleam @@ -0,0 +1,59 @@ +import gall/trace +import gleam/option.{None, Some} +import gleeunit/should + +// --------------------------------------------------------------------------- +// Trace event emission — must not crash +// --------------------------------------------------------------------------- + +pub fn tool_call_does_not_crash_test() { + let meta = + trace.Metadata( + tool: "observe", + path: Some("src/gall/session.gleam"), + sha: None, + session_id: Some("test-123"), + duration_ms: None, + ) + // Should return Nil without crashing + trace.tool_call("observe", meta) + |> should.equal(Nil) +} + +pub fn tool_result_does_not_crash_test() { + let meta = + trace.Metadata( + tool: "git_show_file", + path: Some("src/gall/daemon.gleam"), + sha: Some("abc123"), + session_id: None, + duration_ms: None, + ) + trace.tool_result("git_show_file", meta, 42) + |> should.equal(Nil) +} + +pub fn tool_call_with_minimal_metadata_test() { + let meta = + trace.Metadata( + tool: "act", + path: None, + sha: None, + session_id: None, + duration_ms: None, + ) + trace.tool_call("act", meta) + |> should.equal(Nil) +} + +// --------------------------------------------------------------------------- +// Event name constants +// --------------------------------------------------------------------------- + +pub fn event_names_are_correct_test() { + trace.tool_call_event() + |> should.equal(["gall", "tool", "call"]) + + trace.tool_result_event() + |> should.equal(["gall", "tool", "result"]) +} From 103c143a71417385712b7ad5b997848902dd3ab7 Mon Sep 17 00:00:00 2001 From: Reed Date: Mon, 2 Mar 2026 01:45:54 +0100 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=9F=A2=20tools.gleam=20extraction=20+?= =?UTF-8?q?=20record=5Fread=20fix=20+=20trace=20scaffolding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract all tool schemas from daemon.gleam and mcp.gleam into tools.gleam — single source of truth for tool names, descriptions, and input schemas. daemon uses daemon_tools_json(), mcp uses mcp_tools_json() (ADO-only, with name-required commit). Fix record_read bug: session.act() was called but the updated session s2 was discarded (`let _ = s2`), so HEAD never advanced on @read. Now s2 is propagated through Active state. Add trace.gleam with BEAM-native telemetry scaffolding: - Event names: [:gall, :tool, :call] and [:gall, :tool, :result] - Metadata type with tool, path, sha, session_id, duration_ms - Erlang FFI (gall_trace_ffi.erl) that calls :telemetry.execute/3 or falls back to no-op Format-only changes in gall.gleam, session.gleam, store.gleam, gall_store_test.gleam from gleam format. 40 tests pass (17 original + 23 new). Co-Authored-By: Claude Opus 4.6 --- src/gall.gleam | 22 +---- src/gall/daemon.gleam | 189 +++++++---------------------------- src/gall/mcp.gleam | 86 +++------------- src/gall/session.gleam | 9 +- src/gall/store.gleam | 5 +- src/gall/tools.gleam | 195 +++++++++++++++++++++++++++++++++++++ src/gall/trace.gleam | 75 ++++++++++++++ src/gall_trace_ffi.erl | 21 ++++ test/gall_store_test.gleam | 3 +- 9 files changed, 350 insertions(+), 255 deletions(-) create mode 100644 src/gall/tools.gleam create mode 100644 src/gall/trace.gleam create mode 100644 src/gall_trace_ffi.erl diff --git a/src/gall.gleam b/src/gall.gleam index ceff8ad..06f4816 100644 --- a/src/gall.gleam +++ b/src/gall.gleam @@ -253,13 +253,7 @@ fn event_loop( False -> { let frag = thought_shard(chunk, mcp_state) let _ = store.write(frag, store_dir) - event_loop( - mcp_state, - port, - sock, - store_dir, - [chunk, ..thought_acc], - ) + event_loop(mcp_state, port, sock, store_dir, [chunk, ..thought_acc]) } } } @@ -285,13 +279,7 @@ fn event_loop( None -> Nil Some(response) -> send_socket(sock, response) } - event_loop( - next_state, - port, - sock, - store_dir, - thought_acc, - ) + event_loop(next_state, port, sock, store_dir, thought_acc) } } } @@ -494,11 +482,7 @@ fn write_mcp_config(path: String, sock_path: String) -> Nil { /// Write an exit record to base/EXIT. fn write_exit_record(base: String, code: Int, status: String) -> Nil { let content = - "exit_code: " - <> int.to_string(code) - <> "\nstatus: " - <> status - <> "\n" + "exit_code: " <> int.to_string(code) <> "\nstatus: " <> status <> "\n" let _ = simplifile.write(base <> "/EXIT", content) Nil } diff --git a/src/gall/daemon.gleam b/src/gall/daemon.gleam index 04ecab9..34137a2 100644 --- a/src/gall/daemon.gleam +++ b/src/gall/daemon.gleam @@ -30,6 +30,7 @@ import gall/config as gall_config import gall/json import gall/session import gall/store +import gall/tools import gleam/int import gleam/list import gleam/option.{type Option, None, Some} @@ -120,12 +121,7 @@ pub type SessionState { /// gall_dir = work_dir <> "/.gall" — derived, not stored separately. /// branch = GALL_BRANCH env (if set) else git_current_branch — already normalized. pub type State { - State( - work_dir: String, - branch: String, - alex_key: String, - sess: SessionState, - ) + State(work_dir: String, branch: String, alex_key: String, sess: SessionState) } // --------------------------------------------------------------------------- @@ -195,12 +191,19 @@ fn handle( case method { "initialize" -> handle_initialize(state, id, json) "notifications/initialized" -> #(state, None, None) - "tools/list" -> #(state, Some(make_response(id, tools_json())), None) + "tools/list" -> #( + state, + Some(make_response(id, tools.daemon_tools_json())), + None, + ) "tools/call" -> handle_tool_call(state, id, json) "resources/list" -> handle_resources_list(state, id) "resources/read" -> handle_resources_read(state, id, json) - "resources/templates/list" -> - #(state, Some(make_response(id, resource_templates_json())), None) + "resources/templates/list" -> #( + state, + Some(make_response(id, resource_templates_json())), + None, + ) _ -> #( state, Some(make_error(id, -32_601, "method not found: " <> method)), @@ -231,7 +234,8 @@ fn handle_initialize( let protocol_version = extract_field(json, "protocolVersion") let author = nickname <> "@systemic.engineering" - let session_config = session.SessionConfig(author: author, name: "gall-session") + let session_config = + session.SessionConfig(author: author, name: "gall-session") let s = session.new(session_config) let sid = session_id() let session_rel = @@ -244,14 +248,7 @@ fn handle_initialize( let meta = meta_fragment(author, client_version, protocol_version) let new_sess = - Active( - session: s, - store_dir:, - session_rel:, - tag_name:, - nickname:, - sid:, - ) + Active(session: s, store_dir:, session_rel:, tag_name:, nickname:, sid:) let response = make_response( @@ -308,8 +305,7 @@ fn handle_tool_call( Ok(args) -> { case name { // ADO witnessing — require active session - "observe" | "decide" | "act" -> - call_ado_stateful(state, id, name, args) + "observe" | "decide" | "act" -> call_ado_stateful(state, id, name, args) // Commit — seal session, git commit, sync, reset to Idle "commit" -> call_commit(state, id, args) @@ -347,7 +343,10 @@ fn handle_tool_call( case json.get_string(args, "path") { Error(_) -> #( state, - Some(make_response(id, content_text(err_json("git_blame requires path")))), + Some(make_response( + id, + content_text(err_json("git_blame requires path")), + )), None, ) Ok(path) -> { @@ -365,7 +364,10 @@ fn handle_tool_call( case json.get_string(args, "path") { Error(_) -> #( state, - Some(make_response(id, content_text(err_json("git_show_file requires path")))), + Some(make_response( + id, + content_text(err_json("git_show_file requires path")), + )), None, ) Ok(path) -> { @@ -383,7 +385,10 @@ fn handle_tool_call( _ -> #( state, - Some(make_response(id, content_text(err_json("unknown tool: " <> name)))), + Some(make_response( + id, + content_text(err_json("unknown tool: " <> name)), + )), None, ) } @@ -434,8 +439,7 @@ fn call_commit( sid:, ) -> { let obs_shas = result.unwrap(json.get_list(args, "observations"), []) - let observations = - shas_to_frags(s, list.map(obs_shas, session.ObsRef)) + let observations = shas_to_frags(s, list.map(obs_shas, session.ObsRef)) let #(_, root, sha) = session.commit(s, observations) let _ = store.write(root, sd) @@ -477,11 +481,7 @@ fn call_commit( Some(make_response( id, content_text( - "{\"root_sha\":\"" - <> sha - <> "\",\"tag\":\"" - <> tag - <> "\"}", + "{\"root_sha\":\"" <> sha <> "\",\"tag\":\"" <> tag <> "\"}", ), )), None, @@ -584,12 +584,9 @@ fn record_read( // Update HEAD in session (read advances it like any other fragment) let #(s2, _) = session.act(s, "@read", "file: " <> path) - // Discard the act ref; we emit our own fragment built above - // Actually: we need to get the frag into the session store so fragments_for_ref - // works. For simplicity, use act() result and discard — the @read frag is - // independently written to store by the caller. - let _ = s2 - let next_sess = Active(..active, session: s) + // Use the updated session so HEAD advances on reads. + // The @read fragment is independently written to store by the caller. + let next_sess = Active(..active, session: s2) #(State(..state, sess: next_sess), Some(frag)) } } @@ -859,122 +856,8 @@ fn extract_primitive(s: String) -> String { } } } - // --------------------------------------------------------------------------- -// Tool definitions +// Tool definitions — imported from gall/tools // --------------------------------------------------------------------------- - -fn tools_json() -> String { - "{\"tools\":[" - <> observe_tool() - <> "," - <> decide_tool() - <> "," - <> act_tool() - <> "," - <> commit_tool() - <> "," - <> git_status_tool() - <> "," - <> git_diff_tool() - <> "," - <> git_log_tool() - <> "," - <> git_blame_tool() - <> "," - <> git_show_file_tool() - <> "]}" -} - -fn observe_tool() -> String { - "{\"name\":\"observe\"," - <> "\"description\":\"Record an observation. What you see, at what coordinate.\"," - <> "\"inputSchema\":{\"type\":\"object\"," - <> "\"properties\":{" - <> "\"ref\":{\"type\":\"string\"," - <> "\"description\":\"Source coordinate. file:path, concept:name, section:heading, task:label.\"}," - <> "\"data\":{\"type\":\"string\"," - <> "\"description\":\"What you observed.\"}," - <> "\"decisions\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}," - <> "\"description\":\"dec_sha values from prior decide calls to link as children.\"}}," - <> "\"required\":[\"ref\",\"data\"]}}" -} - -fn decide_tool() -> String { - "{\"name\":\"decide\"," - <> "\"description\":\"Record a decision derived from an observation.\"," - <> "\"inputSchema\":{\"type\":\"object\"," - <> "\"properties\":{" - <> "\"rule\":{\"type\":\"string\"," - <> "\"description\":\"Your structural conclusion.\"}," - <> "\"acts\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}," - <> "\"description\":\"act_sha values from prior act calls to link as children.\"}," - <> "\"obs_sha\":{\"type\":\"string\"," - <> "\"description\":\"Optional. Observation this decision belongs to. Defaults to HEAD.\"}}," - <> "\"required\":[\"rule\"]}}" -} - -fn act_tool() -> String { - "{\"name\":\"act\"," - <> "\"description\":\"Record an action taken.\"," - <> "\"inputSchema\":{\"type\":\"object\"," - <> "\"properties\":{" - <> "\"annotation\":{\"type\":\"string\"," - <> "\"description\":\"Signal kind + summary. What drain filters on. e.g. '@work uphill_late'\"}," - <> "\"data\":{\"type\":\"string\"," - <> "\"description\":\"Structured payload. e.g. 'state:uphill_late\\nid:42\\nscope:src/signal.gleam'\"}}," - <> "\"required\":[\"annotation\"]}}" -} - -fn commit_tool() -> String { - "{\"name\":\"commit\"," - <> "\"description\":\"Seal the session and commit to gestalt. Call once at the end of the task.\"," - <> "\"inputSchema\":{\"type\":\"object\"," - <> "\"properties\":{" - <> "\"observations\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}," - <> "\"description\":\"obs_sha values to seal as the session root's children.\"}}," - <> "\"required\":[]}}" -} - -fn git_status_tool() -> String { - "{\"name\":\"git_status\"," - <> "\"description\":\"Show working tree status of the current project.\"," - <> "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}" -} - -fn git_diff_tool() -> String { - "{\"name\":\"git_diff\"," - <> "\"description\":\"Show unstaged changes. Optional path filter.\"," - <> "\"inputSchema\":{\"type\":\"object\"," - <> "\"properties\":{" - <> "\"path\":{\"type\":\"string\",\"description\":\"Optional file path filter.\"}}}}" -} - -fn git_log_tool() -> String { - "{\"name\":\"git_log\"," - <> "\"description\":\"Show recent commit history. Optional path filter and count.\"," - <> "\"inputSchema\":{\"type\":\"object\"," - <> "\"properties\":{" - <> "\"path\":{\"type\":\"string\",\"description\":\"Optional file path filter.\"}," - <> "\"n\":{\"type\":\"string\",\"description\":\"Number of commits (default 20).\"}}}" - <> "}" -} - -fn git_blame_tool() -> String { - "{\"name\":\"git_blame\"," - <> "\"description\":\"Show per-line commit attribution for a file. Records a @read annotation in the current session.\"," - <> "\"inputSchema\":{\"type\":\"object\"," - <> "\"properties\":{" - <> "\"path\":{\"type\":\"string\",\"description\":\"File path relative to project root.\"}}," - <> "\"required\":[\"path\"]}}" -} - -fn git_show_file_tool() -> String { - "{\"name\":\"git_show_file\"," - <> "\"description\":\"Show file content at a given ref. Records a @read annotation in the current session.\"," - <> "\"inputSchema\":{\"type\":\"object\"," - <> "\"properties\":{" - <> "\"path\":{\"type\":\"string\",\"description\":\"File path relative to project root.\"}," - <> "\"ref\":{\"type\":\"string\",\"description\":\"Git ref (default HEAD).\"}}," - <> "\"required\":[\"path\"]}}" -} +// All tool schemas are defined in tools.gleam. +// daemon uses tools.daemon_tools_json() for the tools/list response. diff --git a/src/gall/mcp.gleam b/src/gall/mcp.gleam index a33fae6..935b1fc 100644 --- a/src/gall/mcp.gleam +++ b/src/gall/mcp.gleam @@ -19,6 +19,7 @@ import fragmentation import gall/json import gall/session +import gall/tools import gleam/dynamic import gleam/int import gleam/list @@ -54,7 +55,11 @@ pub fn handle( case method { "initialize" -> handle_initialize(state, id, json) "notifications/initialized" -> #(state, None, None) - "tools/list" -> #(state, Some(make_response(id, tools_json())), None) + "tools/list" -> #( + state, + Some(make_response(id, tools.mcp_tools_json())), + None, + ) "tools/call" -> handle_tool_call(state, id, json) _ -> #( state, @@ -141,12 +146,7 @@ fn handle_tool_call( case json.decode(args_str) { Error(_) -> #( state, - Some( - make_response( - id, - content_text(err_json("invalid args json")), - ), - ), + Some(make_response(id, content_text(err_json("invalid args json")))), None, ) Ok(args) -> { @@ -383,7 +383,8 @@ fn do_extract_balanced( let rest = string.drop_start(s, 1) let new_acc = acc <> first case first { - c if c == open -> do_extract_balanced(rest, open, close, depth + 1, new_acc) + c if c == open -> + do_extract_balanced(rest, open, close, depth + 1, new_acc) c if c == close -> case depth - 1 { 0 -> new_acc @@ -415,71 +416,8 @@ fn extract_primitive(s: String) -> String { } } } - // --------------------------------------------------------------------------- -// Tool definitions +// Tool definitions — imported from gall/tools // --------------------------------------------------------------------------- - -fn tools_json() -> String { - "{\"tools\":[" - <> observe_tool() - <> "," - <> decide_tool() - <> "," - <> act_tool() - <> "," - <> commit_tool() - <> "]}" -} - -fn observe_tool() -> String { - "{\"name\":\"observe\"," - <> "\"description\":\"Record an observation. What you see, at what coordinate.\"," - <> "\"inputSchema\":{\"type\":\"object\"," - <> "\"properties\":{" - <> "\"ref\":{\"type\":\"string\"," - <> "\"description\":\"Source coordinate. Use file:path, concept:name, section:heading, or task:label.\"}," - <> "\"data\":{\"type\":\"string\"," - <> "\"description\":\"What you observed.\"}," - <> "\"decisions\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}," - <> "\"description\":\"dec_sha values from prior decide calls to link as children.\"}}," - <> "\"required\":[\"ref\",\"data\"]}}" -} - -fn decide_tool() -> String { - "{\"name\":\"decide\"," - <> "\"description\":\"Record a decision derived from an observation.\"," - <> "\"inputSchema\":{\"type\":\"object\"," - <> "\"properties\":{" - <> "\"rule\":{\"type\":\"string\"," - <> "\"description\":\"Your structural conclusion.\"}," - <> "\"acts\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}," - <> "\"description\":\"act_sha values from prior act calls to link as children.\"}," - <> "\"obs_sha\":{\"type\":\"string\"," - <> "\"description\":\"Optional. The observation this decision belongs to. Defaults to HEAD.\"}}," - <> "\"required\":[\"rule\"]}}" -} - -fn act_tool() -> String { - "{\"name\":\"act\"," - <> "\"description\":\"Record an action taken.\"," - <> "\"inputSchema\":{\"type\":\"object\"," - <> "\"properties\":{" - <> "\"annotation\":{\"type\":\"string\"," - <> "\"description\":\"Signal kind + summary. What drain filters on. e.g. '@work uphill_late'\"}," - <> "\"data\":{\"type\":\"string\"," - <> "\"description\":\"Structured payload. e.g. 'state:uphill_late\\nid:42\\nscope:src/signal.gleam'\"}}," - <> "\"required\":[\"annotation\"]}}" -} - -fn commit_tool() -> String { - "{\"name\":\"commit\"," - <> "\"description\":\"Seal the session. Call once at the end of the task.\"," - <> "\"inputSchema\":{\"type\":\"object\"," - <> "\"properties\":{" - <> "\"name\":{\"type\":\"string\"," - <> "\"description\":\"Session name.\"}," - <> "\"observations\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}," - <> "\"description\":\"obs_sha values to seal as the session root's children.\"}}," - <> "\"required\":[\"name\"]}}" -} +// All tool schemas are defined in tools.gleam. +// MCP mode uses tools.mcp_tools_json() for the tools/list response. diff --git a/src/gall/session.gleam b/src/gall/session.gleam index 466af12..be7546e 100644 --- a/src/gall/session.gleam +++ b/src/gall/session.gleam @@ -88,7 +88,11 @@ pub fn last_root(session: Session) -> Option(#(fragmentation.Fragment, String)) /// data: structured payload (goes into Fragment.data) /// e.g. "state:uphill_late\nid:42\nscope:src/signal.gleam" /// Returns updated session and an ActRef. -pub fn act(session: Session, annotation: String, data: String) -> #(Session, Ref) { +pub fn act( + session: Session, + annotation: String, + data: String, +) -> #(Session, Ref) { let w = witnessed(session.config, annotation) let content = annotation <> "\n" <> data let frag = @@ -193,8 +197,7 @@ pub fn commit( observations, ) let sha = fragmentation.hash_fragment(root) - let updated = - Session(..session, last_root: Some(#(root, sha)), head: sha) + let updated = Session(..session, last_root: Some(#(root, sha)), head: sha) #(updated, root, sha) } diff --git a/src/gall/store.gleam b/src/gall/store.gleam index 2353d87..f978387 100644 --- a/src/gall/store.gleam +++ b/src/gall/store.gleam @@ -37,10 +37,7 @@ pub fn write( /// /// Returns Ok(Nil) if all fragments are present and unmodified. /// Returns Error("missing: ") or Error("tampered: ") on failure. -pub fn verify( - root: fragmentation.Fragment, - dir: String, -) -> Result(Nil, String) { +pub fn verify(root: fragmentation.Fragment, dir: String) -> Result(Nil, String) { walk.collect(root) |> do_verify(dir) } diff --git a/src/gall/tools.gleam b/src/gall/tools.gleam new file mode 100644 index 0000000..522b1da --- /dev/null +++ b/src/gall/tools.gleam @@ -0,0 +1,195 @@ +// Tool schema definitions for the gall MCP protocol. +// +// Both daemon.gleam (stdio MCP) and mcp.gleam (unix socket MCP) import +// tool schemas from here instead of defining them inline. Single source +// of truth for tool names, descriptions, and input schemas. +// +// Two tool sets: +// - ADO tools: observe, decide, act, commit — session-scoped witnessing +// - Git tools: status, diff, log, blame, show_file — always available +// +// daemon.gleam exposes both sets. mcp.gleam exposes ADO only. + +// --------------------------------------------------------------------------- +// Tool name constants +// --------------------------------------------------------------------------- + +/// All tool names, in the order they appear in the daemon's tools/list. +pub fn tool_names() -> List(String) { + list_append(ado_tool_names(), git_tool_names()) +} + +/// ADO witnessing tool names. +pub fn ado_tool_names() -> List(String) { + ["observe", "decide", "act", "commit"] +} + +/// Git tool names (daemon-only). +pub fn git_tool_names() -> List(String) { + ["git_status", "git_diff", "git_log", "git_blame", "git_show_file"] +} + +// --------------------------------------------------------------------------- +// Composed tool lists (for tools/list responses) +// --------------------------------------------------------------------------- + +/// Full tool list for daemon mode (ADO + git). +pub fn daemon_tools_json() -> String { + "{\"tools\":[" + <> observe_schema() + <> "," + <> decide_schema() + <> "," + <> act_schema() + <> "," + <> commit_schema() + <> "," + <> git_status_schema() + <> "," + <> git_diff_schema() + <> "," + <> git_log_schema() + <> "," + <> git_blame_schema() + <> "," + <> git_show_file_schema() + <> "]}" +} + +/// Tool list for MCP mode (ADO only, uses mcp_commit_schema which requires name). +pub fn mcp_tools_json() -> String { + "{\"tools\":[" + <> observe_schema() + <> "," + <> decide_schema() + <> "," + <> act_schema() + <> "," + <> mcp_commit_schema() + <> "]}" +} + +// --------------------------------------------------------------------------- +// ADO tool schemas +// --------------------------------------------------------------------------- + +pub fn observe_schema() -> String { + "{\"name\":\"observe\"," + <> "\"description\":\"Record an observation. What you see, at what coordinate.\"," + <> "\"inputSchema\":{\"type\":\"object\"," + <> "\"properties\":{" + <> "\"ref\":{\"type\":\"string\"," + <> "\"description\":\"Source coordinate. file:path, concept:name, section:heading, task:label.\"}," + <> "\"data\":{\"type\":\"string\"," + <> "\"description\":\"What you observed.\"}," + <> "\"decisions\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}," + <> "\"description\":\"dec_sha values from prior decide calls to link as children.\"}}," + <> "\"required\":[\"ref\",\"data\"]}}" +} + +pub fn decide_schema() -> String { + "{\"name\":\"decide\"," + <> "\"description\":\"Record a decision derived from an observation.\"," + <> "\"inputSchema\":{\"type\":\"object\"," + <> "\"properties\":{" + <> "\"rule\":{\"type\":\"string\"," + <> "\"description\":\"Your structural conclusion.\"}," + <> "\"acts\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}," + <> "\"description\":\"act_sha values from prior act calls to link as children.\"}," + <> "\"obs_sha\":{\"type\":\"string\"," + <> "\"description\":\"Optional. Observation this decision belongs to. Defaults to HEAD.\"}}," + <> "\"required\":[\"rule\"]}}" +} + +pub fn act_schema() -> String { + "{\"name\":\"act\"," + <> "\"description\":\"Record an action taken.\"," + <> "\"inputSchema\":{\"type\":\"object\"," + <> "\"properties\":{" + <> "\"annotation\":{\"type\":\"string\"," + <> "\"description\":\"Signal kind + summary. What drain filters on. e.g. '@work uphill_late'\"}," + <> "\"data\":{\"type\":\"string\"," + <> "\"description\":\"Structured payload. e.g. 'state:uphill_late\\nid:42\\nscope:src/signal.gleam'\"}}," + <> "\"required\":[\"annotation\"]}}" +} + +pub fn commit_schema() -> String { + "{\"name\":\"commit\"," + <> "\"description\":\"Seal the session and commit to gestalt. Call once at the end of the task.\"," + <> "\"inputSchema\":{\"type\":\"object\"," + <> "\"properties\":{" + <> "\"observations\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}," + <> "\"description\":\"obs_sha values to seal as the session root's children.\"}}," + <> "\"required\":[]}}" +} + +/// MCP-mode commit schema. Requires a session name (unlike daemon mode). +pub fn mcp_commit_schema() -> String { + "{\"name\":\"commit\"," + <> "\"description\":\"Seal the session. Call once at the end of the task.\"," + <> "\"inputSchema\":{\"type\":\"object\"," + <> "\"properties\":{" + <> "\"name\":{\"type\":\"string\"," + <> "\"description\":\"Session name.\"}," + <> "\"observations\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}," + <> "\"description\":\"obs_sha values to seal as the session root's children.\"}}," + <> "\"required\":[\"name\"]}}" +} + +// --------------------------------------------------------------------------- +// Git tool schemas (daemon-only) +// --------------------------------------------------------------------------- + +pub fn git_status_schema() -> String { + "{\"name\":\"git_status\"," + <> "\"description\":\"Show working tree status of the current project.\"," + <> "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}" +} + +pub fn git_diff_schema() -> String { + "{\"name\":\"git_diff\"," + <> "\"description\":\"Show unstaged changes. Optional path filter.\"," + <> "\"inputSchema\":{\"type\":\"object\"," + <> "\"properties\":{" + <> "\"path\":{\"type\":\"string\",\"description\":\"Optional file path filter.\"}}}}" +} + +pub fn git_log_schema() -> String { + "{\"name\":\"git_log\"," + <> "\"description\":\"Show recent commit history. Optional path filter and count.\"," + <> "\"inputSchema\":{\"type\":\"object\"," + <> "\"properties\":{" + <> "\"path\":{\"type\":\"string\",\"description\":\"Optional file path filter.\"}," + <> "\"n\":{\"type\":\"string\",\"description\":\"Number of commits (default 20).\"}}}" + <> "}" +} + +pub fn git_blame_schema() -> String { + "{\"name\":\"git_blame\"," + <> "\"description\":\"Show per-line commit attribution for a file. Records a @read annotation in the current session.\"," + <> "\"inputSchema\":{\"type\":\"object\"," + <> "\"properties\":{" + <> "\"path\":{\"type\":\"string\",\"description\":\"File path relative to project root.\"}}," + <> "\"required\":[\"path\"]}}" +} + +pub fn git_show_file_schema() -> String { + "{\"name\":\"git_show_file\"," + <> "\"description\":\"Show file content at a given ref. Records a @read annotation in the current session.\"," + <> "\"inputSchema\":{\"type\":\"object\"," + <> "\"properties\":{" + <> "\"path\":{\"type\":\"string\",\"description\":\"File path relative to project root.\"}," + <> "\"ref\":{\"type\":\"string\",\"description\":\"Git ref (default HEAD).\"}}," + <> "\"required\":[\"path\"]}}" +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn list_append(a: List(String), b: List(String)) -> List(String) { + case a { + [] -> b + [first, ..rest] -> [first, ..list_append(rest, b)] + } +} diff --git a/src/gall/trace.gleam b/src/gall/trace.gleam new file mode 100644 index 0000000..4198bdc --- /dev/null +++ b/src/gall/trace.gleam @@ -0,0 +1,75 @@ +/// Telemetry scaffolding for gall. +/// +/// Event names follow [:gall, , ] convention. +/// Uses a simple Erlang FFI that calls :telemetry.execute/3 if available, +/// falling back to a no-op. +/// +/// This is scaffolding — the actual emission points get wired when +/// the dispatch layers are built. +import gleam/option.{type Option} + +// --------------------------------------------------------------------------- +// Metadata +// --------------------------------------------------------------------------- + +pub type Metadata { + Metadata( + tool: String, + path: Option(String), + sha: Option(String), + session_id: Option(String), + duration_ms: Option(Int), + ) +} + +// --------------------------------------------------------------------------- +// Event name constants +// --------------------------------------------------------------------------- + +/// Event: [:gall, :tool, :call] +pub fn tool_call_event() -> List(String) { + ["gall", "tool", "call"] +} + +/// Event: [:gall, :tool, :result] +pub fn tool_result_event() -> List(String) { + ["gall", "tool", "result"] +} + +// --------------------------------------------------------------------------- +// Emit helpers +// --------------------------------------------------------------------------- + +/// Emit a tool_call telemetry event. +pub fn tool_call(name: String, meta: Metadata) -> Nil { + emit(tool_call_event(), name, meta, option.None) +} + +/// Emit a tool_result telemetry event with duration. +pub fn tool_result(name: String, meta: Metadata, duration_ms: Int) -> Nil { + emit( + tool_result_event(), + name, + Metadata(..meta, duration_ms: option.Some(duration_ms)), + option.Some(duration_ms), + ) +} + +// --------------------------------------------------------------------------- +// Internal emission +// --------------------------------------------------------------------------- + +fn emit( + event: List(String), + name: String, + meta: Metadata, + _duration: Option(Int), +) -> Nil { + // Scaffolding: calls gall_trace_ffi:execute/3 which either calls + // :telemetry.execute/3 or is a no-op if telemetry is not available. + // For now, pure no-op until telemetry dep is added. + execute_ffi(event, name, meta) +} + +@external(erlang, "gall_trace_ffi", "execute") +fn execute_ffi(event: List(String), name: String, meta: Metadata) -> Nil diff --git a/src/gall_trace_ffi.erl b/src/gall_trace_ffi.erl new file mode 100644 index 0000000..eb55ce3 --- /dev/null +++ b/src/gall_trace_ffi.erl @@ -0,0 +1,21 @@ +-module(gall_trace_ffi). +-export([execute/3]). + +%% Telemetry emission. Attempts to call telemetry:execute/3 if the +%% telemetry application is available. Falls back to a no-op. +%% +%% Event: list of binaries (e.g. [<<"gall">>, <<"tool">>, <<"call">>]) +%% Name: tool name binary +%% Meta: Gleam Metadata record (opaque — passed through to telemetry as-is) + +execute(Event, Name, Meta) -> + EventAtoms = [binary_to_atom(E, utf8) || E <- Event], + Measurements = #{tool => Name}, + Metadata = #{meta => Meta}, + try + telemetry:execute(EventAtoms, Measurements, Metadata) + catch + error:undef -> ok; + error:_ -> ok + end, + nil. diff --git a/test/gall_store_test.gleam b/test/gall_store_test.gleam index 69b4261..1b3b489 100644 --- a/test/gall_store_test.gleam +++ b/test/gall_store_test.gleam @@ -33,8 +33,7 @@ fn make_fragment( children |> list.map(fragmentation.hash_fragment) |> string.join("") - let r = - fragmentation.ref(fragmentation.hash(data <> children_sha), "obs") + let r = fragmentation.ref(fragmentation.hash(data <> children_sha), "obs") fragmentation.fragment(r, fixed_witnessed(), data, children) }