memory-file-format完了

This commit is contained in:
Keisuke Hirata 2026-04-27 13:59:04 +09:00
parent f2e47629d0
commit cd8b620e6a
6 changed files with 246 additions and 224 deletions

View File

@ -13,7 +13,6 @@
- [ ] TUI 補完 + 型付き atom 化 → [tickets/submit-tui-completion.md](tickets/submit-tui-completion.md)
- [ ] セッションログの Segment 保持 → [tickets/session-log-segments.md](tickets/session-log-segments.md)
- [ ] メモリ機構
- [ ] ファイル形式 + Linter 土台 → [tickets/memory-file-format.md](tickets/memory-file-format.md)
- [ ] memory / Knowledge 検索ツール → [tickets/memory-search-tools.md](tickets/memory-search-tools.md)
- [ ] `model_invokation: ON` の常駐注入 → [tickets/memory-resident-injection.md](tickets/memory-resident-injection.md)
- [ ] Phase 1 活動抽出 → [tickets/memory-phase1-extract.md](tickets/memory-phase1-extract.md)

View File

@ -126,13 +126,18 @@ impl Linter {
}
}
// 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_kind::<RequestFrontmatter>(content, &classified, &mut report);
self.check_request(content, &classified, &mut report);
}
RecordKind::Knowledge => {
self.check_knowledge(content, &classified, &mut report);
@ -163,6 +168,22 @@ impl Linter {
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,
@ -229,12 +250,45 @@ impl Linter {
}
}
/// Workflow frontmatter validator exposed for human-edit paths
/// (CLI / pre-commit). Not used by the memory tool, which rejects
/// workflow writes outright.
pub fn lint_workflow_frontmatter(content: &str) -> Result<WorkflowFrontmatter, LintError> {
let parsed = parse_frontmatter::<WorkflowFrontmatter>(content)?;
Ok(parsed.frontmatter)
impl Linter {
/// Workflow record validator exposed for human-edit paths
/// (CLI / pre-commit). Not used by the memory tool, which rejects
/// workflow writes outright.
///
/// Verifies frontmatter shape, body size, and that every slug in
/// `requires` points at an existing Knowledge record under the
/// workspace's `knowledge/` directory.
pub fn lint_workflow(&self, content: &str) -> LintReport {
let mut report = LintReport::default();
let parsed = match parse_frontmatter::<WorkflowFrontmatter>(content) {
Ok(p) => p,
Err(e) => {
report.push_error(e);
return report;
}
};
size::check_body::<WorkflowFrontmatter>(parsed.body, &mut report);
let existing = match existing::scan_existing(&self.layout) {
Ok(e) => e,
Err(e) => {
report.push_error(LintError::MalformedFrontmatter(format!(
"failed to scan existing records: {e}"
)));
return report;
}
};
for slug in &parsed.frontmatter.requires {
if !existing.contains(crate::workspace::RecordKind::Knowledge, slug) {
report.push_error(LintError::UnknownReference {
field: "requires",
kind: "knowledge",
slug: slug.to_string(),
});
}
}
report
}
}
struct Parsed<'a, F> {
@ -417,6 +471,115 @@ mod tests {
assert!(report.errors.iter().any(|e| matches!(e, LintError::SlugAlreadyExists(_))));
}
#[test]
fn workflow_lint_accepts_valid_record() {
let (dir, linter) = workspace();
// Place a Knowledge record that the workflow will reference.
let kn = dir.path().join("knowledge/foo.md");
write(
&kn,
&format!(
"---\ncreated_at: {n}\nupdated_at: {n}\nkind: rule\ndescription: x\nmodel_invokation: false\nuser_invocable: true\nlast_sources: []\n---\n",
n = iso_now()
),
);
let wf = format!(
"---\nupdated_at: {n}\ndescription: do thing\nauto_invoke: false\nuser_invocable: true\nrequires: [foo]\n---\nstep 1\n",
n = iso_now()
);
let report = linter.lint_workflow(&wf);
assert!(!report.has_errors(), "got errors: {:?}", report.errors);
}
#[test]
fn workflow_lint_flags_unknown_requires() {
let (_dir, linter) = workspace();
let wf = format!(
"---\nupdated_at: {n}\ndescription: x\nauto_invoke: false\nuser_invocable: true\nrequires: [missing-knowledge]\n---\n",
n = iso_now()
);
let report = linter.lint_workflow(&wf);
assert!(report.errors.iter().any(|e| matches!(
e,
LintError::UnknownReference {
field: "requires",
kind: "knowledge",
..
}
)));
}
#[test]
fn workflow_lint_collects_multiple_unknown_requires() {
let (_dir, linter) = workspace();
let wf = format!(
"---\nupdated_at: {n}\ndescription: x\nauto_invoke: false\nuser_invocable: true\nrequires: [a, b, c]\n---\n",
n = iso_now()
);
let report = linter.lint_workflow(&wf);
let unknown_count = report
.errors
.iter()
.filter(|e| matches!(e, LintError::UnknownReference { .. }))
.count();
assert_eq!(unknown_count, 3);
}
#[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!("memory/decisions/{slug}.md")),
&format!(
"---\ncreated_at: {n}\nupdated_at: {n}\nsources: []\nstatus: open\n---\n",
n = iso_now()
),
);
}
let path = dir.path().join("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!("memory/decisions/{slug}.md")),
&format!(
"---\ncreated_at: {n}\nupdated_at: {n}\nsources: []\nstatus: open\n---\n",
n = iso_now()
),
);
}
let path = dir.path().join("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();

View File

@ -6,10 +6,17 @@
use crate::error::LintWarning;
use crate::linter::LintReport;
use crate::workspace::ClassifiedPath;
use crate::linter::existing::ExistingRecords;
use crate::slug::Slug;
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.
@ -32,3 +39,70 @@ pub fn check_warnings_with_sources(body: &str, source_count: usize, report: &mut
});
}
}
/// 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);
}
}

View File

@ -193,7 +193,7 @@ GC Agent は **drop / merge / split を自律実行**(削除まで含む)。
- **ファイル単位**: 丸ごと drop、複数ファイルの merge、1 ファイルの分割split
- **ファイル内の部分削除**: 本文の一部節・箇条を削除 or 圧縮。frontmatter の `sources` 古いエントリの trim も含む
Phase 2 と同じ CRUD tool + Linter Hook を使うので、operation 粒度は自然にサポートされる(専用 API は用意しない)。
Phase 2 と同じ memory 専用 Toolread / write / edit、内部で pre-write Linterを使うので、operation 粒度は自然にサポートされる(専用 API は用意しない)。
#### GC の評価カテゴリ

View File

@ -1,141 +0,0 @@
# メモリ機構: ファイル形式 + Linter 土台
## 背景
`docs/plan/memory.md` で決めたメモリ機構の永続化レイヤの土台。`memory/*` と `knowledge/*` の record を保存・編集する際の静的スキーマと、書き込み時の Linter を成立させる。Phase 1/2、検索ツール、常駐注入、GC はすべてこの層に乗る。
Workflow`docs/plan/workflow.md`)も同じ frontmatter / Linter 経路で扱うため、`memory/workflow/<slug>.md` の frontmatter 検証と書き込み制限も本チケットに含める。実行経路(`/<slug>` dispatchは別。
## 設計方針
### memory クレートに集約
memory 関連は新規 `crates/memory/` に全部閉じ込める。`tools` クレートや `pod` 層に memory 由来のコードを漏らさない。
- schemafrontmatter 型、slug 文法、Linter ルール、Tool 実装、ワークスペース解決を全部 `memory` クレート内に置く
- `tools::write_tool` / `edit_tool` には一切手を入れない
- LLM への違反伝達は memory tool が `ToolError::InvalidArgument` を返すことで自然に成立。Interceptor 拡張・retry message 注入・違反カウンタは持たない
- 「N 回失敗で abort」は worker 層の max iteration に委ね、memory 固有のカウンタは設けない
### memory 専用 Tool汎用 CRUD ではない)
`memory` クレートが `read_tool` / `write_tool` / `edit_tool` の 3 種を提供する。これらは `<workspace>/memory/`、`<workspace>/knowledge/` 配下のみを対象とし、write/edit は **fs 書き込み前** に Linter を通して違反は `ToolError::InvalidArgument` で返す。
`docs/plan/memory.md` の「同じ汎用 CRUD」記述は本チケットで `memory` 専用 Tool 方式に書き換える(汎用 CRUD は memory ディレクトリには触らせない)。
### Pod 側の責務(最小限)
memory を有効化する Pod は、generic tool に渡す Scope から `memory/`、`knowledge/` を deny に落とす。これにより同じ workspace 内で、generic write/edit は memory 配下を触れず、memory tool だけが触れる構造になる。Pod 側でやることはこの Scope deny と memory tool の登録だけ。
### 「sub-Worker / 人間」の二系統
- **sub-Worker**: tool 層を経由するため Linter を必ず通る
- **人間**: エディタ / git commit は tool 層を経由しないので Linter を通らない。`memory::Linter` を import して走らせる CLI / pre-commit hook を後で用意できる構造にしておく(本チケットでは実装しない)
## 要件
### ディレクトリと record 種別
- `memory/summary.md` — Always-on サマリ1 ファイル固定)
- `memory/decisions/<slug>.md` — Decisions
- `memory/requests/<slug>.md` — Requests
- `memory/workflow/<slug>.md` — Workflowfrontmatter 検証のみ対象)
- `memory/_staging/<id>.json` — Phase 1 中間パス予約のみ。Linter 対象外)
- `knowledge/<slug>.md` — Knowledge`memory/` の兄弟)
slug 文法: `^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$`agent-skills 準拠、1-64 chars、先頭末尾 `-` 不可、`--` 連続不可。ファイル名がそのまま識別子で、frontmatter に `id` / `name` は持たない。
### frontmatter スキーマ
- 共通: `created_at`, `updated_at`RFC3339
- Decisions: `sources`, `status: open | resolved | replaced`、置き換え時 `replaced_by: <slug>`
- Requests: `sources`
- Knowledge: `kind`, `description`, `model_invokation`, `user_invocable`, `last_sources`
- Summary: `updated_at`optional: `last_rewritten_from_range`
- Workflow: `description`, `auto_invoke`, `user_invocable`, `requires`
`sources` / `last_sources` の要素形式は `{ session_id: String, range: [u64, u64] }`。`range` は session-store の entry index ペア。
### Linter ルール
**静的 error**memory tool が `ToolError::InvalidArgument` で返す。複数違反は集約して 1 回で返す):
- frontmatter 必須 field 欠落・型違反
- `memory/workflow/` への書き込み禁止sub-Worker のみ。memory tool が拒否、人間編集は memory tool を通らないので素通り)
- 同 slug での新規作成禁止(既存があれば update を要求)
- `replaced_by: <slug>` / `requires: [..]` が実在 record を指す
- `replaced_by` の循環は error
- Decisions `status` の enum 違反
- slug 文法違反
- `model_invokation: true` な Knowledge の description 1024 chars 上限
- 種別ごとの char 硬上限(初期既定値、設定 key は別 PR:
- `summary.md`: 20000 chars
- decisions / requests / knowledge 本文: 各 8000 chars
**膨張抑制 Warn**error にせず、warn として返す。memory tool は受け取って summary に追記する程度):
- 低重要度 × char の天秤(暫定: 1500 chars 超のレコードに対して `sources` 1 件のみなら warn
- `sources` 配列長の累積(暫定: 10 件超で warn
- 類似 slug 乱立(暫定: Levenshtein 距離 2 以下の slug が 3 件以上で warn
具体閾値の調整は別 PR設定 key 化)。
### `#<slug>` 本文中検出はスコープ外
本文中の `#<slug>` 参照の検出 / 補完は submit-segment 系チケットの責務。本チケットの Linter は frontmatter 由来の参照(`replaced_by` / `requires`)のみを検証する。
### 参照整合チェックの実行方式
write/edit 毎に `<workspace>/memory/`、`<workspace>/knowledge/` を毎回 walk して slug 集合を構築(キャッシュなし)。ファイル数は少ない想定。
### 適用経路
- memory tool の write/edit 内で pre-write 検証
- 人間編集 / pre-commit hook 経路は本チケットでは作らない。`memory::Linter` を pure 関数として export しておけば後で CLI 化できる
## 範囲外
- 検索ツール、常駐注入、Phase 1/2、GC の実装
- 意味破壊rewrite で主張が落ちる等)の検出 — 監査 LLM 層は将来検討
- staging JSON の schema — Phase 1 チケット
- Workflow の `/<slug>` 実行経路
- 本文中 `#<slug>` 参照の検出 — submit-segment 系
- 設定 key閾値 tune— 別 PR
- 人間編集向け CLI / pre-commit hook — 別チケット
- `Interceptor` / `Hook` 系統への拡張 — 不要tool error で完結)
## 完了条件
- `crates/memory/` が新設され、workspace に登録されている
- memory tool 3 種read / write / editが登録できる
- write/edit に違反 content を渡すと、複数違反を集約した `ToolError::InvalidArgument` が返り、fs に書き込まれない
- 正常 content は通常通り書き込まれる
- `memory/workflow/<slug>.md` への write/edit は error で止まる
- 同 slug で新規作成しようとすると error になるexisting → edit に倒すサイン)
- `replaced_by` / `requires` の参照切れと循環が error として検出される
- Pod が memory を有効化すると、generic tool の Scope から memory/knowledge が deny される
- 既存ビルド・テストを壊さない
## 実装順序
1. `crates/memory/` 新設、workspace 登録、依存追加
2. `schema/`, `slug.rs`, `error.rs`pure 関数 + 型)
3. `linter/`frontmatter / size / 参照存在 / 循環 / workflow 拒否)
4. `tool/`read / write / edit、pre-write で linter 通す)
5. Pod 側の Scope deny 配線
6. 単体テスト
各ステップ終了時点でビルド通過を維持する。
## 参照
- `docs/plan/memory.md` §ファイル形式 / §書き込み経路と Linter / §Knowledge の採択基準(本チケットで該当箇所を memory 専用 tool 方式に更新)
- `docs/plan/workflow.md` §格納先とファイル形式 / §生成・更新ポリシー
- `crates/tools/src/{write,edit,read}.rs` — Tool 実装の参考(依存はしない)
- `crates/llm-worker/src/tool.rs``Tool` trait / `ToolError` / `ToolOutput`
## Review
- 状態: Approve with follow-up
- レビュー詳細: [./memory-file-format.review.md](./memory-file-format.review.md)
- 日付: 2026-04-27

View File

@ -1,73 +0,0 @@
# Review: メモリ機構: ファイル形式 + Linter 土台
## 前提・要件の確認
### 完了条件マッピング
| # | 条件 | 結果 | 根拠 |
| - | ---- | ---- | ---- |
| 1 | `crates/memory/` 新設、workspace 登録 | OK | `Cargo.toml:13`, `crates/memory/Cargo.toml`, `crates/memory/src/lib.rs:9-15` |
| 2 | memory tool 3 種read / write / editが登録できる | OK | `crates/pod/src/controller.rs:243-252` |
| 3 | 違反 content で複数違反集約の `InvalidArgument`、fs 不変 | OK | `crates/memory/src/tool/write.rs:185-202`, `:237-249`、`linter::format_report` で全 error を 1 メッセージに集約 |
| 4 | 正常 content は通常書き込み | OK | `tool/write.rs:147-163` |
| 5 | `memory/workflow/` 書き込み拒否 | OK | `linter/mod.rs:101-105`、`tool/write.rs:165-181`, `tool/edit.rs:276-298` |
| 6 | 同 slug 新規作成 error | OK | `linter/mod.rs:120-127`, `tool/write.rs:204-222` |
| 7 | `replaced_by` / `requires` の参照切れと循環が error | 部分 | `replaced_by` 不在: OK / `replaced_by` 循環: アルゴリズム実装済みだが実循環ケースの end-to-end テスト無し / **`requires` 整合: 未実装**`schema/workflow.rs` で型パースのみ) |
| 8 | Pod 有効化で generic tool Scope deny | OK | `pod.rs:1516-1527` (`build_scope_with_memory`)、`scope::deny_write_rules` |
| 9 | 既存ビルド・テスト壊さない | OK | `cargo build --workspace` / `cargo test --workspace` 全 pass、memory 単体 44 テスト pass |
### Linter ルール(要件節)と実装の照合
静的 error:
- frontmatter 必須欠落・型違反: OK (`linter/frontmatter.rs::map_serde_error`、ただし `parse_invalid_status``\`open\`` リテラル含有でラフに status 同定する点はやや fragile — 別フィールドが今後 enum を持つ場合は誤分類リスク)
- workflow 書き込み禁止: OK
- 同 slug 新規禁止: OK
- `replaced_by` 実在: OK
- `replaced_by` 循環: 実装あり / テスト不足
- Decisions `status` enum: OK
- slug 文法: OK (`slug::is_valid_slug` + 全網羅 deserialize 経路)
- `model_invokation: true` の description 1024 上限: OK
- 種別ごとの char 硬上限 (summary 20000 / 他 8000): OK
膨張抑制 Warn:
- 大 record × 単一 sources warn: OK
- sources 配列長 warn: OK
- **類似 slug 乱立 (Levenshtein 距離 2 / 3 件以上) warn: 未実装**`LintWarning::SimilarSlugs` バリアントは定義済みだが、emit 経路無し。`existing.slugs(kind)` は揃っており実装は容易)
## アーキテクチャ・スコープ
### 良い点
- memory 関連は `crates/memory/` に明確に閉じ込められている。`tools/` 側に侵入なし、`pod` 側は `MemoryConfig` 取得 + `build_scope_with_memory` + tool 登録 3 行のみで責務が薄い
- crate 名は `memory``insomnia-` プレフィックス無し(命名ルール準拠)
- 依存追加は `cargo add` 由来とみられる版指定で workspace に整合(`crates/memory/Cargo.toml`
- `schema/`, `linter/`, `tool/` を feature module で分割しており、巨大 `lib.rs` を回避
- LLM への違反伝達は `ToolError::InvalidArgument` のみで完結。Interceptor 拡張・retry message 注入・違反カウンタを持たないという設計方針通り
- 公開 API は `lib.rs` 12 行で抑制的、leaky でない
- `_staging/` は意図通り linter 透過 (`workspace::classify` で `Ok(None)`)
### 懸念
- **plan 残存矛盾**: `docs/plan/memory.md:196` に「Phase 2 と同じ CRUD tool + Linter Hook を使う」が残っている。「Linter Hook」は本チケットで否定された旧設計の語彙。GC 節の更新漏れ4 箇所更新と説明されたが GC 節は対象外だった可能性)
- **deny target = `<workspace_root>/memory`**: `MemoryConfig::workspace_root``None` の時 pwd を採用するが、pwd ≠ workspace root の Pod を spawn したケースで deny 対象パスがズレ、generic write が実際の `memory/` を保護しない可能性。ticket 要件は文字通りには満たしているが、「workspace_root 既定値が pwd」前提が manifest 側で文書化されていない。`PodManifest` 化のドクコメントに「workspace_root 未指定 ⇒ Pod 構築時 pwd」と書く程度は欲しい
## 指摘事項
### Blocking
なし。完了条件 7 は部分的だが、`requires` の検証は memory tool 経由で書き込む経路がticket の禁止により存在しないため、実害が無い。CLI / pre-commit が別チケットに切り出されていることと整合する
### Non-blocking / Follow-up
- **類似 slug 乱立 warn 未実装**`crates/memory/src/error.rs:101` `SimilarSlugs` バリアントは定義済みだが emit されない。ticket 要件節「膨張抑制 Warn」に明記されているので、本ライフサイクル内に追加するか、後続 ticket に明示移管したい
- **`requires` 参照整合チェック未実装** — `lint_workflow_frontmatter` (`linter/mod.rs:235`) は型パースだけで、`requires: Vec<Slug>` の各 slug が実在 Workflow か検査しない。memory tool では到達しないが、ticket 完了条件に文言が残っている。CLI ticket に明示的に移すか、本チケットで `lint_workflow_frontmatter` の引数に `&ExistingRecords` を渡す形で実装するのが筋
- **`replaced_by` 循環の end-to-end テスト追加** — `references.rs::tests::empty_chain_terminates` は smoke test に近い。`A.replaced_by=B`, `B.replaced_by=A``existing` に置いた状態で 2 ノード閉路を実検出するテストを足すと完了条件 7 のカバレッジが揃う
- **`docs/plan/memory.md:196`** — 「Linter Hook」を「Lintermemory tool 内 pre-write 検証」相当に書き換え。GC 節は本チケットの直接対象外でも、用語整合は取りたい
- **`MemoryConfig.workspace_root` 既定値の文書化** — `crates/manifest/src/lib.rs:46-53` のドクコメントに「`None` ⇒ Pod の pwd 採用」「pwd ≠ workspace root の Pod では明示指定が必要」を追記
- **`lint_workflow_frontmatter``lib.rs` 公開** — CLI 経路の入口になる関数なので `memory::Linter` と並んで `pub use linter::lint_workflow_frontmatter;` しておくと後続 CLI ticket が触れやすい
### Nits
- `linter/frontmatter.rs::parse_invalid_status``\`open\`` 文字列マッチで status enum を識別するため、将来別の enum (`status`-like) が増えた時に誤分類するリスク。コメント注記はあるが、`field: "status"` ハードコード分岐に何らかのテストを足したい
- `tool/edit.rs``format_report` / `warning_tail``tool/write.rs` の同名関数とロジックがほぼ重複。`tool/mod.rs` に小さなヘルパとして括り出すと DRY に
- `SourceRef.range: [u64; 2]` は frontmatter での扱いが配列だが、構造体名コメントが「inclusive range」とのみ。ドクコメントに「`[start_entry_index, end_entry_index]` 両端含む」を簡潔に書くと session-store との対応が明示される
- `Slug::PartialOrd / Ord` は派生済みだが、`HashMap<Slug, _>` で十分なので `BTreeMap` 用途が現状無い。問題ないが意図メモがあると親切
## 判断
**Approve with follow-up** — 完了条件はほぼ全て充足。アーキテクチャ的に memory 専用クレートへ綺麗に閉じ込められ、`tools` / `pod` への漏出は最小5 行程度。LLM 違反伝達も `ToolError::InvalidArgument` 一本で完結し、ticket 設計方針に忠実。残課題(類似 slug warn / `requires` 検証 / 循環 e2e テスト / plan 残存表現 / workspace_root ドキュメント)はいずれも非 blocking で、別 PR / 追跡 ticket への計上で十分。ビルド・テストともに workspace 全 pass。