refactor: remove repository-local yoi authority

This commit is contained in:
2026-09-22 22:52:41 +09:00
parent 043cb7bf22
commit 5fb3cb20cd
45 changed files with 487 additions and 9151 deletions
Generated
-12
View File
@@ -2565,18 +2565,11 @@ dependencies = [
"agen",
"async-trait",
"chrono",
"libc",
"lint-common",
"manifest",
"schemars",
"serde",
"serde_json",
"serde_yaml",
"sha2 0.11.0",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tracing",
"uuid",
]
@@ -4731,18 +4724,15 @@ dependencies = [
"agen",
"async-trait",
"chrono",
"fs4",
"project-record",
"rusqlite",
"schemars",
"serde",
"serde_json",
"serde_yaml",
"sha2 0.11.0",
"tempfile",
"thiserror 2.0.18",
"tokio",
"toml",
]
[[package]]
@@ -6261,12 +6251,10 @@ dependencies = [
"client",
"futures",
"manifest",
"memory",
"project-record",
"reqwest",
"serde",
"serde_json",
"serde_yaml",
"server-api",
"session-analytics",
"session-store",
+1 -1
View File
@@ -68,7 +68,7 @@ Key docs:
## 5. Development
This repository dogfoods Yoi to develop Yoi. Work is tracked through `.yoi/tickets/` and `yoi ticket ...`; git history plus Ticket files are the authoritative project record.
This repository dogfoods Yoi to develop Yoi. Work is tracked through Workspace Server-backed Tickets and `yoi ticket ...`; Git history plus append-only Ticket and Merge Request evidence are the authoritative project record. Repository-local `.yoi/tickets` trees are ignored legacy input.
Common checks:
-7
View File
@@ -7,19 +7,12 @@ license.workspace = true
[dependencies]
async-trait = { workspace = true }
chrono = { version = "0.4", features = ["serde"] }
libc = { workspace = true }
lint-common = { workspace = true }
agen = { workspace = true }
manifest = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
serde_yaml = "0.9.34"
thiserror = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true, features = ["v7", "serde"] }
[dev-dependencies]
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+9 -17
View File
@@ -2,28 +2,20 @@
## Role
`memory` owns generated memory, records, staging/consolidation mechanics, linting, and audit observations.
`memory` owns the shared types used by Workspace-backed Memory operations:
- Memory API request/response DTOs
- extraction candidate and evidence schemas
- audit event schemas
- pure Memory document frontmatter schemas and parsing helpers
## Boundaries
Owns:
The crate does **not** own persistence. Workspace Memory records, staging candidates, and audit observations are stored by the Workspace Server control plane and accessed by Workers through typed API operations.
- memory/record parsing and validation
- memory lint subcommand backend behavior
- staging and consolidation file mechanics
- audit log observation writes
- generated memory prompt support where it belongs below the CLI
The crate intentionally contains no repository-local layout, cwd/ancestor discovery, resident-file reader, staging writer, audit/usage log writer, generic filesystem scope helper, or local backend executor. The product CLI does not expose `yoi memory lint`.
Does not own:
- authoritative project records (`.yoi/tickets/`, git history)
- normal Worker turn orchestration (`agen`)
- product CLI command shape (`yoi`)
- curated workflow definitions (`workflow`)
## Design notes
Memory is useful context, not authority. It should preserve small durable preferences and rationale without duplicating tickets, docs, or implementation reports.
Memory is useful context, not implementation authority. Tickets, Objectives, repository files, Git history, and append-only session records remain the exact evidence surfaces for implementation state.
## See also
+3 -214
View File
@@ -1,26 +1,12 @@
//! Append-only JSONL audit log for memory workers and tools.
//! Typed Memory audit events sent to the Workspace authority.
//!
//! The log is evidence-only observability data under
//! `.yoi/memory/_logs/current.log`. It is intentionally separate from
//! `_staging` and `_usage`, and consolidation never consumes it. Operators can
//! follow the latest stream with:
//!
//! ```text
//! tail -f .yoi/memory/_logs/current.log
//! ```
use std::collections::BTreeMap;
use std::fs::{self, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
//! Runtime Workers construct these DTOs and submit them through the Memory
//! backend operation boundary. Repository-local audit logs are not an authority.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::workspace::WorkspaceLayout;
fn is_zero_usize(value: &usize) -> bool {
*value == 0
}
@@ -260,200 +246,3 @@ impl AuditEvent {
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecordSnapshot {
pub kind: String,
pub slug: String,
pub path: PathBuf,
pub hash: String,
}
/// Append one audit event to `.yoi/memory/_logs/current.log`.
pub fn append_audit_event(layout: &WorkspaceLayout, event: &AuditEvent) -> io::Result<()> {
let path = layout.audit_current_log_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let line = serde_json::to_string(event)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
let mut file = OpenOptions::new().create(true).append(true).open(path)?;
writeln!(file, "{line}")?;
Ok(())
}
pub fn append_worker_lifecycle(
layout: &WorkspaceLayout,
audit: WorkerLifecycleAudit,
) -> io::Result<()> {
append_audit_event(
layout,
&AuditEvent::new(AuditPayload::WorkerLifecycle(audit)),
)
}
pub fn append_record_operation(
layout: &WorkspaceLayout,
audit: RecordOperationAudit,
) -> io::Result<()> {
append_audit_event(
layout,
&AuditEvent::new(AuditPayload::RecordOperation(audit)),
)
}
pub fn append_record_usage(layout: &WorkspaceLayout, audit: RecordUsageAudit) -> io::Result<()> {
append_audit_event(layout, &AuditEvent::new(AuditPayload::RecordUsage(audit)))
}
pub fn file_hash(path: &Path) -> io::Result<Option<String>> {
match fs::read(path) {
Ok(bytes) => Ok(Some(hash_bytes(&bytes))),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err),
}
}
pub fn hash_bytes(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut out = String::with_capacity("sha256:".len() + digest.len() * 2);
out.push_str("sha256:");
for byte in digest {
use std::fmt::Write as _;
let _ = write!(&mut out, "{byte:02x}");
}
out
}
pub fn snapshot_records(layout: &WorkspaceLayout) -> BTreeMap<String, RecordSnapshot> {
let mut out = BTreeMap::new();
snapshot_one(&mut out, "summary", "summary", layout.summary_path());
snapshot_dir(&mut out, "decision", layout.decisions_dir());
snapshot_dir(&mut out, "request", layout.requests_dir());
out
}
pub fn operation_counts_from_snapshots(
before: &BTreeMap<String, RecordSnapshot>,
after: &BTreeMap<String, RecordSnapshot>,
) -> OperationCounts {
let mut counts = OperationCounts::default();
for (key, after_record) in after {
match before.get(key) {
None => counts.write += 1,
Some(before_record) if before_record.hash != after_record.hash => counts.edit += 1,
Some(_) => {}
}
}
for key in before.keys() {
if !after.contains_key(key) {
counts.delete += 1;
}
}
counts
}
fn snapshot_dir(out: &mut BTreeMap<String, RecordSnapshot>, kind: &str, dir: PathBuf) {
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let Some(slug) = name.strip_suffix(".md").map(str::to_string) else {
continue;
};
snapshot_one(out, kind, &slug, path);
}
}
fn snapshot_one(out: &mut BTreeMap<String, RecordSnapshot>, kind: &str, slug: &str, path: PathBuf) {
if !path.is_file() {
return;
}
let Ok(Some(hash)) = file_hash(&path) else {
return;
};
out.insert(
format!("{kind}/{slug}"),
RecordSnapshot {
kind: kind.to_string(),
slug: slug.to_string(),
path,
hash,
},
);
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn setup() -> (TempDir, WorkspaceLayout) {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
(dir, layout)
}
#[test]
fn appends_jsonl_to_current_log() {
let (_dir, layout) = setup();
let run_id = Uuid::now_v7();
append_worker_lifecycle(
&layout,
WorkerLifecycleAudit {
run_id,
worker: AuditWorker::MemoryExtract,
status: WorkerLifecycleStatus::Started,
trigger: AuditTrigger::TokenThreshold,
reason: "tokens_threshold_reached".to_string(),
memory_settings: None,
model: None,
usage: None,
extract: None,
consolidation: None,
},
)
.unwrap();
let text = fs::read_to_string(layout.audit_current_log_path()).unwrap();
let value: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
assert_eq!(value["event"], "worker_lifecycle");
assert_eq!(value["worker"], "memory_extract");
assert_eq!(value["status"], "started");
assert_eq!(value["run_id"], run_id.to_string());
}
#[test]
fn counts_created_edited_deleted_records() {
let (dir, layout) = setup();
let decision_dir = dir.path().join(".yoi/memory/decisions");
fs::create_dir_all(&decision_dir).unwrap();
fs::write(decision_dir.join("a.md"), "old").unwrap();
fs::write(decision_dir.join("gone.md"), "old").unwrap();
let before = snapshot_records(&layout);
fs::write(decision_dir.join("a.md"), "new").unwrap();
fs::remove_file(decision_dir.join("gone.md")).unwrap();
fs::write(decision_dir.join("created.md"), "new").unwrap();
let after = snapshot_records(&layout);
let counts = operation_counts_from_snapshots(&before, &after);
assert_eq!(counts.write, 1);
assert_eq!(counts.edit, 1);
assert_eq!(counts.delete, 1);
}
#[test]
fn hash_has_sha256_prefix() {
assert_eq!(hash_bytes(b"abc").len(), "sha256:".len() + 64);
assert!(hash_bytes(b"abc").starts_with("sha256:"));
}
}
+6 -372
View File
@@ -1,26 +1,15 @@
//! Workspace-backend operations for Memory tools.
//! Typed transport boundary for Workspace-backed Memory operations.
//!
//! These types are the typed HTTP boundary used by runtime workers that have
//! Workspace authority but no direct local filesystem authority. The local
//! executor intentionally lives in the memory crate so the workspace backend and
//! future non-HTTP hosts share validation and path semantics.
//! Runtime Workers serialize these DTOs to the Workspace API. Persistence and
//! validation are owned by the Workspace authority; this crate deliberately has
//! no repository-local filesystem executor.
use std::fs;
use std::io;
use std::io::Write;
use chrono::Utc;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::audit::{AuditEvent, append_audit_event};
use crate::consolidate::list_staging_entries_snapshot;
use crate::extract::{
ExtractedCandidate, ExtractedPayload, StagingEvidence, write_staging, write_staging_candidate,
};
use crate::audit::AuditEvent;
use crate::extract::{ExtractedCandidate, ExtractedPayload, StagingEvidence};
use crate::schema::{SourceEvidenceRef, SourceRef};
use crate::workspace::WorkspaceLayout;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "operation", rename_all = "snake_case")]
@@ -166,19 +155,6 @@ pub struct MemoryConsolidationOutput {
pub total_bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct MemoryStagingCloseDispositionRecord {
schema_version: u32,
candidate_id: String,
staging_path: String,
recorded_at: String,
action: MemoryStagingCloseAction,
reason: String,
affected_memory: Vec<MemoryStagingAffectedMemory>,
}
const STAGING_RESOLUTIONS_FILE: &str = "_resolutions.jsonl";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryBackendAckOutput {
pub summary: String,
@@ -189,345 +165,3 @@ pub struct MemoryStagingWriteOutput {
pub staging_count: usize,
pub staging_ids: Vec<String>,
}
pub fn execute_memory_backend_operation(
layout: &WorkspaceLayout,
operation: MemoryBackendOperation,
) -> io::Result<MemoryBackendOperationResult> {
match operation {
MemoryBackendOperation::Query(operation) => Ok(MemoryBackendOperationResult::ToolOutput(
execute_query(layout, operation),
)),
MemoryBackendOperation::ReadDocument(_operation) => Err(io::Error::new(
io::ErrorKind::Unsupported,
"Memory document operations require WorkspaceAuthority-backed executor",
)),
MemoryBackendOperation::UpdateDocument(_operation) => Err(io::Error::new(
io::ErrorKind::Unsupported,
"Memory document operations require WorkspaceAuthority-backed executor",
)),
MemoryBackendOperation::ResidentSummary(_operation) => Ok(
MemoryBackendOperationResult::ToolOutput(execute_resident_summary(layout)),
),
MemoryBackendOperation::AppendAudit(operation) => {
append_audit_event(layout, &operation.event)?;
Ok(MemoryBackendOperationResult::Acknowledged(
MemoryBackendAckOutput {
summary: "memory audit event appended".to_string(),
},
))
}
MemoryBackendOperation::StageCandidate(operation) => {
let written = write_staging_candidate(
layout,
operation.source,
&operation.extract_run_id,
operation.candidate,
operation.evidence,
operation.source_refs,
)?;
Ok(MemoryBackendOperationResult::StagingWritten(
MemoryStagingWriteOutput {
staging_count: 1,
staging_ids: vec![written.id.to_string()],
},
))
}
MemoryBackendOperation::StageExtracted(operation) => {
let written = write_staging(layout, operation.source, operation.payload)?;
Ok(MemoryBackendOperationResult::StagingWritten(
MemoryStagingWriteOutput {
staging_count: written.len(),
staging_ids: written.iter().map(|item| item.id.to_string()).collect(),
},
))
}
MemoryBackendOperation::StagingList(operation) => {
execute_staging_list(layout, operation).map(MemoryBackendOperationResult::ToolOutput)
}
MemoryBackendOperation::StagingRead(operation) => {
execute_staging_read(layout, operation).map(MemoryBackendOperationResult::ToolOutput)
}
MemoryBackendOperation::StagingClose(operation) => {
execute_staging_close(layout, operation).map(MemoryBackendOperationResult::ToolOutput)
}
}
}
fn execute_resident_summary(layout: &WorkspaceLayout) -> MemoryToolOutput {
match crate::collect_resident_summary(layout) {
Some(summary) => MemoryToolOutput {
summary: "resident memory summary collected".to_string(),
content: Some(summary),
},
None => MemoryToolOutput {
summary: "resident memory summary unavailable".to_string(),
content: None,
},
}
}
fn execute_query(layout: &WorkspaceLayout, operation: MemoryQueryOperation) -> MemoryToolOutput {
let Some(document) = crate::collect_resident_summary(layout) else {
let suffix = operation
.query
.as_deref()
.map(|query| format!(" matching {query:?}"))
.unwrap_or_default();
return MemoryToolOutput {
summary: format!("No memory document content found{suffix}"),
content: None,
};
};
if !matches_query(&document, operation.query.as_deref()) {
let suffix = operation
.query
.as_deref()
.map(|query| format!(" matching {query:?}"))
.unwrap_or_default();
return MemoryToolOutput {
summary: format!("No memory document content found{suffix}"),
content: None,
};
}
tool_output_from_string(excerpt(&document, operation.query.as_deref()))
}
fn execute_staging_list(
layout: &WorkspaceLayout,
operation: MemoryStagingListOperation,
) -> io::Result<MemoryToolOutput> {
let limit = operation.limit.unwrap_or(20).min(100);
let snapshot = list_staging_entries_snapshot(layout);
let total = snapshot.entries.len();
let invalid_count = snapshot.invalid_count;
let records = snapshot
.entries
.into_iter()
.take(limit)
.map(|entry| {
serde_json::json!({
"candidate_id": entry.id.to_string(),
"bytes": entry.bytes,
"path": entry.path.display().to_string(),
"source": entry.record.source,
"kind": entry.record.kind,
"claim": entry.record.claim,
"why_useful": entry.record.why_useful,
"staleness": entry.record.staleness,
"evidence_count": entry.record.evidence.len(),
"source_ref_count": entry.record.source_refs.len(),
})
})
.collect::<Vec<_>>();
Ok(MemoryToolOutput {
summary: format!(
"Listed {} of {total} staging candidate(s); invalid_count={invalid_count}",
records.len()
),
content: Some(serde_json::to_string_pretty(&records).map_err(io::Error::other)?),
})
}
fn execute_staging_read(
layout: &WorkspaceLayout,
operation: MemoryStagingReadOperation,
) -> io::Result<MemoryToolOutput> {
let entry = find_staging_entry(layout, &operation.candidate_id)?;
Ok(MemoryToolOutput {
summary: format!("Read staging candidate {}", entry.id),
content: Some(serde_json::to_string_pretty(&entry.record).map_err(io::Error::other)?),
})
}
fn execute_staging_close(
layout: &WorkspaceLayout,
operation: MemoryStagingCloseOperation,
) -> io::Result<MemoryToolOutput> {
if operation.reason.trim().is_empty() {
return Err(invalid_input("reason is required"));
}
let entry = find_staging_entry(layout, &operation.candidate_id)?;
let disposition = MemoryStagingCloseDispositionRecord {
schema_version: 1,
candidate_id: entry.id.to_string(),
staging_path: entry.path.display().to_string(),
recorded_at: Utc::now().to_rfc3339(),
action: operation.action,
reason: operation.reason,
affected_memory: operation.affected_memory,
};
let resolutions_path = layout.memory_dir().join(STAGING_RESOLUTIONS_FILE);
if let Some(parent) = resolutions_path.parent() {
fs::create_dir_all(parent)?;
}
let mut line = serde_json::to_string(&disposition).map_err(io::Error::other)?;
line.push('\n');
fs::OpenOptions::new()
.create(true)
.append(true)
.open(&resolutions_path)?
.write_all(line.as_bytes())?;
fs::remove_file(&entry.path)?;
Ok(MemoryToolOutput {
summary: format!("Closed staging candidate {}", entry.id),
content: Some(serde_json::to_string_pretty(&disposition).map_err(io::Error::other)?),
})
}
fn find_staging_entry(
layout: &WorkspaceLayout,
candidate_id: &str,
) -> io::Result<crate::consolidate::StagingEntry> {
let candidate_id = Uuid::parse_str(candidate_id)
.map_err(|err| invalid_input(format!("invalid candidate_id: {err}")))?;
list_staging_entries_snapshot(layout)
.entries
.into_iter()
.find(|entry| entry.id == candidate_id)
.ok_or_else(|| invalid_input(format!("staging candidate not found: {candidate_id}")))
}
fn matches_query(content: &str, query: Option<&str>) -> bool {
match query.map(str::trim).filter(|query| !query.is_empty()) {
Some(query) => content.to_lowercase().contains(&query.to_lowercase()),
None => true,
}
}
fn excerpt(content: &str, query: Option<&str>) -> String {
let lines: Vec<&str> = content.lines().collect();
let Some(query) = query.map(str::trim).filter(|query| !query.is_empty()) else {
return lines
.iter()
.take(20)
.copied()
.collect::<Vec<_>>()
.join("\n");
};
let query_lower = query.to_lowercase();
let hit = lines
.iter()
.position(|line| line.to_lowercase().contains(&query_lower))
.unwrap_or(0);
let start = hit.saturating_sub(2);
let end = (hit + 3).min(lines.len());
lines[start..end].join("\n")
}
fn tool_output_from_string(value: String) -> MemoryToolOutput {
const SUMMARY_THRESHOLD: usize = 200;
if value.len() <= SUMMARY_THRESHOLD {
MemoryToolOutput {
summary: value,
content: None,
}
} else {
let lines = value.lines().count();
let first_line: String = value
.lines()
.next()
.unwrap_or("")
.chars()
.take(80)
.collect();
MemoryToolOutput {
summary: format!("{lines} lines | {first_line}…"),
content: Some(value),
}
}
}
fn invalid_input(message: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidInput, message.into())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extract::{CandidateKind, ExtractedCandidate};
#[test]
fn consolidation_operation_rejects_caller_owned_thresholds() {
let error =
serde_json::from_value::<MemoryConsolidateStagingOperation>(serde_json::json!({
"force": false,
"threshold_files": 1,
}))
.unwrap_err();
assert!(error.to_string().contains("threshold_files"));
}
#[test]
fn staging_list_read_close_records_reason_and_deletes_candidate() {
let temp = tempfile::tempdir().unwrap();
let layout = WorkspaceLayout::resolve(temp.path());
let source = SourceRef {
segment_id: "segment-1".into(),
range: [0, 1],
};
let payload = ExtractedPayload {
candidates: vec![ExtractedCandidate {
kind: CandidateKind::Preference,
claim: "User prefers short reviews".into(),
why_useful: "Review style preference".into(),
staleness: None,
evidence_ids: Vec::new(),
}],
};
let result = execute_memory_backend_operation(
&layout,
MemoryBackendOperation::StageExtracted(MemoryStageExtractedOperation {
source,
payload,
}),
)
.unwrap();
let candidate_id = match result {
MemoryBackendOperationResult::StagingWritten(output) => output.staging_ids[0].clone(),
_ => panic!("expected staging write output"),
};
let list = execute_memory_backend_operation(
&layout,
MemoryBackendOperation::StagingList(MemoryStagingListOperation { limit: Some(10) }),
)
.unwrap();
let MemoryBackendOperationResult::ToolOutput(list) = list else {
panic!("expected list tool output")
};
assert!(list.content.unwrap().contains(&candidate_id));
let read = execute_memory_backend_operation(
&layout,
MemoryBackendOperation::StagingRead(MemoryStagingReadOperation {
candidate_id: candidate_id.clone(),
}),
)
.unwrap();
let MemoryBackendOperationResult::ToolOutput(read) = read else {
panic!("expected read tool output")
};
assert!(read.content.unwrap().contains("short reviews"));
execute_memory_backend_operation(
&layout,
MemoryBackendOperation::StagingClose(MemoryStagingCloseOperation {
candidate_id: candidate_id.clone(),
action: MemoryStagingCloseAction::Applied,
reason: "Merged into the Memory document.".into(),
affected_memory: vec![MemoryStagingAffectedMemory {
operation: MemoryStagingAffectedMemoryOperation::Edit,
}],
}),
)
.unwrap();
let snapshot = list_staging_entries_snapshot(&layout);
assert!(snapshot.entries.is_empty());
let resolutions =
fs::read_to_string(layout.memory_dir().join(STAGING_RESOLUTIONS_FILE)).unwrap();
assert!(resolutions.contains(&candidate_id));
assert!(resolutions.contains("Merged into the Memory document."));
}
}
-12
View File
@@ -1,12 +0,0 @@
//! Memory staging queue helpers.
//!
//! Staging candidates are consumed by the backend-managed memory-consolidation
//! Worker through MemoryStaging tools. This module only exposes bounded staging
//! listing/read support; consolidation decisions are recorded by the backend close
//! operation before a staging file is deleted.
mod staging;
pub use staging::{
StagingEntriesSnapshot, StagingEntry, list_staging_entries, list_staging_entries_snapshot,
};
-210
View File
@@ -1,210 +0,0 @@
//! `_staging/*.json` を列挙して [`StagingRecord`] に展開する読み込みヘルパー。
//!
//! consolidation 起動時のスナップショット(consumed ID list 確定)と、整理 step
//! が終わった後の cleanup の双方で使う。`.consolidation.lock` のような
//! 占有ファイルは UUIDv7 として parse できないので自然に除外される。
//!
//! [`StagingRecord`] のスキーマは extract が書き出す側 (`crate::extract`)
//! と単一の真実源 — ここでは読み出す側だけを担当する。
use std::path::PathBuf;
use uuid::Uuid;
use crate::extract::StagingRecord;
use crate::workspace::WorkspaceLayout;
/// staging に積まれている 1 件分のエントリ。`id` は UUIDv7 で、ファイル名
/// `<id>.json` を逆引きしたもの。
#[derive(Debug, Clone)]
pub struct StagingEntry {
pub id: Uuid,
pub path: PathBuf,
pub record: StagingRecord,
/// このファイルのバイト長。Backendのconsolidation閾値判定に使用する。
pub bytes: u64,
}
/// staging directory の検査結果。`entries` は current schema として読めた
/// staging のみで、`invalid_count` は `.json` だが staging として採用できなかった
/// ファイル数。
#[derive(Debug, Clone, Default)]
pub struct StagingEntriesSnapshot {
pub entries: Vec<StagingEntry>,
pub invalid_count: usize,
}
/// `<staging_dir>/*.json` を読んで UUIDv7 順に並べた [`StagingEntry`]
/// 配列を返す。staging_dir が存在しなければ空配列。読めないファイルや
/// JSON parse 失敗は `tracing::warn!` してスキップ(壊れた個別ファイルが
/// consolidation 全体を止めないように)。
pub fn list_staging_entries(layout: &WorkspaceLayout) -> Vec<StagingEntry> {
list_staging_entries_snapshot(layout).entries
}
/// `<staging_dir>/*.json` を読んで valid staging と invalid staging 件数を返す。
/// invalid は自動 migration / 削除 / archive せず、観測可能にするための件数だけを
/// 呼び出し側へ渡す。
pub fn list_staging_entries_snapshot(layout: &WorkspaceLayout) -> StagingEntriesSnapshot {
let dir = layout.staging_dir();
let entries = match std::fs::read_dir(&dir) {
Ok(it) => it,
Err(_) => return StagingEntriesSnapshot::default(),
};
let mut out: Vec<StagingEntry> = Vec::new();
let mut invalid_count = 0;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
if ext != "json" {
continue;
}
let stem = match path.file_stem().and_then(|s| s.to_str()) {
Some(s) => s,
None => {
invalid_count += 1;
continue;
}
};
let id = match Uuid::parse_str(stem) {
Ok(u) => u,
Err(e) => {
invalid_count += 1;
tracing::warn!(path = %path.display(), error = %e, "failed to parse staging entry id");
continue;
}
};
let bytes = match std::fs::metadata(&path) {
Ok(m) => m.len(),
Err(_) => 0,
};
let raw = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) => {
invalid_count += 1;
tracing::warn!(path = %path.display(), error = %e, "failed to read staging entry");
continue;
}
};
let record = match serde_json::from_str::<StagingRecord>(&raw) {
Ok(r) => r,
Err(e) => {
invalid_count += 1;
tracing::warn!(path = %path.display(), error = %e, "failed to parse staging entry");
continue;
}
};
out.push(StagingEntry {
id,
path,
record,
bytes,
});
}
out.sort_by_key(|e| e.id);
StagingEntriesSnapshot {
entries: out,
invalid_count,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extract::{CandidateKind, ExtractedCandidate, ExtractedPayload, write_staging};
use crate::schema::SourceRef;
fn candidate_payload(claim: &str) -> ExtractedPayload {
ExtractedPayload {
candidates: vec![ExtractedCandidate {
kind: CandidateKind::Lesson,
claim: claim.into(),
why_useful: "useful for later consolidation".into(),
staleness: None,
evidence_ids: Vec::new(),
}],
}
}
fn source(segment_id: &str, range: [u64; 2]) -> SourceRef {
SourceRef {
segment_id: segment_id.into(),
range,
}
}
#[test]
fn lists_in_uuidv7_order() {
let tmp = tempfile::TempDir::new().unwrap();
let layout = WorkspaceLayout::new(tmp.path().to_path_buf());
let id1 = write_staging(&layout, source("s", [0, 1]), candidate_payload("one"))
.unwrap()
.remove(0)
.id;
let id2 = write_staging(&layout, source("s", [2, 3]), candidate_payload("two"))
.unwrap()
.remove(0)
.id;
let id3 = write_staging(&layout, source("s", [4, 5]), candidate_payload("three"))
.unwrap()
.remove(0)
.id;
let entries = list_staging_entries(&layout);
let ids: Vec<Uuid> = entries.iter().map(|e| e.id).collect();
assert_eq!(ids, vec![id1, id2, id3]);
}
#[test]
fn skips_lock_file_and_counts_invalid_json() {
let tmp = tempfile::TempDir::new().unwrap();
let layout = WorkspaceLayout::new(tmp.path().to_path_buf());
let _id = write_staging(&layout, source("s", [0, 1]), candidate_payload("kept"))
.unwrap()
.remove(0)
.id;
// Drop a non-UUID json file, an unparsable UUID-named json file, an
// old batch-schema UUID-named json file, and a bare lock file alongside.
// Lock files are not `.json`; invalid `.json` files are surfaced
// separately instead of being mistaken for an empty staging directory.
std::fs::write(layout.staging_dir().join("not-a-uuid.json"), "{}").unwrap();
let bad_id = Uuid::now_v7();
std::fs::write(layout.staging_dir().join(format!("{bad_id}.json")), "{").unwrap();
let old_schema_id = Uuid::now_v7();
std::fs::write(
layout.staging_dir().join(format!("{old_schema_id}.json")),
serde_json::json!({
"source": {
"session_id": "legacy-session",
"range": [0, 1]
},
"requests": []
})
.to_string(),
)
.unwrap();
std::fs::write(layout.staging_dir().join(".consolidation.lock"), "{}").unwrap();
let entries = list_staging_entries(&layout);
assert_eq!(entries.len(), 1);
let snapshot = list_staging_entries_snapshot(&layout);
assert_eq!(snapshot.entries.len(), 1);
assert_eq!(snapshot.invalid_count, 3);
assert_eq!(snapshot.entries[0].record.claim, "kept");
}
#[test]
fn missing_dir_returns_empty() {
let tmp = tempfile::TempDir::new().unwrap();
let layout = WorkspaceLayout::new(tmp.path().to_path_buf());
// No staging dir at all.
assert!(list_staging_entries(&layout).is_empty());
}
}
-116
View File
@@ -1,116 +0,0 @@
//! Errors raised by the memory subsystem.
use std::path::PathBuf;
use lint_common::RecordLintError;
use thiserror::Error;
/// Top-level error for memory operations that don't fit the lint flow.
#[derive(Debug, Error)]
pub enum MemoryError {
#[error("path is not under the memory tree: {}", .0.display())]
OutsideMemoryTree(PathBuf),
#[error("path is not absolute: {}", .0.display())]
RelativePath(PathBuf),
#[error("io error at {}: {source}", .path.display())]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
impl MemoryError {
pub fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
Self::Io {
path: path.into(),
source,
}
}
}
/// A single Linter violation. Multiple are aggregated in a [`LintReport`].
///
/// `Display` produces a one-line message used directly in the `ToolError`
/// payload returned to the LLM.
#[derive(Debug, Clone, Error, PartialEq, Eq)]
pub enum LintError {
#[error("path is not a valid memory record location: {}", .0.display())]
InvalidPath(PathBuf),
#[error("path is for a different record kind than expected at this location: {}", .0.display())]
WrongRecordKind(PathBuf),
#[error(transparent)]
Record(#[from] RecordLintError),
#[error("missing required frontmatter field: `{0}`")]
MissingField(&'static str),
#[error("invalid value for `{field}`: {message}")]
InvalidField {
field: &'static str,
message: String,
},
#[error("Decisions `status` must be one of open|resolved|replaced (got `{0}`)")]
InvalidStatus(String),
#[error("body exceeds the size limit for this record kind: {actual} chars > {limit}")]
BodyTooLong { actual: usize, limit: usize },
#[error("slug `{0}` already exists; use the edit tool instead of creating a new record")]
SlugAlreadyExists(String),
#[error("`{field}` references unknown {kind} slug `{slug}`")]
UnknownReference {
field: &'static str,
kind: &'static str,
slug: String,
},
#[error("`replaced_by` chain forms a cycle: {chain}")]
ReplacedByCycle { chain: String },
#[error("`replaced_by` must point to a different slug than the record itself")]
ReplacedBySelf,
}
/// A single Linter warning (non-blocking).
///
/// Warnings ride along in the `ToolOutput.summary` so the agent can act
/// on them when convenient; they never abort the write.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LintWarning {
/// Single-source record exceeds the importance/size threshold.
LowImportanceLargeRecord { chars: usize },
/// `sources` array has grown past the soft cap.
SourcesOverflow { count: usize },
/// Multiple slugs in the same kind are within Levenshtein distance 2.
SimilarSlugs(Vec<String>),
}
impl std::fmt::Display for LintWarning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::LowImportanceLargeRecord { chars } => write!(
f,
"record is large ({chars} chars) but only has 1 source — consider splitting or trimming"
),
Self::SourcesOverflow { count } => write!(
f,
"`sources` has {count} entries — consider keeping only the most recent and relying on git log for the rest"
),
Self::SimilarSlugs(slugs) => {
write!(f, "similar slugs detected (consider merging): ")?;
for (i, s) in slugs.iter().enumerate() {
if i > 0 {
write!(f, ", ")?;
}
write!(f, "{s}")?;
}
Ok(())
}
}
}
}
+4 -18
View File
@@ -1,25 +1,12 @@
//! extract: memory candidate extraction.
//! Memory candidate extraction domain types and Worker tool boundary.
//!
//! 通常 Worker の post-run hook で発火する disposable Engine と、その
//! 出力を `<workspace>/.yoi/memory/_staging/<id>.json` に書き出す
//! ヘルパーを提供する。Worker 側はこのモジュールから:
//!
//! - [`build_extract_input`] を sub-Engine の最初の user 入力に
//! - [`write_extracted_tool`] を唯一のツールとして
//! - [`write_staging`] で受け取った JSON を staging に書き出し
//!
//! の順で組み立てる。system prompt は Worker の `PromptCatalog`
//! (`WorkerPrompt::MemoryExtractSystem`) で管理される。pointer 永続化
//! (session-store の `LogEntry::Extension`、domain `"memory.extract"`)は
//! Worker 側が責務を持つ。
//!
//! 出力 JSON の wrap は [`write_staging`] が `source: { segment_id, range }`
//! を機械付与する形で担当し、LLM には source を推論させない。
//! Worker-side extraction produces typed candidates that are submitted to the
//! Workspace backend. Staging persistence is owned by that backend; this module
//! does not write repository-local files.
mod input;
mod payload;
mod pointer;
mod staging;
mod tool;
pub use input::build_extract_input;
@@ -28,7 +15,6 @@ pub use payload::{
StagingRecord,
};
pub use pointer::{ExtractPointerPayload, fold_pointer};
pub use staging::{StagingWriteResult, write_staging, write_staging_candidate};
pub use tool::{ExtractWorkerContext, write_extracted_tool};
/// session-store `LogEntry::Extension` で使う domain 名。
-142
View File
@@ -1,142 +0,0 @@
//! extract staging writer.
//!
//! Staging is flat: one file is one candidate and one consolidation decision
//! unit. The transitional extract tool still submits `ExtractedPayload` with
//! `candidates[]`; this writer expands it into one [`StagingRecord`] per
//! candidate.
use std::fs;
use std::io;
use std::path::PathBuf;
use uuid::Uuid;
use crate::extract::payload::{
ExtractedCandidate, ExtractedPayload, StagingEvidence, StagingRecord,
};
use crate::schema::{SourceEvidenceRef, SourceRef};
use crate::workspace::WorkspaceLayout;
/// Filesystem result for a single staged candidate.
#[derive(Debug, Clone)]
pub struct StagingWriteResult {
pub id: Uuid,
pub path: PathBuf,
}
/// Write one flat staging JSON file per extracted candidate.
///
/// Returns an empty vector when `payload` has no candidates.
pub fn write_staging(
layout: &WorkspaceLayout,
source: SourceRef,
payload: ExtractedPayload,
) -> io::Result<Vec<StagingWriteResult>> {
if payload.candidates.is_empty() {
return Ok(Vec::new());
}
let extract_run_id = Uuid::now_v7().to_string();
let mut written = Vec::with_capacity(payload.candidates.len());
for candidate in payload.candidates {
written.push(write_staging_candidate(
layout,
source.clone(),
&extract_run_id,
candidate,
Vec::new(),
Vec::new(),
)?);
}
Ok(written)
}
pub fn write_staging_candidate(
layout: &WorkspaceLayout,
source: SourceRef,
extract_run_id: &str,
candidate: ExtractedCandidate,
evidence: Vec<StagingEvidence>,
source_refs: Vec<SourceEvidenceRef>,
) -> io::Result<StagingWriteResult> {
let dir = layout.staging_dir();
fs::create_dir_all(&dir)?;
let id = Uuid::now_v7();
let record = StagingRecord::from_candidate(
id.to_string(),
extract_run_id.to_string(),
source,
candidate,
evidence,
source_refs,
);
let path = dir.join(format!("{}.json", id));
let bytes = serde_json::to_vec_pretty(&record).map_err(io::Error::other)?;
fs::write(&path, bytes)?;
Ok(StagingWriteResult { id, path })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extract::payload::{CandidateKind, ExtractedCandidate};
fn layout() -> WorkspaceLayout {
let dir = tempfile::tempdir().unwrap();
// leak tempdir for the duration of the test process; sufficient for unit tests
let path = dir.keep();
WorkspaceLayout::new(path)
}
fn source() -> SourceRef {
SourceRef {
segment_id: "segment-1".into(),
range: [1, 3],
}
}
fn candidate(kind: CandidateKind, claim: &str) -> ExtractedCandidate {
ExtractedCandidate {
kind,
claim: claim.into(),
why_useful: "useful for future consolidation".into(),
staleness: None,
evidence_ids: Vec::new(),
}
}
#[test]
fn writes_one_file_per_candidate() {
let layout = layout();
let payload = ExtractedPayload {
candidates: vec![
candidate(CandidateKind::Preference, "Prefer implementation tickets"),
candidate(CandidateKind::Decision, "Use flat staging records"),
],
};
let results = write_staging(&layout, source(), payload).unwrap();
assert_eq!(results.len(), 2);
assert_ne!(results[0].id, results[1].id);
let first = fs::read_to_string(&results[0].path).unwrap();
let second = fs::read_to_string(&results[1].path).unwrap();
let first_record: StagingRecord = serde_json::from_str(&first).unwrap();
let second_record: StagingRecord = serde_json::from_str(&second).unwrap();
assert_eq!(first_record.kind, CandidateKind::Preference);
assert_eq!(second_record.kind, CandidateKind::Decision);
assert_eq!(first_record.extract_run_id, second_record.extract_run_id);
assert_eq!(first_record.source.segment_id, "segment-1");
}
#[test]
fn empty_payload_writes_nothing() {
let layout = layout();
let results = write_staging(&layout, source(), ExtractedPayload::default()).unwrap();
assert!(results.is_empty());
assert!(!layout.staging_dir().exists());
}
}
+38 -21
View File
@@ -1,30 +1,47 @@
//! Memory subsystem support shared by Worker and Workspace authority paths.
//! Shared Memory domain and Workspace API transport types.
//!
//! Normal runtime Memory access is mediated through Workspace-backed backend
//! operations. Generic filesystem tools must not touch local memory paths; Worker
//! is responsible for denying them at the Scope level when memory is enabled.
//! Normal Memory persistence is owned by the Workspace control plane. This crate
//! intentionally contains no repository-local `.yoi` layout, discovery, reader,
//! writer, or fallback implementation.
pub mod audit;
pub mod backend;
pub mod consolidate;
pub mod error;
pub mod extract;
pub mod linter;
pub mod resident;
pub mod schema;
pub mod scope;
pub mod usage;
pub mod workspace;
pub use error::{LintError, LintWarning, MemoryError};
pub use extract::ExtractPointerPayload;
pub use lint_common::{RecordLintError, Slug, is_valid_slug};
pub use linter::{LintReport, Linter};
pub use resident::collect_resident_summary;
pub use scope::deny_write_rules;
pub use usage::{
UsageEvent, UsageEventKind, UsageRecordSnapshot, UsageReport, UsageReportRecord, UsageSource,
append_resident_exposure_event, append_usage_event, append_use_event, build_usage_report,
snapshot_record_from_bytes, snapshot_record_from_layout,
};
pub use workspace::WorkspaceLayout;
#[cfg(test)]
mod authority_tests {
#[test]
fn production_source_has_no_repository_local_memory_provider() {
let production = [
include_str!("lib.rs")
.split_once("#[cfg(test)]\nmod authority_tests")
.map(|(production, _)| production)
.expect("Memory authority test module marker"),
include_str!("backend.rs"),
include_str!("audit.rs"),
include_str!("extract/mod.rs"),
include_str!("extract/input.rs"),
include_str!("extract/payload.rs"),
include_str!("extract/pointer.rs"),
include_str!("extract/tool.rs"),
]
.join("\n");
for forbidden in [
"WorkspaceLayout",
"execute_memory_backend_operation",
"collect_resident_summary",
"deny_write_rules",
"write_staging_candidate",
".yoi/memory",
] {
assert!(
!production.contains(forbidden),
"repository-local Memory provider returned through {forbidden}"
);
}
}
}
-121
View File
@@ -1,121 +0,0 @@
//! Walks `<workspace>/memory/{decisions,requests}/` to collect
//! the slug set the linter needs for reference-integrity and
//! same-slug-duplication checks.
//!
//! No caching: each lint call walks fresh. Tree size is expected to
//! stay small (hundreds of files, not thousands).
use std::collections::{HashMap, HashSet};
use std::io;
use std::path::Path;
use crate::Slug;
use crate::schema::{DecisionFrontmatter, RequestFrontmatter, split_frontmatter};
use crate::workspace::{RecordKind, WorkspaceLayout};
/// Snapshot of every record currently on disk under the workspace.
///
/// Carries enough metadata to answer:
/// - "does slug X of kind K exist?" (same-slug duplication, reference checks)
/// - "what is X's `replaced_by`?" (cycle detection)
/// - "what other slugs of kind K exist?" (similar-slug warning)
#[derive(Debug, Default, Clone)]
pub struct ExistingRecords {
decisions: HashMap<Slug, DecisionMeta>,
requests: HashSet<Slug>,
}
#[derive(Debug, Clone)]
pub struct DecisionMeta {
pub replaced_by: Option<Slug>,
}
impl ExistingRecords {
pub fn contains(&self, kind: RecordKind, slug: &Slug) -> bool {
match kind {
RecordKind::Decision => self.decisions.contains_key(slug),
RecordKind::Request => self.requests.contains(slug),
RecordKind::Summary => false,
}
}
pub fn decision(&self, slug: &Slug) -> Option<&DecisionMeta> {
self.decisions.get(slug)
}
pub fn slugs(&self, kind: RecordKind) -> Vec<&Slug> {
match kind {
RecordKind::Decision => self.decisions.keys().collect(),
RecordKind::Request => self.requests.iter().collect(),
RecordKind::Summary => Vec::new(),
}
}
}
/// Walk the workspace and collect every record.
pub fn scan_existing(layout: &WorkspaceLayout) -> io::Result<ExistingRecords> {
let mut out = ExistingRecords::default();
scan_dir(&layout.decisions_dir(), |path, slug| {
let meta = read_decision_meta(path);
out.decisions.insert(slug, meta);
})?;
scan_dir(&layout.requests_dir(), |path, slug| {
// Parse to validate but discard contents — only slug existence
// matters for reference checks. Parse failure is silently
// ignored: existing record corruption isn't this write's
// responsibility to fix.
let _ = parse_silent::<RequestFrontmatter>(path);
out.requests.insert(slug);
})?;
Ok(out)
}
fn scan_dir<F>(dir: &Path, mut visit: F) -> io::Result<()>
where
F: FnMut(&Path, Slug),
{
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(err),
};
for entry in entries {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let stem = match path.file_stem().and_then(|s| s.to_str()) {
Some(s) => s,
None => continue,
};
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
if ext != "md" {
continue;
}
if let Ok(slug) = Slug::parse(stem) {
visit(&path, slug);
}
}
Ok(())
}
fn read_decision_meta(path: &Path) -> DecisionMeta {
match parse_silent::<DecisionFrontmatter>(path) {
Some(fm) => DecisionMeta {
replaced_by: fm.replaced_by,
},
None => DecisionMeta { replaced_by: None },
}
}
fn parse_silent<F>(path: &Path) -> Option<F>
where
F: serde::de::DeserializeOwned,
{
let content = std::fs::read_to_string(path).ok()?;
let (yaml, _) = split_frontmatter(&content).ok()?;
serde_yaml::from_str::<F>(yaml).ok()
}
-64
View File
@@ -1,64 +0,0 @@
//! YAML frontmatter parsing helpers shared by every kind.
use lint_common::RecordLintError;
use serde::de::DeserializeOwned;
use crate::error::LintError;
/// Strict YAML deserialization that maps serde errors into the linter's
/// `MissingField` / `InvalidField` / `MalformedFrontmatter` taxonomy
/// when possible.
pub fn deserialize_strict<F: DeserializeOwned>(yaml: &str) -> Result<F, LintError> {
serde_yaml::from_str::<F>(yaml).map_err(map_serde_error)
}
fn map_serde_error(err: serde_yaml::Error) -> LintError {
let msg = err.to_string();
// `missing field \`X\`` is the exact pattern serde uses for missing
// required fields. Hoist into the typed variant so the LLM sees a
// crisp message it can act on.
if let Some(field) = parse_missing_field(&msg) {
return LintError::MissingField(field);
}
if let Some((field, message)) = parse_invalid_status(&msg) {
if field == "status" {
return LintError::InvalidStatus(message);
}
return LintError::InvalidField { field, message };
}
LintError::Record(RecordLintError::MalformedFrontmatter(msg))
}
fn parse_missing_field(msg: &str) -> Option<&'static str> {
let needle = "missing field `";
let start = msg.find(needle)? + needle.len();
let end = msg[start..].find('`')? + start;
let field_name = &msg[start..end];
static FIELDS: &[&str] = &[
"created_at",
"updated_at",
"sources",
"status",
"kind",
"description",
"requires",
];
FIELDS.iter().copied().find(|n| *n == field_name)
}
fn parse_invalid_status(msg: &str) -> Option<(&'static str, String)> {
// serde renders enum failures as: "unknown variant `Foo`, expected one of ..."
// We can't reliably attribute it to a specific field from the message
// alone, so we conservatively label it as `status` only when the
// message mentions one of the DecisionStatus variants in the
// expected set.
if msg.contains("unknown variant") && msg.contains("`open`") {
let needle = "unknown variant `";
let start = msg.find(needle)? + needle.len();
let end = msg[start..].find('`')? + start;
let bad = msg[start..end].to_string();
return Some(("status", bad));
}
None
}
-447
View File
@@ -1,447 +0,0 @@
//! Pre-write Linter for the memory subsystem.
//!
//! The linter is pure: given a [`WorkspaceLayout`], a target path, and
//! the proposed file content (raw bytes), it returns a [`LintReport`]
//! aggregating every applicable rule violation. The memory tool calls
//! this *before* committing to fs and surfaces a non-empty `errors`
//! collection back to the LLM as `ToolError::InvalidArgument`.
//!
//! Reference-integrity checks (`replaced_by` / `requires` existence,
//! cycle detection) walk the whole `memory/` tree
//! each call. No caching; the trees are expected to be small.
mod existing;
mod frontmatter;
mod references;
mod size;
mod warnings;
use std::path::Path;
use lint_common::RecordLintError;
use serde::de::DeserializeOwned;
use crate::error::{LintError, LintWarning};
use crate::schema::{
DecisionFrontmatter, RequestFrontmatter, SummaryFrontmatter, split_frontmatter,
};
use crate::workspace::{ClassifiedPath, RecordKind, WorkspaceLayout};
pub use existing::{ExistingRecords, scan_existing};
/// Aggregated linter result. `errors` empty ⇒ write proceeds.
#[derive(Debug, Default, Clone)]
pub struct LintReport {
pub errors: Vec<LintError>,
pub warnings: Vec<LintWarning>,
}
impl LintReport {
pub fn has_errors(&self) -> bool {
!self.errors.is_empty()
}
pub fn extend_errors(&mut self, more: impl IntoIterator<Item = LintError>) {
self.errors.extend(more);
}
pub fn push_error(&mut self, err: LintError) {
self.errors.push(err);
}
pub fn push_warning(&mut self, w: LintWarning) {
self.warnings.push(w);
}
}
/// Operation context: is this a brand-new file or an update of an
/// existing one? Affects same-slug duplication check.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WriteMode {
Create,
Update,
}
/// Stateless entry point holding the workspace layout. Cheap to clone.
#[derive(Debug, Clone)]
pub struct Linter {
layout: WorkspaceLayout,
}
impl Linter {
pub fn new(layout: WorkspaceLayout) -> Self {
Self { layout }
}
pub fn layout(&self) -> &WorkspaceLayout {
&self.layout
}
/// Lint a proposed write to `path` with the given UTF-8 `content`.
///
/// `mode` tells the linter whether the path already existed at the
/// moment of write — Create triggers same-slug duplication checks,
/// Update doesn't.
pub fn lint(&self, path: &Path, content: &str, mode: WriteMode) -> LintReport {
let mut report = LintReport::default();
// 1. Path classification.
let classified = match self.layout.classify(path) {
Ok(Some(cp)) => cp,
Ok(None) => {
report.push_error(LintError::InvalidPath(path.to_path_buf()));
return report;
}
Err(e) => {
report.push_error(e);
return report;
}
};
// 3. Frontmatter parse + kind-specific structural checks +
// size limits. Reference-integrity needs the existing
// record set, fetched once below.
let existing = match existing::scan_existing(&self.layout) {
Ok(e) => e,
Err(e) => {
report.push_error(LintError::Record(RecordLintError::MalformedFrontmatter(
format!("failed to scan existing records: {e}"),
)));
return report;
}
};
// Same-slug check on Create.
if mode == WriteMode::Create {
if let Some(slug) = &classified.slug {
if existing.contains(classified.kind, slug) {
report.push_error(LintError::SlugAlreadyExists(slug.to_string()));
}
}
}
// Similar-slug clustering warning. Skipped for Summary (no slug).
if let Some(slug) = &classified.slug {
warnings::check_similar_slugs(slug, classified.kind, &existing, &mut report);
}
// Frontmatter parse dispatch by kind.
match classified.kind {
RecordKind::Decision => {
self.check_decision(content, &classified, &existing, &mut report);
}
RecordKind::Request => {
self.check_request(content, &classified, &mut report);
}
RecordKind::Summary => {
self.check_kind::<SummaryFrontmatter>(content, &classified, &mut report);
}
}
report
}
fn check_kind<F>(&self, content: &str, cp: &ClassifiedPath, report: &mut LintReport)
where
F: DeserializeOwned + crate::schema::Frontmatter,
{
let parsed = match parse_frontmatter::<F>(content) {
Ok(p) => p,
Err(e) => {
report.push_error(e);
return;
}
};
let body = parsed.body;
size::check_body::<F>(body, report);
warnings::check_warnings_kindless(cp, body, report);
let _ = parsed.frontmatter; // discarded after structural checks
}
fn check_request(&self, content: &str, _cp: &ClassifiedPath, report: &mut LintReport) {
let parsed = match parse_frontmatter::<RequestFrontmatter>(content) {
Ok(p) => p,
Err(e) => {
report.push_error(e);
return;
}
};
size::check_body::<RequestFrontmatter>(parsed.body, report);
warnings::check_warnings_with_sources(
parsed.body,
parsed.frontmatter.sources.len(),
report,
);
}
fn check_decision(
&self,
content: &str,
cp: &ClassifiedPath,
existing: &ExistingRecords,
report: &mut LintReport,
) {
let parsed = match parse_frontmatter::<DecisionFrontmatter>(content) {
Ok(p) => p,
Err(e) => {
report.push_error(e);
return;
}
};
let fm = parsed.frontmatter;
size::check_body::<DecisionFrontmatter>(parsed.body, report);
// replaced_by structural rules.
if let Some(target) = &fm.replaced_by {
if let Some(self_slug) = &cp.slug {
if target == self_slug {
report.push_error(LintError::ReplacedBySelf);
}
}
references::check_replaced_by(cp.slug.as_ref(), target, existing, report);
}
warnings::check_warnings_with_sources(parsed.body, fm.sources.len(), report);
}
}
struct Parsed<'a, F> {
frontmatter: F,
body: &'a str,
}
fn parse_frontmatter<F: DeserializeOwned>(content: &str) -> Result<Parsed<'_, F>, LintError> {
let (yaml, body) = split_frontmatter(content)?;
let fm = frontmatter::deserialize_strict::<F>(yaml)?;
Ok(Parsed {
frontmatter: fm,
body,
})
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use std::path::PathBuf;
use tempfile::TempDir;
fn write(p: &std::path::Path, content: &str) {
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(p, content).unwrap();
}
fn iso_now() -> String {
Utc::now().to_rfc3339()
}
fn workspace() -> (TempDir, Linter) {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
let linter = Linter::new(layout);
(dir, linter)
}
#[test]
fn outside_memory_tree_rejected() {
let (dir, linter) = workspace();
let path = dir.path().join("src/main.rs");
let report = linter.lint(&path, "ignored", WriteMode::Create);
assert!(
report
.errors
.iter()
.any(|e| matches!(e, LintError::InvalidPath(_)))
);
}
#[test]
fn decision_with_unknown_replaced_by_errors() {
let (dir, linter) = workspace();
let path = dir.path().join(".yoi/memory/decisions/foo.md");
let content = format!(
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: replaced\nreplaced_by: ghost\n---\nbody\n",
now = iso_now()
);
let report = linter.lint(&path, &content, WriteMode::Create);
assert!(
report
.errors
.iter()
.any(|e| matches!(e, LintError::UnknownReference { .. }))
);
}
#[test]
fn decision_replaced_by_self_errors() {
let (dir, linter) = workspace();
let path = dir.path().join(".yoi/memory/decisions/foo.md");
let content = format!(
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: replaced\nreplaced_by: foo\n---\nbody\n",
now = iso_now()
);
let report = linter.lint(&path, &content, WriteMode::Update);
assert!(
report
.errors
.iter()
.any(|e| matches!(e, LintError::ReplacedBySelf))
);
}
#[test]
fn decision_replaced_by_existing_ok() {
let (dir, linter) = workspace();
// Pre-create the target.
let target = dir.path().join(".yoi/memory/decisions/bar.md");
write(
&target,
&format!(
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: open\n---\nbar body\n",
now = iso_now()
),
);
let path = dir.path().join(".yoi/memory/decisions/foo.md");
let content = format!(
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: replaced\nreplaced_by: bar\n---\nbody\n",
now = iso_now()
);
let report = linter.lint(&path, &content, WriteMode::Create);
assert!(!report.has_errors(), "got errors: {:?}", report.errors);
}
#[test]
fn missing_required_field_errors() {
let (dir, linter) = workspace();
let path = dir.path().join(".yoi/memory/decisions/foo.md");
// Missing `status`.
let content = format!(
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\n---\nbody\n",
now = iso_now()
);
let report = linter.lint(&path, &content, WriteMode::Create);
assert!(report.errors.iter().any(|e| matches!(
e,
LintError::MissingField(_)
| LintError::Record(RecordLintError::MalformedFrontmatter(_))
)));
}
#[test]
fn summary_path_accepted() {
let (dir, linter) = workspace();
let path = dir.path().join(".yoi/memory/summary.md");
let content = format!(
"---\nupdated_at: {now}\n---\nsummary body\n",
now = iso_now()
);
let report = linter.lint(&path, &content, WriteMode::Update);
assert!(!report.has_errors(), "got errors: {:?}", report.errors);
}
#[test]
fn create_when_existing_errors() {
let (dir, linter) = workspace();
let path = dir.path().join(".yoi/memory/decisions/foo.md");
write(
&path,
&format!(
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: open\n---\nold\n",
now = iso_now()
),
);
let content = format!(
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: open\n---\nnew\n",
now = iso_now()
);
let report = linter.lint(&path, &content, WriteMode::Create);
assert!(
report
.errors
.iter()
.any(|e| matches!(e, LintError::SlugAlreadyExists(_)))
);
}
#[test]
fn similar_slugs_warns_on_cluster() {
let (dir, linter) = workspace();
// Two existing decisions within Levenshtein 2 of `db-pool`:
// `db-pol` (1 deletion), `db-pools` (1 insertion).
for slug in ["db-pol", "db-pools"] {
write(
&dir.path().join(format!(".yoi/memory/decisions/{slug}.md")),
&format!(
"---\ncreated_at: {n}\nupdated_at: {n}\nsources: []\nstatus: open\n---\n",
n = iso_now()
),
);
}
let path = dir.path().join(".yoi/memory/decisions/db-pool.md");
let content = format!(
"---\ncreated_at: {n}\nupdated_at: {n}\nsources: []\nstatus: open\n---\nbody\n",
n = iso_now()
);
let report = linter.lint(&path, &content, WriteMode::Create);
let warned = report
.warnings
.iter()
.any(|w| matches!(w, LintWarning::SimilarSlugs(slugs) if slugs.len() >= 3));
assert!(
warned,
"expected SimilarSlugs warning, got {:?}",
report.warnings
);
}
#[test]
fn similar_slugs_silent_when_distant() {
let (dir, linter) = workspace();
for slug in ["alpha", "bravo"] {
write(
&dir.path().join(format!(".yoi/memory/decisions/{slug}.md")),
&format!(
"---\ncreated_at: {n}\nupdated_at: {n}\nsources: []\nstatus: open\n---\n",
n = iso_now()
),
);
}
let path = dir.path().join(".yoi/memory/decisions/charlie.md");
let content = format!(
"---\ncreated_at: {n}\nupdated_at: {n}\nsources: []\nstatus: open\n---\n",
n = iso_now()
);
let report = linter.lint(&path, &content, WriteMode::Create);
assert!(
!report
.warnings
.iter()
.any(|w| matches!(w, LintWarning::SimilarSlugs(_))),
"unexpected SimilarSlugs warning: {:?}",
report.warnings
);
}
#[test]
fn body_size_limit_errors() {
let (dir, linter) = workspace();
let path = dir.path().join(".yoi/memory/decisions/foo.md");
let big_body = "x".repeat(8001);
let content = format!(
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: open\n---\n{body}",
now = iso_now(),
body = big_body
);
let report = linter.lint(&path, &content, WriteMode::Create);
assert!(
report
.errors
.iter()
.any(|e| matches!(e, LintError::BodyTooLong { .. }))
);
// Sanity: ensure path was treated as PathBuf consistently.
let _ = PathBuf::from(path);
}
}
-67
View File
@@ -1,67 +0,0 @@
//! Reference-integrity checks: `replaced_by` existence + cycle detection.
use std::collections::HashSet;
use crate::Slug;
use crate::error::LintError;
use crate::linter::ExistingRecords;
use crate::linter::LintReport;
use crate::workspace::RecordKind;
/// Validate a Decision's `replaced_by` against the existing record set.
///
/// `self_slug` is the slug of the record currently being written (None
/// only when the path was malformed and we shouldn't even reach here).
pub fn check_replaced_by(
self_slug: Option<&Slug>,
target: &Slug,
existing: &ExistingRecords,
report: &mut LintReport,
) {
// Existence: target must already be a Decision on disk.
if !existing.contains(RecordKind::Decision, target) {
report.push_error(LintError::UnknownReference {
field: "replaced_by",
kind: "decision",
slug: target.to_string(),
});
return;
}
// Cycle: walk the chain target → target.replaced_by → ... and
// ensure we never revisit `self_slug` or any node twice.
let mut visited = HashSet::new();
if let Some(s) = self_slug {
visited.insert(s.clone());
}
let mut cursor = Some(target.clone());
let mut chain: Vec<String> = Vec::new();
while let Some(node) = cursor {
if !visited.insert(node.clone()) {
chain.push(node.to_string());
report.push_error(LintError::ReplacedByCycle {
chain: chain.join(" -> "),
});
return;
}
chain.push(node.to_string());
cursor = existing.decision(&node).and_then(|m| m.replaced_by.clone());
}
}
#[cfg(test)]
mod tests {
use super::*;
// Smoke test: cycle detection terminates on a 2-node loop where the
// existing tree already contains A↔B and the new write would close
// the loop. A direct unit test against `check_replaced_by` is
// exercised by linter::tests; here we just guard the loop bound.
#[test]
fn empty_chain_terminates() {
let mut report = LintReport::default();
let existing = ExistingRecords::default();
let target = Slug::parse("foo").unwrap();
check_replaced_by(None, &target, &existing, &mut report);
assert_eq!(report.errors.len(), 1);
}
}
-15
View File
@@ -1,15 +0,0 @@
//! Body size limit checks.
use crate::error::LintError;
use crate::linter::LintReport;
use crate::schema::Frontmatter;
pub fn check_body<F: Frontmatter>(body: &str, report: &mut LintReport) {
let chars = body.chars().count();
if chars > F::BODY_LIMIT {
report.push_error(LintError::BodyTooLong {
actual: chars,
limit: F::BODY_LIMIT,
});
}
}
-106
View File
@@ -1,106 +0,0 @@
//! Soft warnings: low-importance large records, sources accumulation.
//!
//! Similar-slug warnings need the existing record set and are
//! integrated into the main linter pass when implemented; this file
//! covers per-write checks that only need the proposed content.
use crate::Slug;
use crate::error::LintWarning;
use crate::linter::LintReport;
use crate::linter::existing::ExistingRecords;
use crate::workspace::{ClassifiedPath, RecordKind};
const LARGE_BODY_THRESHOLD: usize = 1500;
const SOURCES_OVERFLOW_THRESHOLD: usize = 10;
const SIMILAR_SLUG_DISTANCE: usize = 2;
/// Cluster size (including the new slug) at which the similar-slug
/// warning fires. 3 follows `docs/plan/memory.md` §Linter (`類似 slug
/// 乱立`) — two existing close neighbours plus the new write.
const SIMILAR_SLUG_CLUSTER_MIN: usize = 3;
/// For kinds that don't carry a `sources` array (Summary), emit only
/// the body-size warning.
pub fn check_warnings_kindless(_cp: &ClassifiedPath, body: &str, _report: &mut LintReport) {
let _ = body;
// Summary intentionally has no warning band — the per-record
// size:importance heuristic doesn't apply to a single rolling file.
}
/// For kinds with `sources`, consult
/// both the body length and the sources count.
pub fn check_warnings_with_sources(body: &str, source_count: usize, report: &mut LintReport) {
let chars = body.chars().count();
if source_count <= 1 && chars >= LARGE_BODY_THRESHOLD {
report.push_warning(LintWarning::LowImportanceLargeRecord { chars });
}
if source_count > SOURCES_OVERFLOW_THRESHOLD {
report.push_warning(LintWarning::SourcesOverflow {
count: source_count,
});
}
}
/// Emit a `SimilarSlugs` warning when the proposed slug joins a cluster
/// of `SIMILAR_SLUG_CLUSTER_MIN` or more slugs in the same kind that
/// are pairwise within `SIMILAR_SLUG_DISTANCE` Levenshtein steps of the
/// new one. The reported list includes the new slug, sorted to keep
/// the warning text deterministic.
pub fn check_similar_slugs(
new_slug: &Slug,
kind: RecordKind,
existing: &ExistingRecords,
report: &mut LintReport,
) {
let mut neighbours: Vec<String> = existing
.slugs(kind)
.into_iter()
.filter(|s| *s != new_slug)
.filter(|s| levenshtein(new_slug.as_str(), s.as_str()) <= SIMILAR_SLUG_DISTANCE)
.map(|s| s.to_string())
.collect();
if neighbours.len() + 1 < SIMILAR_SLUG_CLUSTER_MIN {
return;
}
neighbours.push(new_slug.to_string());
neighbours.sort();
report.push_warning(LintWarning::SimilarSlugs(neighbours));
}
/// Iterative two-row Levenshtein distance over chars.
fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
if a.is_empty() {
return b.len();
}
if b.is_empty() {
return a.len();
}
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut curr: Vec<usize> = vec![0; b.len() + 1];
for (i, ca) in a.iter().enumerate() {
curr[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let cost = if ca == cb { 0 } else { 1 };
curr[j + 1] = (curr[j] + 1).min(prev[j + 1] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[b.len()]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn levenshtein_basics() {
assert_eq!(levenshtein("", ""), 0);
assert_eq!(levenshtein("a", ""), 1);
assert_eq!(levenshtein("", "ab"), 2);
assert_eq!(levenshtein("kitten", "sitting"), 3);
assert_eq!(levenshtein("foo", "foo"), 0);
assert_eq!(levenshtein("abc", "abd"), 1);
assert_eq!(levenshtein("abcd", "acbd"), 2);
}
}
-91
View File
@@ -1,91 +0,0 @@
//! Workspace memory resident-enumeration helpers.
//!
//! Surfaces used by the Worker system-prompt assembler:
//!
//! - [`collect_resident_summary`] — the body of
//! `<workspace>/.yoi/memory/summary.md` when it parses as a summary
//! record and has non-empty body.
//!
//! Files that fail to read or parse are skipped silently — the Linter
//! enforces shape on write, so a malformed file here means external
//! tampering and we'd rather degrade than panic.
use crate::schema::{SummaryFrontmatter, split_frontmatter};
use crate::workspace::WorkspaceLayout;
/// Read `<workspace>/.yoi/memory/summary.md` for resident prompt
/// injection. Returns only the markdown body (frontmatter stripped), and
/// degrades to `None` for missing, unreadable, malformed, or empty records.
pub fn collect_resident_summary(layout: &WorkspaceLayout) -> Option<String> {
let raw = std::fs::read_to_string(layout.summary_path()).ok()?;
let (yaml, body) = split_frontmatter(&raw).ok()?;
let _fm: SummaryFrontmatter = serde_yaml::from_str(yaml).ok()?;
let body = body.trim_matches(&['\n', '\r'][..]);
if body.trim().is_empty() {
None
} else {
Some(body.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use std::path::Path;
use tempfile::TempDir;
fn now() -> String {
Utc::now().to_rfc3339()
}
fn write_summary(dir: &Path, body: &str) {
let path = dir.join(".yoi/memory/summary.md");
let content = format!("---\nupdated_at: {n}\n---\n{body}", n = now());
std::fs::write(path, content).unwrap();
}
fn setup() -> (TempDir, WorkspaceLayout) {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".yoi/memory")).unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
(dir, layout)
}
#[test]
fn missing_summary_returns_none() {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
assert!(collect_resident_summary(&layout).is_none());
}
#[test]
fn summary_returns_body_without_frontmatter() {
let (dir, layout) = setup();
write_summary(dir.path(), "remember this\n");
let got = collect_resident_summary(&layout).unwrap();
assert_eq!(got, "remember this");
assert!(!got.contains("updated_at"));
assert!(!got.contains("---"));
}
#[test]
fn malformed_summary_returns_none() {
let (dir, layout) = setup();
std::fs::write(
dir.path().join(".yoi/memory/summary.md"),
"---\nthis is not yaml: : :\n---\nbody\n",
)
.unwrap();
assert!(collect_resident_summary(&layout).is_none());
}
#[test]
fn empty_summary_body_returns_none() {
let (dir, layout) = setup();
write_summary(dir.path(), " \n");
assert!(collect_resident_summary(&layout).is_none());
}
}
+5 -13
View File
@@ -1,10 +1,9 @@
//! Common frontmatter helpers and shared types.
use lint_common::RecordLintError;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::error::LintError;
pub use lint_common::Frontmatter;
/// Reference to a session-store entry range. Stored in `sources` /
@@ -138,14 +137,13 @@ pub struct SourceEvidenceRef {
}
/// Split a markdown document into `(yaml_frontmatter, body)`.
pub fn split_frontmatter(content: &str) -> Result<(&str, &str), LintError> {
lint_common::split_frontmatter(content).map_err(Into::into)
pub fn split_frontmatter(content: &str) -> Result<(&str, &str), RecordLintError> {
lint_common::split_frontmatter(content)
}
#[cfg(test)]
mod tests {
use super::*;
use lint_common::RecordLintError;
#[test]
fn splits_simple() {
@@ -158,19 +156,13 @@ mod tests {
#[test]
fn no_leading_delim_errors() {
let err = split_frontmatter("hello").unwrap_err();
assert!(matches!(
err,
LintError::Record(RecordLintError::MissingFrontmatter)
));
assert!(matches!(err, RecordLintError::MissingFrontmatter));
}
#[test]
fn no_closing_delim_errors() {
let err = split_frontmatter("---\nfoo: 1\nno close\n").unwrap_err();
assert!(matches!(
err,
LintError::Record(RecordLintError::MalformedFrontmatter(_))
));
assert!(matches!(err, RecordLintError::MalformedFrontmatter(_)));
}
#[test]
+3 -5
View File
@@ -1,9 +1,7 @@
//! Frontmatter schemas for memory records.
//! Typed frontmatter schemas for Memory documents.
//!
//! Each record kind has its own typed `*Frontmatter` struct. The linter
//! deserializes the YAML between the leading `---` markers into the
//! kind-appropriate struct; field-level errors are surfaced as
//! [`LintError::MissingField`] / [`LintError::InvalidField`].
//! These structures and pure parsing helpers are shared by Workspace-backed
//! validation. They do not select or traverse a repository-local Memory tree.
mod common;
mod decision;
-44
View File
@@ -1,44 +0,0 @@
//! Helpers for constructing `ScopeRule` entries that exclude the
//! memory tree from the generic CRUD tools' write surface.
//!
//! Worker is expected to call [`deny_write_rules`] when memory is enabled
//! and append the result to the manifest's `scope.deny` list before
//! constructing the [`Scope`] passed to the local WorkdirSession provider. The
//! memory tools themselves bypass generic WorkdirSession filesystem operations and
//! write directly under the workspace root, so this deny does not affect them.
use std::path::Path;
use manifest::{Permission, ScopeRule};
use crate::workspace::WorkspaceLayout;
/// Build a deny rule that strips Write permission from `<workspace>/.yoi/memory/`.
pub fn deny_write_rules(layout: &WorkspaceLayout) -> Vec<ScopeRule> {
vec![deny_write(layout.memory_dir().as_path())]
}
fn deny_write(target: &Path) -> ScopeRule {
ScopeRule {
target: target.to_path_buf(),
permission: Permission::Write,
recursive: true,
symlink_policy: Default::default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn deny_targets_memory() {
let layout = WorkspaceLayout::new(PathBuf::from("/ws"));
let rules = deny_write_rules(&layout);
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].target, PathBuf::from("/ws/.yoi/memory"));
assert_eq!(rules[0].permission, Permission::Write);
assert!(rules[0].recursive);
}
}
-371
View File
@@ -1,371 +0,0 @@
//! Workspace-local usage event log for memory records.
//!
//! The log is append-only JSONL under the workspace's `.yoi/` tree. It is
//! intentionally evidence-only: aggregation reports explicit context reads and
//! resident exposure cost telemetry, but it does not classify records as
//! tidy-protected records.
use std::collections::{BTreeMap, HashMap};
use std::fs::{self, OpenOptions};
use std::io::{self, BufRead, Write};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::workspace::{RecordKind, WorkspaceLayout};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UsageEventKind {
Use,
ResidentExposure,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum UsageSource {
MemoryRead,
ResidentInjection,
}
impl UsageSource {
pub fn as_str(self) -> &'static str {
match self {
Self::MemoryRead => "MemoryRead",
Self::ResidentInjection => "ResidentInjection",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageRecordSnapshot {
pub kind: String,
pub slug: String,
pub file_bytes: u64,
pub file_tokens_estimate: u64,
}
impl UsageRecordSnapshot {
pub fn from_bytes(kind: RecordKind, slug: impl Into<String>, bytes: &[u8]) -> Self {
Self {
kind: kind.as_str().to_string(),
slug: slug.into(),
file_bytes: bytes.len() as u64,
file_tokens_estimate: estimate_tokens(bytes.len()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageEvent {
pub id: Uuid,
pub occurred_at: DateTime<Utc>,
pub segment_id: String,
pub event: UsageEventKind,
pub source: UsageSource,
pub records: Vec<UsageRecordSnapshot>,
}
impl UsageEvent {
pub fn new(
segment_id: impl Into<String>,
event: UsageEventKind,
source: UsageSource,
records: Vec<UsageRecordSnapshot>,
) -> Self {
Self {
id: Uuid::now_v7(),
occurred_at: Utc::now(),
segment_id: segment_id.into(),
event,
source,
records,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageReport {
pub records: Vec<UsageReportRecord>,
}
impl UsageReport {
pub fn empty() -> Self {
Self {
records: Vec::new(),
}
}
pub fn is_empty(&self) -> bool {
self.records.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageReportRecord {
pub kind: String,
pub slug: String,
pub use_count: u64,
pub last_used_at: Option<DateTime<Utc>>,
pub source_breakdown: BTreeMap<String, u64>,
pub resident_exposure_count: u64,
pub estimated_tokens_per_injection: u64,
pub estimated_total_resident_exposure_tokens: u64,
}
#[derive(Debug, Default)]
struct Accumulator {
kind: String,
slug: String,
use_count: u64,
last_used_at: Option<DateTime<Utc>>,
source_breakdown: BTreeMap<String, u64>,
resident_exposure_count: u64,
last_resident_tokens: u64,
}
/// Append one usage event to the workspace-local JSONL log.
pub fn append_usage_event(layout: &WorkspaceLayout, event: &UsageEvent) -> io::Result<()> {
let path = layout.usage_events_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let line = serde_json::to_string(event)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
let mut file = OpenOptions::new().create(true).append(true).open(path)?;
writeln!(file, "{line}")?;
Ok(())
}
/// Convenience for a successful explicit record read.
pub fn append_use_event(
layout: &WorkspaceLayout,
segment_id: impl Into<String>,
source: UsageSource,
records: Vec<UsageRecordSnapshot>,
) -> io::Result<()> {
if records.is_empty() {
return Ok(());
}
append_usage_event(
layout,
&UsageEvent::new(segment_id, UsageEventKind::Use, source, records),
)
}
/// Convenience for resident model-invocation exposure cost telemetry.
pub fn append_resident_exposure_event(
layout: &WorkspaceLayout,
segment_id: impl Into<String>,
records: Vec<UsageRecordSnapshot>,
) -> io::Result<()> {
if records.is_empty() {
return Ok(());
}
append_usage_event(
layout,
&UsageEvent::new(
segment_id,
UsageEventKind::ResidentExposure,
UsageSource::ResidentInjection,
records,
),
)
}
/// Read a record from the workspace and build the snapshot stored in usage
/// events. `slug` should be `"summary"` for [`RecordKind::Summary`].
pub fn snapshot_record_from_layout(
layout: &WorkspaceLayout,
kind: RecordKind,
slug: &str,
) -> io::Result<UsageRecordSnapshot> {
let path = record_path(layout, kind, slug)?;
let bytes = fs::read(path)?;
Ok(UsageRecordSnapshot::from_bytes(
kind,
slug.to_string(),
&bytes,
))
}
pub fn snapshot_record_from_bytes(
kind: RecordKind,
slug: impl Into<String>,
bytes: &[u8],
) -> UsageRecordSnapshot {
UsageRecordSnapshot::from_bytes(kind, slug, bytes)
}
fn record_path(
layout: &WorkspaceLayout,
kind: RecordKind,
slug: &str,
) -> io::Result<std::path::PathBuf> {
match kind {
RecordKind::Summary => Ok(layout.summary_path()),
RecordKind::Decision => {
let slug = crate::Slug::parse(slug.to_string()).map_err(invalid_slug_error)?;
Ok(layout.decision_path(&slug))
}
RecordKind::Request => {
let slug = crate::Slug::parse(slug.to_string()).map_err(invalid_slug_error)?;
Ok(layout.request_path(&slug))
}
}
}
fn invalid_slug_error(err: lint_common::RecordLintError) -> io::Error {
io::Error::new(io::ErrorKind::InvalidInput, err)
}
/// Aggregate the append-only usage log into per-record evidence.
pub fn build_usage_report(layout: &WorkspaceLayout) -> io::Result<UsageReport> {
let path = layout.usage_events_path();
if !path.exists() {
return Ok(UsageReport::empty());
}
let file = fs::File::open(path)?;
let reader = io::BufReader::new(file);
let mut acc: HashMap<(String, String), Accumulator> = HashMap::new();
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let event: UsageEvent = match serde_json::from_str(&line) {
Ok(event) => event,
Err(err) => {
tracing::warn!(error = %err, "Skipping malformed memory usage event log line");
continue;
}
};
for record in event.records {
let key = (record.kind.clone(), record.slug.clone());
let entry = acc.entry(key).or_insert_with(|| Accumulator {
kind: record.kind.clone(),
slug: record.slug.clone(),
..Accumulator::default()
});
match event.event {
UsageEventKind::Use => {
entry.use_count += 1;
let source = event.source.as_str().to_string();
*entry.source_breakdown.entry(source).or_insert(0) += 1;
entry.last_used_at = Some(
entry
.last_used_at
.map(|prev| prev.max(event.occurred_at))
.unwrap_or(event.occurred_at),
);
}
UsageEventKind::ResidentExposure => {
entry.resident_exposure_count += 1;
entry.last_resident_tokens = record.file_tokens_estimate;
}
}
}
}
let mut records: Vec<UsageReportRecord> = acc
.into_values()
.map(|a| UsageReportRecord {
kind: a.kind,
slug: a.slug,
use_count: a.use_count,
last_used_at: a.last_used_at,
source_breakdown: a.source_breakdown,
resident_exposure_count: a.resident_exposure_count,
estimated_tokens_per_injection: a.last_resident_tokens,
estimated_total_resident_exposure_tokens: a
.last_resident_tokens
.saturating_mul(a.resident_exposure_count),
})
.collect();
records.sort_by(|a, b| (&a.kind, &a.slug).cmp(&(&b.kind, &b.slug)));
Ok(UsageReport { records })
}
fn estimate_tokens(bytes: usize) -> u64 {
(bytes as u64).div_ceil(4)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn setup() -> (TempDir, WorkspaceLayout) {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
(dir, layout)
}
#[test]
fn aggregates_use_and_resident_exposure_separately() {
let (_dir, layout) = setup();
let decision = snapshot_record_from_bytes(RecordKind::Decision, "alpha", b"abcd");
let request = snapshot_record_from_bytes(RecordKind::Request, "policy", b"abcdefgh");
append_use_event(
&layout,
"session-a",
UsageSource::MemoryRead,
vec![decision.clone()],
)
.unwrap();
append_use_event(
&layout,
"session-a",
UsageSource::MemoryRead,
vec![request.clone()],
)
.unwrap();
append_use_event(
&layout,
"session-b",
UsageSource::MemoryRead,
vec![request.clone()],
)
.unwrap();
append_resident_exposure_event(&layout, "session-b", vec![request]).unwrap();
let report = build_usage_report(&layout).unwrap();
let decision = report
.records
.iter()
.find(|r| r.kind == "decision" && r.slug == "alpha")
.unwrap();
assert_eq!(decision.use_count, 1);
assert_eq!(decision.source_breakdown["MemoryRead"], 1);
assert_eq!(decision.resident_exposure_count, 0);
assert!(decision.last_used_at.is_some());
let request = report
.records
.iter()
.find(|r| r.kind == "request" && r.slug == "policy")
.unwrap();
assert_eq!(request.use_count, 2);
assert_eq!(request.source_breakdown["MemoryRead"], 2);
assert_eq!(request.resident_exposure_count, 1);
assert_eq!(request.estimated_tokens_per_injection, 2);
assert_eq!(request.estimated_total_resident_exposure_tokens, 2);
}
#[test]
fn resident_only_record_does_not_increment_use_count() {
let (_dir, layout) = setup();
let snapshot = snapshot_record_from_bytes(RecordKind::Decision, "policy", b"abcdefgh");
append_resident_exposure_event(&layout, "session", vec![snapshot]).unwrap();
let report = build_usage_report(&layout).unwrap();
let record = &report.records[0];
assert_eq!(record.use_count, 0);
assert!(record.last_used_at.is_none());
assert!(record.source_breakdown.is_empty());
assert_eq!(record.resident_exposure_count, 1);
}
}
-363
View File
@@ -1,363 +0,0 @@
//! Workspace-level path layout for the memory subsystem.
//!
//! `WorkspaceLayout` carries the root used by the memory subsystem.
//! All yoi-managed memory content lives under the conventional
//! `<root>/.yoi/` subdirectory — alongside workspace project records
//! generated durable memory. The trees inside it:
//!
//! - `<root>/.yoi/memory/summary.md`
//! - `<root>/.yoi/memory/decisions/<slug>.md`
//! - `<root>/.yoi/memory/requests/<slug>.md`
//! - `<root>/.yoi/memory/_staging/<id>.json`
//! - `<root>/.yoi/memory/_logs/current.log` (append-only audit log)
//!
//! `memory/` is reserved for session-derived / generated state;
//! `memory.workspace_root` pins this root explicitly. Without an explicit
//! root, resolution searches upward from the Worker pwd for a `.yoi/memory`
//! marker; `.yoi` project records alone are not a memory marker.
use std::path::{Path, PathBuf};
use crate::Slug;
use crate::error::LintError;
#[cfg(test)]
use lint_common::RecordLintError;
const YOI_DIR: &str = ".yoi";
const MEMORY_DIR: &str = "memory";
const SUMMARY_FILE: &str = "summary.md";
const DECISIONS_DIR: &str = "decisions";
const REQUESTS_DIR: &str = "requests";
const STAGING_DIR: &str = "_staging";
const USAGE_DIR: &str = "_usage";
const LOGS_DIR: &str = "_logs";
const USAGE_EVENTS_FILE: &str = "events.jsonl";
const AUDIT_CURRENT_LOG_FILE: &str = "current.log";
/// What kind of record a path under the memory tree represents.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecordKind {
Summary,
Decision,
Request,
}
impl RecordKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Summary => "summary",
Self::Decision => "decision",
Self::Request => "request",
}
}
}
/// A path classified into a kind and (where applicable) a slug.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClassifiedPath {
pub kind: RecordKind,
pub slug: Option<Slug>,
}
/// Workspace-rooted layout. Cheap to clone.
#[derive(Debug, Clone)]
pub struct WorkspaceLayout {
root: PathBuf,
}
impl WorkspaceLayout {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
/// Resolve a layout from the nearest Memory marker.
///
/// Resolution searches `default_root` and its ancestors for the nearest
/// `.yoi/memory` directory. This legacy local-storage helper owns its path
/// policy directly; resolved Worker Manifests do not carry storage paths.
pub fn resolve(default_root: &Path) -> Self {
let root =
find_memory_marker_root(default_root).unwrap_or_else(|| default_root.to_path_buf());
Self::new(root)
}
pub fn root(&self) -> &Path {
&self.root
}
/// `<root>/.yoi/`. The base of every other memory path.
pub fn yoi_dir(&self) -> PathBuf {
self.root.join(YOI_DIR)
}
pub fn memory_dir(&self) -> PathBuf {
self.yoi_dir().join(MEMORY_DIR)
}
pub fn summary_path(&self) -> PathBuf {
self.memory_dir().join(SUMMARY_FILE)
}
pub fn decisions_dir(&self) -> PathBuf {
self.memory_dir().join(DECISIONS_DIR)
}
pub fn requests_dir(&self) -> PathBuf {
self.memory_dir().join(REQUESTS_DIR)
}
pub fn staging_dir(&self) -> PathBuf {
self.memory_dir().join(STAGING_DIR)
}
pub fn usage_dir(&self) -> PathBuf {
self.memory_dir().join(USAGE_DIR)
}
pub fn usage_events_path(&self) -> PathBuf {
self.usage_dir().join(USAGE_EVENTS_FILE)
}
pub fn audit_logs_dir(&self) -> PathBuf {
self.memory_dir().join(LOGS_DIR)
}
/// Tail-friendly latest memory audit log path.
///
/// Operators can inspect live memory worker and tool events with:
/// `tail -f .yoi/memory/_logs/current.log`.
pub fn audit_current_log_path(&self) -> PathBuf {
self.audit_logs_dir().join(AUDIT_CURRENT_LOG_FILE)
}
pub fn decision_path(&self, slug: &Slug) -> PathBuf {
self.decisions_dir().join(format!("{slug}.md"))
}
pub fn request_path(&self, slug: &Slug) -> PathBuf {
self.requests_dir().join(format!("{slug}.md"))
}
/// Classify a path under the memory tree. Returns `None` if the
/// path is not under `.yoi/memory/`
/// of this workspace, or if it lives in
/// `_staging/` / `_usage/` / `_logs/` (opaque subsystem-owned trees).
///
/// On a conventional path that's *almost* a record but malformed
/// (e.g. `.yoi/memory/decisions/Foo.md` with an invalid slug),
/// returns `Err(LintError::Record(InvalidSlug) | InvalidPath)` so the caller
/// can surface it as a write violation.
pub fn classify(&self, path: &Path) -> Result<Option<ClassifiedPath>, LintError> {
let memory = self.memory_dir();
let rel = match path.strip_prefix(&memory) {
Ok(r) => r,
Err(_) => return Ok(None),
};
let mut comps = rel.components();
let first = match comps.next() {
Some(c) => c.as_os_str(),
None => return Err(LintError::InvalidPath(path.to_path_buf())),
};
if first == SUMMARY_FILE {
if comps.next().is_some() {
return Err(LintError::InvalidPath(path.to_path_buf()));
}
return Ok(Some(ClassifiedPath {
kind: RecordKind::Summary,
slug: None,
}));
}
if first == STAGING_DIR || first == USAGE_DIR || first == LOGS_DIR {
// Linter opts out of subsystem-owned opaque trees.
return Ok(None);
}
let kind = if first == DECISIONS_DIR {
RecordKind::Decision
} else if first == REQUESTS_DIR {
RecordKind::Request
} else {
return Err(LintError::InvalidPath(path.to_path_buf()));
};
let rest: PathBuf = comps.collect();
let cp = classify_kinded_md(&rest, kind, path)?;
Ok(Some(cp))
}
}
fn find_memory_marker_root(default_root: &Path) -> Option<PathBuf> {
default_root
.ancestors()
.find(|ancestor| ancestor.join(YOI_DIR).join(MEMORY_DIR).is_dir())
.map(Path::to_path_buf)
}
fn classify_kinded_md(
rel: &Path,
kind: RecordKind,
full_path: &Path,
) -> Result<ClassifiedPath, LintError> {
let mut comps = rel.components();
let first = match comps.next() {
Some(c) => c,
None => return Err(LintError::InvalidPath(full_path.to_path_buf())),
};
if comps.next().is_some() {
// Subdirectories under the record kind aren't allowed.
return Err(LintError::InvalidPath(full_path.to_path_buf()));
}
let name = first.as_os_str();
let s = name
.to_str()
.ok_or_else(|| LintError::InvalidPath(full_path.to_path_buf()))?;
let stem = s
.strip_suffix(".md")
.ok_or_else(|| LintError::InvalidPath(full_path.to_path_buf()))?;
let slug = Slug::parse(stem)?;
Ok(ClassifiedPath {
kind,
slug: Some(slug),
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use tempfile::TempDir;
fn layout() -> WorkspaceLayout {
WorkspaceLayout::new(PathBuf::from("/ws"))
}
#[test]
fn classifies_summary() {
let cp = layout()
.classify(&PathBuf::from("/ws/.yoi/memory/summary.md"))
.unwrap()
.unwrap();
assert_eq!(cp.kind, RecordKind::Summary);
assert!(cp.slug.is_none());
}
#[test]
fn classifies_decision_with_slug() {
let cp = layout()
.classify(&PathBuf::from("/ws/.yoi/memory/decisions/foo-bar.md"))
.unwrap()
.unwrap();
assert_eq!(cp.kind, RecordKind::Decision);
assert_eq!(cp.slug.unwrap().as_str(), "foo-bar");
}
#[test]
fn staging_tree_is_opaque_to_classifier() {
assert!(
layout()
.classify(&PathBuf::from("/ws/.yoi/memory/_staging/abc.json"))
.unwrap()
.is_none()
);
}
#[test]
fn usage_tree_is_opaque_to_classifier() {
let cp = layout()
.classify(&PathBuf::from("/ws/.yoi/memory/_usage/events.jsonl"))
.unwrap();
assert!(cp.is_none());
}
#[test]
fn logs_tree_is_opaque_to_classifier() {
let cp = layout()
.classify(&PathBuf::from("/ws/.yoi/memory/_logs/current.log"))
.unwrap();
assert!(cp.is_none());
}
#[test]
fn outside_returns_none() {
assert!(
layout()
.classify(&PathBuf::from("/elsewhere/file.md"))
.unwrap()
.is_none()
);
assert!(
layout()
.classify(&PathBuf::from("/ws/src/main.rs"))
.unwrap()
.is_none()
);
}
#[test]
fn invalid_slug_rejected() {
let err = layout()
.classify(&PathBuf::from("/ws/.yoi/memory/decisions/Foo.md"))
.unwrap_err();
assert!(matches!(
err,
LintError::Record(RecordLintError::InvalidSlug(_))
));
}
#[test]
fn nested_under_record_dir_rejected() {
let err = layout()
.classify(&PathBuf::from("/ws/.yoi/memory/decisions/sub/foo.md"))
.unwrap_err();
assert!(matches!(err, LintError::InvalidPath(_)));
}
#[test]
fn unknown_top_level_dir_rejected() {
let err = layout()
.classify(&PathBuf::from("/ws/.yoi/memory/something/foo.md"))
.unwrap_err();
assert!(matches!(err, LintError::InvalidPath(_)));
}
#[test]
fn resolve_selects_nearest_ancestor_memory_marker_when_workspace_root_missing() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("workspace");
let child = workspace.join(".worktree/child");
std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap();
std::fs::create_dir_all(&child).unwrap();
let layout = WorkspaceLayout::resolve(&child);
assert_eq!(layout.root(), workspace.as_path());
}
#[test]
fn resolve_ignores_child_project_records_without_memory_marker() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("workspace");
let child = workspace.join(".worktree/child");
std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap();
std::fs::create_dir_all(child.join(".yoi/tickets")).unwrap();
let layout = WorkspaceLayout::resolve(&child);
assert_eq!(layout.root(), workspace.as_path());
}
#[test]
fn yoi_project_records_alone_do_not_define_memory_marker_root() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("workspace");
let child = workspace.join("child");
std::fs::create_dir_all(workspace.join(".yoi/tickets")).unwrap();
std::fs::create_dir_all(&child).unwrap();
assert_eq!(find_memory_marker_root(&child), None);
let layout = WorkspaceLayout::resolve(&child);
assert_eq!(layout.root(), child.as_path());
}
}
-4
View File
@@ -8,17 +8,13 @@ license.workspace = true
project-record = { workspace = true }
async-trait = { workspace = true }
chrono = { version = "0.4", default-features = false, features = ["clock"] }
fs4 = { workspace = true, features = ["sync"] }
agen = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_yaml = "0.9.34"
sha2.workspace = true
rusqlite.workspace = true
thiserror.workspace = true
tempfile.workspace = true
toml = { workspace = true }
[dev-dependencies]
tempfile.workspace = true
File diff suppressed because it is too large Load Diff
+60 -3652
View File
File diff suppressed because it is too large Load Diff
+24 -41
View File
@@ -12,13 +12,12 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::{
AcceptedOrchestrationPlan, LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord,
NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord,
Result as TicketResult, Ticket, TicketBackend, TicketBodyReplacement, TicketDoctorDiagnostic,
TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug,
TicketIntakeSummary, TicketListState, TicketMarkReady, TicketRef, TicketRelation,
TicketRelationKind, TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState,
default_author,
AcceptedOrchestrationPlan, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent,
NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult,
Ticket, TicketBackend, TicketBodyReplacement, TicketDoctorDiagnostic, TicketDoctorReport,
TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary,
TicketListState, TicketMarkReady, TicketRef, TicketRelation, TicketRelationKind,
TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState, default_author,
};
const DEFAULT_LIST_LIMIT: usize = 50;
@@ -119,16 +118,15 @@ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 14] = [
];
const CREATE_DESCRIPTION: &str = "Create a Ticket through the configured typed Ticket backend. \
Inputs mirror the Ticket `item.md` fields; `title` is required, `body` is Markdown, and the \
backend assigns the id and writes the local Ticket file layout under the configured backend root.";
`title` is required, `body` is Markdown, and the backend assigns the id.";
const EDIT_ITEM_DESCRIPTION: &str = "Edit a Ticket item through the configured typed Ticket backend. \
This updates the current item title/body and appends an audited item_edit thread event. Intended for \
This updates the current item title/body and appends an audited item_edit event. Intended for \
User/Companion authoring surfaces, not Orchestrator implementation control.";
const LIST_DESCRIPTION: &str = "Query Tickets from the configured typed Ticket backend as a bounded \
overview. The local backend supports workflow-state selection; Workspace-backed Workers replace this \
definition with the richer authoritative text/event/evidence/relation/Objective/time/attention query.";
overview. Workspace-backed Workers replace this definition with the richer authoritative \
text/event/evidence/relation/Objective/time/attention query.";
const SHOW_DESCRIPTION: &str = "Show one Ticket by id or exact query through the configured typed \
Ticket backend. Output includes bounded Markdown body, recent thread events, resolution, and artifact \
Ticket backend. Output includes bounded Markdown body, recent events, resolution, and artifact \
metadata; Workspace-backed Workers replace this definition with the richer authoritative evidence projection.";
const COMMENT_DESCRIPTION: &str = "Append a typed Ticket comment event. `body` is Markdown.";
const PLAN_DESCRIPTION: &str = "Append a typed Ticket plan event. `body` is Markdown.";
@@ -149,8 +147,7 @@ Ticket backend with a bounded `state_changed` event. Treat `queued -> inprogress
as the implementation acceptance step: implementation side effects should happen only after that \
transition is accepted and recorded. Orchestrator may return `ready` or `queued` Tickets to `planning` only with a concrete missing decision/information reason.";
const CLOSE_DESCRIPTION: &str = "Close a Ticket with a Markdown resolution through the typed Ticket \
backend. The backend sets `state: closed`, writes resolution.md, updates item.md, and appends \
a close event.";
backend. The backend sets the Ticket state to closed and appends a close event.";
const RELATION_RECORD_DESCRIPTION: &str = "Record a forward typed Ticket-to-Ticket relation as durable \
project-level metadata. Supported kinds are depends_on, blocks, related, supersedes, and duplicate_of; \
inverse views are derived, not stored.";
@@ -215,7 +212,7 @@ pub fn ticket_tool_description(name: &str, record_language: Option<&str>) -> Str
/// Backend object used by the LLM-facing Ticket tools.
///
/// Tool execution is intentionally parameterized by this wrapper rather than by
/// `LocalTicketBackend` so Worker hosts can supply an API-backed implementation
/// `TicketBackend` so Worker hosts can supply an API-backed implementation
/// without changing the model-visible tool surface.
#[derive(Clone)]
pub struct TicketToolBackend {
@@ -247,8 +244,8 @@ impl TicketToolBackend {
}
}
impl From<LocalTicketBackend> for TicketToolBackend {
fn from(backend: LocalTicketBackend) -> Self {
impl From<crate::SqliteTicketBackend> for TicketToolBackend {
fn from(backend: crate::SqliteTicketBackend) -> Self {
let record_language = backend.record_language().map(str::to_string);
Self::new(backend).with_record_language(record_language.as_deref())
}
@@ -1950,9 +1947,8 @@ mod tests {
}
}
fn backend(temp: &TempDir) -> LocalTicketBackend {
LocalTicketBackend::new(temp.path().join("tickets"))
.with_target_authority(Arc::new(TestTargetAuthority))
fn backend(temp: &TempDir) -> crate::SqliteTicketBackend {
sqlite_backend(temp)
}
fn sqlite_backend(temp: &TempDir) -> crate::SqliteTicketBackend {
@@ -2323,7 +2319,6 @@ mod tests {
let close = tool_by_name(backend.clone(), "TicketClose");
let body_secret = "ITEM_BODY_SECRET_DO_NOT_LIST";
let thread_secret = "THREAD_SECRET_DO_NOT_LIST";
let artifact_secret = "ARTIFACT_SECRET_DO_NOT_LIST";
let resolution_secret = "RESOLUTION_SECRET_DO_NOT_LIST";
let mut ticket = NewTicket::new("Leak Probe");
ticket.body = MarkdownText::new(format!("Item body {body_secret}"));
@@ -2335,15 +2330,6 @@ mod tests {
NewTicketEvent::new(TicketEventKind::Comment, format!("Thread {thread_secret}")),
)
.unwrap();
std::fs::write(
temp.path()
.join("tickets")
.join(&created.id)
.join("artifacts")
.join("secret.txt"),
artifact_secret,
)
.unwrap();
close
.execute(
&json!({
@@ -2364,12 +2350,7 @@ mod tests {
.await
.unwrap();
let listed_content = listed.content.unwrap();
for secret in [
body_secret,
thread_secret,
artifact_secret,
resolution_secret,
] {
for secret in [body_secret, thread_secret, resolution_secret] {
assert!(!listed_content.contains(secret));
}
let listed_json: Value = serde_json::from_str(&listed_content).unwrap();
@@ -2584,7 +2565,7 @@ mod tests {
closed
.events
.iter()
.any(|event| event.kind == TicketEventKind::StateChanged)
.any(|event| event.kind == TicketEventKind::Close)
);
}
@@ -2889,7 +2870,7 @@ mod tests {
.await
.unwrap_err();
assert!(error.to_string().contains("state changed concurrently"));
assert!(matches!(error, ToolError::ExecutionFailed(_)));
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning);
assert!(!record.events.iter().any(|event| {
@@ -3068,7 +3049,7 @@ mod tests {
}
#[tokio::test]
async fn ticket_create_uses_opaque_id_under_backend_root() {
async fn ticket_create_uses_opaque_id_without_repository_local_writes() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let create = tool_by_name(backend.clone(), "TicketCreate");
@@ -3083,7 +3064,9 @@ mod tests {
let id = value["id"].as_str().unwrap();
assert!(!id.contains("escape"));
assert!(!temp.path().join("escape").exists());
assert!(temp.path().join("tickets").join(id).is_dir());
assert!(!temp.path().join(".yoi").exists());
assert!(!temp.path().join("tickets").exists());
assert!(temp.path().join("tickets.db").is_file());
assert_eq!(
backend.list(crate::TicketListQuery::all()).unwrap().len(),
1
+22 -9
View File
@@ -1493,15 +1493,6 @@ pub struct WorkerRuntimeExecutionBackend<F = ProfileRuntimeWorkerFactory> {
spawn_restore_timeout: Duration,
}
impl WorkerRuntimeExecutionBackend<ProfileRuntimeWorkerFactory> {
pub fn from_workspace(workspace_root: impl Into<PathBuf>) -> Result<Self, String> {
let workspace_root = workspace_root.into();
let factory = ProfileRuntimeWorkerFactory::new(&workspace_root)
.with_runtime_store_dir(workspace_root.join(".yoi/runtime-store"));
Self::new(factory)
}
}
impl<F> WorkerRuntimeExecutionBackend<F>
where
F: RuntimeWorkerFactory,
@@ -2957,6 +2948,20 @@ mod tests {
use manifest::{Scope, WorkerManifest};
use session_store::{LogEntry, WorkerMetadataStore};
#[test]
fn production_source_has_no_repository_derived_runtime_store() {
let production = include_str!("worker_backend.rs")
.split_once("#[cfg(test)]\nmod tests")
.map(|(production, _)| production)
.expect("worker backend test module marker");
for forbidden in ["from_workspace", ".yoi/runtime-store"] {
assert!(
!production.contains(forbidden),
"repository-derived Runtime store returned through {forbidden}"
);
}
}
#[test]
fn restored_router_uses_workspace_proxy_for_logical_only_attachment() {
let root = tempfile::tempdir().unwrap();
@@ -4593,6 +4598,10 @@ mod tests {
}];
let detail = runtime.create_worker(request).unwrap();
assert!(
!repo.path().join(".yoi").exists(),
"Worker launch must not create repository-local authority"
);
runtime
.send_input(&detail.worker_ref, WorkerInput::user("inspect tools"))
.unwrap();
@@ -4631,6 +4640,10 @@ mod tests {
runtime.stop_worker(&detail.worker_ref, None).unwrap();
let restored_without_workdir = runtime.restore_worker(&detail.worker_ref).unwrap();
assert!(restored_without_workdir.workdir_attachments.is_empty());
assert!(
!repo.path().join(".yoi").exists(),
"Worker restore must not create repository-local authority"
);
}
#[test]
+3 -8
View File
@@ -1287,15 +1287,10 @@ where
"ticket tools require Backend Workspace API authority",
));
}
let ticket_backend = crate::feature::builtin::ticket::TicketFeatureBackend::WorkspaceClient(
feature_registry.add_module(crate::feature::builtin::ticket::ticket_tools_feature(
workspace_client,
);
feature_registry.add_module(
crate::feature::builtin::ticket::ticket_tools_feature_with_backend(
ticket_backend,
ticket_access,
),
);
ticket_access,
));
}
if feature_config.merge_request.any() {
let workspace_client = worker.workspace_client_handle();
+14
View File
@@ -832,6 +832,20 @@ permission = "write"
);
}
#[test]
fn clean_workspace_manifest_resolution_does_not_create_repository_local_yoi() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("runtime-workspace");
std::fs::create_dir(&workspace).unwrap();
let cli = Cli::try_parse_from(["yoi worker", "--workspace", workspace.to_str().unwrap()])
.unwrap();
let (manifest, _loader) = resolve_manifest(&cli).unwrap();
assert_eq!(manifest.worker.name, "runtime-workspace");
assert!(!workspace.join(".yoi").exists());
}
#[test]
fn normal_startup_uses_builtin_defaults_without_local_profile_discovery() {
let tmp = TempDir::new().unwrap();
+1 -4
View File
@@ -21,10 +21,7 @@ pub mod worker_observation;
pub mod workspace_worker_discovery;
pub use task::{TaskFeature, task_tools_feature};
pub use ticket::{
TicketFeature, TicketFeatureAccess, ticket_tools_feature, ticket_tools_feature_with_access,
ticket_tools_feature_with_backend,
};
pub use ticket::{TicketFeature, TicketFeatureAccess, ticket_tools_feature};
pub use worker_observation::{
CompositeWorkerObservationProvider, WorkerObservationError, WorkerObservationFeature,
WorkerObservationProvider, WorkerObservationSubject, WorkerObservationSubjectRef,
+103 -449
View File
@@ -1,34 +1,28 @@
//! Built-in Ticket feature adapter.
//!
//! The ticket crate owns Ticket domain logic and Tool implementations. This
//! module only resolves the local backend root, declares the built-in feature,
//! and contributes those tools through the normal feature registry path.
//! module binds an authority-scoped Workspace client, declares the built-in
//! feature, and contributes those tools through the normal registry path.
use std::{
path::{Path, PathBuf},
sync::Arc,
};
use std::sync::Arc;
use async_trait::async_trait;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use ticket::{
LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent,
NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult,
Ticket, TicketBackend, TicketBackendOperation, TicketBackendOperationResult,
TicketDoctorReport, TicketError, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery,
TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, TicketStateChange,
TicketSummary, TicketWorkflowState,
config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig},
MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation,
OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult, Ticket, TicketBackend,
TicketBackendOperation, TicketBackendOperationResult, TicketDoctorReport, TicketError,
TicketIdOrSlug, TicketIntakeSummary, TicketListQuery, TicketRef, TicketRelation,
TicketRelationKind, TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState,
tool::{TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description, ticket_tools},
};
use crate::feature::{
FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError,
FeatureInstructionContribution, FeatureInstructionDeclaration, FeatureInstructionId,
FeatureModule, ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration,
ToolDefinition,
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureInstructionContribution,
FeatureInstructionDeclaration, FeatureInstructionId, FeatureModule, ServiceDeclaration,
ServiceId, ToolContribution, ToolDeclaration, ToolDefinition,
};
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
@@ -278,8 +272,8 @@ fn workspace_ticket_read_definition(
const FEATURE_ID: &str = "ticket";
const FEATURE_NAME: &str = "Ticket tools";
const FEATURE_DESCRIPTION: &str = "Typed local Ticket work-item operations over a bounded backend root. \
The tools operate through the ticket crate backend and do not grant generic filesystem write scope.";
const FEATURE_DESCRIPTION: &str =
"Typed Ticket operations through the authoritative Workspace API.";
const TICKET_WORKFLOW_INSTRUCTION_ID: &str = "ticket.workflow";
const TICKET_WORKFLOW_PROMPT_REF: &str = "common.tickets";
pub const TICKET_SERVICE_ID: &str = "ticket.authority";
@@ -302,10 +296,6 @@ fn is_canonical_ticket_resource_key(resource_key: &str) -> bool {
})
}
struct BackendTicketService {
backend: TicketToolBackend,
}
struct WorkspaceTicketService {
backend: WorkspaceHttpTicketBackend,
}
@@ -323,12 +313,6 @@ fn ticket_handoff_from_record(ticket: Ticket) -> Result<TicketHandoff, TicketErr
})
}
impl TicketService for BackendTicketService {
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
ticket_handoff_from_record(self.backend.show(ticket_ref.into())?)
}
}
impl TicketService for WorkspaceTicketService {
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
ticket_handoff_from_record(self.backend.show_unprojected(ticket_ref)?)
@@ -478,98 +462,20 @@ const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[
"TicketOrchestrationPlanQuery",
];
#[derive(Clone, Debug)]
pub enum TicketFeatureBackend {
Local { root: PathBuf },
WorkspaceClient(Arc<dyn WorkspaceClient>),
}
impl From<PathBuf> for TicketFeatureBackend {
fn from(root: PathBuf) -> Self {
Self::Local { root }
}
}
impl From<&Path> for TicketFeatureBackend {
fn from(root: &Path) -> Self {
Self::Local {
root: root.to_path_buf(),
}
}
}
impl From<&PathBuf> for TicketFeatureBackend {
fn from(root: &PathBuf) -> Self {
Self::Local { root: root.clone() }
}
}
#[derive(Clone, Debug)]
pub struct TicketFeature {
backend: TicketFeatureBackend,
record_language: Option<String>,
config_error: Option<String>,
workspace_client: Arc<dyn WorkspaceClient>,
access: TicketFeatureAccess,
}
impl TicketFeature {
pub fn new(backend_root: impl Into<PathBuf>) -> Self {
Self::new_with_access(backend_root, TicketFeatureAccess::workspace_authoring())
}
pub fn new_with_access(backend_root: impl Into<PathBuf>, access: TicketFeatureAccess) -> Self {
Self::with_backend(
TicketFeatureBackend::Local {
root: backend_root.into(),
},
access,
)
}
pub fn with_backend(backend: TicketFeatureBackend, access: TicketFeatureAccess) -> Self {
pub fn new(workspace_client: Arc<dyn WorkspaceClient>, access: TicketFeatureAccess) -> Self {
Self {
backend,
record_language: None,
config_error: None,
workspace_client,
access,
}
}
pub fn for_workspace(workspace: impl AsRef<Path>) -> Self {
Self::for_workspace_with_access(workspace, TicketFeatureAccess::workspace_authoring())
}
pub fn for_workspace_with_access(
workspace: impl AsRef<Path>,
access: TicketFeatureAccess,
) -> Self {
let workspace = workspace.as_ref();
match TicketConfig::load_workspace(workspace) {
Ok(config) => {
let backend_root = config.backend_root().to_path_buf();
let record_language = config.ticket_record_language().map(str::to_string);
let mut feature = Self::new_with_access(backend_root, access);
feature.record_language = record_language;
feature
}
Err(error) => Self {
backend: TicketFeatureBackend::Local {
root: workspace.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH),
},
record_language: None,
config_error: Some(error.to_string()),
access,
},
}
}
pub fn backend_root(&self) -> Option<&Path> {
match &self.backend {
TicketFeatureBackend::Local { root } => Some(root),
TicketFeatureBackend::WorkspaceClient(_) => None,
}
}
pub fn access(&self) -> TicketFeatureAccess {
self.access
}
@@ -578,46 +484,12 @@ impl TicketFeature {
self.access.tool_names()
}
fn usable_backend_root(&self) -> Result<PathBuf, String> {
let Some(root) = self.backend_root() else {
return Err("ticket backend is not local filesystem backed".to_string());
};
let root = root
.canonicalize()
.map_err(|error| format!("ticket backend root is not usable: {error}"))?;
if !root.is_dir() {
return Err("ticket backend root is not a directory".to_string());
}
Ok(root)
fn workspace_client(&self) -> Arc<dyn WorkspaceClient> {
self.workspace_client.clone()
}
fn tool_backend(&self, context: &mut FeatureInstallContext<'_>) -> Option<TicketToolBackend> {
match &self.backend {
TicketFeatureBackend::Local { root: _ } => {
let usable_root = match self.usable_backend_root() {
Ok(root) => root,
Err(reason) => {
context
.diagnostics()
.push(FeatureDiagnostic::warning(format!(
"Ticket tools not registered: {reason}; root={} ",
self.backend_root()
.map(|root| root.display().to_string())
.unwrap_or_else(|| "<non-local>".to_string())
)));
return None;
}
};
Some(
LocalTicketBackend::new(usable_root)
.with_record_language(self.record_language.as_deref())
.into(),
)
}
TicketFeatureBackend::WorkspaceClient(client) => Some(
TicketToolBackend::new(WorkspaceHttpTicketBackend::new(client.clone()))
.with_record_language(self.record_language.as_deref()),
),
}
fn tool_backend(&self) -> TicketToolBackend {
TicketToolBackend::new(WorkspaceHttpTicketBackend::new(self.workspace_client()))
}
}
@@ -635,32 +507,18 @@ impl FeatureModule for TicketFeature {
for name in enabled_tool_names {
descriptor = descriptor.with_tool(ToolDeclaration::new(
name,
ticket_tool_description(name, self.record_language.as_deref()),
ticket_tool_description(name, None),
));
}
descriptor
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
if let Some(error) = &self.config_error {
context
.diagnostics()
.push(FeatureDiagnostic::warning(format!(
"Ticket tools not registered: {error}"
)));
return Ok(());
}
let Some(backend) = self.tool_backend(context) else {
return Ok(());
};
let ticket_service: Arc<dyn TicketService> = match &self.backend {
TicketFeatureBackend::WorkspaceClient(client) => Arc::new(WorkspaceTicketService {
backend: WorkspaceHttpTicketBackend::new(client.clone()),
}),
TicketFeatureBackend::Local { .. } => Arc::new(BackendTicketService {
backend: backend.clone(),
}),
};
let backend = self.tool_backend();
let workspace_client = self.workspace_client();
let ticket_service: Arc<dyn TicketService> = Arc::new(WorkspaceTicketService {
backend: WorkspaceHttpTicketBackend::new(workspace_client.clone()),
});
context.services().provide(
ServiceDeclaration::new(
ServiceId::builtin(TICKET_SERVICE_ID),
@@ -675,10 +533,6 @@ impl FeatureModule for TicketFeature {
ticket_workflow_instruction(),
))?;
let allowed_tool_names = self.enabled_tool_names();
let workspace_client = match &self.backend {
TicketFeatureBackend::WorkspaceClient(client) => Some(client.clone()),
TicketFeatureBackend::Local { .. } => None,
};
let mut tools = context.tools();
for definition in ticket_tools(backend) {
let (meta, _) = definition();
@@ -689,13 +543,15 @@ impl FeatureModule for TicketFeature {
{
continue;
}
let definition = match (name.as_str(), workspace_client.as_ref()) {
("QueryTicket", Some(client)) => {
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)
}
("ShowTicket", Some(client)) => {
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show)
}
let definition = match name.as_str() {
"QueryTicket" => workspace_ticket_read_definition(
workspace_client.clone(),
WorkspaceTicketReadKind::Query,
),
"ShowTicket" => workspace_ticket_read_definition(
workspace_client.clone(),
WorkspaceTicketReadKind::Show,
),
_ => definition,
};
tools.register(ToolContribution::new(name, definition))?;
@@ -1371,22 +1227,11 @@ impl TicketBackend for WorkspaceHttpTicketBackend {
}
}
pub fn ticket_tools_feature(workspace: impl AsRef<Path>) -> TicketFeature {
TicketFeature::for_workspace(workspace)
}
pub fn ticket_tools_feature_with_access(
workspace: impl AsRef<Path>,
pub fn ticket_tools_feature(
workspace_client: Arc<dyn WorkspaceClient>,
access: TicketFeatureAccess,
) -> TicketFeature {
TicketFeature::for_workspace_with_access(workspace, access)
}
pub fn ticket_tools_feature_with_backend(
backend: impl Into<TicketFeatureBackend>,
access: TicketFeatureAccess,
) -> TicketFeature {
TicketFeature::with_backend(backend.into(), access)
TicketFeature::new(workspace_client, access)
}
#[cfg(test)]
@@ -1397,29 +1242,31 @@ mod tests {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
use tempfile::TempDir;
fn make_ticket_root(root: &Path) {
std::fs::create_dir_all(root).unwrap();
#[test]
fn production_source_has_no_local_ticket_feature_backend() {
let production = include_str!("ticket.rs")
.split_once("#[cfg(test)]\nmod tests")
.map(|(production, _)| production)
.expect("Ticket feature test module marker");
for forbidden in [
"TicketFeatureBackend",
"LocalTicketBackend",
"for_workspace",
".yoi/tickets",
] {
assert!(
!production.contains(forbidden),
"repository-local Ticket feature returned through {forbidden}"
);
}
}
fn write_ticket_config(workspace: &Path, content: &str) {
let yoi_dir = workspace.join(".yoi");
std::fs::create_dir_all(&yoi_dir).unwrap();
std::fs::write(yoi_dir.join("workspace.toml"), content).unwrap();
}
fn pending_tool_description(
pending_tools: &[agen::tool::ToolDefinition],
name: &str,
) -> String {
pending_tools
.iter()
.find_map(|definition| {
let (meta, _) = definition();
(meta.name == name).then_some(meta.description)
})
.expect("tool exists")
fn workspace_feature(access: TicketFeatureAccess) -> TicketFeature {
let client: Arc<dyn WorkspaceClient> = Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"),
);
ticket_tools_feature(client, access)
}
#[test]
@@ -1482,8 +1329,7 @@ mod tests {
#[test]
fn descriptor_declares_ticket_tools() {
let temp = TempDir::new().unwrap();
let feature = ticket_tools_feature(temp.path());
let feature = workspace_feature(TicketFeatureAccess::workspace_authoring());
let descriptor = feature.descriptor();
assert_eq!(descriptor.id.to_string(), "builtin:ticket");
assert_eq!(descriptor.runtime, FeatureRuntimeKind::Builtin);
@@ -1500,9 +1346,7 @@ mod tests {
#[test]
fn read_only_descriptor_declares_only_state_tools() {
let temp = TempDir::new().unwrap();
let feature =
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::read_only());
let feature = workspace_feature(TicketFeatureAccess::read_only());
let descriptor = feature.descriptor();
assert_eq!(feature.access(), TicketFeatureAccess::read_only());
assert_eq!(descriptor.tools.len(), READ_ONLY_TOOL_NAMES.len());
@@ -1518,9 +1362,7 @@ mod tests {
#[test]
fn workflow_descriptor_declares_workflow_tools() {
let temp = TempDir::new().unwrap();
let feature =
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::workflow());
let feature = workspace_feature(TicketFeatureAccess::workflow());
let descriptor = feature.descriptor();
assert_eq!(feature.access(), TicketFeatureAccess::workflow());
assert_eq!(
@@ -1535,12 +1377,7 @@ mod tests {
#[test]
fn additive_ticket_capabilities_expose_expected_tool_surfaces() {
let temp = TempDir::new().unwrap();
let workspace_authoring = ticket_tools_feature_with_access(
temp.path(),
TicketFeatureAccess::workspace_authoring(),
);
let workspace_authoring = workspace_feature(TicketFeatureAccess::workspace_authoring());
let workspace_descriptor = workspace_authoring.descriptor();
let workspace_tools = workspace_descriptor
.tools
@@ -1552,8 +1389,7 @@ mod tests {
assert!(workspace_tools.contains(&"TicketQueue"));
assert!(!workspace_tools.contains(&"TicketWorkflowState"));
let orchestration =
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::workflow());
let orchestration = workspace_feature(TicketFeatureAccess::workflow());
let orchestration_descriptor = orchestration.descriptor();
let orchestration_tools = orchestration_descriptor
.tools
@@ -1567,8 +1403,7 @@ mod tests {
assert!(!orchestration_tools.contains(&"TicketEditItem"));
assert!(!orchestration_tools.contains(&"TicketQueue"));
let work_report =
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::work_report());
let work_report = workspace_feature(TicketFeatureAccess::work_report());
let work_report_descriptor = work_report.descriptor();
let work_report_tools = work_report_descriptor
.tools
@@ -1578,7 +1413,7 @@ mod tests {
assert!(work_report_tools.contains(&"TicketComment"));
assert!(!work_report_tools.contains(&"TicketWorkflowState"));
let review = ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::review());
let review = workspace_feature(TicketFeatureAccess::review());
let review_descriptor = review.descriptor();
let review_tools = review_descriptor
.tools
@@ -1590,15 +1425,10 @@ mod tests {
#[test]
fn read_only_installation_does_not_expose_mutating_tools() {
let temp = TempDir::new().unwrap();
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature_with_access(
temp.path(),
TicketFeatureAccess::read_only(),
))
.with_module(workspace_feature(TicketFeatureAccess::read_only()))
.install_into_pending(&mut pending_tools, &mut hooks);
assert_eq!(pending_tools.len(), READ_ONLY_TOOL_NAMES.len());
@@ -1619,54 +1449,12 @@ mod tests {
}
}
#[test]
fn read_only_companion_style_context_exposes_ticket_language_guidance() {
let temp = TempDir::new().unwrap();
write_ticket_config(
temp.path(),
r#"
[ticket]
language = "Japanese"
"#,
);
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
let feature =
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::read_only());
let descriptor = feature.descriptor();
let descriptor_description = descriptor
.tools
.iter()
.find(|tool| tool.name == "ShowTicket")
.expect("ShowTicket declared")
.description
.clone();
assert!(descriptor_description.contains("Ticket record language: Japanese"));
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(feature)
.install_into_pending(&mut pending_tools, &mut hooks);
assert_eq!(pending_tools.len(), READ_ONLY_TOOL_NAMES.len());
assert_eq!(report.reports[0].installed_tools, READ_ONLY_TOOL_NAMES);
let description = pending_tool_description(&pending_tools, "ShowTicket");
assert!(description.contains("Ticket record language: Japanese"));
assert!(description.contains("distinct from worker.language"));
assert!(description.contains("Preserve protocol literals"));
}
#[test]
fn workspace_authoring_installation_exposes_authoring_tools() {
let temp = TempDir::new().unwrap();
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature_with_access(
temp.path(),
TicketFeatureAccess::workspace_authoring(),
))
.with_module(workspace_feature(TicketFeatureAccess::workspace_authoring()))
.install_into_pending(&mut pending_tools, &mut hooks);
assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
@@ -1690,169 +1478,28 @@ language = "Japanese"
}
#[test]
fn workspace_authoring_context_exposes_ticket_language_guidance() {
let temp = TempDir::new().unwrap();
write_ticket_config(
temp.path(),
r#"
[ticket]
language = "Japanese"
"#,
);
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
fn workspace_only_ticket_feature_preserves_unrelated_stale_repository_trees() {
let ancestor = tempfile::tempdir().unwrap();
let repository = ancestor.path().join("repository");
std::fs::create_dir_all(repository.join(".yoi/tickets/broken")).unwrap();
std::fs::create_dir_all(ancestor.path().join(".yoi")).unwrap();
std::fs::write(
ancestor.path().join(".yoi/workspace.toml"),
"not valid toml",
)
.unwrap();
std::fs::write(
repository.join(".yoi/tickets/broken/item.md"),
"not a Ticket",
)
.unwrap();
// TicketFeature accepts only an injected Workspace client and access policy;
// there is deliberately no repository/cwd path to pass to this installation.
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature_with_access(
temp.path(),
TicketFeatureAccess::workspace_authoring(),
))
.install_into_pending(&mut pending_tools, &mut hooks);
assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
assert_eq!(
report.reports[0].installed_tools,
WORKSPACE_AUTHORING_TOOL_NAMES
);
let description = pending_tool_description(&pending_tools, "TicketComment");
assert!(description.contains("Ticket record language: Japanese"));
assert!(description.contains("durable Ticket record and Ticket tool body text"));
assert!(description.contains("distinct from worker.language"));
assert!(description.contains("memory.language"));
}
#[test]
fn installs_ticket_tools_when_default_root_is_usable() {
let temp = TempDir::new().unwrap();
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature(temp.path()))
.install_into_pending(&mut pending_tools, &mut hooks);
assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
assert_eq!(report.reports.len(), 1);
assert!(report.reports[0].installed);
assert_eq!(
report.reports[0].installed_tools,
WORKSPACE_AUTHORING_TOOL_NAMES
);
assert!(report.reports[0].skipped.is_empty());
}
#[test]
fn installs_ticket_tools_with_configured_backend_root() {
let temp = TempDir::new().unwrap();
write_ticket_config(
temp.path(),
r#"
[ticket.backend]
provider = "builtin:yoi_local"
root = "tickets"
[ticket.roles.coder]
profile = "project:coder"
"#,
);
make_ticket_root(&temp.path().join("tickets"));
let feature = ticket_tools_feature(temp.path());
assert_eq!(
feature.backend_root(),
Some(temp.path().join("tickets").as_path())
);
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(feature)
.install_into_pending(&mut pending_tools, &mut hooks);
assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
assert!(report.reports[0].diagnostics.is_empty());
}
#[test]
fn malformed_ticket_config_fails_closed() {
let temp = TempDir::new().unwrap();
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
write_ticket_config(
temp.path(),
r#"
[ticket.roles.operator]
profile = "inherit"
"#,
);
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature(temp.path()))
.install_into_pending(&mut pending_tools, &mut hooks);
assert!(pending_tools.is_empty());
assert!(report.reports[0].installed_tools.is_empty());
assert_eq!(report.reports[0].diagnostics.len(), 1);
let message = &report.reports[0].diagnostics[0].message;
assert!(message.contains("Ticket tools not registered"));
assert!(message.contains("unsupported Ticket role `operator`"));
}
#[test]
fn unsupported_ticket_backend_provider_fails_closed() {
let temp = TempDir::new().unwrap();
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
write_ticket_config(
temp.path(),
r#"
[ticket.backend]
provider = "github"
"#,
);
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature(temp.path()))
.install_into_pending(&mut pending_tools, &mut hooks);
assert!(pending_tools.is_empty());
assert!(report.reports[0].installed_tools.is_empty());
assert_eq!(report.reports[0].diagnostics.len(), 1);
let message = &report.reports[0].diagnostics[0].message;
assert!(message.contains("Ticket tools not registered"));
assert!(message.contains("unsupported Ticket backend provider `github`"));
}
#[test]
fn does_not_register_ticket_tools_when_root_is_missing() {
let temp = TempDir::new().unwrap();
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature(temp.path()))
.install_into_pending(&mut pending_tools, &mut hooks);
assert!(pending_tools.is_empty());
assert_eq!(report.reports.len(), 1);
assert!(report.reports[0].installed);
assert!(report.reports[0].installed_tools.is_empty());
assert_eq!(report.reports[0].diagnostics.len(), 1);
assert!(
report.reports[0].diagnostics[0]
.message
.contains("Ticket tools not registered")
);
}
#[test]
fn registers_ticket_tools_for_flat_backend_root() {
let temp = TempDir::new().unwrap();
let root = temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH);
std::fs::create_dir_all(&root).unwrap();
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature(temp.path()))
.with_module(workspace_feature(TicketFeatureAccess::workspace_authoring()))
.install_into_pending(&mut pending_tools, &mut hooks);
assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
@@ -1861,9 +1508,14 @@ provider = "github"
WORKSPACE_AUTHORING_TOOL_NAMES
);
assert!(report.reports[0].diagnostics.is_empty());
assert!(!root.join("open").exists());
assert!(!root.join("pending").exists());
assert!(!root.join("closed").exists());
assert_eq!(
std::fs::read_to_string(ancestor.path().join(".yoi/workspace.toml")).unwrap(),
"not valid toml"
);
assert_eq!(
std::fs::read_to_string(repository.join(".yoi/tickets/broken/item.md")).unwrap(),
"not a Ticket"
);
}
#[tokio::test(flavor = "multi_thread")]
@@ -2057,10 +1709,12 @@ provider = "github"
#[test]
fn workspace_ticket_service_preserves_internal_identity_for_handoff() {
let temp = TempDir::new().unwrap();
let local = LocalTicketBackend::new(temp.path().join("tickets"));
let created = local.create(NewTicket::new("Ticket handoff")).unwrap();
let mut ticket = local.show(TicketIdOrSlug::Id(created.id.clone())).unwrap();
let temp = tempfile::tempdir().unwrap();
let sqlite =
ticket::SqliteTicketBackend::open(temp.path().join("tickets.db"), "workspace-test")
.unwrap();
let created = sqlite.create(NewTicket::new("Ticket handoff")).unwrap();
let mut ticket = sqlite.show(TicketIdOrSlug::Id(created.id.clone())).unwrap();
ticket.meta.resource_key = Some("T-548".to_string());
ticket.meta.workflow_state = TicketWorkflowState::Queued;
+40 -4
View File
@@ -3051,11 +3051,47 @@ mod tests {
})
.await
.unwrap();
SqliteTicketBackend::open(&db_path, "workspace-test")
// Seed the active control-plane authority directly. The malformed legacy
// tree above is retained only as ignored regression input.
SqliteTicketBackend::open_verified(&db_path, "workspace-test").unwrap();
rusqlite::Connection::open(&db_path)
.unwrap()
.import_from_local_backend(&ticket::LocalTicketBackend::new(
dir.path().join(".yoi/tickets"),
))
.execute_batch(
r#"
INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
created_at, updated_at, workflow_state, workflow_state_explicit
) VALUES
('workspace-test', '00000000001J2', '00000000001J2', 'Read bridge', 'open', 'ticket', 'P2',
'Ticket body. Deep Ticket marker. This body is intentionally long enough for bounded projection tests.',
'2026-01-01T00:00:00Z', '2026-01-03T00:00:00Z', 'ready', 1),
('workspace-test', '00000000001J5', '00000000001J5', 'Second ticket', 'open', 'ticket', 'P2',
'Ticket body', '2026-01-01T00:00:00Z', '2026-01-02T00:00:00Z', 'queued', 1),
('workspace-test', '00000000001J6', '00000000001J6', 'Third ticket', 'open', 'ticket', 'P2',
'Ticket body', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 'planning', 1);
INSERT INTO typed_ticket_events (
workspace_id, ticket_id, event_index, kind, author, at, heading, body
) VALUES
('workspace-test', '00000000001J2', 0, 'create', 'test', '2026-01-01T00:00:00Z', 'Created', 'created'),
('workspace-test', '00000000001J5', 0, 'create', 'test', '2026-01-01T00:00:00Z', 'Created', 'created'),
('workspace-test', '00000000001J6', 0, 'create', 'test', '2026-01-01T00:00:00Z', 'Created', 'created');
"#,
)
.unwrap();
rusqlite::Connection::open(&db_path)
.unwrap()
.execute(
"UPDATE typed_tickets SET body = ?3 WHERE workspace_id = ?1 AND ticket_id = ?2",
rusqlite::params![
"workspace-test",
"00000000001J2",
format!(
"Ticket body.\n{}\nDeep Ticket marker.\n",
"x".repeat(70_000)
)
],
)
.unwrap();
rusqlite::Connection::open(&db_path)
.unwrap()
-2
View File
@@ -13,7 +13,6 @@ project-record = { workspace = true }
reqwest = { version = "0.13", default-features = false, features = ["json", "native-tls"] }
chrono = { version = "0.4", default-features = false, features = ["clock"] }
client = { workspace = true }
memory = { workspace = true }
futures = { workspace = true }
manifest = { workspace = true }
worker = { workspace = true }
@@ -26,7 +25,6 @@ ticket = { workspace = true }
tui = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_yaml = "0.9.34"
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal"] }
toml = { workspace = true }
uuid = { workspace = true }
+1 -1
View File
@@ -2,7 +2,7 @@
## Role
`yoi` is the product CLI facade. It owns the installed binary name, top-level argument parsing, normal TUI launch, and product subcommands such as `yoi worker` and `yoi memory lint`.
`yoi` is the product CLI facade. It owns the installed binary name, top-level argument parsing, normal TUI launch, and product subcommands such as `yoi worker` and `yoi ticket`.
## Boundaries
-4
View File
@@ -26,7 +26,6 @@ pub(crate) enum CliCommand {
Objective,
Plugin,
Mcp,
MemoryLint,
Session,
Login,
}
@@ -46,7 +45,6 @@ impl CliCommand {
CliCommand::Objective => "yoi objective",
CliCommand::Plugin => "yoi plugin",
CliCommand::Mcp => "yoi mcp",
CliCommand::MemoryLint => "yoi memory lint",
CliCommand::Session => "yoi session",
CliCommand::Login => "yoi login",
}
@@ -67,7 +65,6 @@ impl CliCommand {
| CliCommand::WorkerCleanup
| CliCommand::Plugin
| CliCommand::Mcp
| CliCommand::MemoryLint
| CliCommand::Session => CliConnectionRequirement::LocalOnly,
}
}
@@ -319,7 +316,6 @@ mod tests {
CliCommand::WorkerCleanup,
CliCommand::Plugin,
CliCommand::Mcp,
CliCommand::MemoryLint,
CliCommand::Session,
] {
assert_eq!(
+30 -94
View File
@@ -1,6 +1,5 @@
mod cli_connection;
mod mcp_cli;
mod memory_lint;
mod objective_cli;
mod plugin_cli;
mod session_cli;
@@ -23,7 +22,6 @@ use cli_connection::{
resolve_local_cli_connection,
};
use client::{BackendAuthTarget, Target, TargetKind, start_device_login, wait_for_device_login};
use memory_lint::{LintCliOptions, LintStatus};
use serde::Deserialize;
use tui::{LaunchMode, LaunchOptions};
use workspace_bootstrap::{
@@ -35,8 +33,6 @@ enum Mode {
Help,
ResumeHelp,
WorkersHelp,
MemoryLintHelp,
MemoryLint(LintCliOptions),
Mcp(mcp_cli::McpCliCommand),
Plugin(plugin_cli::PluginCliCommand),
Objective {
@@ -109,10 +105,6 @@ async fn run(mode: Mode) -> ExitCode {
print_workers_help();
ExitCode::SUCCESS
}
Mode::MemoryLintHelp => {
print_memory_lint_help();
ExitCode::SUCCESS
}
Mode::Login {
backend_url,
no_wait,
@@ -147,14 +139,6 @@ async fn run(mode: Mode) -> ExitCode {
ExitCode::FAILURE
}
},
Mode::MemoryLint(options) => match memory_lint::run(&options) {
Ok(LintStatus::Clean) => ExitCode::SUCCESS,
Ok(LintStatus::Failed) => ExitCode::FAILURE,
Err(e) => {
eprintln!("yoi memory lint: {e}");
ExitCode::FAILURE
}
},
Mode::Mcp(command) => match mcp_cli::run(command) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
@@ -603,22 +587,6 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
resolve_local_cli_connection(connection_resolver, CliCommand::SetupModel)?;
return Ok(Mode::SetupModel);
}
"memory" if args.get(1).map(String::as_str) == Some("lint") => {
let _target =
resolve_local_cli_connection(connection_resolver, CliCommand::MemoryLint)?;
let lint_args = &args[2..];
if lint_args.iter().any(|arg| arg == "--help" || arg == "-h") {
return Ok(Mode::MemoryLintHelp);
}
let options =
memory_lint::parse_lint_args(lint_args).map_err(|e| ParseError(e.to_string()))?;
return Ok(Mode::MemoryLint(options));
}
"memory" => {
return Err(ParseError(
"yoi memory requires the `lint` subcommand".to_string(),
));
}
other if !other.starts_with('-') => {
return Err(ParseError(format!("unknown command `{other}`")));
}
@@ -1921,7 +1889,6 @@ Host commands:
objective <COMMAND> Manage Objectives through a Backend target
plugin <COMMAND> Author/check/pack explicit Plugin packages
mcp <COMMAND> Inspect configured MCP servers
memory lint Lint local memory files
session <COMMAND> Inspect/prune Standalone session logs
Standalone binaries:
@@ -1985,12 +1952,6 @@ fn print_resume_help() {
println!("{RESUME_HELP}");
}
fn print_memory_lint_help() {
println!(
"yoi memory lint\n\nUsage:\n yoi memory lint [OPTIONS]\n\nOptions:\n --workspace <PATH> Workspace root to lint (defaults to cwd)\n --json Emit a JSON report\n --warnings-as-errors Return failure when warnings are present\n -h, --help Print help\n"
);
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2621,9 +2582,36 @@ backend = "shared"
}
#[test]
fn parse_memory_without_lint_is_usage_error() {
let err = parse_args_from(["memory"]).unwrap_err();
assert_eq!(err.to_string(), "yoi memory requires the `lint` subcommand");
fn removed_memory_command_is_unknown_without_touching_legacy_trees() {
let temp = tempfile::tempdir().unwrap();
let legacy = temp.path().join(".yoi/memory");
std::fs::create_dir_all(&legacy).unwrap();
let malformed = legacy.join("summary.md");
std::fs::write(&malformed, "not: [valid").unwrap();
let workspace = temp.path().to_string_lossy().into_owned();
for args in [
vec!["memory"],
vec!["memory", "lint"],
vec!["memory", "lint", "--help"],
vec!["memory", "lint", "--workspace", workspace.as_str()],
] {
let err = parse_args_from(args).unwrap_err();
assert_eq!(err.to_string(), "unknown command `memory`");
}
assert_eq!(std::fs::read_to_string(malformed).unwrap(), "not: [valid");
assert_eq!(std::fs::read_dir(&legacy).unwrap().count(), 1);
let production = include_str!("main.rs")
.split_once("#[cfg(test)]\nmod tests")
.map(|(production, _)| production)
.expect("CLI test module marker");
for forbidden in ["MemoryLint", "memory_lint", ".yoi/memory"] {
assert!(
!production.contains(forbidden),
"repository-local Memory CLI returned through {forbidden}"
);
}
}
#[test]
@@ -3035,27 +3023,6 @@ backend = "shared"
assert_eq!(err.to_string(), "yoi setup-model does not accept arguments");
}
#[test]
fn parse_memory_lint_mode() {
match parse_args_from([
"memory",
"lint",
"--workspace",
"/tmp/ws",
"--json",
"--warnings-as-errors",
])
.unwrap()
{
Mode::MemoryLint(options) => {
assert_eq!(options.workspace, Some(PathBuf::from("/tmp/ws")));
assert!(options.json);
assert!(options.warnings_as_errors);
}
_ => panic!("expected MemoryLint mode"),
}
}
#[test]
fn plugin_cli_rejects_ambient_catalog_commands_and_options() {
for args in [
@@ -3131,30 +3098,6 @@ backend = "shared"
);
}
#[test]
fn parse_memory_lint_rejects_usage_errors() {
let err = parse_args_from(["memory", "lint", "--workspace"]).unwrap_err();
assert_eq!(err.to_string(), "--workspace requires a value");
}
#[test]
fn parse_memory_lint_workspace_equals() {
match parse_args_from(["memory", "lint", "--workspace=/tmp/ws"]).unwrap() {
Mode::MemoryLint(options) => {
assert_eq!(options.workspace, Some(PathBuf::from("/tmp/ws")));
assert!(!options.json);
assert!(!options.warnings_as_errors);
}
_ => panic!("expected MemoryLint mode"),
}
}
#[test]
fn memory_lint_with_other_second_word_is_usage_error() {
let err = parse_args_from(["memory", "other"]).unwrap_err();
assert_eq!(err.to_string(), "yoi memory requires the `lint` subcommand");
}
#[test]
fn parse_rejects_legacy_resume_flags() {
let cases = [
@@ -3287,13 +3230,6 @@ backend = "shared"
assert!(!RESUME_HELP.contains("local workspace"));
}
#[test]
fn parse_memory_lint_help() {
match parse_args_from(["memory", "lint", "--help"]).unwrap() {
Mode::MemoryLintHelp => {}
_ => panic!("expected MemoryLintHelp mode"),
}
}
#[test]
fn parse_backend_panel_uses_backend_dashboard_only() {
match parse_args_from([
-507
View File
@@ -1,507 +0,0 @@
use std::fmt;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use memory::linter::WriteMode;
use memory::{Linter, WorkspaceLayout};
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LintCliOptions {
pub workspace: Option<PathBuf>,
pub json: bool,
pub warnings_as_errors: bool,
}
impl LintCliOptions {
fn workspace_root(&self) -> Result<PathBuf, LintCliError> {
let cwd = std::env::current_dir().map_err(LintCliError::CurrentDir)?;
Ok(match &self.workspace {
Some(path) if path.is_absolute() => path.clone(),
Some(path) => cwd.join(path),
None => cwd,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UsageError {
MissingValue(&'static str),
UnknownArgument(String),
UnexpectedArgument(String),
}
impl fmt::Display for UsageError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingValue(flag) => write!(f, "{flag} requires a value"),
Self::UnknownArgument(arg) => write!(f, "unknown memory lint argument: {arg}"),
Self::UnexpectedArgument(arg) => write!(f, "unexpected memory lint argument: {arg}"),
}
}
}
pub fn parse_lint_args(args: &[String]) -> Result<LintCliOptions, UsageError> {
let mut options = LintCliOptions {
workspace: None,
json: false,
warnings_as_errors: false,
};
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--json" => {
options.json = true;
i += 1;
}
"--warnings-as-errors" => {
options.warnings_as_errors = true;
i += 1;
}
"--workspace" => {
let raw = args
.get(i + 1)
.ok_or(UsageError::MissingValue("--workspace"))?;
if raw.starts_with('-') {
return Err(UsageError::MissingValue("--workspace"));
}
options.workspace = Some(PathBuf::from(raw));
i += 2;
}
arg if arg.starts_with("--workspace=") => {
let value = arg.trim_start_matches("--workspace=");
if value.is_empty() {
return Err(UsageError::MissingValue("--workspace"));
}
options.workspace = Some(PathBuf::from(value));
i += 1;
}
arg if arg.starts_with('-') => {
return Err(UsageError::UnknownArgument(arg.to_string()));
}
arg => return Err(UsageError::UnexpectedArgument(arg.to_string())),
}
}
Ok(options)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LintStatus {
Clean,
Failed,
}
#[derive(Debug)]
pub enum LintCliError {
CurrentDir(io::Error),
Io { path: PathBuf, source: io::Error },
Output(io::Error),
}
impl fmt::Display for LintCliError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::CurrentDir(source) => write!(f, "failed to resolve current directory: {source}"),
Self::Io { path, source } => write!(f, "io error at {}: {source}", path.display()),
Self::Output(source) => write!(f, "failed to write lint output: {source}"),
}
}
}
impl std::error::Error for LintCliError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::CurrentDir(source) | Self::Io { source, .. } | Self::Output(source) => {
Some(source)
}
}
}
}
pub fn run(options: &LintCliOptions) -> Result<LintStatus, LintCliError> {
let stdout = io::stdout();
run_with_writer(options, stdout.lock())
}
pub fn run_with_writer<W: Write>(
options: &LintCliOptions,
mut writer: W,
) -> Result<LintStatus, LintCliError> {
let workspace = options.workspace_root()?;
let report = lint_workspace(&workspace)?;
if options.json {
write_json_report(&mut writer, &report)?;
} else {
write_human_report(&mut writer, &report)?;
}
if report.counts.errors > 0 || (options.warnings_as_errors && report.counts.warnings > 0) {
Ok(LintStatus::Failed)
} else {
Ok(LintStatus::Clean)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct WorkspaceLintReport {
pub workspace: String,
pub files: Vec<FileLintReport>,
pub errors: Vec<LintDiagnostic>,
pub warnings: Vec<LintDiagnostic>,
pub counts: LintCounts,
}
#[derive(Debug, Clone, Serialize)]
pub struct FileLintReport {
pub path: String,
pub errors: Vec<String>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct LintDiagnostic {
pub path: String,
pub message: String,
}
#[derive(Debug, Clone, Copy, Default, Serialize)]
pub struct LintCounts {
pub files: usize,
pub errors: usize,
pub warnings: usize,
pub failing_files: usize,
}
pub fn lint_workspace(workspace: &Path) -> Result<WorkspaceLintReport, LintCliError> {
let layout = WorkspaceLayout::new(workspace.to_path_buf());
let linter = Linter::new(layout.clone());
let mut paths = collect_record_paths(&layout)?;
paths.sort();
let mut files = Vec::with_capacity(paths.len());
for path in paths {
let content = std::fs::read_to_string(&path).map_err(|source| LintCliError::Io {
path: path.clone(),
source,
})?;
let lint = linter.lint(&path, &content, WriteMode::Update);
files.push(FileLintReport {
path: display_path(workspace, &path),
errors: lint.errors.iter().map(ToString::to_string).collect(),
warnings: lint.warnings.iter().map(ToString::to_string).collect(),
});
}
let counts = count_files(&files);
let errors = diagnostics_for(&files, DiagnosticKind::Error);
let warnings = diagnostics_for(&files, DiagnosticKind::Warning);
Ok(WorkspaceLintReport {
workspace: workspace.display().to_string(),
files,
errors,
warnings,
counts,
})
}
fn collect_record_paths(layout: &WorkspaceLayout) -> Result<Vec<PathBuf>, LintCliError> {
let mut paths = Vec::new();
let summary = layout.summary_path();
if is_file(&summary)? {
paths.push(summary);
}
collect_md_files(&layout.decisions_dir(), &mut paths)?;
collect_md_files(&layout.requests_dir(), &mut paths)?;
Ok(paths)
}
fn is_file(path: &Path) -> Result<bool, LintCliError> {
match std::fs::metadata(path) {
Ok(meta) => Ok(meta.is_file()),
Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(false),
Err(source) => Err(LintCliError::Io {
path: path.to_path_buf(),
source,
}),
}
}
fn collect_md_files(dir: &Path, paths: &mut Vec<PathBuf>) -> Result<(), LintCliError> {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(source) => {
return Err(LintCliError::Io {
path: dir.to_path_buf(),
source,
});
}
};
for entry in entries {
let entry = entry.map_err(|source| LintCliError::Io {
path: dir.to_path_buf(),
source,
})?;
let path = entry.path();
let file_type = entry.file_type().map_err(|source| LintCliError::Io {
path: path.clone(),
source,
})?;
if file_type.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("md") {
paths.push(path);
}
}
Ok(())
}
fn diagnostics_for(files: &[FileLintReport], kind: DiagnosticKind) -> Vec<LintDiagnostic> {
files
.iter()
.flat_map(|file| {
let messages = match kind {
DiagnosticKind::Error => &file.errors,
DiagnosticKind::Warning => &file.warnings,
};
messages.iter().map(|message| LintDiagnostic {
path: file.path.clone(),
message: message.clone(),
})
})
.collect()
}
#[derive(Debug, Clone, Copy)]
enum DiagnosticKind {
Error,
Warning,
}
fn count_files(files: &[FileLintReport]) -> LintCounts {
files.iter().fold(
LintCounts {
files: files.len(),
..LintCounts::default()
},
|mut counts, file| {
counts.errors += file.errors.len();
counts.warnings += file.warnings.len();
if !file.errors.is_empty() {
counts.failing_files += 1;
}
counts
},
)
}
fn display_path(workspace: &Path, path: &Path) -> String {
path.strip_prefix(workspace)
.unwrap_or(path)
.display()
.to_string()
}
fn write_human_report<W: Write>(
writer: &mut W,
report: &WorkspaceLintReport,
) -> Result<(), LintCliError> {
writeln!(writer, "Workspace: {}", report.workspace).map_err(LintCliError::Output)?;
for file in &report.files {
let status = if file.errors.is_empty() && file.warnings.is_empty() {
"ok"
} else if file.errors.is_empty() {
"warning"
} else {
"error"
};
writeln!(writer, "{status}: {}", file.path).map_err(LintCliError::Output)?;
for error in &file.errors {
writeln!(writer, " error: {error}").map_err(LintCliError::Output)?;
}
for warning in &file.warnings {
writeln!(writer, " warning: {warning}").map_err(LintCliError::Output)?;
}
}
writeln!(
writer,
"Summary: files={} errors={} warnings={} failing_files={}",
report.counts.files,
report.counts.errors,
report.counts.warnings,
report.counts.failing_files
)
.map_err(LintCliError::Output)
}
fn write_json_report<W: Write>(
writer: &mut W,
report: &WorkspaceLintReport,
) -> Result<(), LintCliError> {
serde_json::to_writer_pretty(&mut *writer, report)
.map_err(|source| LintCliError::Output(io::Error::other(source)))?;
writeln!(writer).map_err(LintCliError::Output)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
use tempfile::TempDir;
fn write(path: &Path, content: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, content).unwrap();
}
fn valid_summary() -> &'static str {
"---\nupdated_at: 2026-05-31T00:00:00Z\n---\nsummary body\n"
}
fn valid_request() -> &'static str {
"---\ncreated_at: 2026-05-31T00:00:00Z\nupdated_at: 2026-05-31T00:00:00Z\nsources: []\n---\nrequest body\n"
}
fn warning_request() -> String {
format!(
"---\ncreated_at: 2026-05-31T00:00:00Z\nupdated_at: 2026-05-31T00:00:00Z\nsources:\n - segment_id: seg\n range: [0, 1]\n---\n{}\n",
"x".repeat(1500)
)
}
#[test]
fn parses_lint_options() {
let args = vec![
"--workspace".to_string(),
"/tmp/ws".to_string(),
"--json".to_string(),
"--warnings-as-errors".to_string(),
];
let parsed = parse_lint_args(&args).unwrap();
assert_eq!(parsed.workspace, Some(PathBuf::from("/tmp/ws")));
assert!(parsed.json);
assert!(parsed.warnings_as_errors);
}
#[test]
fn rejects_lint_usage_errors() {
assert_eq!(
parse_lint_args(&["--workspace".to_string()]).unwrap_err(),
UsageError::MissingValue("--workspace")
);
assert_eq!(
parse_lint_args(&["--workspace".to_string(), "--json".to_string()]).unwrap_err(),
UsageError::MissingValue("--workspace")
);
assert_eq!(
parse_lint_args(&["--bogus".to_string()]).unwrap_err(),
UsageError::UnknownArgument("--bogus".to_string())
);
assert_eq!(
parse_lint_args(&["extra".to_string()]).unwrap_err(),
UsageError::UnexpectedArgument("extra".to_string())
);
}
#[test]
fn lints_only_workspace_memory_records() {
let dir = TempDir::new().unwrap();
let root = dir.path();
write(&root.join(".yoi/memory/summary.md"), valid_summary());
write(
&root.join(".yoi/memory/requests/request-one.md"),
valid_request(),
);
write(
&root.join(".yoi/memory/_logs/ignored.md"),
"not frontmatter",
);
let report = lint_workspace(root).unwrap();
assert_eq!(
report
.files
.iter()
.map(|file| file.path.as_str())
.collect::<Vec<_>>(),
vec![
".yoi/memory/requests/request-one.md",
".yoi/memory/summary.md",
]
);
assert_eq!(report.counts.files, 2);
assert_eq!(report.counts.errors, 0);
}
#[test]
fn invalid_records_count_as_lint_failures() {
let dir = TempDir::new().unwrap();
let root = dir.path();
write(&root.join(".yoi/memory/summary.md"), "missing frontmatter");
let report = lint_workspace(root).unwrap();
assert_eq!(report.counts.files, 1);
assert_eq!(report.counts.errors, 1);
assert_eq!(report.counts.failing_files, 1);
assert!(report.files[0].errors[0].contains("frontmatter"));
}
#[test]
fn warnings_as_errors_changes_status_without_changing_report() {
let dir = TempDir::new().unwrap();
let root = dir.path();
write(
&root.join(".yoi/memory/requests/large-record.md"),
&warning_request(),
);
let mut output = Vec::new();
let status = run_with_writer(
&LintCliOptions {
workspace: Some(root.to_path_buf()),
json: false,
warnings_as_errors: true,
},
&mut output,
)
.unwrap();
assert_eq!(status, LintStatus::Failed);
let text = String::from_utf8(output).unwrap();
assert!(text.contains("warnings=1"));
assert!(text.contains("errors=0"));
}
#[test]
fn json_output_is_machine_readable() {
let dir = TempDir::new().unwrap();
let root = dir.path();
write(&root.join(".yoi/memory/summary.md"), valid_summary());
let mut output = Vec::new();
let status = run_with_writer(
&LintCliOptions {
workspace: Some(root.to_path_buf()),
json: true,
warnings_as_errors: false,
},
&mut output,
)
.unwrap();
assert_eq!(status, LintStatus::Clean);
let parsed: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(parsed["workspace"], root.display().to_string());
assert_eq!(parsed["counts"]["files"], 1);
assert_eq!(parsed["files"][0]["path"], ".yoi/memory/summary.md");
assert!(parsed["files"][0]["errors"].as_array().unwrap().is_empty());
}
}
+11 -9
View File
@@ -1,15 +1,17 @@
# Generated memory records
# Workspace Memory authority
Yoi keeps generated memory under `.yoi/memory/` for durable, low-volume context:
Generated Memory is durable Workspace context owned by the Workspace Server control plane. Runtime Workers query and mutate it through typed Workspace API operations; they do not read or write repository-local paths.
- `summary.md` is optional resident context.
- `decisions/*.md` capture durable decisions and rationale.
- `requests/*.md` capture durable user preferences or standing requests.
The active Memory document and staging/audit lifecycle are stored by the Server-owned backend. Worker-side `memory` code contains only shared schema, extraction, audit, and transport types needed to construct those operations.
Memory records are not workspace record authority for exact implementation state. Use tickets, objectives, repository files, git history, and session logs for exact current facts.
Repository or ancestor `.yoi/memory`, `.yoi/knowledge`, and malformed marker trees are ignored. There is no cwd/ancestor discovery, dual read, startup import, or automatic migration. The removed `yoi memory lint` command is intentionally not a compatibility surface; invoking `yoi memory ...` is an unknown command.
## Historical Knowledge records
Memory remains supporting context rather than implementation authority. Use Tickets, Objectives, repository files, Git history, and append-only session records for exact current facts.
Older workspaces may contain `.yoi/knowledge/`. Knowledge is no longer an active supported feature or workspace record authority. Current memory tooling ignores it; archive or inspect those files manually if needed.
## Upgrade guidance
Future Agent Skills are intentionally separate and are not implemented by this design note.
Old repository-local Memory data is not imported automatically. Before upgrading from a version that used `.yoi/memory`, explicitly export any data that must be retained with that old version and import it into the Workspace-backed product surface available to the deployment. Stale files may be archived or deleted after verification.
## Preserved filesystem boundaries
This removal does not affect repository checkouts, Git metadata, Runtime-owned Workdirs/session logs/artifacts, Server data directories, or XDG client/server configuration. It removes only ambient repository-local Memory authority.
+3 -3
View File
@@ -6,7 +6,7 @@ This decision records the layout policy for Ticket `00001KTTB479X`.
## Roots
- `role_workspace_root`: the launched role runtime workspace root and Ticket backend context.
- `role_workspace_root`: the launched role Runtime's explicit execution/workdir root. Ticket authority remains the Workspace Server control-plane database reached through the Workspace API.
- `original_workspace_root`: the repository root from which implementation worktrees should be created.
- `implementation_worktree_root`: `<original_workspace_root>/.worktree`.
- `merge_target_workspace_root`: the workspace/branch where merge-completion validates and merges.
@@ -15,9 +15,9 @@ When these roots differ, implementation worktrees must be created under `impleme
## Policy
- The filesystem Ticket backend remains authoritative for project records; this design does not introduce a Git-external Ticket store.
- Workspace Server Ticket records remain authoritative; none of these filesystem roots selects or hosts a Ticket backend.
- Main-workspace draft Tickets are not an implicit Orchestrator queue. Queue acceptance still requires explicit Ticket state and routing checks.
- Implementation branches should not include unrelated Orchestrator Ticket churn. Orchestration progress stays in the Orchestrator workspace/Ticket backend; child implementation worktrees contain implementation diffs and branch-local artifacts only when needed.
- Implementation branches should not include unrelated Orchestrator bookkeeping. Orchestration progress stays in the Workspace Server Ticket/event record; child implementation worktrees contain implementation diffs and branch-local artifacts only when needed.
- Prompt and workflow guidance must treat merge-ready dossier as a checkpoint. If reviewer approval, clean/safe target workspace, and standing/user merge authority are present, Orchestrator should continue through merge, validation, Ticket close, and cleanup instead of stopping solely because a dossier exists.
## Implemented in this branch
+2 -2
View File
@@ -6,7 +6,7 @@ That rule shapes the crate split. The runtime can restart, attach, compact, or d
## Core layers
- `yoi` owns the product CLI and top-level command shape. It is the façade that wires profile selection, memory linting, and normal TUI launch.
- `yoi` owns the product CLI and top-level command shape. It is the façade that wires connection selection, product commands, and normal TUI launch.
- `worker` turns a `Engine` into a named runtime entity with scope, session persistence, protocol handling, tools, and Worker metadata integration.
- `agen` owns model-facing turns: history append, retries, continuation, pruning/compaction mechanics, tool loops, and provider-independent callbacks.
- `session-store` owns replayable append-only conversation/session logs.
@@ -16,7 +16,7 @@ That rule shapes the crate split. The runtime can restart, attach, compact, or d
- `client` contains reusable Backend Workspace/Runtime clients plus the shared `Target` boundary. Its normal targets are exactly Standalone and Backend; it is not a subprocess launcher or repository-local product backend.
- `manifest` resolves Profiles, Manifests, model/provider references, scopes, prompts, and tool permission policy into a runtime contract.
- `tools` implements built-in tools with bounded output and policy-aware execution.
- `memory` owns generated memory summary/decision/request records, linting, staging, and audit observations.
- `memory` owns shared Memory domain, extraction, audit-event, and Workspace API transport types. Persistence and lifecycle execution belong to the Workspace Server control plane.
- `workspace-server` is the local Workspace control-plane seam. It can project Tickets, Workers, lifecycle, usage, and orchestration events, but browser/API operations must stay on opaque backend identities instead of raw local paths, sockets, Worker names, or session files.
- `tui` is a UI over either one in-process Standalone session or Backend Workspace/Runtime Worker authority; it should not invent a local Worker catalog or durable product state.
+13 -78
View File
@@ -1,19 +1,19 @@
# Tickets and development workflow
Yoi project work is tracked through Tickets. For normal use, interact with Tickets through `yoi panel`, Ticket tools, the `yoi ticket ...` CLI, and typed role surfaces. Git history plus Ticket files remain the authoritative state-transition record behind those interfaces.
Yoi project work is tracked through Tickets. For normal use, interact with Tickets through `yoi panel`, Ticket tools, the `yoi ticket ...` CLI, and typed role surfaces. Workspace Server control-plane records plus the append-only Ticket event and Merge Request evidence streams are authoritative.
The current local backend stores each Ticket in the flat `.yoi/tickets/<ticket-id>/` layout. The directory name is the canonical opaque Ticket id: a fixed-width Crockford base32 Unix epoch millisecond timestamp. Slugs and frontmatter `id`/`slug` fields are not current-state authority. That storage detail matters for maintainers and backend compatibility, but it is not the primary user-facing workflow.
Ticket and Objective records are stored in the Workspace Server database. Repository-local `.yoi/tickets`, `.yoi/objectives`, and config files are ignored legacy input, not a debugging or compatibility backend.
Do not treat ad-hoc chat summaries, memory records, or Worker notifications as the final source of project state. Notifications are hints to inspect concrete state, not proof of completion.
## Concepts
- `Ticket`: durable project/orchestration record. It contains requirements, decisions, plans, implementation reports, reviews, artifacts, and resolution history.
- `Objective`: first-class medium-term goal record. It stores goal, motivation/background, strategy/design direction, success criteria/exit conditions, decision context, current Objective lifecycle, and canonical Ticket links under `.yoi/objectives/<objective-id>/item.md`. Objective context is judgment/background context; it is not implementation authority and does not replace reading each Ticket body/thread/artifacts.
- `Objective`: first-class medium-term goal record stored by the Workspace Server. It stores goal, motivation/background, strategy/design direction, success criteria/exit conditions, decision context, current Objective lifecycle, and canonical Ticket links. Objective context is judgment/background context; it is not implementation authority and does not replace reading each Ticket body, thread, and evidence.
- `Task`: session-local progress tracking inside a Worker. It is not the project record.
- `Assignment`: a concrete delegation from an Orchestrator to a coder/reviewer Worker or task-specific helper Worker.
- `IntentPacket`: the short implementation/review contract derived from a Ticket and handed to an Assignment.
- `LocalTicketBackend`: the current `.yoi/tickets/` markdown/thread/artifacts storage backend.
- `Ticket backend`: the Workspace Server control-plane database exposed through typed Workspace APIs.
- `Ticket relation`: durable project-level Ticket-to-Ticket metadata stored as forward canonical-id relations (`depends_on`, `blocks`, `related`, `supersedes`, `duplicate_of`). Inverse views such as `blocked_by` are derived, not stored.
A Ticket may represent a feature, bug, cleanup, design decision, investigation, workflow change, release task, or orchestration task. The common requirement is that the Ticket is a concrete work item that can be implemented, reviewed, validated, and closed on its own terms.
@@ -27,7 +27,7 @@ Use the highest-level interface that matches the work:
- Inside Workers, use typed Ticket tools for Ticket records and typed Merge Request tools for immutable implementation/review/completion evidence.
- For multi-step work, follow the typed Ticket role surfaces and recorded Ticket lifecycle gates.
Maintainers can inspect the local `.yoi/tickets/` files directly when debugging storage, but normal user instructions should go through `yoi panel`, Ticket tools, or `yoi ticket ...`.
Maintainers inspect Ticket state through the authenticated Workspace API, Server administration surfaces, and database diagnostics. Repository-local Ticket files are not storage authority.
## Ticket tools inside Workers
@@ -45,7 +45,7 @@ Workers with the Ticket and operation-specific Merge Request built-in features c
Profile-visible Ticket catalogs are intentionally smaller than the former broad read catalog: Workspace authoring exposes 9 tools instead of 13, workflow exposes 10 instead of 12, and review exposes only `QueryTicket` plus `ShowTicket` (2 instead of 6). The `QueryTicket` schema is regression-guarded below 8 KiB while consolidating relation/evidence/attention discovery; diagnostics are not projected into normal profiles, while specialized orchestration-plan commands remain visible only to workflow roles that need their distinct semantics.
These tools operate through the typed Ticket backend. They are not arbitrary filesystem write permission to `.yoi/tickets/`.
These tools operate through the typed Workspace Ticket API. They do not grant arbitrary filesystem access and never select repository-local Ticket storage.
Relation tools are for non-hierarchical project metadata only. Use canonical opaque Ticket ids, store forward relations only, and keep runtime execution planning (capacity, ordering decisions, do-not-parallelize notes, Worker/session/worktree ownership) in OrchestrationPlan or session-local records instead of relation metadata. Unresolved `depends_on` and incoming unresolved `blocks` are queue/acceptance blockers; `related` is not blocking, and `supersedes` / `duplicate_of` are diagnostics rather than automatic lifecycle transitions.
@@ -60,51 +60,11 @@ Do not bypass Ticket lifecycle gates just because Ticket tools are available. Ti
## Objective records
Objectives are lightweight medium-term project records, not Tickets, Ticket relations, OrchestrationPlan execution records, or Worker/session claims. Use them when a goal spans several concrete Tickets and the durable motivation, design direction, success criteria, or decision context would otherwise be repeated or lost.
Objectives are Workspace Server control-plane records, not files in a checkout. Use typed Objective tools or `yoi objective ...`; both resolve the selected Backend/Workspace through the shared client target and call the Workspace API.
The local Objective surface stores records under:
Objective-to-Ticket links are context links only: they are not dependency, blocking, ordering, ownership, or scheduling relations. Objective lifecycle does not drive Ticket state or authorize implementation. A role reading Objective context must still inspect each Ticket and its current Merge Request evidence.
```text
.yoi/objectives/<objective-id>
item.md
```
`<objective-id>` is the canonical opaque path-derived id: a fixed-width Crockford base32 Unix epoch millisecond timestamp. Do not treat Objective titles or slug words as link authority.
`item.md` uses YAML frontmatter plus Markdown body:
```yaml
---
title: "Improve orchestration evidence"
state: "active" # active|paused|done|archived
created_at: "2026-06-09T00:00:00Z"
updated_at: "2026-06-09T00:00:00Z"
linked_tickets: ["00001KTKMS0VG"]
---
```
The Markdown body should include these sections:
- `## Goal`
- `## Motivation / background`
- `## Strategy / design direction`
- `## Success criteria / exit conditions`
- `## Decision context`
Linked Tickets must be canonical opaque Ticket ids that exist in the configured Ticket backend root. Objective-to-Ticket links are context links only: they are not dependency, blocking, ordering, ownership, or scheduling relations. Use typed Ticket relations for Ticket-to-Ticket dependency/blocking/related metadata, OrchestrationPlan records for routing/execution plans, and Worker/session claims for runtime ownership hints.
Objective lifecycle is only Objective lifecycle. `active`, `paused`, `done`, and `archived` do not drive Ticket `state`, do not authorize implementation, and do not close linked Tickets. A role reading Objective context must still inspect each Ticket body, thread, artifacts, explicit Ticket relations, and OrchestrationPlan records before acting.
The maintainer CLI is:
```sh
yoi objective create --title "..." [--ticket <ticket-id> ...]
yoi objective list [--state active|paused|done|archived|all]
yoi objective show <objective-id>
yoi objective doctor
```
The first version intentionally does not implement roadmap scheduling, milestones, OKRs, graph solving, Objective-mandatory Ticket creation, Objective thread/artifact history, or broad panel UX. Future UX can surface Objective context around Tickets as long as it remains background context and never substitutes for reading the Ticket.
Repository-local `.yoi/objectives` trees are ignored. There is no automatic import or cwd/ancestor fallback.
## Ticket configuration
@@ -268,40 +228,15 @@ A useful Ticket states:
- reviews;
- final resolution when closed.
Keep long research dumps out of the item body. Put necessary artifacts under the Ticket's `artifacts/` directory and summarize the conclusion in the thread.
Keep long research dumps out of the item body. Attach necessary bounded artifacts through the Ticket evidence surface and summarize the conclusion in the thread.
Do not store secrets, credentials, private prompt contents, or raw logs containing secrets in Ticket bodies, thread entries, artifacts, diagnostics, or model-visible prompts.
## Backend/maintainer CLI: `yoi ticket`
## Backend CLI: `yoi ticket`
The product CLI exposes the typed Ticket backend for repository maintenance and validation. It operates on the configured `.yoi/tickets/` storage and is the preferred command-line surface when editing Tickets outside a Worker.
The product CLI resolves an explicit or configured Backend target and performs Ticket operations through the Workspace API. It does not open a repository-local backend. Use command help for the current typed command surface.
```sh
yoi ticket create --title "..." [--priority P2]
yoi ticket list [--state planning|ready|queued|inprogress|done|closed|all] [--limit n]
yoi ticket show <id>
yoi ticket comment <id> [--role comment|plan|decision|implementation_report] [--file path]
yoi ticket review <id> --approve|--request-changes [--file path]
yoi ticket state <id> <planning|ready|queued|inprogress|done>
yoi ticket close <id> [--resolution text|--file path]
yoi ticket doctor
```
`yoi ticket list` is a capped overview/selection command. It should remain readable for humans and safe for model context: use it to find a canonical id, then use `yoi ticket show <id>` before routing, closing, planning, or implementation decisions.
`yoi ticket state` records current lifecycle transitions among active states. Closing must use `yoi ticket close` so the backend writes the required `resolution.md` and passes `yoi ticket doctor`; `done` and `closed` remain distinct states.
The current LocalTicketBackend stores records under:
```text
.yoi/tickets/<ticket-id>
item.md
thread.md
artifacts/
resolution.md # closed Tickets only
```
Backend integrations must preserve this format until an explicit migration changes it. `thread.md` is an append-only typed event log: existing events such as `create`, `comment`, `plan`, `decision`, `implementation_report`, `review`, `state_changed`, and `close` remain valid, while `state_changed` records durable transition metadata (`from`, `to`, `reason`, optional `field`, plus `author` and `at`) and `intake_summary` records the bounded Intake outcome body. Thread events are audit history, not current-state authority; current state belongs in `item.md` frontmatter or the owning backend record. The repository-root `work-items/` path is no longer a live mutable backend; do not recreate it for Ticket records. Human users should prefer `yoi panel`, Ticket tools, or `yoi ticket ...` when working directly with repository records.
Legacy `ticket init` and `ticket import-local` workflows are unsupported. No automatic migration reads old `.yoi/tickets` trees; export data with an older version before upgrading if it must be retained, then import it through an explicit supported Workspace surface.
## Validation
@@ -0,0 +1,14 @@
# Repository-local `.yoi` authority removal
This release removes the remaining production readers, writers, discovery helpers, fallbacks, and import paths that treated a repository-local `.yoi` tree as product authority.
Removed surfaces include:
- Ticket filesystem config loading, `LocalTicketBackend`, and local-to-SQLite import helpers;
- Worker Ticket feature constructors backed by local paths;
- Memory repository layout/discovery, local resident/query/staging/audit/usage executors, and `yoi memory lint`;
- the Runtime helper that derived its store from `<workspace>/.yoi/runtime-store`.
Workspace identity, metadata, Ticket, Objective, Memory, and launch configuration remain owned by the Workspace Server control-plane database and typed Workspace APIs. Client routing remains in XDG client configuration. Runtime-owned Workdirs, session logs, artifacts, Server data directories, repository/Git files, explicit Plugin package files, and the legacy home-directory secret-store boundary are unchanged.
There is no automatic migration or compatibility fallback. Before upgrading from a version that stored active Ticket or Memory data in a repository `.yoi` tree, explicitly export data with the old version and import it through a supported Workspace-backed surface. Stale or malformed repository/ancestor `.yoi` trees are ignored by normal operation and may be archived or removed after verification.
@@ -0,0 +1,51 @@
# T-452 final repository-local `.yoi` authority audit
Date: 2026-09-22
Objective: O-12
Ticket: T-452
## Baseline and method
The implementation branch was created from `develop` at `043cb7bf22bd59d1d553e3f5a7c86c20502fad30` after confirming that ref was the then-current provider-resolved `origin/develop`. This audit follows normal entrypoints through constructors and typed backends to their I/O boundary. A raw `.yoi` string search was used only to enumerate candidates; strings in comments, negative regression fixtures, explicit artifacts, and preserved storage boundaries are not treated as authority by themselves.
The final pre-review pass must refresh from `develop` after T-629 is integrated. T-629 owns conversion of the existing panel E2E setup; this change neither restores nor duplicates that legacy fixture.
## Reachability and disposition
| Surface | Entry point and pre-change path | Disposition |
| --- | --- | --- |
| Ticket config | `TicketConfig::load_workspace` read `<workspace>/.yoi/workspace.toml`, fell back to `.yoi/ticket.config.toml`, and defaulted to `.yoi/tickets`. | Removed the filesystem loader, backend path/provider types, fallback parser, and path resolution. `ticket::config` now contains only filesystem-independent fixed-role domain types. |
| Ticket provider | Public `LocalTicketBackend` performed Ticket tree reads/writes. `SqliteTicketBackend::import_from_local_backend` converted that tree into SQLite. | Removed both production providers/helpers and their filesystem-only tests. SQLite fixtures now seed `typed_tickets`/events directly. Deliberately stale Ticket/Objective trees remain only as ignored-input regressions. |
| Worker Ticket tools | `TicketFeatureBackend::Local`, `TicketFeature::for_workspace[_with_access]`, and local constructors could install filesystem-backed tools even though the normal controller already selected a Workspace client. | Removed the local variant and constructors. The feature now accepts only an injected `Arc<dyn WorkspaceClient>` and constructs `WorkspaceHttpTicketBackend`; there is no provider-selection enum or filesystem path parameter. |
| Memory CLI | `yoi memory lint` -> `memory_lint::run` -> cwd/`--workspace` -> `WorkspaceLayout` -> filesystem linter. | Removed the mode, parser/help branch, module, and CLI dependency. Old invocations fail as unknown before target/cwd resolution and do not read or mutate malformed legacy input. No replacement Memory UX or compatibility shim was added. |
| Memory provider | `WorkspaceLayout::resolve` searched cwd/ancestors and supplied `.yoi/memory` paths to query/resident, staging, consolidation, audit, usage, scope, and lint implementations. `execute_memory_backend_operation` dispatched operations to that layout. | Removed layout/discovery, local executor, resident/scope/usage writers, extraction/consolidation staging files, filesystem audit implementation, and path-scanning linter modules. Kept Backend operation DTOs, extraction payload/tool contracts, audit event DTOs, provenance/frontmatter schema helpers, and lint-common slug types because they are filesystem-independent shared domain/API types. Workspace Server SQLite authority remains the Memory persistence executor. |
| Runtime store | Public `WorkerRuntimeExecutionBackend::from_workspace` derived `<workspace>/.yoi/runtime-store`. | Removed. Runtime callers continue to pass explicit Runtime-owned store/config paths; no repository fallback was introduced. |
| Workspace/client/bootstrap | Normal `yoi init`, client target resolution, Workspace catalog/metadata, Server bootstrap, and repository mapping were already cut over by T-468. | Preserved. Existing root/ancestor stale-marker and clean-init tests continue to assert Server DB/API plus XDG client routing, with no repository write. |
| Profile/Skill/Prompt | Workspace projections were already sourced from the Server virtual config tree; direct Worker startup uses explicit manifest/builtin authority. | Preserved. Existing malformed local profile/prompt/skill fixtures are negative tests. A clean direct Worker-startup regression now verifies that manifest resolution does not create `.yoi`. |
| Plugin | T-603 already removed ambient `.yoi/plugins` discovery. | Preserved the source assertions and explicit `.yoi-plugin` offline package operations. No install/runtime fallback was added. |
| Objective | Worker Objective tools already require Workspace API authority; Workspace Server stores Objective rows in SQLite. | Preserved. Filesystem-only Objective fixtures are ignored-input regressions, not import setup. |
| Web/TUI | Both consume Workspace HTTP/client models and do not construct repository Ticket/Memory backends. | Preserved. Their checks validate the current typed API/routing surfaces; T-629 separately owns the panel E2E fixture modernization. |
## Regression evidence added or strengthened
- Source assertions reject the concrete production provider/constructor chain rather than banning every `.yoi` string: Ticket config/provider/import, Worker local Ticket feature, Memory layout/executor/staging entrypoints (including every compiled extraction implementation module), Runtime `from_workspace`, and CLI `memory_lint` mode.
- `TicketFeature` has no repository/cwd input: installation accepts only an authority-scoped Workspace client and access policy. A regression verifies that separately present malformed repository-root Ticket input and malformed ancestor Workspace input remain byte-for-byte unchanged while the Workspace tool surface installs.
- The removed Memory CLI is unknown with bare, `lint`, help, and legacy `--workspace` forms; a malformed `.yoi/memory` fixture remains byte-for-byte unchanged.
- Direct Worker manifest resolution against a clean explicit workspace does not create `.yoi`. A Runtime integration regression additionally creates a Worker from a clean Git repository, executes a turn, stops/restores the Worker, and verifies that launch and restore never create repository-local `.yoi` authority.
- Ticket tool mutation uses a direct SQLite backend and asserts `tickets.db` is written while `.yoi`/`tickets` are not.
- Workspace Server authority seeds SQLite Ticket/Objectives directly, proves filesystem-only records are ignored, and proves legacy Memory staging cannot enter the Backend consolidation backlog.
## Remaining `.yoi` classifications
The post-change source inventory contains the following intentional classes:
1. **Negative regression input or assertions:** stale/malformed Workspace, Ticket, Objective, Memory, Profile, Skill, Prompt, and Plugin trees; assertions that clean operation creates no `.yoi`; leak-prevention fixtures.
2. **Preserved runtime/server boundaries:** Runtime-owned Workdirs and run artifacts, explicit Server data/config directories, session-store compatibility for the legacy home-directory secret/session boundary, and generated ignore files that prevent execution artifacts from entering Git.
3. **Explicit artifacts:** repository/Git files selected by the user and `.yoi-plugin` package paths passed directly to offline `new`/`check`/`pack` commands.
4. **Documentation/history:** current guidance describing removal and historical reports retained as historical evidence.
None of these classes performs cwd/ancestor authority discovery, automatic import, dual read, or repository-local fallback. The repository checkout and Git metadata remain ordinary filesystem inputs; removing filesystem use in general is outside T-452.
## Upgrade contract
There is no automatic migration, startup import, compatibility reader, or delete pass. Deployments that still need legacy repository-local Ticket or Memory data must export it with the old version before upgrading and import it through a supported Workspace-backed surface. Stale trees are ignored and can be archived or removed only after operator verification.