From c2d353d3145671e4901d2dd856d664d2bb8c4b29 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:28:28 -0500 Subject: [PATCH] feat(memory): add structured provenance metadata fixes #106 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/configuration.md | 9 +- docs/headless-contract.md | 40 ++- src-rust/crates/cli/src/headless.rs | 137 ++++++- src-rust/crates/cli/src/main.rs | 6 +- .../headless_contract/result.example.json | 2 +- .../headless_contract/result.schema.json | 34 +- .../session-brief.example.json | 2 +- .../session-brief.schema.json | 4 +- src-rust/crates/core/src/claudemd.rs | 339 +++++++++++++++++- src-rust/crates/query/src/lib.rs | 125 ++++++- src-rust/crates/query/src/session_memory.rs | 195 ++++++++-- 11 files changed, 824 insertions(+), 69 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 26630d8..87cc383 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -161,8 +161,10 @@ Auto-extracted memories are approval-gated in hosted review mode. By default, hosted sessions write reviewable JSON candidates under `.coven-code/memory-candidates/` instead of appending directly to durable `.coven-code/AGENTS.md` memory. Each candidate records content, category, -confidence, provenance, source trust, proposed scope, proposed visibility, -status, and any rejection reason. +confidence, structured provenance, source trust, proposed scope, proposed +visibility, status, and any rejection reason. Auto-extracted provenance records +the session id, source kind, creator, and best-effort repository and commit +references; it does not store secret values. Trusted deployments can opt into direct durable writes only when the source trust meets the configured threshold: @@ -373,8 +375,9 @@ Frontmatter fields: | `scope` | Intended scope, such as `user`, `tenant`, `installation`, `repo`, `branch`, or `pr`. | | `trust` | Source trust. Hosted review enforces this against `hostedReview.memoryTrustThreshold`. Supported values include `system_policy`, `maintainer_approved`, `default_branch_code`, `model_inferred`, `contributor_input`, `fork_input`, and `unknown`. | | `visibility` | Intended review visibility: `public_review`, `private_review`, or `security_private`. Hosted public reviews exclude `security_private` memory by default. | -| `source` | Provenance source kind, for example `manual`, `github_pr`, `github_pr_review`, or `session_memory_extraction`. | +| `source` | Provenance source kind, for example `manual`, `github_pr`, `github_pr_review`, or `session-memory-extraction`. | | `source_ref` | Source reference such as `owner/repo#123`, a commit SHA, or another non-secret audit handle. | +| `source_repo`, `source_commit`, `source_actor` | Optional structured provenance for repository slug, commit SHA, and source actor. Store references only, not secret values. | | `expires_at` | Optional expiry date in `YYYY-MM-DD` format. Expired hosted memory is ignored. | | `retention_class` | Optional lifecycle class such as `standard`, `short_lived`, `security`, or `legal_hold`. | | `redacted_at` | Marks content as redacted. Hosted review keeps the metadata visible but replaces the body with a redaction stub. | diff --git a/docs/headless-contract.md b/docs/headless-contract.md index c529ebc..46edf16 100644 --- a/docs/headless-contract.md +++ b/docs/headless-contract.md @@ -1,6 +1,6 @@ # coven-code Headless Execution Contract -**Contract version: `2`** - Status: **Locked** (V2 / structured review output) +**Contract version: `3`** - Status: **Locked** (V3 / memory provenance in review artifacts) This document is the single source of truth for the interface between `coven-github` (the GitHub ingress adapter) and `coven-code` (the execution @@ -64,7 +64,7 @@ The adapter is the **producer**; the runtime is the **consumer**. The brief is ```json { - "contract_version": "2", + "contract_version": "3", "trigger": "issue_assigned", "repo": { "owner": "OpenCoven", @@ -96,7 +96,7 @@ The adapter is the **producer**; the runtime is the **consumer**. The brief is | Field | Type | Notes | |---|---|---| -| `contract_version` | string | MUST be `"2"`. Consumers MUST reject a brief whose major version they do not implement. | +| `contract_version` | string | MUST be `"3"`. Consumers MUST reject a brief whose major version they do not implement. | | `trigger` | string enum | `issue_assigned` \| `pr_review_comment` \| `issue_mention`. | | `repo.owner` | string | | | `repo.name` | string | | @@ -132,7 +132,7 @@ the file MAY be absent. ```json { - "contract_version": "2", + "contract_version": "3", "status": "success", "branch": "cody/fix-issue-42", "commits": [ @@ -157,7 +157,18 @@ the file MAY be absent. "id": "mem_review_policy", "trust": "maintainer-approved", "visibility": "public_review", - "scope": "managed" + "scope": "managed", + "source": "session-memory-extraction", + "source_ref": null, + "source_repo": "OpenCoven/coven-code", + "source_commit": "0123456789abcdef0123456789abcdef01234567", + "source_actor": "BunsDev", + "session_id": "sess-artifact", + "transcript_ref": "sha256:abc123", + "created_by": "coven-code", + "provenance": [ + "session:sess-artifact;source:session-memory-extraction;repo:OpenCoven/coven-code;commit:0123456" + ] } ] } @@ -170,7 +181,7 @@ the file MAY be absent. | Field | Type | Notes | |---|---|---| -| `contract_version` | string | MUST be `"2"`. Producers MUST emit it. | +| `contract_version` | string | MUST be `"3"`. Producers MUST emit it. | | `status` | string enum | `success` \| `failure` \| `partial` \| `needs_input`. See [3.3](#33-status). | | `branch` | string \| null | Branch the runtime pushed. `null` when no branch was created. The adapter only opens a PR when `branch` is set **and** `commits` is non-empty. | | `commits` | array | `{ "sha": string, "message": string }`. MAY be empty. | @@ -181,9 +192,9 @@ the file MAY be absent. | `exit_reason` | string enum \| null | `null` on success; otherwise the terminal cause. See [3.4](#34-exit_reason). | > **Drift note (supersedes `COVEN-GITHUB.md`):** the prose result envelope listed -> an `events` array. Progress/event streaming is **not** part of the v2 result +> an `events` array. Progress/event streaming is **not** part of the v3 result > envelope — it is deferred to M2 and will travel over a separate channel. The -> v2 envelope carries terminal task state only. Producers MUST NOT rely on +> v3 envelope carries terminal task state only. Producers MUST NOT rely on > `events` being read. ### 3.2 `review` @@ -202,7 +213,7 @@ the intended code. It is required on every result. Non-review tasks MUST set | `tests_run` | array | Commands run while reviewing, with `passed`, `failed`, `not_run`, or `unknown` status. | | `no_findings_reason` | string \| null | File-backed explanation for a clean review. MAY be `null` for degraded/partial output when `evidence_status` and `limitations` explain why a substantive clean-review conclusion was not possible. | | `limitations` | string[] | Evidence gaps, skipped checks, or other caveats. | -| `memory` | object | Memory audit report: `domains_loaded` (hosted memory domains eligible for this review, e.g. `default-branch`; empty for local runs) and `entries` (every loaded memory entry with its stable `id`, effective `trust` label after hosted caps/floors, optional `visibility`, and load `scope`). Lets the consumer audit which memory inputs could have influenced findings and cross-check `memory_refs` citations. | +| `memory` | object | Memory audit report: `domains_loaded` (hosted memory domains eligible for this review, e.g. `default-branch`; empty for local runs) and `entries` (every loaded memory entry with its stable `id`, effective `trust` label after hosted caps/floors, optional `visibility`, load `scope`, structured file-level provenance fields such as `source`, `source_ref`, `source_repo`, `source_commit`, `session_id`, and `created_by`, plus compact per-entry `provenance` strings found in durable memory content). Lets the consumer audit which memory inputs could have influenced findings and cross-check `memory_refs` citations. | Each finding carries `severity`, `file`, optional `line`, `title`, `body`, and optional `recommendation`. Valid severities are `info`, `low`, `medium`, `high`, @@ -259,7 +270,7 @@ A process **killed by signal**, or one that **times out** (the adapter enforces ## 5. Security invariants -These are non-negotiable for v2: +These are non-negotiable for v3: 1. The session brief is **tokenless**. Serializing a brief MUST NOT produce an `auth` field, a `"token"` field, or a credential-bearing `clone_url` @@ -284,11 +295,16 @@ The contract is versioned by the single `contract_version` string, which tracks the version. - A change that adds a **required** field, removes a field, renames a field, changes a type, or changes exit-code/status semantics is **breaking** and MUST - bump `contract_version` to `"2"`, update both schemas and fixtures, and ship a - migration note here. + bump `contract_version` to the next major version, update both schemas and + fixtures, and ship a migration note here. - Consumers MUST reject a payload whose major version they do not implement, rather than silently mis-parsing it. +Migration note for v3: `review.memory.entries[]` now includes structured +file-level provenance fields and compact per-entry provenance strings alongside +memory ids and effective trust labels. Consumers that validate result envelopes +must accept the new required nullable provenance fields and `provenance` arrays. + --- ## 7. Conformance artifacts diff --git a/src-rust/crates/cli/src/headless.rs b/src-rust/crates/cli/src/headless.rs index d43a499..e1e0300 100644 --- a/src-rust/crates/cli/src/headless.rs +++ b/src-rust/crates/cli/src/headless.rs @@ -1,4 +1,4 @@ -//! coven-github headless execution contract (contract version `2`). +//! coven-github headless execution contract (contract version `3`). //! //! This module is the **coven-code side** of the interface locked in the //! `coven-github` repo (`docs/headless-contract.md` + `docs/contracts/`). The @@ -33,7 +33,7 @@ use std::collections::{BTreeSet, VecDeque}; use std::path::{Path, PathBuf}; /// Major contract version this build implements (contract §6). -pub const CONTRACT_VERSION: &str = "2"; +pub const CONTRACT_VERSION: &str = "3"; /// Environment variable carrying the GitHub App **installation access token** /// used to authenticate `git push`. This is the ONLY git credential channel; it @@ -499,7 +499,7 @@ pub enum Status { not(test), expect( dead_code, - reason = "contract v2 reserves needs_input for the M2 clarification path" + reason = "contract v3 reserves needs_input for the M2 clarification path" ) )] NeedsInput, @@ -514,7 +514,7 @@ pub enum ExitReason { not(test), expect( dead_code, - reason = "contract v2 reserves test_failure for future verifier integration" + reason = "contract v3 reserves test_failure for future verifier integration" ) )] TestFailure, @@ -523,7 +523,7 @@ pub enum ExitReason { not(test), expect( dead_code, - reason = "contract v2 reserves git_conflict for future git conflict detection" + reason = "contract v3 reserves git_conflict for future git conflict detection" ) )] GitConflict, @@ -587,6 +587,24 @@ pub struct ReviewMemoryEntry { pub visibility: Option, /// Memory scope the entry was loaded from (managed/user/project/local). pub scope: String, + /// Provenance source kind, for example `manual` or `session-memory-extraction`. + pub source: Option, + /// Non-secret reference to the source artifact, such as owner/repo#123. + pub source_ref: Option, + /// Repository slug associated with the memory provenance. + pub source_repo: Option, + /// Commit SHA associated with the memory provenance. + pub source_commit: Option, + /// Actor associated with the memory source, when known. + pub source_actor: Option, + /// Session id that produced the memory, when known. + pub session_id: Option, + /// Non-secret transcript reference or hash, when known. + pub transcript_ref: Option, + /// Tool or actor that created the memory metadata. + pub created_by: Option, + /// Compact per-entry provenance strings found in durable memory content. + pub provenance: Vec, } /// Enumerate the memory entries and domains loaded for a review of @@ -610,6 +628,15 @@ pub fn collect_review_memory( )), visibility: file.frontmatter.visibility.map(|v| serde_enum_label(&v)), scope: serde_enum_label(&file.scope), + source: file.frontmatter.source.clone(), + source_ref: file.frontmatter.source_ref.clone(), + source_repo: file.frontmatter.source_repo.clone(), + source_commit: file.frontmatter.source_commit.clone(), + source_actor: file.frontmatter.source_actor.clone(), + session_id: file.frontmatter.session_id.clone(), + transcript_ref: file.frontmatter.transcript_ref.clone(), + created_by: file.frontmatter.created_by.clone(), + provenance: memory_inline_provenance(&file.content), }) .collect(); let domains_loaded = if config.hosted_review_enabled() { @@ -625,6 +652,27 @@ pub fn collect_review_memory( } } +fn memory_inline_provenance(content: &str) -> Vec { + let mut seen = BTreeSet::new(); + for line in content.lines() { + let Some((_, rest)) = line.rsplit_once("provenance:") else { + continue; + }; + let trimmed = rest + .split(")*") + .next() + .unwrap_or(rest) + .trim() + .trim_end_matches('*') + .trim_end_matches(')') + .trim(); + if !trimmed.is_empty() { + seen.insert(trimmed.to_string()); + } + } + seen.into_iter().collect() +} + /// Render a unit enum's serde label (kebab/snake-case string form). fn serde_enum_label(value: &T) -> String { match serde_json::to_value(value) { @@ -1289,7 +1337,7 @@ pub enum ReviewEvidenceStatus { Complete, #[allow( dead_code, - reason = "contract v2 reserves partial review evidence for future adapters" + reason = "contract v3 reserves partial review evidence for future adapters" )] Partial, Missing, @@ -1309,7 +1357,7 @@ pub struct ReviewFinding { #[serde(rename_all = "snake_case")] #[allow( dead_code, - reason = "contract v2 reserves structured findings for the review parser" + reason = "contract v3 reserves structured findings for the review parser" )] pub enum ReviewSeverity { Info, @@ -1330,7 +1378,7 @@ pub struct ReviewTestRun { #[serde(rename_all = "snake_case")] #[allow( dead_code, - reason = "contract v2 reserves structured test evidence for verifier integration" + reason = "contract v3 reserves structured test evidence for verifier integration" )] pub enum ReviewTestStatus { Passed, @@ -1612,7 +1660,7 @@ mod tests { fn sample_review_brief() -> SessionBrief { let raw = r#"{ - "contract_version": "2", + "contract_version": "3", "trigger": "issue_mention", "repo": { "owner": "o", "name": "r", "clone_url": "https://github.com/o/r.git", "default_branch": "main" }, "task": { "kind": "respond_to_mention", "issue_number": 7, "comment_body": "review this" }, @@ -1652,7 +1700,7 @@ mod tests { let ws = tempfile::tempdir().unwrap(); std::fs::write( ws.path().join("AGENTS.md"), - "---\nid: mem_local_fact\ntrust: maintainer_approved\nsource: unit-test\n---\nLocal fact.", + "---\nid: mem_local_fact\ntrust: maintainer_approved\nsource: unit-test\nsource_ref: OpenCoven/coven-code#106\nsource_repo: OpenCoven/coven-code\nsource_commit: 0123456789abcdef0123456789abcdef01234567\nsession_id: sess-review-memory\ncreated_by: coven-code\n---\n- **[project-fact]** Local fact. *(confidence: 90%, provenance: session:sess-review-memory;source:session-memory-extraction;repo:OpenCoven/coven-code;commit:0123456)*\n", ) .unwrap(); let config = claurst_core::config::Config::default(); @@ -1667,6 +1715,25 @@ mod tests { .expect("project memory entry is reported"); assert_eq!(entry.scope, "project"); assert_eq!(entry.trust, "maintainer-approved"); + assert_eq!(entry.source.as_deref(), Some("unit-test")); + assert_eq!( + entry.source_ref.as_deref(), + Some("OpenCoven/coven-code#106") + ); + assert_eq!(entry.source_repo.as_deref(), Some("OpenCoven/coven-code")); + assert_eq!( + entry.source_commit.as_deref(), + Some("0123456789abcdef0123456789abcdef01234567") + ); + assert_eq!(entry.session_id.as_deref(), Some("sess-review-memory")); + assert_eq!(entry.created_by.as_deref(), Some("coven-code")); + assert_eq!( + entry.provenance, + vec![ + "session:sess-review-memory;source:session-memory-extraction;repo:OpenCoven/coven-code;commit:0123456" + .to_string() + ] + ); } #[test] @@ -1705,6 +1772,17 @@ mod tests { trust: "maintainer-approved".to_string(), visibility: Some("public_review".to_string()), scope: "managed".to_string(), + source: Some("session-memory-extraction".to_string()), + source_ref: None, + source_repo: Some("OpenCoven/coven-code".to_string()), + source_commit: Some("0123456789abcdef0123456789abcdef01234567".to_string()), + source_actor: Some("BunsDev".to_string()), + session_id: Some("sess-artifact".to_string()), + transcript_ref: Some("sha256:abc123".to_string()), + created_by: Some("coven-code".to_string()), + provenance: vec![ + "session:sess-artifact;source:session-memory-extraction".to_string() + ], }], }; @@ -1723,6 +1801,29 @@ mod tests { assert_eq!(memory_value["entries"][0]["id"], "mem_policy"); assert_eq!(memory_value["entries"][0]["trust"], "maintainer-approved"); assert_eq!(memory_value["entries"][0]["scope"], "managed"); + assert_eq!( + memory_value["entries"][0]["source"], + "session-memory-extraction" + ); + assert_eq!( + memory_value["entries"][0]["source_repo"], + "OpenCoven/coven-code" + ); + assert_eq!(memory_value["entries"][0]["session_id"], "sess-artifact"); + assert_eq!( + memory_value["entries"][0]["provenance"][0], + "session:sess-artifact;source:session-memory-extraction" + ); + } + + #[test] + fn inline_memory_provenance_uses_trailing_metadata_field() { + let content = "- **[project-fact]** The artifact has `provenance: Vec` entries *(confidence: 90%, provenance: session:sess-1;source:session-memory-extraction)*"; + + assert_eq!( + memory_inline_provenance(content), + vec!["session:sess-1;source:session-memory-extraction".to_string()] + ); } // ── Input conformance ─────────────────────────────────────────────────── @@ -1742,7 +1843,7 @@ mod tests { .. } )); - brief.ensure_supported_version().expect("v2 is supported"); + brief.ensure_supported_version().expect("v3 is supported"); } #[test] @@ -1761,7 +1862,7 @@ mod tests { // The adapter emits a tokenless brief; the runtime must NOT require an // `auth`/`token` field to parse it. let raw = r#"{ - "contract_version": "2", + "contract_version": "3", "trigger": "issue_assigned", "repo": { "owner": "o", "name": "r", "clone_url": "https://github.com/o/r.git", "default_branch": "main" }, "task": { "kind": "fix_issue", "issue_number": 1, "issue_title": "t", "issue_body": "b" }, @@ -1793,7 +1894,7 @@ mod tests { // Contract §6: additive fields within a major version are backward // compatible; the consumer must not choke on them. let raw = r#"{ - "contract_version": "2", + "contract_version": "3", "trigger": "issue_assigned", "repo": { "owner": "o", "name": "r", "clone_url": "https://github.com/o/r.git", "default_branch": "main", "topics": ["x"] }, "task": { "kind": "fix_issue", "issue_number": 1, "issue_title": "t", "issue_body": "b" }, @@ -1808,10 +1909,10 @@ mod tests { #[test] fn rejects_unsupported_major_version() { let mut brief = sample_brief(); - brief.contract_version = "3".to_string(); + brief.contract_version = "4".to_string(); assert!( brief.ensure_supported_version().is_err(), - "a v3 brief must be rejected by a v2 runtime" + "a v4 brief must be rejected by a v3 runtime" ); } @@ -1920,7 +2021,7 @@ mod tests { "result has key `{key}` not permitted by schema (additionalProperties:false)" ); } - assert_eq!(obj["contract_version"], json!("2")); + assert_eq!(obj["contract_version"], json!("3")); let status_enum = props["status"]["enum"].as_array().unwrap(); assert!(status_enum.contains(&obj["status"]), "status out of enum"); @@ -1983,7 +2084,7 @@ mod tests { #[test] fn review_context_produces_structured_pr_review_evidence() { let raw = r#"{ - "contract_version": "2", + "contract_version": "3", "trigger": "issue_mention", "repo": { "owner": "o", "name": "r", "clone_url": "https://github.com/o/r.git", "default_branch": "main" }, "task": { "kind": "respond_to_mention", "issue_number": 7, "comment_body": "review this" }, @@ -2319,7 +2420,7 @@ N/A #[test] fn address_review_comment_without_commits_is_successful_review_output() { let raw = r#"{ - "contract_version": "2", + "contract_version": "3", "trigger": "pr_review_comment", "repo": { "owner": "o", "name": "r", "clone_url": "https://github.com/o/r.git", "default_branch": "main" }, "task": { "kind": "address_review_comment", "pr_number": 7, "comment_body": "Please review this behavior.", "diff_hunk": null }, diff --git a/src-rust/crates/cli/src/main.rs b/src-rust/crates/cli/src/main.rs index 55a3e09..06c55c7 100644 --- a/src-rust/crates/cli/src/main.rs +++ b/src-rust/crates/cli/src/main.rs @@ -5353,7 +5353,7 @@ mod tests { #[test] fn hosted_review_headless_gets_extra_turn_budget() { let raw = r#"{ - "contract_version": "2", + "contract_version": "3", "trigger": "issue_mention", "repo": { "owner": "o", "name": "r", "clone_url": "https://github.com/o/r.git", "default_branch": "main" }, "task": { "kind": "respond_to_mention", "issue_number": 7, "comment_body": "review this" }, @@ -5375,7 +5375,7 @@ mod tests { #[test] fn hosted_review_headless_caps_existing_turn_budget() { let raw = r#"{ - "contract_version": "2", + "contract_version": "3", "trigger": "issue_mention", "repo": { "owner": "o", "name": "r", "clone_url": "https://github.com/o/r.git", "default_branch": "main" }, "task": { "kind": "respond_to_mention", "issue_number": 7, "comment_body": "review this" }, @@ -5397,7 +5397,7 @@ mod tests { #[test] fn non_review_headless_keeps_turn_budget() { let raw = r#"{ - "contract_version": "2", + "contract_version": "3", "trigger": "issue_mention", "repo": { "owner": "o", "name": "r", "clone_url": "https://github.com/o/r.git", "default_branch": "main" }, "task": { "kind": "respond_to_mention", "issue_number": 7, "comment_body": "answer this" }, diff --git a/src-rust/crates/cli/tests/headless_contract/result.example.json b/src-rust/crates/cli/tests/headless_contract/result.example.json index d03aaf2..e79a6db 100644 --- a/src-rust/crates/cli/tests/headless_contract/result.example.json +++ b/src-rust/crates/cli/tests/headless_contract/result.example.json @@ -1,5 +1,5 @@ { - "contract_version": "2", + "contract_version": "3", "status": "success", "branch": "cody/fix-issue-42", "commits": [ diff --git a/src-rust/crates/cli/tests/headless_contract/result.schema.json b/src-rust/crates/cli/tests/headless_contract/result.schema.json index de8f76b..663d813 100644 --- a/src-rust/crates/cli/tests/headless_contract/result.schema.json +++ b/src-rust/crates/cli/tests/headless_contract/result.schema.json @@ -2,14 +2,14 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/OpenCoven/coven-github/docs/contracts/result.schema.json", "title": "coven-code headless result envelope", - "description": "Terminal task state coven-code --headless writes to --output. Contract version 2.", + "description": "Terminal task state coven-code --headless writes to --output. Contract version 3.", "type": "object", "additionalProperties": false, "required": ["contract_version", "status", "commits", "files_changed", "summary", "pr_body", "review"], "properties": { "contract_version": { "type": "string", - "const": "2" + "const": "3" }, "status": { "type": "string", @@ -120,12 +120,38 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["id", "trust", "visibility", "scope"], + "required": [ + "id", + "trust", + "visibility", + "scope", + "source", + "source_ref", + "source_repo", + "source_commit", + "source_actor", + "session_id", + "transcript_ref", + "created_by", + "provenance" + ], "properties": { "id": { "type": "string" }, "trust": { "type": "string" }, "visibility": { "type": ["string", "null"] }, - "scope": { "type": "string" } + "scope": { "type": "string" }, + "source": { "type": ["string", "null"] }, + "source_ref": { "type": ["string", "null"] }, + "source_repo": { "type": ["string", "null"] }, + "source_commit": { "type": ["string", "null"] }, + "source_actor": { "type": ["string", "null"] }, + "session_id": { "type": ["string", "null"] }, + "transcript_ref": { "type": ["string", "null"] }, + "created_by": { "type": ["string", "null"] }, + "provenance": { + "type": "array", + "items": { "type": "string" } + } } } } diff --git a/src-rust/crates/cli/tests/headless_contract/session-brief.example.json b/src-rust/crates/cli/tests/headless_contract/session-brief.example.json index 92719bd..487466e 100644 --- a/src-rust/crates/cli/tests/headless_contract/session-brief.example.json +++ b/src-rust/crates/cli/tests/headless_contract/session-brief.example.json @@ -1,5 +1,5 @@ { - "contract_version": "2", + "contract_version": "3", "trigger": "issue_assigned", "repo": { "owner": "OpenCoven", diff --git a/src-rust/crates/cli/tests/headless_contract/session-brief.schema.json b/src-rust/crates/cli/tests/headless_contract/session-brief.schema.json index 076465f..bd06bc0 100644 --- a/src-rust/crates/cli/tests/headless_contract/session-brief.schema.json +++ b/src-rust/crates/cli/tests/headless_contract/session-brief.schema.json @@ -2,14 +2,14 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/OpenCoven/coven-github/docs/contracts/session-brief.schema.json", "title": "coven-code headless session brief", - "description": "Tokenless read-context payload the adapter writes for coven-code --headless --context. Contract version 2.", + "description": "Tokenless read-context payload the adapter writes for coven-code --headless --context. Contract version 3.", "type": "object", "additionalProperties": false, "required": ["trigger", "repo", "task", "familiar", "workspace"], "properties": { "contract_version": { "type": "string", - "const": "2" + "const": "3" }, "trigger": { "type": "string", diff --git a/src-rust/crates/core/src/claudemd.rs b/src-rust/crates/core/src/claudemd.rs index 693a9d1..14c6e87 100644 --- a/src-rust/crates/core/src/claudemd.rs +++ b/src-rust/crates/core/src/claudemd.rs @@ -50,6 +50,12 @@ pub struct MemoryFrontmatter { #[serde(default)] pub source_ref: Option, #[serde(default)] + pub source_repo: Option, + #[serde(default)] + pub source_commit: Option, + #[serde(default)] + pub source_actor: Option, + #[serde(default)] pub expires_at: Option, #[serde(default)] pub retention_class: Option, @@ -69,6 +75,233 @@ pub struct MemoryFrontmatter { pub confidence: Option, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct MemoryProvenance { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_repo: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_commit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_actor: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transcript_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub confidence: Option, +} + +impl MemoryProvenance { + pub fn new(source_kind: impl Into, created_by: impl Into) -> Self { + Self { + created_by: non_empty_string(created_by.into()), + source_kind: non_empty_string(source_kind.into()), + created_at: Some(chrono::Utc::now().to_rfc3339()), + ..Default::default() + } + } + + pub fn manual(created_by: impl Into) -> Self { + Self::new("manual", created_by) + } + + pub fn session_memory_extraction(session_id: impl Into) -> Self { + Self::new("session-memory-extraction", "coven-code").with_session_id(session_id) + } + + pub fn with_session_id(mut self, session_id: impl Into) -> Self { + self.session_id = non_empty_string(session_id.into()); + self + } + + pub fn with_source_repo(mut self, source_repo: impl Into) -> Self { + self.source_repo = non_empty_string(source_repo.into()); + self + } + + pub fn with_source_commit(mut self, source_commit: impl Into) -> Self { + self.source_commit = non_empty_string(source_commit.into()); + self + } + + pub fn with_source_actor(mut self, source_actor: impl Into) -> Self { + self.source_actor = non_empty_string(source_actor.into()); + self + } + + pub fn with_transcript_ref(mut self, transcript_ref: impl Into) -> Self { + self.transcript_ref = non_empty_string(transcript_ref.into()); + self + } + + pub fn with_confidence(mut self, confidence: f32) -> Self { + self.confidence = Some(confidence.clamp(0.0, 1.0)); + self + } + + pub fn compact_string(&self) -> String { + let mut parts = Vec::new(); + if let Some(session_id) = self + .session_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + parts.push(format!( + "session:{}", + compact_provenance_component(session_id) + )); + } + if let Some(source_kind) = self + .source_kind + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + parts.push(format!( + "source:{}", + compact_provenance_component(source_kind) + )); + } + if let Some(source_repo) = self + .source_repo + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + parts.push(format!( + "repo:{}", + compact_provenance_component(source_repo) + )); + } + if let Some(source_commit) = self + .source_commit + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + let commit = source_commit.trim(); + let short_len = commit + .char_indices() + .nth(7) + .map(|(idx, _)| idx) + .unwrap_or(commit.len()); + parts.push(format!( + "commit:{}", + compact_provenance_component(&commit[..short_len]) + )); + } + if let Some(source_actor) = self + .source_actor + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + parts.push(format!( + "actor:{}", + compact_provenance_component(source_actor) + )); + } + if let Some(created_by) = self + .created_by + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + parts.push(format!("by:{}", compact_provenance_component(created_by))); + } + if let Some(transcript_ref) = self + .transcript_ref + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + parts.push(format!( + "transcript:{}", + compact_provenance_component(transcript_ref) + )); + } + if let Some(created_at) = self + .created_at + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + parts.push(format!( + "created:{}", + compact_provenance_component(created_at) + )); + } + if let Some(confidence) = self.confidence { + parts.push(format!( + "confidence:{:.0}", + confidence.clamp(0.0, 1.0) * 100.0 + )); + } + parts.join(";") + } + + pub fn populate_frontmatter(&self, frontmatter: &mut MemoryFrontmatter) { + frontmatter.source = self.source_kind.clone(); + frontmatter.created_by = self.created_by.clone(); + frontmatter.source_repo = self.source_repo.clone(); + frontmatter.source_commit = self.source_commit.clone(); + frontmatter.source_actor = self.source_actor.clone(); + frontmatter.session_id = self.session_id.clone(); + frontmatter.transcript_ref = self.transcript_ref.clone(); + frontmatter.created_at = self.created_at.clone(); + frontmatter.confidence = self.confidence; + } + + pub fn frontmatter_pairs(&self) -> Vec<(&'static str, String)> { + let mut pairs = Vec::new(); + if let Some(source_kind) = self.source_kind.as_deref() { + pairs.push(("source", source_kind.to_string())); + } + if let Some(created_by) = self.created_by.as_deref() { + pairs.push(("created_by", created_by.to_string())); + } + if let Some(source_repo) = self.source_repo.as_deref() { + pairs.push(("source_repo", source_repo.to_string())); + } + if let Some(source_commit) = self.source_commit.as_deref() { + pairs.push(("source_commit", source_commit.to_string())); + } + if let Some(source_actor) = self.source_actor.as_deref() { + pairs.push(("source_actor", source_actor.to_string())); + } + if let Some(session_id) = self.session_id.as_deref() { + pairs.push(("session_id", session_id.to_string())); + } + if let Some(transcript_ref) = self.transcript_ref.as_deref() { + pairs.push(("transcript_ref", transcript_ref.to_string())); + } + if let Some(created_at) = self.created_at.as_deref() { + pairs.push(("created_at", created_at.to_string())); + } + if let Some(confidence) = self.confidence { + pairs.push(("confidence", format!("{}", confidence.clamp(0.0, 1.0)))); + } + pairs + } +} + +fn non_empty_string(value: String) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn compact_provenance_component(value: &str) -> String { + value + .trim() + .chars() + .filter(|ch| *ch != ';' && *ch != '\n' && *ch != '\r') + .collect() +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum MemoryVisibility { @@ -196,6 +429,15 @@ pub fn parse_frontmatter(content: &str) -> (MemoryFrontmatter, &str) { "visibility" => fm.visibility = parse_memory_visibility(&val), "source" => fm.source = Some(strip_frontmatter_value(&val).to_string()), "source_ref" => fm.source_ref = Some(strip_frontmatter_value(&val).to_string()), + "source_repo" => { + fm.source_repo = Some(strip_frontmatter_value(&val).to_string()) + } + "source_commit" => { + fm.source_commit = Some(strip_frontmatter_value(&val).to_string()) + } + "source_actor" => { + fm.source_actor = Some(strip_frontmatter_value(&val).to_string()) + } "expires_at" => fm.expires_at = Some(strip_frontmatter_value(&val).to_string()), "retention_class" => { fm.retention_class = Some(strip_frontmatter_value(&val).to_string()) @@ -210,7 +452,7 @@ pub fn parse_frontmatter(content: &str) -> (MemoryFrontmatter, &str) { "transcript_ref" => { fm.transcript_ref = Some(strip_frontmatter_value(&val).to_string()) } - "confidence" => fm.confidence = val.parse().ok(), + "confidence" => fm.confidence = strip_frontmatter_value(&val).parse().ok(), _ => {} } } @@ -475,6 +717,27 @@ pub fn format_memory_file_for_prompt(file: &MemoryFileInfo, options: &MemoryLoad if let Some(session_id) = file.frontmatter.session_id.as_deref() { attrs.push_str(&format!(" session_id=\"{}\"", xml_escape_attr(session_id))); } + if let Some(source_repo) = file.frontmatter.source_repo.as_deref() { + attrs.push_str(&format!(" repo=\"{}\"", xml_escape_attr(source_repo))); + } + if let Some(source_commit) = file.frontmatter.source_commit.as_deref() { + attrs.push_str(&format!(" commit=\"{}\"", xml_escape_attr(source_commit))); + } + if let Some(source_actor) = file.frontmatter.source_actor.as_deref() { + attrs.push_str(&format!(" actor=\"{}\"", xml_escape_attr(source_actor))); + } + if let Some(created_by) = file.frontmatter.created_by.as_deref() { + attrs.push_str(&format!(" created_by=\"{}\"", xml_escape_attr(created_by))); + } + if let Some(transcript_ref) = file.frontmatter.transcript_ref.as_deref() { + attrs.push_str(&format!( + " transcript_ref=\"{}\"", + xml_escape_attr(transcript_ref) + )); + } + if let Some(created_at) = file.frontmatter.created_at.as_deref() { + attrs.push_str(&format!(" created_at=\"{}\"", xml_escape_attr(created_at))); + } format!("\n{}\n", attrs, xml_escape_text(body)) } @@ -696,6 +959,32 @@ mod tests { assert_eq!(body.trim(), "Use explicit auth checks."); } + #[test] + fn memory_provenance_populates_frontmatter_fields() { + let mut fm = MemoryFrontmatter::default(); + let provenance = MemoryProvenance::new("session-memory-extraction", "coven-code") + .with_session_id("sess-1") + .with_source_repo("OpenCoven/coven-code") + .with_source_commit("0123456789abcdef0123456789abcdef01234567") + .with_source_actor("BunsDev") + .with_transcript_ref("sha256:abc123") + .with_confidence(0.82); + + provenance.populate_frontmatter(&mut fm); + + assert_eq!(fm.source.as_deref(), Some("session-memory-extraction")); + assert_eq!(fm.created_by.as_deref(), Some("coven-code")); + assert_eq!(fm.session_id.as_deref(), Some("sess-1")); + assert_eq!(fm.source_repo.as_deref(), Some("OpenCoven/coven-code")); + assert_eq!( + fm.source_commit.as_deref(), + Some("0123456789abcdef0123456789abcdef01234567") + ); + assert_eq!(fm.source_actor.as_deref(), Some("BunsDev")); + assert_eq!(fm.transcript_ref.as_deref(), Some("sha256:abc123")); + assert_eq!(fm.confidence, Some(0.82)); + } + #[test] fn parse_frontmatter_none() { let content = "No frontmatter here"; @@ -923,6 +1212,50 @@ mod tests { assert!(memory_file_allowed_for_options(&with_source, &options)); } + #[test] + fn hosted_review_accepts_structured_source_provenance_only_with_source_kind() { + let options = MemoryLoadOptions::hosted_review(); + let with_session_only = MemoryFileInfo { + path: PathBuf::from("managed.md"), + scope: MemoryScope::Managed, + content: "session-only policy".to_string(), + frontmatter: MemoryFrontmatter { + trust: Some(MemorySourceTrust::SystemPolicy), + visibility: Some(MemoryVisibility::PublicReview), + session_id: Some("sess-1".to_string()), + source_repo: Some("OpenCoven/coven-code".to_string()), + source_commit: Some("0123456789abcdef0123456789abcdef01234567".to_string()), + ..Default::default() + }, + mtime: None, + }; + + assert_eq!( + effective_memory_trust(&with_session_only, &options), + MemorySourceTrust::ContributorInput + ); + assert!(!memory_file_allowed_for_options( + &with_session_only, + &options + )); + + let with_structured_source = MemoryFileInfo { + frontmatter: MemoryFrontmatter { + source: Some("session-memory-extraction".to_string()), + ..with_session_only.frontmatter.clone() + }, + ..with_session_only + }; + assert_eq!( + effective_memory_trust(&with_structured_source, &options), + MemorySourceTrust::SystemPolicy + ); + assert!(memory_file_allowed_for_options( + &with_structured_source, + &options + )); + } + #[test] fn local_mode_does_not_floor_unattributed_trust() { let file = MemoryFileInfo { @@ -1027,6 +1360,8 @@ mod tests { source: Some("github_pr".to_string()), source_ref: Some("OpenCoven/coven-code#123".to_string()), session_id: Some("sess-1".to_string()), + source_repo: Some("OpenCoven/coven-code".to_string()), + source_commit: Some("0123456789abcdef0123456789abcdef01234567".to_string()), ..Default::default() }, mtime: None, @@ -1038,6 +1373,8 @@ mod tests { assert!(prompt.contains("trust=\"maintainer-approved\"")); assert!(prompt.contains("source_ref=\"OpenCoven/coven-code#123\"")); assert!(prompt.contains("session_id=\"sess-1\"")); + assert!(prompt.contains("repo=\"OpenCoven/coven-code\"")); + assert!(prompt.contains("commit=\"0123456789abcdef0123456789abcdef01234567\"")); assert!(prompt.contains("Always cite auth policy.")); } diff --git a/src-rust/crates/query/src/lib.rs b/src-rust/crates/query/src/lib.rs index 73c4ef8..466901c 100644 --- a/src-rust/crates/query/src/lib.rs +++ b/src-rust/crates/query/src/lib.rs @@ -43,12 +43,15 @@ use claurst_api::{ AnthropicStreamEvent, ApiMessage, ApiToolDefinition, CreateMessageRequest, StreamAccumulator, StreamHandler, SystemPrompt, ThinkingConfig, }; +use claurst_core::claudemd::MemoryProvenance; use claurst_core::config::Config; use claurst_core::cost::CostTracker; use claurst_core::error::ClaudeError; use claurst_core::types::{ContentBlock, Message, ToolResultContent, UsageInfo}; use claurst_tools::{Tool, ToolContext, ToolResult}; use serde_json::Value; +use std::path::Path; +use std::process::Command; use std::sync::Arc; use tokio::sync::mpsc; use tracing::{debug, error, info, warn}; @@ -75,6 +78,85 @@ pub enum QueryOutcome { BudgetExceeded { cost_usd: f64, limit_usd: f64 }, } +fn build_session_memory_provenance(session_id: &str, working_dir: &Path) -> MemoryProvenance { + let mut provenance = MemoryProvenance::session_memory_extraction(session_id); + + if let Some(remote_url) = git_command_output(working_dir, &["remote", "get-url", "origin"]) { + if let Some(repo_slug) = parse_origin_repo_slug(&remote_url) { + provenance = provenance.with_source_repo(repo_slug); + } + } + + if let Some(commit) = git_command_output(working_dir, &["rev-parse", "HEAD"]) { + provenance = provenance.with_source_commit(commit); + } + + if let Ok(actor) = std::env::var("GITHUB_ACTOR") { + provenance = provenance.with_source_actor(actor); + } + + provenance +} + +fn git_command_output(working_dir: &Path, args: &[&str]) -> Option { + let output = Command::new("git") + .args(args) + .current_dir(working_dir) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8(output.stdout).ok()?; + let trimmed = text.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn parse_origin_repo_slug(remote_url: &str) -> Option { + let trimmed = remote_url.trim(); + let cutoff = trimmed + .char_indices() + .find(|(_, ch)| *ch == '?' || *ch == '#') + .map(|(idx, _)| idx) + .unwrap_or(trimmed.len()); + let trimmed = trimmed[..cutoff].trim_end_matches('/'); + if trimmed.is_empty() { + return None; + } + let without_git = trimmed.strip_suffix(".git").unwrap_or(trimmed); + let candidate = if let Some((_, path)) = without_git.split_once("github.com/") { + path + } else if let Some((_, path)) = without_git.split_once("github.com:") { + path + } else if let Some((_, path)) = without_git.rsplit_once(':') { + path + } else { + without_git + }; + let components: Vec<&str> = candidate + .trim_matches('/') + .split('/') + .filter(|component| !component.trim().is_empty()) + .collect(); + if components.len() < 2 { + return None; + } + let owner = components[components.len() - 2]; + let repo = components[components.len() - 1]; + if [owner, repo].iter().any(|component| { + !component + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.') + }) { + return None; + } + Some(format!("{owner}/{repo}")) +} + /// Configuration for a single query-loop invocation. #[derive(Clone)] pub struct QueryConfig { @@ -2005,10 +2087,7 @@ pub async fn run_query_loop( let working_dir_clone = tool_ctx.working_dir.clone(); let runtime_mode = tool_ctx.config.runtime_mode(); let hosted_review_config = tool_ctx.config.hosted_review.clone(); - let memory_provenance = format!( - "session:{};source:session-memory-extraction", - tool_ctx.session_id - ); + let session_id_clone = tool_ctx.session_id.clone(); // Build a fresh client using the same API key. This avoids // requiring an Arc in the existing run_query_loop signature. @@ -2022,6 +2101,10 @@ pub async fn run_query_loop( ) { let sm_client = std::sync::Arc::new(sm_client); tokio::spawn(async move { + let memory_provenance = build_session_memory_provenance( + &session_id_clone, + &working_dir_clone, + ); let extractor = session_memory::SessionMemoryExtractor::new(&model_clone); match extractor @@ -2618,6 +2701,40 @@ mod tests { } } + #[test] + fn parses_github_origin_repo_slug_without_credentials() { + assert_eq!( + parse_origin_repo_slug("https://github.com/OpenCoven/coven-code.git").as_deref(), + Some("OpenCoven/coven-code") + ); + assert_eq!( + parse_origin_repo_slug("git@github.com:OpenCoven/coven-code.git").as_deref(), + Some("OpenCoven/coven-code") + ); + assert_eq!( + parse_origin_repo_slug("https://token@example@github.com/OpenCoven/coven-code.git") + .as_deref(), + Some("OpenCoven/coven-code") + ); + assert_eq!( + parse_origin_repo_slug("https://github.com/OpenCoven/coven-code.git?token=SECRET") + .as_deref(), + Some("OpenCoven/coven-code") + ); + assert_eq!( + parse_origin_repo_slug("https://github.com/OpenCoven/coven-code.git#token=SECRET") + .as_deref(), + Some("OpenCoven/coven-code") + ); + } + + #[test] + fn ignores_origin_strings_without_repo_slug() { + assert!(parse_origin_repo_slug("").is_none()); + assert!(parse_origin_repo_slug("https://github.com/OpenCoven").is_none()); + assert!(parse_origin_repo_slug("https://github.com/token@example").is_none()); + } + // ---- build_system_prompt tests ------------------------------------------ #[test] diff --git a/src-rust/crates/query/src/session_memory.rs b/src-rust/crates/query/src/session_memory.rs index 48f0e97..78a2ea3 100644 --- a/src-rust/crates/query/src/session_memory.rs +++ b/src-rust/crates/query/src/session_memory.rs @@ -18,6 +18,7 @@ use claurst_api::{ AnthropicStreamEvent, ApiMessage, CreateMessageRequest, StreamAccumulator, StreamHandler, SystemPrompt, }; +use claurst_core::claudemd::MemoryProvenance; use claurst_core::hosted_review::{HostedReviewConfig, MemorySourceTrust, RuntimeMode}; use claurst_core::team_memory_sync::scan_for_secrets; use claurst_core::types::{Message, Role}; @@ -117,7 +118,7 @@ pub struct MemoryCandidate { pub content: String, pub category: MemoryCategory, pub confidence: f32, - pub provenance: String, + pub provenance: MemoryProvenance, pub source_trust: MemorySourceTrust, pub proposed_scope: String, pub proposed_visibility: String, @@ -132,7 +133,7 @@ pub struct MemoryCandidate { impl MemoryCandidate { fn from_memory( memory: &ExtractedMemory, - provenance: &str, + provenance: &MemoryProvenance, proposed_scope: &str, proposed_visibility: &str, rejection_reason: Option<&str>, @@ -142,7 +143,7 @@ impl MemoryCandidate { content: memory.content.clone(), category: memory.category.clone(), confidence: memory.confidence, - provenance: provenance.to_string(), + provenance: provenance.clone(), source_trust: memory.source_trust, proposed_scope: proposed_scope.to_string(), proposed_visibility: proposed_visibility.to_string(), @@ -180,7 +181,7 @@ impl MemoryCandidateStore { pub async fn write_pending( &self, memories: &[ExtractedMemory], - provenance: &str, + provenance: &MemoryProvenance, proposed_scope: &str, proposed_visibility: &str, rejection_reason: Option<&str>, @@ -481,7 +482,7 @@ impl SessionMemoryExtractor { pub async fn persist_with_provenance( memories: &[ExtractedMemory], target_path: &Path, - provenance: Option<&str>, + provenance: Option<&MemoryProvenance>, ) -> anyhow::Result<()> { if memories.is_empty() { return Ok(()); @@ -499,19 +500,26 @@ impl SessionMemoryExtractor { Err(e) => return Err(e.into()), }; + let existing = if let Some(provenance) = provenance { + apply_provenance_frontmatter(existing, provenance) + } else { + existing + }; + // Build the new entries block let date_str = chrono::Local::now().format("%Y-%m-%d").to_string(); let mut new_block = format!("\n### Session memories — {}\n\n", date_str); + let provenance_label = provenance + .map(MemoryProvenance::compact_string) + .filter(|provenance| !provenance.trim().is_empty()) + .map(|provenance| format!(", provenance: {provenance}")) + .unwrap_or_default(); for memory in memories { let trust_label = if memory.source_trust.is_unknown() { String::new() } else { format!(", trust: {}", source_trust_label(memory.source_trust)) }; - let provenance_label = provenance - .filter(|p| !p.trim().is_empty()) - .map(|p| format!(", provenance: {}", p)) - .unwrap_or_default(); new_block.push_str(&format!( "- **[{}]** {} *(confidence: {:.0}%{}{})*\n", memory.category.label(), @@ -564,7 +572,7 @@ impl SessionMemoryExtractor { candidate_store: &MemoryCandidateStore, mode: RuntimeMode, hosted_config: &HostedReviewConfig, - provenance: &str, + provenance: &MemoryProvenance, ) -> anyhow::Result { if memories.is_empty() { return Ok(MemoryPersistenceOutcome::Skipped); @@ -633,6 +641,53 @@ impl SessionMemoryExtractor { } } +fn apply_provenance_frontmatter(existing: String, provenance: &MemoryProvenance) -> String { + let pairs = provenance + .frontmatter_pairs() + .into_iter() + .filter(|(key, _)| matches!(*key, "source" | "created_by")) + .collect::>(); + if pairs.is_empty() { + return existing; + } + + let rendered_pairs = pairs + .iter() + .map(|(key, value)| format!("{key}: {}", quote_frontmatter_value(value))) + .collect::>() + .join("\n"); + + if !existing.starts_with("---") { + return format!("---\n{rendered_pairs}\n---\n{existing}"); + } + + let after_first = &existing[3..]; + let Some(end) = after_first.find("\n---") else { + return format!("---\n{rendered_pairs}\n---\n{existing}"); + }; + let yaml = after_first[..end].trim(); + let body = &after_first[end + 4..]; + let mut preserved = Vec::new(); + for line in yaml.lines() { + let key = line.split_once(':').map(|(key, _)| key.trim()); + if key.is_some_and(|key| pairs.iter().any(|(pair_key, _)| *pair_key == key)) { + continue; + } + preserved.push(line.to_string()); + } + preserved.push(rendered_pairs); + format!("---\n{}\n---{body}", preserved.join("\n")) +} + +fn quote_frontmatter_value(value: &str) -> String { + let escaped = value + .trim() + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace(['\n', '\r'], " "); + format!("\"{escaped}\"") +} + fn hosted_memory_candidate_reason(config: &HostedReviewConfig) -> String { if !config.allow_auto_memory_persistence { return "hosted-approval-required".to_string(); @@ -749,8 +804,15 @@ fn parse_extraction_response(response: &str) -> Vec { #[cfg(test)] mod tests { use super::*; + use claurst_core::claudemd::{load_memory_file, MemoryScope}; use claurst_core::types::Message; + fn test_provenance() -> MemoryProvenance { + MemoryProvenance::session_memory_extraction("test-session") + .with_source_repo("OpenCoven/coven-code") + .with_source_commit("0123456789abcdef0123456789abcdef01234567") + } + fn make_user(text: &str) -> Message { Message::user(text) } @@ -1005,7 +1067,7 @@ MEMORY: code_pattern | 7 | Uses builder pattern"; &candidate_store, RuntimeMode::HostedReview, &config, - "session:test-session;source:session-memory-extraction", + &test_provenance(), ) .await .unwrap(); @@ -1027,8 +1089,20 @@ MEMORY: code_pattern | 7 | Uses builder pattern"; assert_eq!(candidate.status, MemoryCandidateStatus::Pending); assert_eq!(candidate.source_trust, MemorySourceTrust::ForkInput); assert_eq!( - candidate.provenance, - "session:test-session;source:session-memory-extraction" + candidate.provenance.session_id.as_deref(), + Some("test-session") + ); + assert_eq!( + candidate.provenance.source_kind.as_deref(), + Some("session-memory-extraction") + ); + assert_eq!( + candidate.provenance.source_repo.as_deref(), + Some("OpenCoven/coven-code") + ); + assert_eq!( + candidate.provenance.source_commit.as_deref(), + Some("0123456789abcdef0123456789abcdef01234567") ); assert_eq!( candidate.rejection_reason.as_deref(), @@ -1061,7 +1135,7 @@ MEMORY: code_pattern | 7 | Uses builder pattern"; &candidate_store, RuntimeMode::HostedReview, &config, - "session:test-session;source:session-memory-extraction", + &test_provenance(), ) .await .unwrap(); @@ -1104,7 +1178,7 @@ MEMORY: code_pattern | 7 | Uses builder pattern"; &candidate_store, RuntimeMode::HostedReview, &config, - "session:test-session;source:session-memory-extraction", + &test_provenance(), ) .await .unwrap(); @@ -1117,7 +1191,7 @@ MEMORY: code_pattern | 7 | Uses builder pattern"; assert!(content.contains("Maintainers require explicit error handling")); assert!(content.contains("trust: maintainer-approved")); assert!( - content.contains("provenance: session:test-session;source:session-memory-extraction"), + content.contains("provenance: session:test-session;source:session-memory-extraction;repo:OpenCoven/coven-code;commit:0123456"), "durable hosted entries must carry source/session provenance: {content}" ); assert!(!dir @@ -1141,16 +1215,97 @@ MEMORY: code_pattern | 7 | Uses builder pattern"; SessionMemoryExtractor::persist_with_provenance( &memories, &target, - Some("session:sess-42;source:unit-test"), + Some( + &MemoryProvenance::new("unit-test", "coven-code") + .with_session_id("sess-42") + .with_source_repo("OpenCoven/coven-code") + .with_source_commit("abcdef0123456789abcdef0123456789abcdef01"), + ), ) .await .unwrap(); let content = fs::read_to_string(&target).await.unwrap(); - assert!(content.contains("provenance: session:sess-42;source:unit-test")); + assert!(content.contains( + "provenance: session:sess-42;source:unit-test;repo:OpenCoven/coven-code;commit:abcdef0" + )); assert!(content.contains("trust: maintainer-approved")); } + #[tokio::test] + async fn persisted_auto_extracted_memory_records_structured_source_context() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("AGENTS.md"); + let memories = vec![ExtractedMemory { + content: "Structured provenance fact".to_string(), + category: MemoryCategory::ProjectFact, + confidence: 0.85, + source_trust: MemorySourceTrust::MaintainerApproved, + }]; + let provenance = MemoryProvenance::session_memory_extraction("sess-structured") + .with_source_repo("OpenCoven/coven-code") + .with_source_commit("fedcba9876543210fedcba9876543210fedcba98"); + + SessionMemoryExtractor::persist_with_provenance(&memories, &target, Some(&provenance)) + .await + .unwrap(); + + let content = fs::read_to_string(&target).await.unwrap(); + assert!(content.contains( + "provenance: session:sess-structured;source:session-memory-extraction;repo:OpenCoven/coven-code;commit:fedcba9" + )); + assert!(content.contains("confidence: 85%")); + let loaded = load_memory_file(&target, MemoryScope::Local).unwrap(); + assert_eq!( + loaded.frontmatter.source.as_deref(), + Some("session-memory-extraction") + ); + assert_eq!(loaded.frontmatter.created_by.as_deref(), Some("coven-code")); + assert!(loaded.frontmatter.session_id.is_none()); + assert!(loaded.frontmatter.source_repo.is_none()); + assert!(loaded.frontmatter.source_commit.is_none()); + } + + #[tokio::test] + async fn memory_candidate_json_round_trips_structured_provenance() { + let dir = tempfile::tempdir().unwrap(); + let store = MemoryCandidateStore::for_working_dir(dir.path()); + let memories = vec![ExtractedMemory { + content: "Candidate provenance fact".to_string(), + category: MemoryCategory::ProjectFact, + confidence: 0.75, + source_trust: MemorySourceTrust::ContributorInput, + }]; + let provenance = MemoryProvenance::session_memory_extraction("sess-json") + .with_source_repo("OpenCoven/coven-code") + .with_source_commit("1111111111111111111111111111111111111111") + .with_source_actor("BunsDev"); + + let candidates = store + .write_pending( + &memories, + &provenance, + "hosted-review", + "durable-memory", + Some("hosted-approval-required"), + ) + .await + .unwrap(); + + let stored = store.read_candidate(&candidates[0].id).await.unwrap(); + assert_eq!(stored.provenance, provenance); + assert_eq!(stored.provenance.session_id.as_deref(), Some("sess-json")); + assert_eq!( + stored.provenance.source_kind.as_deref(), + Some("session-memory-extraction") + ); + assert_eq!( + stored.provenance.source_repo.as_deref(), + Some("OpenCoven/coven-code") + ); + assert_eq!(stored.provenance.source_actor.as_deref(), Some("BunsDev")); + } + #[tokio::test] async fn candidate_approval_promotes_to_durable_memory() { let dir = tempfile::tempdir().unwrap(); @@ -1165,7 +1320,7 @@ MEMORY: code_pattern | 7 | Uses builder pattern"; let candidates = store .write_pending( &memories, - "session-memory-extraction", + &test_provenance(), "hosted-review", "durable-memory", Some("hosted-approval-required"), @@ -1196,7 +1351,7 @@ MEMORY: code_pattern | 7 | Uses builder pattern"; let candidates = store .write_pending( &memories, - "session-memory-extraction", + &test_provenance(), "hosted-review", "durable-memory", None,