Compare commits

..

No commits in common. "88b91a2c5599af26c3f19fc8e1aaa3fe8e4cac19" and "9e0f8e9aad6f2a1d3b08311be54f64e2595888cc" have entirely different histories.

158 changed files with 6248 additions and 5810 deletions

View File

@ -2,13 +2,13 @@
title: "効果的な Memory システム設計・検証"
state: "active"
created_at: "2026-06-20T15:16:00Z"
updated_at: "2026-07-17T18:10:00Z"
linked_tickets: ["00001KSKBPHRG", "00001KT02TCCG", "00001KTGCAFXG", "00001KSKBPTHR", "00001KXMEZNYC", "00001KXMK7YMC", "00001KXNYXNM6", "00001KXRM6G0G", "00001KXMK846H"]
updated_at: "2026-07-15T21:18:00Z"
linked_tickets: ["00001KSKBPHRG", "00001KT02TCCG", "00001KTGCAFXG", "00001KSKBPTHR"]
---
## Goal
Yoi の Memory / Knowledge / Skills / generated context / resident context / retrieval / usage metrics を、実際の開発・設計・レビュー・オーケストレーションに効く sensemaking substrate として再設計・検証する。Memory は短期・変化前提の context、Knowledge は育てる long-term note、Skill は移植可能な workflow として分け、この Objective ではそれらと authority record / docs / typed tools の境界を再整理する。
Yoi の Memory / generated memory / resident context / retrieval / usage metrics を、実際の開発・設計・レビュー・オーケストレーションに効く sensemaking substrate として再設計・検証する。Knowledge は separate record kind として削除する方針であり、この Objective では Memory と authority record / Skill / docs の境界を再整理する。
この Objective でいう「効果的な Memory システム」は、単に多く保存する仕組みではなく、作業中の問いに対して relevant material を集め、根拠を検証可能にし、再表現・仮説形成・反証探索・意思決定・成果物への反映を低コストにする仕組みである。
@ -41,7 +41,7 @@ Yoi の現行 Memory は、この流れのうち「保存」と「一部の検
- Ticket / task / question ごとの shoebox がない。
- shoebox から evidence snippets を切り出し、source / provenance / applicability / confidence と共に扱う evidence file がない。
- `summary`, `decision`, `request` は durable memory storage taxonomy であり、sensemaking 用 schema としては粗い。Knowledge は古い record kind をそのまま残すのではなく、育てる long-term note subsystem として再設計する。再利用可能な手順は Skill、保守された設計資料は Knowledge / docs / Ticket decisions に寄せる。
- `summary`, `decision`, `request` は durable memory storage taxonomy であり、sensemaking 用 schema としては粗い。Knowledge record kind は削除方針なので、再利用可能な手順は Skill、保守された設計資料は docs / Ticket decisions に寄せる。
- decision は残るが、hypothesis space、alternative、rejected reason、disconfirming evidence が残りにくい。
- reviewer / orchestrator が confirmation bias を避けるための反証探索導線が弱い。関連する手順誘導は旧 Workflow ではなく Skill と role prompt / typed tools へ寄せる。
- resident exposure と explicit retrieval は観測できても、Memory が product に効いたかは測りにくい。
@ -67,16 +67,7 @@ Memory 墓場化の最初の原因は、保存情報が現在の問いに集ま
この段階では大きな永続 schema 追加に飛びつかず、report / Ticket artifact / bounded generated context として検証してよい。
### 3. Session Overview を extract の足場にする
現在の extract は、tool call / tool result summary を含む flat slice から意味を復元しようとして断片化しやすい。改善方針は、main Worker が通常 Assistant Message として Progress message を残し、user messages + Assistant messages を Session Overview として先に読む形にする。
- Progress message は専用 Tool ではなく通常 Message とし、ユーザーへの進捗報告と extract 用 semantic summary を兼ねる。
- extract worker には専用の read-only evidence tools を渡し、Overview で重要そうに見えた箇所だけ session range / tool summaries / source anchors を探索させる。
- extract worker は Memory / Knowledge / Skill を直接更新しない。output は必ず staging を挟み、source / provenance を host 側で機械的に保持する。
- trigger は初期実装では現行通り Worker run cycle 完了後の threshold 判定にする。LLM call 単位や Run 中の Overview accumulation trigger は含めない。
### 4. Memory を authority にしない
### 3. Memory を authority にしない
Memory は Ticket、docs、git history、session logs、user instruction の代替ではない。Memory は authority record への evidence index / schema / reasoning aid として扱う。
@ -87,7 +78,7 @@ Memory は Ticket、docs、git history、session logs、user instruction の代
- Memory の断定をそのまま authority として使わない。
- Ticket body/thread/artifacts を読まずに Objective や Memory だけで実装判断できる状態を作らない。
### 5. 反証探索を first-class にする
### 4. 反証探索を first-class にする
より効果的な Memory は、過去方針を思い出すだけでなく、現在案を疑うために使える必要がある。
@ -101,7 +92,7 @@ Reviewer / Orchestrator / Intake の導線では、次を探せるようにす
- authority boundary risks
- prior failures / reports
### 6. Metrics は exposure から product impact へ寄せる
### 5. Metrics は exposure から product impact へ寄せる
Memory が prompt に入った、または query されたことは成功ではない。評価は次を区別する。
@ -114,7 +105,7 @@ Memory が prompt に入った、または query されたことは成功では
- contradicted / invalidated
- led to docs or decision update
### 7. 後続 Ticket は concrete slice に分割する
### 6. 後続 Ticket は concrete slice に分割する
この Objective は中期的な設計・検証の一元化 record であり、umbrella Ticket ではない。実装や調査は、単独で実装・レビュー・close できる concrete Ticket に分割する。
@ -127,10 +118,6 @@ Memory が prompt に入った、または query されたことは成功では
- Reviewer Skill / review process に反証探索を入れる。
- Memory usage metrics を product impact oriented に拡張する。
- stale / contradiction / renewal の検出・表示を設計する。
- turn 中の Progress message を通常 Assistant Message として残す prompt/guidance を追加する (`00001KXMEZNYC`)。
- Session Overview + Evidence index を使う extract input を設計・実装する。
- extract worker 専用の read-only evidence search/read/source-anchor tools を設計する。
- extract output を staging に限定し、source range と output entry を結びつける schema を設計する。
## Success criteria / exit conditions
@ -139,9 +126,6 @@ Memory が prompt に入った、または query されたことは成功では
- Ticket / Objective / docs / session logs / Memory / Skills の authority boundary が明確で、Memory が authority を僭称せず、Skill は手順資源として外部状態 authority を持たない。
- 少なくとも一つの実作業 routing / review / design analysis で、task-bound shoebox または evidence file が生成・利用され、作業品質にどう効いたかが確認されている。
- Memory records または関連 artifacts が source / provenance / applicability / staleness / supports-or-refutes のいずれかを扱えるようになっている。
- extract が User / Assistant messages 由来の Session Overview を primary input とし、tool logs を evidence として探索できる設計になっている。
- extract worker 専用の read-only evidence tools が設計され、main Worker の tool surface を増やさない方針になっている。
- extract output は direct Memory / Knowledge / Skill write ではなく staging を挟む方針になっている。
- Reviewer / Orchestrator が supporting evidence だけでなく、contradicting evidence / stale assumptions / rejected alternatives を探す導線を持っている。
- Memory usage metrics が resident exposure と product impact を区別している。
- 古い Memory が放置されるのではなく、stale / superseded / contradicted / needs-review として扱える方針がある。
@ -157,14 +141,13 @@ Memory が prompt に入った、または query されたことは成功では
- Memory は durable project authority ではない。Ticket、docs、git history、session logs、明示 user instruction の代替として使わない。
- Objective context は判断背景であり、個別実装の authority は各 Ticket body/thread/artifacts と明示的な Ticket relations / OrchestrationPlan records にある。
- `history` に残らない context-only injection を改善案にしない。新しい context input は history に commit する原則を守る。
- Knowledge は古い unused record kind をそのまま残すのではなく、育てる long-term Markdown note subsystem として再設計する。再利用可能な手順・作法は Agent Skills (`.yoi/skills/<skill>/SKILL.md`) へ、durable policy/rationale は Knowledge / maintained docs / Ticket decisions へ、外部状態 authority は typed feature/tool surface へ分ける。
- Knowledge record kind は削除方針。再利用可能な手順・作法は Agent Skills (`.yoi/skills/<skill>/SKILL.md`) へ、durable policy/rationale は Memory decisions / maintained docs / Ticket decisions へ、外部状態 authority は typed feature/tool surface へ分ける。
- Generated memory / Ticket / docs / report / Skill の境界を再定義する場合は、authority boundary と migration/staleness を明示する。
- 関連する既存 Ticket:
- `00001KSKBPHRG` — Prompt / Workflow 評価メトリクスと改善 Offer
- `00001KT02TCCG` — Memory prompt: conditional guidance and proactive lookup
- `00001KTGCAFXG` — Use .yoi/memory marker for repo-local memory root
- `00001KSKBPTHR` — ワークスペースのメモリーをLintするヘッドレスCLI
- `00001KXMEZNYC` — ターン中のProgress messageを残す指示を追加する
## Historical references / prior design sources
@ -241,10 +224,7 @@ HermesAgent で特に重要だった点:
この Objective での再解釈:
- HermesAgent の `MEMORY.md` / `USER.md` / `skills` の分離は、Yoi の Memory / Knowledge / Skills / prompt resources / docs / Ticket decision / generated memory の責務再整理に使える。
- Yoi は foreground isolation 自体をすでに持つため、取り入れるべきなのは isolation そのものではなく、Overview を足場にした maintenance / extraction の質改善である。
- main Worker が通常 Assistant Message として残す Progress message は、人間向け進捗報告と machine-readable session overview を兼ねられる。
- extract worker は専用 read-only evidence tools で必要箇所だけ探索し、direct write ではなく staging に出力する。
- HermesAgent の `MEMORY.md` / `USER.md` / `skills` の分離は、Yoi の Memory / Skills / prompt resources / docs / Ticket decision / generated memory の責務再整理に使える。
- reusable procedure, reviewer focus, orchestration tactic, project preference, user preference, design invariant を同じ Memory bucket に入れると墓場化しやすい。
- `Nothing to save.` / empty extraction allowed は重要だが、保存抑制だけでは効果的な Memory にはならない。保存されたものが task-bound shoebox / evidence / schema / hypothesis / product に接続される必要がある。
- frozen snapshot / prompt cache 配慮は Yoi の history/context 加工原則と整合するが、それだけでは retrieval / resurfacing / disconfirmation は解決しない。
@ -262,7 +242,6 @@ Codex と HermesAgent の調査から、Yoi が継承すべきものと、継承
- stale / noisy / unused entries の cleanup。
- procedural memory と declarative memory の分離。
- session search / usage metrics / linter feedback を consolidation に入れる設計。
- user / Assistant messages から作る Session Overview を semantic guide にし、tool logs を evidence として探索する設計。
足りないもの:

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,8 @@
---
title: 'Remove workflow tracking and workflow resources'
state: 'closed'
state: 'inprogress'
created_at: '2026-07-15T19:02:13Z'
updated_at: '2026-07-15T21:39:23Z'
updated_at: '2026-07-15T21:25:42Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-07-15T19:54:36Z'

View File

@ -1,41 +0,0 @@
Workflow tracking / Workflow resource / Workflow invocation machinery を削除・レビュー・merge・検証した。
実装内容:
- model-visible `ActiveWorkflowList`, `ActiveWorkflowComplete`, `ActiveWorkflowCancel` tools を削除。
- Worker active-workflow durable state、extension snapshotting、compaction/rehydration handling、prompt re-injection paths を削除。
- Workflow registry/resource loading と `/workflow-slug` invocation behavior を削除。
- `crates/workflow` crate と tracked `resources/workflows/*` resources を削除。
- `WorkflowInvoke` を protocol / TUI / web generated protocol surfaces から削除。
- slash workflow completion behavior と related web console completion surface を削除。
- `.yoi/workflow` memory/workspace authority と workflow usage-source handling を削除。
- resident workflow / workflow invocation wording を prompts/docs/config から削除。
- old persisted `kind: "workflow"` `SystemItem`s は `LegacyIgnored` として bounded diagnostic/ignore behavior のみにした。
- first-class Skills support はこの Ticket では実装していない。
Review:
- 初回 review は web/generated `workflow_invoke` surface、stale workflow role/test、docs wording で `request_changes`
- 2回目 review は slash workflow completion path で `request_changes`
- 3回目 review は TUI test fixture が legacy workflow SystemItem を active snapshot carrier として使っている blocker で `request_changes`
- `bc48094d fix: update workflow-free tui tests` 後の focused re-review は `approve`
Merge / validation:
- Merge commit: `2f260029 merge: remove workflow machinery`
- Final validation passed:
- `rg "ActiveWorkflow|active_workflow|Active workflow" . || true`
- `rg "resources/workflows|\\.yoi/workflow|workflow invocation|Resident workflows|workflow_invoke" . || true`
- `git diff --check`
- `cargo test -p ticket`
- `cargo test -p session-store --lib --tests`
- `cargo test -p worker --lib --tests`
- `cargo test -p tui --lib --tests`
- `cargo test -p yoi --tests`
- `cd web/workspace && deno task check`
- `cd web/workspace && deno task test`
- `cargo check -p yoi`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/workflow-removal-final-validation-1784151359.txt`
Cleanup:
- Implementation worktree/branch cleanup will be performed after close commit。
- Per user instruction, `StopPod` is not used。

View File

@ -327,160 +327,3 @@ Required fix:
- Update the test fixture to use a non-legacy active SystemItem kind or otherwise align it with workflow ignore semantics。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-15T21:32:50Z -->
## Implementation report
Third follow-up implementation progress report after request_changes:
- Coder Pod `yoi-coder-00001KXKJGYGD-workflow-removal` completed the remaining TUI test blocker fix。
- Follow-up commit: `bc48094dde08f5a1b20a060623b7a4c786d81ee2` (`fix: update workflow-free tui tests`)。
- Orchestrator inspected worktree status, branch log, commit stats, and `git diff --check 83ad7506..HEAD`; worktree was clean and diff check passed。
Fix summary:
- Updated `live_system_snapshot_replaces_task_store` so the task-store snapshot fixture is carried by active `task_reminder` `SystemItem` text instead of legacy `kind: "workflow"`
- Legacy workflow `SystemItem`s remain ignored/drop-diagnostic only; no workflow active behavior was re-enabled。
- Updated stale dashboard routing-message test assertions to match workflow-free queue guidance while running full TUI tests。
Coder-reported validation passed:
- `cargo test -p tui --lib live_system_snapshot_replaces_task_store -- --nocapture`
- `cargo test -p tui --lib live_legacy_workflow_system_item_is_ignored -- --nocapture`
- `git diff --check`
- `cargo test -p tui --lib --tests`
- `cargo check -p yoi`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
Next action:
- Request focused re-review of the remaining TUI test blocker and overall acceptance before merge/close decisions。
---
<!-- event: review author: reviewer at: 2026-07-15T21:35:26Z status: approve -->
## Review: approve
Focused re-review result: approve
Evidence:
- Verified follow-up `bc48094d` with clean implementation worktree。
- Focused greps for `ActiveWorkflow|active_workflow|Active workflow`, `workflow_invoke`, `resources/workflows`, `.yoi/workflow`, `workflow invocation`, `Resident workflows`, and workflow completion/resolver surfaces are clean。
- `/` is no longer a web completion sigil and protocol completion docs now only cover file/Knowledge。
- `live_system_snapshot_replaces_task_store` now uses active `task_reminder` and passes。
- `live_legacy_workflow_system_item_is_ignored` also passes。
- `git diff --check` and `cargo check -p yoi` pass。
- Legacy persisted `kind: "workflow"` remains bounded to `LegacyIgnored` / diagnostic-or-drop behavior and TUI ignores it。
- No broad first-class Skills implementation observed; remaining workflow text is ticket lifecycle naming, legacy ignore/rejection tests/docs, or historical/non-active comments, not active Workflow invocation/resource authority。
Blockers: none。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-15T21:39:00Z -->
## Implementation report
Merge / final validation report:
- Reviewer approved the implementation after final TUI workflow-free snapshot test fix。
- Merged branch `work/00001KXKJGYGD-remove-workflow-tracking` into orchestration branch with merge commit `2f260029 merge: remove workflow machinery`
- Ticket record conflicts from child branch were resolved in favor of Orchestrator-authoritative Ticket records; implementation code/resource changes were merged。
- Final validation was rerun in `/home/hare/Projects/yoi/.worktree/orchestration` after merge。
Validation passed:
- `rg "ActiveWorkflow|active_workflow|Active workflow" . || true`
- `rg "resources/workflows|\\.yoi/workflow|workflow invocation|Resident workflows|workflow_invoke" . || true`
- `git diff --check`
- `cargo test -p ticket`
- `cargo test -p session-store --lib --tests`
- `cargo test -p worker --lib --tests`
- `cargo test -p tui --lib --tests`
- `cargo test -p yoi --tests`
- `cd web/workspace && deno task check`
- `cd web/workspace && deno task test`
- `cargo check -p yoi`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
Validation log:
- `/run/user/1000/yoi/yoi-orchestrator/bash-output/workflow-removal-final-validation-1784151359.txt`
Reviewed/merged implementation commits:
- `d801b269 refactor: remove workflow machinery`
- `d30dca2d fix: remove remaining workflow invoke surfaces`
- `0b56052a fix: remove workflow completion surface`
- `bc48094d fix: update workflow-free tui tests`
Notes:
- ActiveWorkflow tools/state and Workflow resource/invocation/completion surfaces were removed。
- Legacy persisted `kind: "workflow"` SystemItems are bounded to ignored/diagnostic behavior only。
- Broad first-class Skills support was intentionally not implemented in this Ticket。
- Per user instruction, no `StopPod` cleanup was performed。
---
<!-- event: state_changed author: orchestrator at: 2026-07-15T21:39:07Z from: inprogress to: done reason: implementation_validated field: state -->
## State changed
Implementation was reviewed, merged into orchestration branch, and final validation passed. Moving to done before closure.
---
<!-- event: state_changed author: hare at: 2026-07-15T21:39:23Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-15T21:39:23Z status: closed -->
## 完了
Workflow tracking / Workflow resource / Workflow invocation machinery を削除・レビュー・merge・検証した。
実装内容:
- model-visible `ActiveWorkflowList`, `ActiveWorkflowComplete`, `ActiveWorkflowCancel` tools を削除。
- Worker active-workflow durable state、extension snapshotting、compaction/rehydration handling、prompt re-injection paths を削除。
- Workflow registry/resource loading と `/workflow-slug` invocation behavior を削除。
- `crates/workflow` crate と tracked `resources/workflows/*` resources を削除。
- `WorkflowInvoke` を protocol / TUI / web generated protocol surfaces から削除。
- slash workflow completion behavior と related web console completion surface を削除。
- `.yoi/workflow` memory/workspace authority と workflow usage-source handling を削除。
- resident workflow / workflow invocation wording を prompts/docs/config から削除。
- old persisted `kind: "workflow"` `SystemItem`s は `LegacyIgnored` として bounded diagnostic/ignore behavior のみにした。
- first-class Skills support はこの Ticket では実装していない。
Review:
- 初回 review は web/generated `workflow_invoke` surface、stale workflow role/test、docs wording で `request_changes`
- 2回目 review は slash workflow completion path で `request_changes`
- 3回目 review は TUI test fixture が legacy workflow SystemItem を active snapshot carrier として使っている blocker で `request_changes`
- `bc48094d fix: update workflow-free tui tests` 後の focused re-review は `approve`
Merge / validation:
- Merge commit: `2f260029 merge: remove workflow machinery`
- Final validation passed:
- `rg "ActiveWorkflow|active_workflow|Active workflow" . || true`
- `rg "resources/workflows|\\.yoi/workflow|workflow invocation|Resident workflows|workflow_invoke" . || true`
- `git diff --check`
- `cargo test -p ticket`
- `cargo test -p session-store --lib --tests`
- `cargo test -p worker --lib --tests`
- `cargo test -p tui --lib --tests`
- `cargo test -p yoi --tests`
- `cd web/workspace && deno task check`
- `cd web/workspace && deno task test`
- `cargo check -p yoi`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/workflow-removal-final-validation-1784151359.txt`
Cleanup:
- Implementation worktree/branch cleanup will be performed after close commit。
- Per user instruction, `StopPod` is not used。
---

View File

@ -1,4 +0,0 @@
{"id":"orch-plan-20260715-212633-1","ticket_id":"00001KXKMX0QM","kind":"after","related_ticket":"00001KXKJGYGD","note":"This Ticket has explicit `depends_on` blockers: `00001KXKJGYGD` is currently `inprogress` and `00001KXKP2A71` remains `queued`. Do not start Skills support until workflow removal and Knowledge removal are both approved/merged/closed or an explicit combined-work decision is made.","author":"orchestrator","at":"2026-07-15T21:26:33Z"}
{"id":"orch-plan-20260715-212638-2","ticket_id":"00001KXKMX0QM","kind":"after","related_ticket":"00001KXKP2A71","note":"Skills support explicitly depends on Knowledge removal. Start after `00001KXKP2A71` is complete, because Skill/Knowledge boundaries and context/resource cleanup should be established before introducing first-class Skill catalog/activation APIs.","author":"orchestrator","at":"2026-07-15T21:26:38Z"}
{"id":"orch-plan-20260715-212645-3","ticket_id":"00001KXKMX0QM","kind":"waiting_capacity_note","note":"Queue routing inspected. Ticket is concrete but blocked by explicit `depends_on` relations: workflow removal is still inprogress/review-fix loop, and Knowledge removal is queued and intentionally waiting for workflow removal. Leave queued; do not transition to inprogress or create a worktree/Pods until both blockers are closed.","author":"orchestrator","at":"2026-07-15T21:26:45Z"}
{"id":"orch-plan-20260715-230053-4","ticket_id":"00001KXKMX0QM","kind":"accepted_plan","accepted_plan":{"summary":"Implement first-class Agent Skills support through Workspace backend authority: `.yoi/skills/<skill-name>/SKILL.md` catalog/lint/read/activation APIs, builtin/workspace discovery and provenance/override diagnostics, progressive disclosure, Worker/Web/CLI shared catalog access without Worker-local scan as primary authority, and role/docs prompt updates while keeping external state control in typed tools/features.","branch":"work/00001KXKMX0QM-agent-skills-support","worktree":"/home/hare/Projects/yoi/.worktree/00001KXKMX0QM-agent-skills-support","role_plan":"Orchestrator accepts queued Ticket after dependencies `00001KXKJGYGD` and `00001KXKP2A71` were approved, merged, validated, closed, and implementation worktrees/branches cleaned. Use sibling Coder for implementation in a dedicated worktree, then sibling Reviewer for read-only review. Orchestrator retains merge/final-validation/close/worktree cleanup authority. Do not call StopPod unless explicitly instructed."},"author":"orchestrator","at":"2026-07-15T23:00:53Z"}

View File

@ -1,8 +1,8 @@
---
title: 'Implement Agent Skills support'
state: 'closed'
state: 'queued'
created_at: '2026-07-15T19:43:46Z'
updated_at: '2026-07-16T00:15:05Z'
updated_at: '2026-07-15T21:26:15Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-07-15T21:26:15Z'

View File

@ -1,52 +0,0 @@
Agent Skills support を実装・レビュー・merge・検証した。
実装内容:
- first-class Skill schema/types と Workspace HTTP client support を追加。
- Workspace backend に Skill discovery / lint / catalog / detail / activation を追加。
- Workspace API endpoints を追加:
- `GET /api/w/{workspace_id}/skills`
- `GET /api/w/{workspace_id}/skills/lint`
- `GET /api/w/{workspace_id}/skills/{name}`
- `GET /api/w/{workspace_id}/skills/{name}/activate`
- `yoi-workspace-server skills list|lint|show` CLI surface を追加。
- Web Workspace API helpers/types を追加。
- builtin Skill resource `resources/skills/agent-skills/SKILL.md` を追加。
- Worker Skill activation method を追加し、`WorkspaceClient::Http` 経由で Skill activation を取得し、`SystemItem::SkillActivation` を commit し、Skill body を engine history に append するようにした。
- Prompt guidance を更新し、Skills は procedural LLM guidance であり external state authority ではないことを明示。
Skill behavior:
- Workspace Skills are read from `.yoi/skills/<skill-name>/SKILL.md`
- Lint validates required `name` / `description`, parent-dir match, name length/pattern, description bounds, optional `license` / `compatibility` / string-map `metadata`
- Unknown frontmatter keys are lint errors。
- Workflow/projection/invocation-shaped keys such as `model_invokation`, `user_invocable`, `graph`, `invocation` are explicitly rejected with `unsupported_workflow_frontmatter_field`
- `allowed-tools` is parsed/diagnosed as experimental ignored/non-authoritative metadata。
- Builtin and workspace Skills are loaded deterministically; workspace Skills override builtin Skills with path-free provenance。
- Catalog responses contain lightweight metadata only; detail/activation returns full `SKILL.md` body。
- References/assets/scripts are Skill-relative and non-executable/non-authoritative; no raw absolute paths are exposed。
- No `/skill-name` syntax, Workflow compatibility path, or Knowledge active support was added。
Review:
- Initial review requested changes for silently accepted unsupported Workflow frontmatter and missing Worker activation/history coverage。
- `4ddfccee fix: reject unsupported skill fields` added unsupported-key diagnostics/regression tests and Worker activation/history tests。
- Focused re-review approved with no blockers。
- Non-blocking note: detail/activation for invalid Skills returns not-found while catalog/lint carries diagnostics; acceptable for this initial authority model。
Merge / validation:
- Merge commit: `1611e04d merge: agent skills support`
- Final validation passed:
- Regression grep for removed Workflow/Knowledge active surfaces。
- `git diff --check`
- `cargo test -p yoi-workspace-server --lib`
- `cargo test -p worker --lib --tests`
- `cargo test -p yoi --tests`
- `cd web/workspace && deno task check`
- `cd web/workspace && deno task test`
- `cargo check -p yoi-workspace-server`
- `cargo check -p yoi`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/agent-skills-final-validation-1784160679.txt`
Cleanup:
- Implementation worktree/branch cleanup will be performed after close commit。
- Per user instruction, `StopPod` is not used。

View File

@ -134,398 +134,4 @@ Marked ready by `yoi ticket state`.
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: orchestrator at: 2026-07-15T21:26:58Z -->
## Decision
Routing decision: blocked_by_dependency
Reason:
- Dashboard queue authorization was inspected, but this Ticket has explicit `depends_on` blockers。
- `00001KXKJGYGD` (`Remove workflow tracking and workflow resources`) is currently `inprogress` and still in reviewer/coder fix loop。
- `00001KXKP2A71` (`Remove Knowledge support`) is still `queued` and was intentionally held until Workflow removal completes。
- This Tickets body depends on both: Workflow projection must be gone, and obsolete Knowledge support should be removed before first-class Agent Skills support introduces new Skill/Knowledge boundaries and Workspace Skill APIs。
- Therefore this routing pass leaves the Ticket `queued` and does not record `queued -> inprogress`, create a worktree, or spawn role Pods。
Evidence checked:
- Ticket body / thread / relations。
- `TicketRelationQuery(00001KXKMX0QM)`: `depends_on 00001KXKJGYGD` and `depends_on 00001KXKP2A71`
- Ticket derived blocker view: `00001KXKJGYGD` is `inprogress`, `00001KXKP2A71` is `queued`
- `TicketOrchestrationPlanQuery(00001KXKMX0QM)`: no prior records; this pass recorded `after` entries and waiting note。
- `TicketList(inprogress)`: `00001KXKJGYGD` active。
- Orchestrator worktree status: clean。
Next action:
- Finish `00001KXKJGYGD` first。
- Re-route and complete `00001KXKP2A71` second。
- Re-route this Skills Ticket after both blockers are approved, merged, validated, and closed。
Escalate if:
- Human explicitly requests a combined branch across Workflow removal / Knowledge removal / Skills support and accepts the larger review-boundary risk。
---
<!-- event: decision author: orchestrator at: 2026-07-15T23:01:21Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Explicit dependencies are now satisfied: `00001KXKJGYGD` Workflow removal and `00001KXKP2A71` Knowledge removal are both approved, merged, final-validated, closed, and their implementation worktrees/branches cleaned。
- `TicketRelationQuery(00001KXKMX0QM)` still records the outgoing `depends_on` edges, but the derived blocker view is empty because both targets are closed。
- `TicketOrchestrationPlanQuery(00001KXKMX0QM)` contained earlier waiting notes for those blockers; this pass records an accepted implementation plan。
- `TicketList(inprogress)` is 0 件。
- Orchestrator worktree is clean and no existing `00001KXKMX0QM` implementation worktree/branch was found。
Evidence checked:
- Ticket body / thread / relations / orchestration plan。
- Closed prerequisite Tickets `00001KXKJGYGD` and `00001KXKP2A71`
- Orchestrator worktree state / worktree list / branch list。
IntentPacket:
Intent:
- Implement first-class Agent Skills support using Workspace backend authority。
- `.yoi/skills/<skill-name>/SKILL.md` becomes tracked workspace Skill data, but Worker-local filesystem scan must not be the primary authority when Workspace backend/client is available。
- Provide shared Skill catalog/lint/read/activation APIs for Worker / Runtime / Web / CLI and progressive disclosure of Skill metadata vs body/resources。
Binding decisions / invariants:
- Skill is LLM-facing procedural guidance/resource, not external state authority, scheduler, script runner, queue runner, or worktree manager。
- Ticket / Worker / workdir / repository / network external state changes remain in typed feature/tool surfaces。
- Workflow projection semantics must not return: no `WorkflowRecord`, `model_invokation`, `user_invocable`, graph/invocation assumptions, or `/workflow-slug` compatibility path。
- Knowledge has been removed as active support; do not rebuild Knowledge under Skill terminology。
- Workspace backend is authority for Skill discovery/lint/catalog/activation。
- Worker with `WorkspaceClient::Http` must receive Skill metadata/body via Workspace API, not local path scan。
- Full `SKILL.md` body is loaded into Worker history/context only on activation/select, not always with the catalog。
- `allowed-tools` is experimental; if implemented, it must not become independent authority without explicit integration with feature/tool permissions。
Requirements / acceptance criteria:
- `.yoi/skills/<skill-name>/SKILL.md` is parsed/linted per Agent Skills required frontmatter and naming rules。
- Workspace backend exposes Skill catalog/list, detail/read, lint/diagnostics, activation body, and resource access or backend-resolved resource authority as appropriate。
- Builtin/workspace loading, override priority, provenance, invalid diagnostics, and non-default/workspace skill cases are covered by tests。
- Worker / Web / CLI use the same Workspace backend Skill catalog view。
- Activation appends/commits Skill body into Worker history before LLM context use。
- Progressive disclosure is tested: catalog metadata is lightweight, full body/resources load only when requested/activated。
- Role prompts/internal prompts/docs are updated to Skill terminology and no Workflow/Knowledge active guidance remains。
- Initial implementation clearly marks unsupported decision points such as `allowed-tools`/scripts/resource execution if not implemented。
Implementation latitude:
- Exact crate/module placement and API DTO shapes may follow current workspace-server / Worker WorkspaceClient patterns。
- Builtin Skill resource layout can be chosen based on existing resource conventions, but must be tested and documented。
- Workspace override policy may choose complete override or conflict diagnostic if documented and tested; prefer a simple deterministic rule。
- Web UI editing can be deferred if backend/API/CLI tests cover catalog/lint/read/activation and Ticket does not require full editor implementation。
Escalate if:
- Skill activation syntax (`/skill-name` vs explicit tool/API/UI activation) requires product decision beyond existing Ticket text。
- Supporting `scripts/` execution would require new authority/sandbox model; prefer unsupported/diagnostic unless explicitly scoped。
- Implementing resource access needs broad backend protocol design beyond bounded read/detail endpoints。
- Any path requires reintroducing Workflow or Knowledge active surfaces。
Validation:
- `git diff --check`
- Skill loader/lint/catalog tests in affected crate(s)。
- `cargo test -p yoi-workspace-server --lib` if Workspace API is touched。
- `cargo test -p worker --lib --tests` if Worker prompt/history/WorkspaceClient activation is touched。
- `cargo test -p yoi --tests` if CLI/input activation is touched。
- `cd web/workspace && deno task check && deno task test` if web/API types are touched。
- `cargo check -p yoi`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
Critical risks / reviewer focus:
- Worker-local scan becoming divergent authority。
- Full Skill body being resident by default rather than activated/progressive。
- Workflow/Knowledge surfaces returning under new names。
- `allowed-tools` or scripts accidentally becoming authority bypass。
- Skill resources leaking raw paths or bypassing Workspace backend authority。
- External state control moving into Skill text instead of typed tools/features。
---
<!-- event: state_changed author: orchestrator at: 2026-07-15T23:01:28Z from: queued to: inprogress reason: accepted_for_implementation field: state -->
## State changed
Dependencies Workflow removal and Knowledge removal are closed, no blockers remain, accepted plan recorded. Moving queued Ticket to inprogress before creating worktree or spawning role Pods.
---
<!-- event: implementation_report author: orchestrator at: 2026-07-15T23:46:45Z -->
## Implementation report
Implementation progress report:
- Coder Pod `yoi-coder-00001KXKMX0QM-agent-skills` completed implementation in `/home/hare/Projects/yoi/.worktree/00001KXKMX0QM-agent-skills-support` on branch `work/00001KXKMX0QM-agent-skills-support`
- Implementation commit: `62ef89a1634fd12a503156ce95baebbf037ef96f` (`feat: add workspace-backed agent skills`)。
- Orchestrator inspected worktree status, branch log, commit stats, and `git diff --check 05c50e32..HEAD`; worktree was clean and diff check passed。
Implementation summary:
- Added first-class Skill schema/types and Workspace HTTP client support in `worker`
- Added Workspace backend Skill discovery/lint/catalog/detail/activation in `yoi-workspace-server`
- Added builtin Skill resource at `resources/skills/agent-skills/SKILL.md`
- Added Workspace server API endpoints:
- `GET /api/w/{workspace_id}/skills`
- `GET /api/w/{workspace_id}/skills/lint`
- `GET /api/w/{workspace_id}/skills/{name}`
- `GET /api/w/{workspace_id}/skills/{name}/activate`
- Added `yoi-workspace-server skills list|lint|show` CLI surface。
- Added Web Workspace API helpers/types for Skill catalog/detail/activation endpoints。
- Added Worker Skill activation method using `WorkspaceClient::Http`; activation commits a `SystemItem::SkillActivation` and appends the Skill body into engine history before later LLM context use。
- Added prompt guidance clarifying Skills are procedural LLM guidance only, not authority for external state changes。
Skill behavior:
- Workspace Skills load from `.yoi/skills/<skill-name>/SKILL.md`
- Lint enforces required `name` / `description`, parent-dir match, name length/pattern, description bounds, optional `license` / `compatibility` / string-map `metadata`
- `allowed-tools` is parsed as metadata but emits explicit ignored/non-authoritative warning。
- Builtin + workspace Skills are loaded by Workspace backend; workspace Skills deterministically override builtin Skills with path-free provenance such as `builtin:agent-skills` / `workspace:debug-rust`
- Catalog responses contain lightweight metadata only; full `SKILL.md` body is returned only from detail/activation endpoints。
- References/assets/scripts are Skill-relative resource refs only; no raw absolute paths are exposed. Resource reads/execution are not implemented, scripts are explicitly non-executable diagnostics。
- No `/skill-name` activation syntax, Workflow projection/compatibility path, or Knowledge active support was added。
Files/resources touched:
- `crates/workspace-server/src/skills.rs`
- `crates/workspace-server/src/server.rs`
- `crates/workspace-server/src/main.rs`
- `crates/workspace-server/src/lib.rs`
- `crates/worker/src/skill.rs`
- `crates/worker/src/worker.rs`
- `crates/worker/src/lib.rs`
- `crates/session-store/src/system_item.rs`
- `crates/tui/src/app.rs`
- `web/workspace/src/lib/workspace/api/http.ts`
- `web/workspace/src/lib/workspace/api/http.test.ts`
- `resources/skills/agent-skills/SKILL.md`
- `resources/prompts/common/tool-usage.md`
Coder-reported validation passed:
- `cargo test -p yoi-workspace-server --lib`
- `cargo test -p worker --lib --tests`
- `cargo check -p yoi-workspace-server`
- `cargo check -p yoi`
- `cargo test -p yoi --tests`
- `cd web/workspace && deno task check && deno task test` (`54 passed`, existing App.svelte warnings only)
- `git diff --check`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
- `rg "WorkflowRecord|model_invokation|user_invocable|KnowledgeQuery|kind = knowledge|#<slug>|workflow_invoke"`: no active regressions found。
Next action:
- Route to external Reviewer Pod before merge/close decisions。
---
<!-- event: review author: reviewer at: 2026-07-15T23:53:00Z status: request_changes -->
## Review: request changes
External review result: request_changes
Blockers:
1. Workflow/unsupported Skill frontmatter is silently accepted instead of linted/diagnosed。
- A `.yoi/skills/*/SKILL.md` containing `model_invokation`, `user_invocable`, graph/invocation fields, etc. will deserialize successfully and can be returned/activated as body。
- `SkillFrontmatter` only lists known fields and uses default serde unknown-field ignoring at `crates/workspace-server/src/skills.rs:40-50`, with parsing around `:290-300`
- This violates the acceptance that workflow projection fields are not accepted as active Skill semantics。
2. Required Worker activation/history behavior lacks coverage。
- `Worker::activate_skill` commits/appends at `crates/worker/src/worker.rs:906-922`
- Worker Skill tests only exercise HTTP catalog fetch at `crates/worker/src/skill.rs:194-237`
- Missing the stated acceptance test that activation appends/commits Skill body into Worker history before LLM context use。
Evidence / validation performed:
- Inspected Ticket and touched files。
- Checked route/API/provenance behavior。
- Ran `cargo test -p yoi-workspace-server skills --lib`
- Ran `cargo test -p worker skill --lib`
- Confirmed implementation worktree remained clean。
Non-blocking note:
- Catalog/detail progressive disclosure and path-free provenance generally look aligned。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-16T00:07:38Z -->
## Implementation report
Follow-up implementation progress report after request_changes:
- Coder Pod `yoi-coder-00001KXKMX0QM-agent-skills` completed the review fixes in `/home/hare/Projects/yoi/.worktree/00001KXKMX0QM-agent-skills-support`
- Follow-up commit: `4ddfccee2d414a2dfe67a940906b505dd237132d` (`fix: reject unsupported skill fields`)。
- Orchestrator inspected worktree status, branch log, commit stats, and `git diff --check 05c50e32..HEAD`; worktree was clean and diff check passed。
Fix summary:
- Added Skill frontmatter unsupported-key detection in `crates/workspace-server/src/skills.rs`
- Unknown frontmatter keys are now lint errors。
- Workflow/projection/invocation-shaped keys such as `model_invokation`, `user_invocable`, `graph`, and `invocation` are explicitly rejected with `unsupported_workflow_frontmatter_field`
- Unsupported/unknown-field Skills no longer become active catalog/detail/activation entries。
- Added regression tests for `model_invokation`, `user_invocable`, `graph`, `invocation`, and generic unknown frontmatter fields。
- Added Worker activation/history coverage in `crates/worker/src/worker.rs`:
- verifies `Worker::activate_skill` fetches activation through Workspace HTTP client;
- verifies Skill body is appended to Worker engine history;
- verifies committed `SystemItem::SkillActivation` content matches the history-visible Skill activation content。
Coder-reported validation passed:
- `cargo test -p yoi-workspace-server skills --lib`
- `cargo test -p worker skill --lib`
- `git diff --check`
- `cargo check -p yoi`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
Web/API types were not changed in this follow-up, so web checks were not rerun。
---
<!-- event: review author: reviewer at: 2026-07-16T00:10:51Z status: approve -->
## Review: approve
Focused re-review result: approve
Evidence:
- Previous blockers fixed:
- Skill frontmatter is now parsed through a raw YAML value。
- Unsupported/workflow-shaped keys are diagnosed as errors (`crates/workspace-server/src/skills.rs:290-302`, `457-528`)。
- Error diagnostics prevent catalog/detail/activation (`skills.rs:393-398`)。
- Regression coverage exists for `model_invokation`, `user_invocable`, `graph`, `invocation`, and generic unknown fields (`skills.rs:687-743`)。
- Worker activation/history is covered by an HTTP-backed activation test verifying backend fetch, history append, and committed `SystemItem::SkillActivation` body equality (`crates/worker/src/worker.rs:5971-6065`)。
- Focused validation passed:
- `cargo test -p yoi-workspace-server skills --lib`
- `cargo test -p worker skill --lib`
- `git diff --check`
- Worktree remained clean。
- Rechecked acceptance focus:
- Workspace backend remains the Skill catalog/detail/lint/activation authority。
- builtin/workspace override/provenance/progressive disclosure behavior remains covered。
- `allowed-tools` / scripts remain diagnostic/non-authoritative。
- no raw-path resource leakage was introduced。
- `rg` for removed Workflow/Knowledge surfaces only finds new rejection logic/tests。
Blockers: none。
Non-blocking note:
- detail/activation for invalid Skills still surface as not-found while catalog/lint carries diagnostics; acceptable for this initial authority model。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-16T00:14:34Z -->
## Implementation report
Merge / final validation report:
- Reviewer approved the implementation after unsupported Skill frontmatter and Worker activation/history fixes。
- Merged branch `work/00001KXKMX0QM-agent-skills-support` into orchestration branch with merge commit `1611e04d merge: agent skills support`
- Final validation was rerun in `/home/hare/Projects/yoi/.worktree/orchestration` after merge。
Validation passed:
- Regression grep for `WorkflowRecord|model_invokation|user_invocable|KnowledgeQuery|kind = knowledge|#<slug>|workflow_invoke` with only expected Skill rejection logic/tests where applicable。
- `git diff --check`
- `cargo test -p yoi-workspace-server --lib`
- `cargo test -p worker --lib --tests`
- `cargo test -p yoi --tests`
- `cd web/workspace && deno task check`
- `cd web/workspace && deno task test`
- `cargo check -p yoi-workspace-server`
- `cargo check -p yoi`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
Validation log:
- `/run/user/1000/yoi/yoi-orchestrator/bash-output/agent-skills-final-validation-1784160679.txt`
Reviewed/merged implementation commits:
- `62ef89a1 feat: add workspace-backed agent skills`
- `4ddfccee fix: reject unsupported skill fields`
Notes:
- Workspace backend is the Skill catalog/detail/lint/activation authority。
- Workspace Skills live under `.yoi/skills/<skill-name>/SKILL.md`; builtin Skill resources are supported with deterministic workspace override/provenance diagnostics。
- Catalog is lightweight; detail/activation returns full body。
- Worker activation through `WorkspaceClient::Http` commits/appends Skill body to history before later LLM context use。
- `allowed-tools` and scripts/resources are diagnostic/non-authoritative in this initial implementation。
- Workflow and Knowledge active surfaces were not reintroduced。
- Per user instruction, no `StopPod` cleanup was performed。
---
<!-- event: state_changed author: orchestrator at: 2026-07-16T00:14:40Z from: inprogress to: done reason: implementation_validated field: state -->
## State changed
Implementation was reviewed, merged into orchestration branch, and final validation passed. Moving to done before closure.
---
<!-- event: state_changed author: hare at: 2026-07-16T00:15:05Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-16T00:15:05Z status: closed -->
## 完了
Agent Skills support を実装・レビュー・merge・検証した。
実装内容:
- first-class Skill schema/types と Workspace HTTP client support を追加。
- Workspace backend に Skill discovery / lint / catalog / detail / activation を追加。
- Workspace API endpoints を追加:
- `GET /api/w/{workspace_id}/skills`
- `GET /api/w/{workspace_id}/skills/lint`
- `GET /api/w/{workspace_id}/skills/{name}`
- `GET /api/w/{workspace_id}/skills/{name}/activate`
- `yoi-workspace-server skills list|lint|show` CLI surface を追加。
- Web Workspace API helpers/types を追加。
- builtin Skill resource `resources/skills/agent-skills/SKILL.md` を追加。
- Worker Skill activation method を追加し、`WorkspaceClient::Http` 経由で Skill activation を取得し、`SystemItem::SkillActivation` を commit し、Skill body を engine history に append するようにした。
- Prompt guidance を更新し、Skills は procedural LLM guidance であり external state authority ではないことを明示。
Skill behavior:
- Workspace Skills are read from `.yoi/skills/<skill-name>/SKILL.md`
- Lint validates required `name` / `description`, parent-dir match, name length/pattern, description bounds, optional `license` / `compatibility` / string-map `metadata`
- Unknown frontmatter keys are lint errors。
- Workflow/projection/invocation-shaped keys such as `model_invokation`, `user_invocable`, `graph`, `invocation` are explicitly rejected with `unsupported_workflow_frontmatter_field`
- `allowed-tools` is parsed/diagnosed as experimental ignored/non-authoritative metadata。
- Builtin and workspace Skills are loaded deterministically; workspace Skills override builtin Skills with path-free provenance。
- Catalog responses contain lightweight metadata only; detail/activation returns full `SKILL.md` body。
- References/assets/scripts are Skill-relative and non-executable/non-authoritative; no raw absolute paths are exposed。
- No `/skill-name` syntax, Workflow compatibility path, or Knowledge active support was added。
Review:
- Initial review requested changes for silently accepted unsupported Workflow frontmatter and missing Worker activation/history coverage。
- `4ddfccee fix: reject unsupported skill fields` added unsupported-key diagnostics/regression tests and Worker activation/history tests。
- Focused re-review approved with no blockers。
- Non-blocking note: detail/activation for invalid Skills returns not-found while catalog/lint carries diagnostics; acceptable for this initial authority model。
Merge / validation:
- Merge commit: `1611e04d merge: agent skills support`
- Final validation passed:
- Regression grep for removed Workflow/Knowledge active surfaces。
- `git diff --check`
- `cargo test -p yoi-workspace-server --lib`
- `cargo test -p worker --lib --tests`
- `cargo test -p yoi --tests`
- `cd web/workspace && deno task check`
- `cd web/workspace && deno task test`
- `cargo check -p yoi-workspace-server`
- `cargo check -p yoi`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/agent-skills-final-validation-1784160679.txt`
Cleanup:
- Implementation worktree/branch cleanup will be performed after close commit。
- Per user instruction, `StopPod` is not used。
---

View File

@ -1,3 +1,2 @@
{"id":"orch-plan-20260715-202731-1","ticket_id":"00001KXKP2A71","kind":"after","related_ticket":"00001KXKJGYGD","note":"この Ticket は Knowledge removal 自体には blocker relation がないが、現在 `00001KXKJGYGD` が inprogress で Workflow tracking/resource removal を実装中。両者は `crates/worker` prompt/context、`crates/protocol`/session item、resource/docs cleanup、および Workflow required Knowledge dependency 周辺で重なる。Ticket body も Workflow required Knowledge dependency は Workflow removal 側で消える前提としているため、`00001KXKJGYGD` の review/merge/close 後に開始する。","author":"orchestrator","at":"2026-07-15T20:27:31Z"}
{"id":"orch-plan-20260715-202739-2","ticket_id":"00001KXKP2A71","kind":"waiting_capacity_note","note":"Queue routing inspected. `00001KXKP2A71` is concrete and has no typed blocker relation, but it should not be started in parallel with active `00001KXKJGYGD`: both remove old context/resource machinery and likely touch shared prompt/context/session/protocol/resource/docs surfaces. Running both now would create conflict and review-boundary risk. Leave queued until `00001KXKJGYGD` is approved/merged/closed, then re-route Knowledge removal before Skills support `00001KXKMX0QM`.","author":"orchestrator","at":"2026-07-15T20:27:39Z"}
{"id":"orch-plan-20260715-213950-3","ticket_id":"00001KXKP2A71","kind":"accepted_plan","accepted_plan":{"summary":"Remove Knowledge as an active supported feature: delete KnowledgeQuery and kind=knowledge tool support, remove resident Knowledge injection and `#<slug>` protocol/TUI/web reference/completion paths, disable Knowledge in memory lint/consolidation/extraction, stop treating `.yoi/knowledge` as active workspace authority, update prompts/docs, and keep Memory/Ticket/Skill/repository boundaries clear.","branch":"work/00001KXKP2A71-remove-knowledge-support","worktree":"/home/hare/Projects/yoi/.worktree/00001KXKP2A71-remove-knowledge-support","role_plan":"Orchestrator accepts queued Ticket after workflow-removal prerequisite/conflict was approved, merged, validated, closed, and its implementation worktree/branch cleaned. Use sibling Coder for implementation in a dedicated worktree, then sibling Reviewer for read-only review. Orchestrator retains merge/final-validation/close/worktree cleanup authority. Do not call StopPod unless explicitly instructed."},"author":"orchestrator","at":"2026-07-15T21:39:50Z"}

View File

@ -1,8 +1,8 @@
---
title: 'Remove Knowledge support'
state: 'closed'
state: 'queued'
created_at: '2026-07-15T20:04:08Z'
updated_at: '2026-07-15T23:00:18Z'
updated_at: '2026-07-15T20:27:51Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-07-15T20:27:02Z'

View File

@ -1,41 +0,0 @@
Knowledge support removal を実装・レビュー・merge・検証した。
実装内容:
- active `KnowledgeQuery` tool registration/schema exposure を削除。
- Memory tools から `kind = knowledge` support と descriptions を削除。
- Memory crate の Knowledge frontmatter/schema/lint/query/resident-context handling を削除。
- Worker resident Knowledge injection と Knowledge completion state を削除。
- protocol / TUI / web の active `#<slug>` Knowledge reference/completion path を削除。
- Web Console の `#` completion sigil を削除し、`#...` は completion token ではなく plain/unsupported text になった。
- old persisted `kind: "knowledge"` SystemItem は `LegacyKnowledgeIgnored` として bounded compatibility にし、model context へ replay しない。
- `.yoi/knowledge/` は active workspace authority ではなく ignored/manual archive material として docs 更新。
- prompts/docs/tool descriptions から active Knowledge guidance を削除。
- Memory summary/decision/request behavior は維持。
- Agent Skills support はこの Ticket では実装していない。
Review:
- 初回 review は model-visible Ticket tool description と active design docs の stale Knowledge guidance で `request_changes`
- 2回目 review は Web Console `#` completion が active のまま残っていた blocker で `request_changes`
- 3回目 review は docs/report に active Knowledge wording が残っていた blocker で `request_changes`
- `9f527f5e fix: remove stale report knowledge wording` 後の focused re-review は `approve`
Merge / validation:
- Merge commit: `f279eb11 merge: remove knowledge support`
- Final validation passed:
- Focused Knowledge removal grep with only explicit legacy/manual `.yoi/knowledge` note remaining。
- `git diff --check`
- `cargo test -p memory --lib --tests`
- `cargo test -p worker --lib --tests`
- `cargo test -p session-store --lib --tests`
- `cargo test -p tui --lib --tests`
- `cargo test -p yoi --tests`
- `cd web/workspace && deno task check`
- `cd web/workspace && deno task test`
- `cargo check -p yoi`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/knowledge-removal-final-validation-1784156249.txt`
Cleanup:
- Implementation worktree/branch cleanup will be performed after close commit。
- Per user instruction, `StopPod` is not used。

View File

@ -65,436 +65,3 @@ Escalate if:
- Human explicitly wants Knowledge removal and Workflow removal combined in one branch/worktree, or accepts parallel conflict/review-boundary risk。
---
<!-- event: decision author: orchestrator at: 2026-07-15T21:40:21Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Previously recorded conflict reason (`00001KXKJGYGD` inprogress Workflow removal) is now resolved: Workflow removal was approved, merged, validated, closed, and its implementation worktree/branch cleaned。
- `TicketRelationQuery(00001KXKP2A71)` shows only an incoming dependency from Skills support Ticket `00001KXKMX0QM`; this Ticket has no outgoing blockers。
- `TicketOrchestrationPlanQuery(00001KXKP2A71)` contains prior `after 00001KXKJGYGD` / waiting notes, now satisfied, plus the accepted plan recorded in this pass。
- `TicketList(inprogress)` is 0 件。
- Orchestrator worktree is clean and no existing `00001KXKP2A71` implementation worktree/branch was found。
Evidence checked:
- Ticket body / thread / relations / orchestration plan。
- Closed `00001KXKJGYGD` outcome and current repository/worktree state。
- `TicketList(inprogress)` and orchestration worktree status。
IntentPacket:
Intent:
- Knowledge を active supported feature / workspace record authority から削除する。
- Memory は durable summary / decisions / requests として残し、Knowledge は separate record kind として廃止する。
- Future Skills support の前に、Knowledge 固有の context/reference/tool surface を削除して概念境界を単純化する。
Binding decisions / invariants:
- `.yoi/knowledge` を active workspace record authority として扱わない。
- `KnowledgeQuery` を model-visible tool schema から削除する。
- `MemoryRead` / `MemoryWrite` / `MemoryEdit` / `MemoryDelete``kind = knowledge` を削除する。
- resident Knowledge injection / `ResidentKnowledgeEntry` / `model_invokation` field usage を削除する。
- TUI / protocol の `#<slug>` Knowledge reference / completion / chips / styling / system-item path を削除または unsupported にする。
- memory lint / consolidation / extraction は Knowledge record を作成・更新・検査しない。
- Current docs/prompts は Knowledge を active supported feature として説明しない。
- Historical reports may remain if explicitly historical, but current design docs/prompts must not advertise active Knowledge support。
- Skills are not a Knowledge clone; Skills support remains separate dependent work after Knowledge removal。
Requirements / acceptance criteria:
- `KnowledgeQuery` and `kind = knowledge` disappear from active model-visible tool schemas。
- Worker prompt / resident context / compaction / rehydration have no Knowledge section。
- Protocol/TUI/web no longer expose active `#<slug>` Knowledge references or completions。
- Memory lint/consolidation no longer handles Knowledge records as active output/target。
- Docs/prompts are updated to Memory / Ticket / Skill / repository-file boundaries without active Knowledge guidance。
- Existing `.yoi/knowledge` data handling is explicit: ignore / diagnostic / manual archive, without large migration code。
- Affected tests/build pass and dependent Skills support remains queued until this Ticket closes。
Implementation latitude:
- Removal can be direct deletion or fail-closed unsupported diagnostic where compatibility is necessary。
- Exact treatment of old `.yoi/knowledge` directory can be ignore/diagnostic/manual archive note; do not add a broad migration subsystem。
- If a crate/type still needs `knowledge` in historical tests/fixtures, keep only clearly non-active compatibility evidence and document it in test names/comments。
- Web/TUI UX for `#` can become no completion / plain text / reserved unsupported; choose the smallest coherent behavior。
Escalate if:
- Removing Knowledge tools breaks Memory tools in a way that requires rethinking Memory API shape。
- A persisted-session compatibility issue requires more than bounded ignore/drop/diagnostic behavior。
- Skills support must be implemented now to replace a required active Knowledge path。
- A current document appears to require Knowledge as a product feature rather than historical reference。
Validation:
- `rg "KnowledgeQuery|knowledge"` with remaining hits explained/limited to non-active historical/compatibility contexts。
- `rg "#<slug>|ResidentKnowledge|model_invokation|kind = knowledge|\.yoi/knowledge"` with active hits removed or explained。
- `git diff --check`
- `cargo test -p worker --lib --tests`
- `cargo test -p memory --lib --tests` or equivalent affected memory crate tests if present。
- `cargo test -p session-store --lib --tests` if system/reference/session state touched。
- `cargo test -p tui --lib --tests` if TUI references/completions touched。
- `cargo test -p yoi --tests` if CLI/memory commands touched。
- `cd web/workspace && deno task check && deno task test` if web generated/protocol/completion types touched。
- `cargo check -p yoi`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
Current code map / likely touch points:
- Worker memory/knowledge tools and prompt/resident context paths。
- Memory store/lint/consolidation/extraction code。
- Protocol/TUI/web reference/completion/chip surfaces for `#<slug>`
- Session/system item/reference types if Knowledge references are persisted。
- Docs/prompts mentioning Knowledge as active support。
Critical risks / reviewer focus:
- `KnowledgeQuery` or `kind = knowledge` surviving in model-visible tool schema。
- Resident Knowledge still injected through prompt/compaction/rehydration。
- `#<slug>` still parsed as active Knowledge reference/completion。
- Consolidation prompt still asking to create/update Knowledge。
- Docs/prompts continuing to teach Knowledge as supported active feature。
- Accidentally removing Memory summary/decision/request functionality while deleting Knowledge。
---
<!-- event: state_changed author: orchestrator at: 2026-07-15T21:40:26Z from: queued to: inprogress reason: accepted_for_implementation field: state -->
## State changed
Workflow removal dependency/conflict is now closed and no blockers remain. Accepting queued Knowledge removal Ticket for implementation before creating worktree or spawning role Pods.
---
<!-- event: implementation_report author: orchestrator at: 2026-07-15T22:31:18Z -->
## Implementation report
Implementation progress report:
- Coder Pod `yoi-coder-00001KXKP2A71-knowledge-removal` completed implementation in `/home/hare/Projects/yoi/.worktree/00001KXKP2A71-remove-knowledge-support` on branch `work/00001KXKP2A71-remove-knowledge-support`
- Implementation commit: `f786e019 feat: remove active knowledge support`
- Orchestrator inspected worktree status, branch log, commit stats, and `git diff --check f2106407..HEAD`; worktree was clean and diff check passed。
Implementation summary:
- Removed active `KnowledgeQuery` registration/schema exposure。
- Removed `kind = knowledge` from Memory tool kind support and tool descriptions。
- Removed active Knowledge frontmatter/schema/lint/query/resident-context handling from the Memory crate。
- Removed Worker resident Knowledge injection and Knowledge completion state。
- Removed protocol/TUI/web active `#<slug>` / Knowledge reference and completion paths。
- Kept bounded persisted-session compatibility: old session `SystemItem` entries serialized as `knowledge` deserialize into `LegacyKnowledgeIgnored` and do not replay archived Knowledge text into model context。
- Updated prompts/docs to describe Memory summary/decision/request behavior and note old `.yoi/knowledge/` as ignored/manual archive material。
- Did not implement Agent Skills support。
Old `.yoi/knowledge` handling:
- `.yoi/knowledge/` is no longer classified, linted, queried, consolidated, extracted, resident-injected, or treated as workspace record authority。
- Docs say older `.yoi/knowledge/` files are ignored by current memory tooling and can be manually archived/inspected if needed。
- Persisted session Knowledge system items are ignored on restore rather than injected into model context。
Coder-reported validation passed:
- `rg "KnowledgeQuery|kind = knowledge|kind: knowledge|ResidentKnowledge|model_invokation|\\.yoi/knowledge|#<slug>" --glob '!target' --glob '!Cargo.lock'`: only remaining hit is historical ignored `.yoi/knowledge/` note in `docs/design/memory-knowledge.md`
- `rg "knowledge" crates resources docs README.md web --glob '!target' --glob '!Cargo.lock'`: remaining intended hits are historical/ignored `.yoi/knowledge/` docs, compatibility-only `LegacyKnowledgeIgnored`, doc filenames containing `memory-knowledge`, and unrelated `acknowledge` substrings。
- `git diff --check`
- `cargo test -p memory --lib --tests`
- `cargo test -p worker --lib --tests`
- `cargo test -p session-store --lib --tests`
- `cargo test -p tui --lib --tests`
- `cargo test -p yoi --tests`
- `cd web/workspace && deno task check` (passed with pre-existing/accessibility warnings only)
- `cd web/workspace && deno task test` (`52 passed`)
- `cargo check -p yoi`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
Next action:
- Route to external Reviewer Pod before merge/close decisions。
---
<!-- event: review author: reviewer at: 2026-07-15T22:35:41Z status: request_changes -->
## Review: request changes
External review result: request_changes
Blockers:
1. Model-visible Ticket tool description still advertises Knowledge。
- `crates/ticket/src/tool.rs:165` appends tool description text saying `memory.language for Memory/Knowledge`
- This keeps Knowledge in active LLM/tool guidance and violates tool/schema surface acceptance。
2. Current design docs still describe Knowledge as active support。
- `docs/design/overview.md:18` says the `memory` crate owns “Knowledge records.”
- This is active design guidance, not historical note, and contradicts docs/prompts cleanup acceptance。
Positive evidence:
- `MemoryToolKind` now accepts only summary/decision/request。
- `MemoryQuery` scans only summary/decisions/requests。
- Protocol/web completions are file-only。
- Old persisted Knowledge system items are compatibility-ignored。
Validation performed:
- Static read-only review of clean worktree commit `f786e019`
- No tests rerun because static blockers remain。
Non-blocking cleanup:
- `crates/tui/src/input.rs:237-269` has stale comments mentioning `#<typed>`, though code only triggers `@` file completion。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-15T22:41:26Z -->
## Implementation report
Follow-up implementation progress report after request_changes:
- Coder Pod `yoi-coder-00001KXKP2A71-knowledge-removal` completed the stale Knowledge guidance fix。
- Follow-up commit: `ad666503 fix: remove stale knowledge guidance`
- Orchestrator inspected worktree status, branch log, commit stats, and `git diff --check f2106407..HEAD`; worktree was clean and diff check passed。
Fix summary:
- Updated model-visible Ticket tool description in `crates/ticket/src/tool.rs`: replaced `Memory/Knowledge` language guidance with `Memory`
- Updated active design overview in `docs/design/overview.md`: `memory` crate now owns generated Memory summary/decision/request records, not Knowledge records。
- Updated stale TUI comment in `crates/tui/src/input.rs`: removed `#<typed>` from file-completion chip comment。
- Removed stale “Knowledge records” wording from historical test-validity report text in `docs/report/test-validity-20260612/yoi.md`
Coder-reported validation passed:
- Focused active guidance grep for `Memory/Knowledge|Knowledge records|#<typed>` in relevant active files: no hits。
- Required Knowledge-removal grep for `KnowledgeQuery|kind = knowledge|kind: knowledge|ResidentKnowledge|model_invokation|\\.yoi/knowledge|#<slug>`: only remaining hit is `docs/design/memory-knowledge.md:13`, an explicit historical/legacy note saying old `.yoi/knowledge/` is no longer active and current tooling ignores it。
- `git diff --check`
- `cargo check -p yoi`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
No focused behavior tests were re-run for this follow-up because changes were docs/comments/model-visible text only。
---
<!-- event: review author: reviewer at: 2026-07-15T22:44:47Z status: request_changes -->
## Review: request changes
Focused re-review result: request_changes
Fixed from prior review:
- `crates/ticket/src/tool.rs:165` now says Memory only。
- `docs/design/overview.md:18` now says summary/decision/request, not Knowledge records。
- Focused grep/read review found no `KnowledgeQuery`, no `kind=knowledge` Memory tools, no resident Knowledge collection。
- `.yoi/knowledge` remains only as historical/manual note in `docs/design/memory-knowledge.md:13`
- Legacy Knowledge system items are ignored in `crates/session-store/src/system_item.rs:142-177`
- No Agent Skills implementation was observed in the reviewed diff。
Remaining blocker:
- Web Console still treats `#` as an active completion sigil rather than unsupported/plain text。
- `web/workspace/src/lib/workspace/console/composer-completion.ts:4` includes `"#"`
- `composer-completion.ts:38` matches `[:@#]`
- `composer-completion.ts:86-87` maps `#` to `"file"`
- `web/workspace/src/routes/w/[workspaceId]/+page.svelte:316-335,357-362` resolves/applies those completions through the worker completion endpoint。
- This leaves an active `#<typed>` completion path after Knowledge removal, contrary to acceptance item requiring active `#<slug>` Knowledge references/completions to be removed or unsupported/plain text。
Validation performed:
- Static read-only review only; no tests rerun because the blocker is visible in source。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-15T22:50:56Z -->
## Implementation report
Second follow-up implementation progress report after request_changes:
- Coder Pod `yoi-coder-00001KXKP2A71-knowledge-removal` completed the remaining web console `#` completion fix。
- Follow-up commit: `20654f9c fix: disable hash console completions`
- Orchestrator inspected worktree status, branch log, commit stats, and `git diff --check f2106407..HEAD`; worktree was clean and diff check passed。
Fix summary:
- Removed `"#"` from `ComposerCompletionToken.sigil`
- Changed web console completion token regex from `[:@#]` to `[:@]`
- Removed fallback/default remapping that turned unknown sigils, including `#`, into file completion。
- Added focused test asserting `ask #plain` is not a completion token。
- No Knowledge/Skill compatibility or remapping was added。
Coder-reported validation passed:
- Focused grep over web console/route completion files for `"#"`, `[:@#]`, `case "#"`, `#<typed>`, `#<slug>`, `knowledge`: no hits。
- `cd web/workspace && deno task check && deno task test`
- `git diff --check`
- `cargo check -p yoi`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
Next action:
- Request focused re-review of the remaining hash completion blocker and overall acceptance before merge/close decisions。
---
<!-- event: review author: reviewer at: 2026-07-15T22:53:20Z status: request_changes -->
## Review: request changes
Focused re-review result: request_changes
Fixed:
- Web `#` completion blocker is fixed: `web/workspace/src/lib/workspace/console/composer-completion.ts:4,38,80-88` now only accepts `:`/`@`, has no fallback remapping to file, and `composer-completion.test.ts:35` asserts `ask #plain` is not a token。
- Prior code blockers remain fixed: no `KnowledgeQuery`, `MemoryToolKind` is summary/decision/request only, resident injection is summary-only, protocol `CompletionKind` is file-only, `.yoi/knowledge` appears only in the explicit legacy/manual archive note, legacy Knowledge SystemItems return empty history text, and no Skills files/diff were found。
Remaining acceptance blocker:
- Stale active docs/report guidance still advertises Knowledge as active record/tool behavior。
- `docs/report/test-validity-20260612/yoi.md:41` says `memory/Knowledge record path` is linted。
- `docs/report/test-validity-20260612/memory.md:46` refers to `MemoryWrite` “Knowledge/Request 作成”。
- These are not framed as historical/ignored compatibility and violate the docs/prompts cleanup acceptance。
Validation performed:
- Static read-only review only; no tests rerun because static docs acceptance blocker remains。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-15T22:55:19Z -->
## Implementation report
Third follow-up implementation progress report after request_changes:
- Coder Pod `yoi-coder-00001KXKP2A71-knowledge-removal` completed the remaining docs/report wording fix。
- Follow-up commit: `9f527f5e fix: remove stale report knowledge wording`
- Orchestrator inspected worktree status, branch log, commit stats, and `git diff --check f2106407..HEAD`; worktree was clean and diff check passed。
Fix summary:
- Updated `docs/report/test-validity-20260612/yoi.md`: `memory/Knowledge record path` -> `memory record path`
- Updated `docs/report/test-validity-20260612/memory.md`: `MemoryWrite` “Knowledge/Request 作成” -> “Request 作成”。
- Searched nearby `docs/report` files and removed remaining active Knowledge wording there。
Coder-reported validation passed:
- Focused grep for `Knowledge records|Knowledge/Request|memory/Knowledge|MemoryWrite.*Knowledge|KnowledgeQuery|kind = knowledge|#<slug>|#<typed>|\\.yoi/knowledge`: only remaining hit is the explicit legacy/manual note for old `.yoi/knowledge` in `docs/design/memory-knowledge.md`; no active docs/report guidance remains。
- `git diff --check`
- `cargo check -p yoi`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
No behavior tests were re-run for this follow-up because changes were docs/report wording only。
---
<!-- event: review author: reviewer at: 2026-07-15T22:56:59Z status: approve -->
## Review: approve
Focused re-review result: approve
Evidence:
- Report wording blockers are fixed: `docs/report/test-validity-20260612/yoi.md` now says memory record path; `docs/report/test-validity-20260612/memory.md` now says Request 作成。
- Greps/read inspection found no `KnowledgeQuery`, no `kind=knowledge` Memory tools, no resident Knowledge injection, no active Web/TUI/protocol `#` Knowledge completion path。
- `composer-completion.ts` only accepts `:`/`@` and test covers `ask #plain` as not a completion token。
- No stale Memory/Knowledge tool guidance remains。
- `.yoi/knowledge` appears only in the explicit legacy/manual archive note。
- Legacy Knowledge SystemItems are ignored/drop to empty history text。
- Memory summary/decision/request paths remain present。
- No Skills implementation/diff was found。
- `git diff --check f2106407..HEAD` passed。
Blockers: none。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-15T22:59:56Z -->
## Implementation report
Merge / final validation report:
- Reviewer approved the implementation after final docs/report wording fix。
- Merged branch `work/00001KXKP2A71-remove-knowledge-support` into orchestration branch with merge commit `f279eb11 merge: remove knowledge support`
- Final validation was rerun in `/home/hare/Projects/yoi/.worktree/orchestration` after merge。
Validation passed:
- Focused grep for `KnowledgeQuery|kind = knowledge|kind: knowledge|ResidentKnowledge|model_invokation|\\.yoi/knowledge|#<slug>|#<typed>` with only the explicit legacy/manual `.yoi/knowledge` note remaining。
- `git diff --check`
- `cargo test -p memory --lib --tests`
- `cargo test -p worker --lib --tests`
- `cargo test -p session-store --lib --tests`
- `cargo test -p tui --lib --tests`
- `cargo test -p yoi --tests`
- `cd web/workspace && deno task check`
- `cd web/workspace && deno task test`
- `cargo check -p yoi`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
Validation log:
- `/run/user/1000/yoi/yoi-orchestrator/bash-output/knowledge-removal-final-validation-1784156249.txt`
Reviewed/merged implementation commits:
- `f786e019 feat: remove active knowledge support`
- `ad666503 fix: remove stale knowledge guidance`
- `20654f9c fix: disable hash console completions`
- `9f527f5e fix: remove stale report knowledge wording`
Notes:
- Active Knowledge tools/kind/schema/resident context/reference/completion paths were removed。
- Old `.yoi/knowledge` data is ignored/manual archive material only。
- Legacy Knowledge SystemItems are ignored/drop to empty history text。
- Memory summary/decision/request behavior remains intact。
- Agent Skills support was intentionally not implemented in this Ticket。
- Per user instruction, no `StopPod` cleanup was performed。
---
<!-- event: state_changed author: orchestrator at: 2026-07-15T23:00:02Z from: inprogress to: done reason: implementation_validated field: state -->
## State changed
Implementation was reviewed, merged into orchestration branch, and final validation passed. Moving to done before closure.
---
<!-- event: state_changed author: hare at: 2026-07-15T23:00:18Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-15T23:00:18Z status: closed -->
## 完了
Knowledge support removal を実装・レビュー・merge・検証した。
実装内容:
- active `KnowledgeQuery` tool registration/schema exposure を削除。
- Memory tools から `kind = knowledge` support と descriptions を削除。
- Memory crate の Knowledge frontmatter/schema/lint/query/resident-context handling を削除。
- Worker resident Knowledge injection と Knowledge completion state を削除。
- protocol / TUI / web の active `#<slug>` Knowledge reference/completion path を削除。
- Web Console の `#` completion sigil を削除し、`#...` は completion token ではなく plain/unsupported text になった。
- old persisted `kind: "knowledge"` SystemItem は `LegacyKnowledgeIgnored` として bounded compatibility にし、model context へ replay しない。
- `.yoi/knowledge/` は active workspace authority ではなく ignored/manual archive material として docs 更新。
- prompts/docs/tool descriptions から active Knowledge guidance を削除。
- Memory summary/decision/request behavior は維持。
- Agent Skills support はこの Ticket では実装していない。
Review:
- 初回 review は model-visible Ticket tool description と active design docs の stale Knowledge guidance で `request_changes`
- 2回目 review は Web Console `#` completion が active のまま残っていた blocker で `request_changes`
- 3回目 review は docs/report に active Knowledge wording が残っていた blocker で `request_changes`
- `9f527f5e fix: remove stale report knowledge wording` 後の focused re-review は `approve`
Merge / validation:
- Merge commit: `f279eb11 merge: remove knowledge support`
- Final validation passed:
- Focused Knowledge removal grep with only explicit legacy/manual `.yoi/knowledge` note remaining。
- `git diff --check`
- `cargo test -p memory --lib --tests`
- `cargo test -p worker --lib --tests`
- `cargo test -p session-store --lib --tests`
- `cargo test -p tui --lib --tests`
- `cargo test -p yoi --tests`
- `cd web/workspace && deno task check`
- `cd web/workspace && deno task test`
- `cargo check -p yoi`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/knowledge-removal-final-validation-1784156249.txt`
Cleanup:
- Implementation worktree/branch cleanup will be performed after close commit。
- Per user instruction, `StopPod` is not used。
---

View File

@ -1,30 +0,0 @@
---
title: 'ターン中のProgress messageを残す指示を追加する'
state: 'closed'
created_at: '2026-07-16T03:19:36Z'
updated_at: '2026-07-16T16:48:26Z'
assignee: null
---
## 背景
Memory extract が raw tool/result slice から意味を復元しようとすると、目的・判断・現在地が抜けて断片的な抽出になりやすい。専用 Tool を増やすと tool surface が増え、注意分散と token 重複も増える。
長い作業の節目で、main Worker がユーザーに見える通常 Assistant Message として短い Progress message を残せれば、人間への進捗報告と後段 extract 用 Overview の semantic backbone を兼ねられる。
## 要件
- 長い作業、tool loop、調査/実装 phase の節目で、main Worker が短い Progress message を通常 Assistant Message として出すように prompt / guidance を追加または変更する。
- Progress message は user-facing transcript の一部として成立する内容に限定する。
- raw reasoning や迷いの垂れ流しではなく、目的、確認済み事実、判断、未解決点、次の作業を簡潔に書く。
- 専用 Tool は追加しない。
- tool call ごとではなく、意味のある phase boundary / long-running interval / 重要な確認結果の節目で出す。
- Memory extract 側は将来的に user messages + assistant messages を Overview として優先し、tool logs は evidence として参照できる設計に接続する。
## 受け入れ条件
- relevant prompt/resource の指示に、turn 中の Progress message を残す方針が明記されている。
- Progress message の内容制約が明記されている: public に見せられる短い作業状態、確認済み事実、判断、未解決点、次の作業。chain-of-thought や冗長な tool detail は含めない。
- 専用 Tool を増やさず、通常 Assistant Message として記録する方針になっている。
- 既存の最終応答と過剰に重複しない頻度・粒度の guidance がある。
- prompt/resource 変更として `nix build .#yoi` で packaging を検証する。

View File

@ -1,5 +0,0 @@
Progress message guidance was added to `resources/prompts/common/writing.md`.
The prompt now instructs long-running work to emit short ordinary user-visible prose progress updates at meaningful boundaries, without tool calls, hidden notes, chain-of-thought, raw reasoning, secrets, or verbose tool-output details.
Validation: `nix build .#yoi` passed.

View File

@ -1,70 +0,0 @@
<!-- event: create author: "yoi ticket" at: 2026-07-16T03:19:36Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: plan author: hare at: 2026-07-16T03:19:50Z -->
## Plan
背景:
Memory extract が raw tool/result slice から意味を復元しようとすると、目的・判断・現在地が抜けて断片的な抽出になりやすい。専用 Tool を増やすと tool surface が増え、注意分散と token 重複が増えるため、ユーザーに見える通常 Assistant Message を progress / checkpoint として活用し、人間にも機械にも追いやすい transcript を作る。
要件:
- 長い作業・tool loop・調査/実装 phase の節目で、main Worker が短い Progress message を通常 Assistant Message として出すように prompt / guidance を追加または変更する。
- Progress message は user-facing transcript の一部として成立する内容に限定する。raw reasoning や迷いの垂れ流しではなく、目的、確認済み事実、判断、未解決点、次の作業を簡潔に書く。
- 専用 Tool は追加しない。Progress message はユーザーへの進捗報告と、後段 extract 用 Overview の semantic backbone を兼ねる。
- tool call ごとではなく、意味のある phase boundary / long-running interval / 重要な確認結果の節目で出す。
- Memory extract 側は将来的に user messages + assistant messages を Overview として優先し、tool logs は evidence として参照できる設計に接続する。
受け入れ条件:
- relevant prompt/resource の指示に、turn 中の Progress message を残す方針が明記されている。
- Progress message の内容制約が明記されている: public に見せられる短い作業状態、確認済み事実、判断、未解決点、次の作業。chain-of-thought や冗長な tool detail は含めない。
- 専用 Tool を増やさず、通常 Assistant Message として記録する方針になっている。
- 既存の最終応答と過剰に重複しない頻度・粒度の guidance がある。
- prompt/resource 変更として `nix build .#yoi` で packaging を検証する。
---
<!-- event: intake_summary author: hare at: 2026-07-16T03:19:54Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-16T03:19:54Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: hare at: 2026-07-16T16:48:26Z from: ready to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-16T16:48:26Z status: closed -->
## 完了
Progress message guidance was added to `resources/prompts/common/writing.md`.
The prompt now instructs long-running work to emit short ordinary user-visible prose progress updates at meaningful boundaries, without tool calls, hidden notes, chain-of-thought, raw reasoning, secrets, or verbose tool-output details.
Validation: `nix build .#yoi` passed.
---

View File

@ -1,21 +0,0 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KXMK7YMC",
"kind": "depends_on",
"target": "00001KXNYXNM6",
"note": "session-explore extract worker must use the finalized staging source anchor / evidence ref format.",
"author": "yoi ticket",
"at": "2026-07-16T17:19:11Z"
},
{
"ticket_id": "00001KXMK7YMC",
"kind": "depends_on",
"target": "00001KXRM6G0G",
"note": "session-explore extract worker must write flat candidate staging records via stage_candidate.",
"author": "yoi ticket",
"at": "2026-07-17T18:10:00Z"
}
]
}

View File

@ -1,180 +0,0 @@
---
title: 'session-explore feature付きextract workerを実装する'
state: 'planning'
created_at: '2026-07-16T04:34:01Z'
updated_at: '2026-07-17T18:10:00Z'
assignee: null
---
## 背景
Overview-first extract では、extract worker が開始時点の会話 snapshot と filtered Overview projection を参照し、必要な evidence だけを探索して staging candidate を出す。main Worker の tool surface は増やさず、extract worker だけに session exploration 用の read-only tools と staging output tools を与える。
新方針では、extract run ごとの batch payload は使わない。extract worker は candidate ごとに `stage_candidate` を呼び、flat staging record を 1 件ずつ作る。出すべき candidate がない場合は `finish_extraction` で NOP reason を残す。
この Ticket は worker 実装を扱う。先に Ticket `00001KXRM6G0G` で flat candidate staging record schema を実装する。
関連:
- Objective `00001KVJSMQXZ`
- `.yoi/objectives/00001KVJSMQXZ/memory-architecture-overview.md`
- Ticket `00001KXMEZNYC` — ターン中のProgress messageを残す指示を追加する
- Ticket `00001KXRM6G0G` — Flat candidate staging record schemaを実装する
## 実装意図
この Ticket の目的は、extract worker に「何が起きたか」を flat log から推測させるのではなく、開始時点の immutable reference environment を探索させることである。
Reference environment:
```text
Conversation snapshot at extract start
- extract 対象 history / segment range の immutable copy
- message entries
- tool call summaries
- tool result summaries / bounded excerpts
- evidence id -> source anchor table
Filtered Overview projection
- User messages
- Assistant text outputs, including Progress messages and final responses
- committed task context
- evidence ids への bounded index
```
Extract worker:
```text
Overview を読む
-> important candidate を見つける
-> session-explore tools で evidence を確認する
-> stage_candidate(kind, claim, why_useful, staleness, evidence_ids) を候補ごとに呼ぶ
-> 最後に finish_extraction(staged_count, reason) を呼ぶ
```
この変更で目指す改善は recall をむやみに増やすことではない。断片的な activity log を減らし、Consolidation が単体で解決できる最小 candidate record を staging に出す precision 改善を優先する。
## 対象領域
主な対象:
- `crates/worker/src/feature.rs`
- `builtin:session-explore` feature descriptor / contribution registration の追加候補。
- `crates/memory/src/extract/input.rs`
- Overview-first / Evidence-index-second rendering。
- Conversation snapshot / filtered Overview projection の入力作成。
- `crates/memory/src/extract/tool.rs` または隣接 module
- `search_evidence` / `read_evidence` / `stage_candidate` / `finish_extraction` を実装。
- `crates/memory/src/extract/payload.rs`
- Ticket `00001KXRM6G0G` の flat staging record schema を利用。
- `crates/memory/src/extract/staging.rs`
- candidate ごとの staging write。
- `crates/worker/src/worker.rs`
- `run_extract_once` / extract worker 起動。
- extract worker 用 ToolRegistry / enabled feature set の構築。
- `resources/prompts/internal/memory_extract_system.md`
- candidate taxonomy / evidence-confirmation / `stage_candidate` / `finish_extraction` 前提へ更新。
## 実装方針
### 1. Reference environment を extract 開始時点で固定する
extract worker は live Worker state を自由に読むのではなく、extract 開始時点で host が作った immutable reference environment に接続する。
含めるもの:
- source range / segment id。
- Overview projection。
- Evidence index。
- evidence id -> anchor / summary / bounded excerpt table。
含めないもの:
- raw reasoning / chain-of-thought。
- unbounded raw tool result content。
- history に commit されていない hidden context injection。
- mutable live Worker state への直接 access。
### 2. `session-explore` feature を作る
`session-explore` は extract worker 専用の built-in feature とする。
Feature の tool surface:
- `search_evidence`
- query / optional kind / optional limit で Evidence index を検索する。
- output は evidence ids、entry ranges、short summaries、source anchors。
- `read_evidence`
- evidence id または entry range を読む。
- output は bounded message/tool summary/excerpt と source anchor。
- `stage_candidate`
- 1 candidate を flat staging record として保存する。
- input は `kind`, `claim`, `why_useful`, optional `staleness`, `evidence_ids`
- host が evidence ids を source anchors / bounded snippets に解決する。
- `finish_extraction`
- staged count と completion reason / NOP reason を記録する。
制約:
- evidence access は read-only。
- `stage_candidate` は staging write だけであり、Memory / Knowledge / Skill / Ticket / docs を mutate しない。
- main Worker の通常 tool surface には出さない。
- extract worker の enabled feature set は `session-explore` のみにする。
### 3. Extract worker は専用 Worker / Engine として起動する
既存の memory extract sub-engine 起動を、`session-explore` feature のみ有効な worker-like environment に寄せる。
要件:
- foreground Worker history を汚染しない。
- extract prompt / harness messages は canonical foreground history に混ざらない。
- output は `stage_candidate` / `finish_extraction` 経由で host context に戻す。
- empty / NOP は正常結果。
- worker が evidence tools と staging output tools 以外の通常 workspace tools を見ないことを test する。
### 4. Trigger は現行 post-run threshold を維持する
初期実装では既存の post-run trigger を維持する。Worker run cycle が完了してから threshold を判定し、超えていれば extract を発火する。LLM call ごと、または Run 中の途中では発火しない。
この Ticket の中心は scheduler ではなく、extract worker の参照環境、tool surface、flat staging output である。mid-run trigger や Overview accumulation trigger は別 Ticket に分ける。
## 実装要件
- extract 開始時点の conversation snapshot と filtered Overview projection を保持する reference environment を実装する。
- `builtin:session-explore` feature を追加する。
- extract worker は `session-explore` feature のみ有効な Worker / ToolRegistry として起動する。
- `session-explore` feature に bounded read-only evidence tools を実装する。
- `stage_candidate``finish_extraction` を output tools として実装する。
- `stage_candidate` は 1 call = 1 flat staging record を書く。
- `finish_extraction` は 0 件 NOP を正常終了として表現できる。
- `build_extract_input` 相当の入力を Overview-first / Evidence-index-second に変更する。
- extract worker は Ticket `00001KXRM6G0G` の flat staging record schema を使って staging records を出す。
- extract worker は Memory / Knowledge / Skill / Ticket / docs を直接変更しない。
- main Worker の model-visible tool surface は増やさない。
- raw tool result content 全文を無制限に extract prompt / tool output / staging record へ流さない。
- `memory_extract_system.md` を candidate taxonomy / session-explore / evidence-confirmation / `stage_candidate` 前提に更新する。
## 非目標
- flat staging record schema の実装はこの Ticket では行わない。Ticket `00001KXRM6G0G` が先行する。
- staging -> Memory disposition / resolution flow はこの Ticket では実装しない。Ticket `00001KXMK846H` の対象。
- 専用 Progress Tool を main Worker に追加しない。
- Knowledge / Skill / docs を extract worker が直接 rewrite しない。
- 大規模な mid-run extraction scheduler はこの Ticket では作らない。
- 旧 batch staging schema との互換性維持を目的にしない。
## 受け入れ条件
- extract 開始時点の immutable reference environment が作られる。
- filtered Overview projection が User messages / Assistant text outputs / committed task context から作られる。
- `builtin:session-explore` feature が存在し、extract worker にだけ有効化される。
- extract worker が `session-explore` の read-only evidence tools と `stage_candidate` / `finish_extraction` だけを使える。
- `stage_candidate` 1 call が 1 flat staging record を作る。
- candidate がない場合、`finish_extraction` で staged_count=0 / NOP reason を記録できる。
- main Worker の model-visible tool surface が増えていない。
- extract output は staging にだけ書かれ、Memory / Knowledge / Skill への direct write がない。
- empty / no-candidate path が正常終了として扱われる。
- Overview rendering、reference environment immutability、feature-gated tool registry、evidence tool bounds、stage_candidate output の tests が追加または更新されている。
- `cargo test -p memory` と関連 worker tests、または該当 crate の同等テストが通る。
- prompt/resource/code 変更として `nix build .#yoi` が通る。

View File

@ -1,60 +0,0 @@
<!-- event: create author: "yoi ticket" at: 2026-07-16T04:34:01Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-07-16T04:35:41Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-16T04:35:41Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: decision author: hare at: 2026-07-16T04:38:21Z -->
## Decision
ユーザー指示により、設計で止める Ticket ではなく実装 Ticket として再スコープした。Overview-first extract / evidence tools は実装要件に変更し、staging 審査・剪定も resolution/disposition の実装まで含める。
---
<!-- event: decision author: hare at: 2026-07-16T14:06:41Z -->
## Decision
実装者が対象領域と意図を追いやすいように、実装意図、対象 module、実装方針、想定順序を item.md に追記した。特に `extract/input.rs`、`extract/tool.rs`、`extract/payload.rs`、`extract/staging.rs`、`worker.rs`、`memory_extract_system.md` を主な対象として明示し、初期 evidence tools は extract slice 内の bounded read-only search/read/anchor 解決に閉じる方針を示した。
---
<!-- event: decision author: hare at: 2026-07-16T17:19:41Z -->
## Decision
Rescoped from broad Overview-first extract implementation to the worker implementation slice. This Ticket now implements an extract worker that runs with only the builtin:session-explore feature enabled, connected to an immutable conversation snapshot and filtered Overview projection. It depends on Ticket 00001KXNYXNM6 for the staging source anchor / evidence ref format.
---
<!-- event: decision author: hare at: 2026-07-17T18:10:00Z -->
## Decision
Design updated: abandon the old batch `write_extracted` / `decisions` / `discussions` / `attempts` / `requests` staging direction. Extract now stages flat candidate records with `stage_candidate` and finishes with `finish_extraction`. The older dependency on 00001KXNYXNM6 is historical; the active schema dependency is 00001KXRM6G0G.
---

View File

@ -1,71 +0,0 @@
---
title: 'StagingからMemory化する審査・剪定フローを実装する'
state: 'ready'
created_at: '2026-07-16T04:34:07Z'
updated_at: '2026-07-16T04:38:21Z'
assignee: null
---
## 背景
現状の staging は extract output の一時保存としては機能しているが、Memory 化前の審査キューとしての情報が弱い。consolidation が成功すると consumed staging は削除されるため、どの staging entry を Memory 化したか、何を捨てたか、なぜ捨てたか、何を Knowledge / Skill candidate に送るべきだったかが machine-readable に残りにくい。
関連:
- Objective `00001KVJSMQXZ`
- `.yoi/objectives/00001KVJSMQXZ/memory-architecture-overview.md`
- `.yoi/objectives/00001KVJSMQXZ/resources/pirolli-card-2005-sensemaking.md`
## 実装要件
- staging entry ごとの resolution / disposition 記録を実装する。
- 初期実装は append-only JSONL などの軽量な形式でよい。
- consumed staging を削除する場合も、削除前に resolution を残す。
- invalid staging / failed consolidation は既存挙動を壊さず、可能なら diagnostic resolution を残す。
- disposition action を machine-readable にする。
- 最低限: `discard` / `merge_memory` / `replace_memory` / `mark_memory_stale` / `delete_memory` / `defer` / `promote_to_knowledge_candidate` / `promote_to_skill_candidate` / `link_to_authority` / `create_ticket_or_doc_candidate`
- 初期実装で全 action を実際に mutate できない場合も、resolution として記録できるようにする。
- consolidation prompt / input / output flow を更新し、staging entry ごとの disposition を要求する。
- discard reason / defer reason / target record / consolidation run id / consumed_by を記録する。
- record mutation がない場合でも、なぜ Memory 化しなかったかを resolution に残せるようにする。
- Memory 化条件を prompt と実装上の記録に反映する。
- future usefulness reason。
- scope / applicability。
- source anchors。
- staleness condition / invalidation condition。
- なぜ Knowledge / Skill / Ticket / docs ではなく Memory なのか。
- authoritative records の単なる mirror ではないこと。
- staging からの destination routing を resolution に記録する。
- Memory。
- Knowledge candidate。
- Skill candidate。
- Ticket/doc candidate。
- authority link only。
- discard / defer。
- consolidation / tidy を garden operation として強める。
- append より merge / replace / mark stale / delete を優先する guidance を prompt に入れる。
- stale / duplicate / no-scope / no-product-use / authority-duplicate / contradictory records を検出する tidy hints を追加できる範囲で実装する。
- Pirolli & Card の sensemaking flow を implementation note / prompt に反映する。
- shoebox: Overview + Evidence index + selected candidate ranges。
- evidence file: source anchors 付き staging entries。
- schemas/hypotheses: Memory 化すべきか、Knowledge/Skill に送るべきか、stale かの判断。
- product: Memory update / Knowledge candidate / Skill candidate / Ticket/doc update / discard resolution。
## 非目標
- staging を長期 Knowledge store にしない。
- staging entry を全て Memory 化しない。
- Memory を Ticket / docs / git / session log の mirror にしない。
- 初期 slice で完全な vector DB や大規模 search infrastructure を実装しない。
## 受け入れ条件
- consolidation 実行後、staging entry ごとの disposition / resolution が durable に残る。
- consumed staging を削除する前に resolution が記録される。
- `discard` / `merge_memory` / `replace_memory` / `mark_memory_stale` / `delete_memory` / `defer` / `promote_to_knowledge_candidate` / `promote_to_skill_candidate` / `link_to_authority` / `create_ticket_or_doc_candidate` の少なくとも記録表現がある。
- Memory 化には usefulness / scope / source / staleness / destination reason が求められる。
- Knowledge / Skill / Ticket / docs への routing と、Memory に入れない重要 material の扱いが resolution として表現できる。
- resolution log / archive は staging cleanup と競合せず、consolidation lock / consumed snapshot の既存安全性を壊さない。
- resolution write、consumed cleanup、discard/defer path、no-record-change path の tests が追加または更新されている。
- `cargo test -p memory` または該当 crate の同等テストが通る。
- code/prompt/resource 変更として `nix build .#yoi` が通る。

View File

@ -1,33 +0,0 @@
<!-- event: create author: "yoi ticket" at: 2026-07-16T04:34:07Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-07-16T04:35:41Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-16T04:35:41Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: decision author: hare at: 2026-07-16T04:38:21Z -->
## Decision
ユーザー指示により、設計で止める Ticket ではなく実装 Ticket として再スコープした。Overview-first extract / evidence tools は実装要件に変更し、staging 審査・剪定も resolution/disposition の実装まで含める。
---

View File

@ -1 +0,0 @@
{"id":"orch-plan-20260716-192724-1","ticket_id":"00001KXNYXNM6","kind":"accepted_plan","accepted_plan":{"summary":"Implement extract staging entry-level source anchors/evidence refs in the memory crate: add bounded host-resolved source/evidence reference types to staging payload entries with old JSON compatibility, keep record-level SourceRef, render/pass refs through consolidation input, and add serialization/deserialization/unit tests without implementing session-explore tools or staging-to-Memory resolution.","branch":"work/00001KXNYXNM6-staging-source-anchors","worktree":"/home/hare/Projects/yoi/.worktree/00001KXNYXNM6-staging-source-anchors","role_plan":"Orchestrator accepts queued Ticket after inspecting Ticket/relation/plan/workspace state. Use sibling Coder for implementation in a dedicated worktree, then sibling Reviewer for read-only review. Orchestrator retains merge/final-validation/close/worktree cleanup authority. Do not call StopPod unless explicitly instructed."},"author":"orchestrator","at":"2026-07-16T19:27:24Z"}

View File

@ -1,13 +0,0 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KXNYXNM6",
"kind": "related",
"target": "00001KXMK846H",
"note": "staging source anchors feed later staging resolution/disposition and Memoryization filtering.",
"author": "yoi ticket",
"at": "2026-07-16T17:19:11Z"
}
]
}

View File

@ -1,83 +0,0 @@
---
title: 'Extract stagingのsource anchor形式を実装する'
state: 'closed'
created_at: '2026-07-16T17:17:22Z'
updated_at: '2026-07-16T19:45:37Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-07-16T19:26:52Z'
---
## 背景
Overview-first extract では、extract worker が開始時点の会話 snapshot と filtered Overview projection を参照し、必要な evidence だけを探索して staging payload を出す。これを安全に実装するには、先に staging payload / source anchor / evidence reference の形式を固める必要がある。
現状の `StagingRecord` は record 全体に `source: SourceRef` を持つが、個々の `decision` / `discussion` / `attempt` / `request` がどの evidence に基づくかを表現しづらい。consolidation が staging を審査・剪定するためには、entry-level source anchors と overview/evidence refs を辿れる形式が必要である。
関連:
- Objective `00001KVJSMQXZ`
- `.yoi/objectives/00001KVJSMQXZ/memory-architecture-overview.md`
- Ticket `00001KXMK7YMC``session-explore` feature 付き extract worker 実装
- Ticket `00001KXMK846H` — staging から Memory 化する resolution / disposition 実装
## 実装意図
この Ticket は `session-explore` worker の前提となる staging schema を実装する。目的は、extract worker が「根拠を読んだふり」をせず、host が解決した source anchor と staging entry を機械的に結びつけられるようにすることである。
この Ticket では extract worker の feature / tool 実装までは行わない。まず serialization / schema / tests を固める。
## 対象領域
主な対象:
- `crates/memory/src/extract/payload.rs`
- `ExtractedPayload`
- `DecisionEntry` / `DiscussionEntry` / `AttemptEntry` / `RequestEntry`
- entry-level source refs / evidence refs の追加
- `crates/memory/src/extract/staging.rs`
- staging write / read compatibility
- `crates/memory/src/schema/common.rs`
- `SourceRef` との関係確認
- `crates/memory/src/consolidate/input.rs`
- 新形式 staging を consolidation input に表示できるか確認
- relevant tests under `crates/memory`
## 実装要件
- extract staging の source anchor / evidence reference 型を実装する。
- session id / segment id。
- entry range。
- evidence id。
- evidence kind: message / tool_call / tool_result / file_ref / ticket_ref / objective_ref など拡張可能な形。
- optional summary / label。
- `DecisionEntry` / `DiscussionEntry` / `AttemptEntry` / `RequestEntry` に optional entry-level source refs を持たせる。
- 既存 JSON 互換性を壊さないため default empty / skip serializing empty とする。
- 既存の `StagingRecord.source` は維持する。
- record-level source は extract 対象 range 全体を示す。
- entry-level source refs は個々の claim の evidence を示す。
- source refs は extract worker が自由作文した根拠文字列ではなく、host が解決した anchor を参照できる形式にする。
- staging JSON の読み書きで、旧形式 staging が読めることを維持する。
- consolidation input で entry-level source refs が確認できるようにする。
- 最初は human-readable 表示でよい。
- consolidation が未対応でも壊れない optional field として扱う。
- raw tool result content 全文を staging payload に埋め込まない。
## 非目標
- `session-explore` feature / evidence tools をこの Ticket で実装しない。
- extract worker 起動経路をこの Ticket で変更しない。
- staging -> Memory resolution / disposition log をこの Ticket で実装しない。
- Knowledge / Skill / Ticket / docs への routing をこの Ticket で実装しない。
## 受け入れ条件
- staging payload に entry-level source refs / evidence refs を保存できる。
- 既存 `StagingRecord.source` と互換性が維持されている。
- 旧形式 staging JSON が読める。
- new-format staging JSON が serialization / deserialization できる。
- consolidation input に source refs が表示される、または少なくとも lossless に通る。
- source refs は bounded metadata / anchors のみで、raw tool result 全文を含まない。
- relevant unit tests が追加または更新されている。
- `cargo test -p memory` または該当 crate の同等テストが通る。
- code 変更として `nix build .#yoi` が通る。

View File

@ -1,35 +0,0 @@
Extract staging の entry-level source anchors / evidence references を実装・レビュー・merge・検証した。
実装内容:
- `crates/memory/src/schema/common.rs` に bounded source/evidence metadata 型を追加。
- `EvidenceKind(pub String)`
- known constants: `message`, `tool_call`, `tool_result`, `file_ref`, `ticket_ref`, `objective_ref`
- `SourceEvidenceRef` with `session_id`, `segment_id`, `entry_range`, `evidence_id`, `evidence_kind`, `label`, `summary`
- `DecisionEntry`, `DiscussionEntry`, `AttemptEntry`, `RequestEntry` に optional `source_refs: Vec<SourceEvidenceRef>` を追加。
- `source_refs` は default empty / skip serializing empty とし、`#[schemars(skip)]` で LLM extract schema には露出しないようにした。
- 既存 `StagingRecord.source` は record-level source range として維持。
- old staging JSON without `source_refs` の互換性を維持。
- legacy record-level `source.session_id` compatibility を追加。
- new staging JSON with entry-level refs の roundtrip を追加。
- consolidation input は pretty JSON として entry-level refs を lossless に保持するようにした。
- session-explore / resolver tools / extract launch / staging resolution/disposition はこの Ticket では実装していない。
Review:
- Reviewer approved with no blockers。
- Evidence included bounded anchor field definitions, all four entry types, old/new JSON tests, legacy source compatibility, and consolidation preservation。
- Non-blocking note: `label` / `summary` の length/raw-content discipline は type enforcement ではなく semantic/host-policy に残る。
Merge / validation:
- Merge commit: `9a1a75de merge: staging source anchors`
- Final validation passed:
- `cargo fmt --check`
- `git diff --check`
- `cargo test -p memory`
- `cargo check -p yoi`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/staging-source-anchors-final-validation-1784231070.txt`
Cleanup:
- Implementation worktree/branch cleanup will be performed after close commit。
- Per user instruction, `StopPod` is not used。

View File

@ -1,285 +0,0 @@
<!-- event: create author: "yoi ticket" at: 2026-07-16T17:17:22Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: decision author: hare at: 2026-07-16T17:19:41Z -->
## Decision
Overview-first extract implementation was split so this Ticket owns the staging payload / source anchor format first. The extract worker implementation will depend on this format through Ticket 00001KXMK7YMC.
---
<!-- event: state_changed author: workspace-panel at: 2026-07-16T19:26:52Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: orchestrator at: 2026-07-16T19:27:54Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Ticket は Overview-first extract の前提となる staging payload / source anchor / evidence reference schema に限定されており、実装 scope と非目標が明確。
- `TicketRelationQuery(00001KXNYXNM6)` は incoming `depends_on` from `00001KXMK7YMC` と outgoing `related 00001KXMK846H` のみで、この Ticket 自身の blocker はない。
- `TicketOrchestrationPlanQuery(00001KXNYXNM6)` は事前 record なし。今回 accepted plan を記録した。
- `TicketList(inprogress)` は 0 件。
- Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration` は clean で、既存 implementation worktree/branch は見当たらない。
- Bounded code map で `crates/memory/src/extract/payload.rs`, `extract/staging.rs`, `schema/common.rs`, `consolidate/input.rs` 周辺に対象実装があることを確認した。
Evidence checked:
- Ticket body / thread / relations / artifacts。
- `TicketRelationQuery`, `TicketOrchestrationPlanQuery`, `TicketList(inprogress)`
- Orchestrator worktree / branch / worktree state。
- `crates/memory/src` の staging / extract / SourceRef / consolidation input references。
IntentPacket:
Intent:
- Extract staging payload に entry-level source anchors / evidence references を追加し、各 decision/discussion/attempt/request entry がどの host-resolved evidence に基づくかを機械的に辿れるようにする。
- Overview-first extract worker / staging consolidation の前提 schema を固める。
Binding decisions / invariants:
- `StagingRecord.source` は維持し、record-level source は extract 対象 range 全体を示す。
- entry-level source refs は個々の claim/evidence を示す optional field とする。
- 旧形式 staging JSON は読めるままにする。新 field は default empty / skip serializing empty を基本とする。
- source/evidence refs は free-form rationale text ではなく、host が解決できる bounded anchor metadata にする。
- raw tool result content 全文を staging payload に埋め込まない。
- この Ticket では session-explore feature / evidence tools / extract worker 起動経路 / staging -> Memory resolution/disposition は実装しない。
Requirements / acceptance criteria:
- session id / segment id / entry range / evidence id / evidence kind / optional summary-label を表せる source anchor / evidence reference 型を追加する。
- evidence kind は message / tool_call / tool_result / file_ref / ticket_ref / objective_ref などを表せ、将来拡張可能な形にする。
- `DecisionEntry` / `DiscussionEntry` / `AttemptEntry` / `RequestEntry` に optional entry-level source refs を持たせる。
- new-format staging JSON が serialization / deserialization できる。
- old-format staging JSON が deserialization できる。
- consolidation input で entry-level source refs が human-readable に確認できるか、少なくとも lossless に通る。
- relevant `crates/memory` tests を追加/更新する。
Implementation latitude:
- 型名・module placement は既存 `SourceRef` / extract payload / staging style に合わせてよい。
- entry range の exact representation は serde 互換・bounded metadata である限り coder が選んでよい。
- consolidation input rendering は初期実装として readable summary でよい。
- 既存 `SourceRef` を拡張するか、新しい entry-level anchor type を追加するかは互換性と clarity を見て選んでよい。
Escalate if:
- 既存 `SourceRef` の breaking schema change が必要になる場合。
- session-explore tool/evidence resolver 実装なしでは anchor schema を決められないことが判明した場合。
- raw evidence content を staging に入れないと acceptance を満たせない場合。
- staging-to-Memory resolution/disposition まで同時実装しないと schema が成立しない場合。
Validation:
- `git diff --check`
- `cargo test -p memory`
- `cargo check -p yoi`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
- 変更範囲が memory crate 以外へ広がる場合は affected crate tests も実行。
Current code map / likely touch points:
- `crates/memory/src/extract/payload.rs`
- `crates/memory/src/extract/staging.rs`
- `crates/memory/src/schema/common.rs`
- `crates/memory/src/consolidate/input.rs`
- `crates/memory/src/consolidate/staging.rs`
- relevant `crates/memory` tests。
Critical risks / reviewer focus:
- old staging JSON compatibility break。
- entry-level source refs silently serializing noisy empty data。
- source refs becoming free-form LLM-authored evidence text instead of bounded host-resolved anchors。
- raw tool result content leaking into staging payload。
- conflating record-level source range with per-entry evidence anchors。
- accidentally implementing session-explore worker or staging resolution out of scope。
Next action:
- Record `queued -> inprogress`, create dedicated implementation worktree, and route to sibling Coder Pod。
---
<!-- event: state_changed author: orchestrator at: 2026-07-16T19:28:00Z from: queued to: inprogress reason: accepted_for_implementation field: state -->
## State changed
Dashboard queue authorization was inspected, no blockers were found, and an accepted plan was recorded. Moving queued Ticket to inprogress before creating worktree or spawning role Pods.
---
<!-- event: implementation_report author: orchestrator at: 2026-07-16T19:40:54Z -->
## Implementation report
Implementation progress report:
- Coder Pod `yoi-coder-00001KXNYXNM6-staging-anchors` completed implementation in `/home/hare/Projects/yoi/.worktree/00001KXNYXNM6-staging-source-anchors` on branch `work/00001KXNYXNM6-staging-source-anchors`
- Implementation commit: `31798fb2 feat: add staging entry source anchors`
- Orchestrator inspected worktree status, branch log, commit stats, and `git diff --check 08c4547a..HEAD`; worktree was clean and diff check passed。
Implementation summary:
- Added bounded entry-level source/evidence metadata types in `crates/memory/src/schema/common.rs`:
- `EvidenceKind(pub String)` with known constants: `message`, `tool_call`, `tool_result`, `file_ref`, `ticket_ref`, `objective_ref`
- `SourceEvidenceRef` with optional skip-empty serde fields: `session_id`, `segment_id`, `entry_range`, `evidence_id`, `evidence_kind`, `label`, `summary`
- Added `source_refs: Vec<SourceEvidenceRef>` to `DecisionEntry`, `DiscussionEntry`, `AttemptEntry`, and `RequestEntry`
- `source_refs` defaults empty and is skipped during serialization when empty。
- `source_refs` is marked `#[schemars(skip)]` so host-resolved anchors are not exposed as LLM-authored extract-schema fields。
- Existing `StagingRecord.source` remains record-level source range。
- Added deserialization-only compatibility for legacy record-level `SourceRef` using old `source.session_id` when `segment_id` is absent。
- Consolidation input renders staging records as pretty JSON, preserving entry-level refs losslessly。
- Did not implement session-explore, resolver tools, extract launch paths, or staging resolution/disposition。
Files touched:
- `crates/memory/src/schema/common.rs`
- `crates/memory/src/schema/mod.rs`
- `crates/memory/src/extract/payload.rs`
- `crates/memory/src/extract/staging.rs`
- `crates/memory/src/consolidate/input.rs`
- `crates/memory/src/consolidate/staging.rs`
Test/evidence coverage added or updated:
- Old staging JSON without `source_refs` deserializes and reserializes without empty `source_refs`
- New staging JSON with entry-level refs roundtrips through serde, preserving session id / segment id / entry range / evidence id / evidence kind / label / summary。
- Legacy `source.session_id` staging JSON is accepted for compatibility。
- Consolidation rendering includes/preserves entry-level refs in staging JSON input text。
Coder-reported validation passed:
- `cargo fmt`
- `cargo test -p memory`
- `cargo check -p yoi`
- `git diff --check`
- `git diff --check HEAD~1 HEAD`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
Next action:
- Route to external Reviewer Pod before merge/close decisions。
---
<!-- event: review author: reviewer at: 2026-07-16T19:44:01Z status: approve -->
## Review: approve
External review result: approve
Evidence:
- bounded anchor fields and evidence kind constants are added in `crates/memory/src/schema/common.rs:45-98`
- all four staging entry types carry default/skip-empty `source_refs` hidden from extract JSON schema via `#[schemars(skip)]` in `crates/memory/src/extract/payload.rs:45-92`
- old/no-ref JSON and new ref roundtrip tests cover preservation/empty serialization in `payload.rs:121-253`
- legacy record-level `source.session_id` compatibility is covered in `crates/memory/src/consolidate/staging.rs:147-188`
- consolidation passes refs through visible pretty JSON and tests preservation in `crates/memory/src/consolidate/input.rs:60-70,281-318`
Validation performed by reviewer:
- implementation worktree clean before/after。
- `cargo test -p memory` passed。
- `git diff --check HEAD~1..HEAD` passed。
Blockers: none。
Non-blocking note:
- `label` / `summary` の length/raw-content discipline は type enforcement ではなく semantic/host-policy に残る。ただし raw message/tool-result embedding や out-of-scope session-explore/disposition implementation は導入されていない。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-16T19:45:13Z -->
## Implementation report
Merge / final validation report:
- Reviewer approved the implementation with no blockers。
- Merged branch `work/00001KXNYXNM6-staging-source-anchors` into orchestration branch with merge commit `9a1a75de merge: staging source anchors`
- Final validation was rerun in `/home/hare/Projects/yoi/.worktree/orchestration` after merge。
Validation passed:
- `cargo fmt --check`
- `git diff --check`
- `cargo test -p memory`
- `cargo check -p yoi`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
Validation log:
- `/run/user/1000/yoi/yoi-orchestrator/bash-output/staging-source-anchors-final-validation-1784231070.txt`
Reviewed/merged implementation commit:
- `31798fb2 feat: add staging entry source anchors`
Notes:
- `StagingRecord.source` remains the record-level source range。
- Entry-level `source_refs` are optional, default empty, and skipped when empty。
- Old staging JSON compatibility and new source-ref roundtrip are covered by tests。
- Consolidation input preserves entry-level refs as pretty JSON。
- session-explore / resolver tools / extract launch / staging resolution were not implemented in this Ticket。
- Per user instruction, no `StopPod` cleanup was performed。
---
<!-- event: state_changed author: orchestrator at: 2026-07-16T19:45:21Z from: inprogress to: done reason: implementation_validated field: state -->
## State changed
Implementation was reviewed, merged into orchestration branch, and final validation passed. Moving to done before closure.
---
<!-- event: state_changed author: hare at: 2026-07-16T19:45:37Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-16T19:45:37Z status: closed -->
## 完了
Extract staging の entry-level source anchors / evidence references を実装・レビュー・merge・検証した。
実装内容:
- `crates/memory/src/schema/common.rs` に bounded source/evidence metadata 型を追加。
- `EvidenceKind(pub String)`
- known constants: `message`, `tool_call`, `tool_result`, `file_ref`, `ticket_ref`, `objective_ref`
- `SourceEvidenceRef` with `session_id`, `segment_id`, `entry_range`, `evidence_id`, `evidence_kind`, `label`, `summary`
- `DecisionEntry`, `DiscussionEntry`, `AttemptEntry`, `RequestEntry` に optional `source_refs: Vec<SourceEvidenceRef>` を追加。
- `source_refs` は default empty / skip serializing empty とし、`#[schemars(skip)]` で LLM extract schema には露出しないようにした。
- 既存 `StagingRecord.source` は record-level source range として維持。
- old staging JSON without `source_refs` の互換性を維持。
- legacy record-level `source.session_id` compatibility を追加。
- new staging JSON with entry-level refs の roundtrip を追加。
- consolidation input は pretty JSON として entry-level refs を lossless に保持するようにした。
- session-explore / resolver tools / extract launch / staging resolution/disposition はこの Ticket では実装していない。
Review:
- Reviewer approved with no blockers。
- Evidence included bounded anchor field definitions, all four entry types, old/new JSON tests, legacy source compatibility, and consolidation preservation。
- Non-blocking note: `label` / `summary` の length/raw-content discipline は type enforcement ではなく semantic/host-policy に残る。
Merge / validation:
- Merge commit: `9a1a75de merge: staging source anchors`
- Final validation passed:
- `cargo fmt --check`
- `git diff --check`
- `cargo test -p memory`
- `cargo check -p yoi`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/staging-source-anchors-final-validation-1784231070.txt`
Cleanup:
- Implementation worktree/branch cleanup will be performed after close commit。
- Per user instruction, `StopPod` is not used。
---

View File

@ -1,13 +0,0 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KXRM6G0G",
"kind": "related",
"target": "00001KXMK846H",
"note": "flat staging records are the consolidation decision units for later resolution/disposition.",
"author": "yoi ticket",
"at": "2026-07-17T18:10:00Z"
}
]
}

View File

@ -1,78 +0,0 @@
---
title: 'Flat candidate staging record schemaを実装する'
state: 'closed'
created_at: '2026-07-17T18:07:40Z'
updated_at: '2026-07-17T19:56:08Z'
assignee: null
queued_by: 'yoi ticket'
queued_at: '2026-07-17T19:48:08Z'
---
## 背景
旧 staging は extract run ごとの batch payload (`decisions` / `discussions` / `attempts` / `requests`) と entry-level source refs を前提にしていた。しかし、consolidation から見ると batch は session に紐づいた不必要な集団になり、record ごとの discard / merge / promote / defer が曖昧になる。
新方針では、extract は記憶化を検討すべき candidate を kind taxonomy に基づいて切り出し、candidate ごとに flat staging record を作る。
```text
1 extract run = 0..N staging records
1 staging record = 1 candidate = 1 consolidation decision unit
```
旧 batch schema との互換性は不要。clear responsibility と flat records を優先する。
関連:
- Objective `00001KVJSMQXZ`
- `.yoi/objectives/00001KVJSMQXZ/memory-architecture-overview.md`
- Ticket `00001KXMK7YMC` — session-explore feature付きextract workerを実装する
- Ticket `00001KXMK846H` — StagingからMemory化する審査・剪定フローを実装する
## 実装要件
- flat staging record schema を実装する。
- 1 file / record は 1 candidate のみを表す。
- candidate kind は次に限定する。
- `preference`
- `working_assumption`
- `constraint`
- `decision`
- `open_question`
- `lesson`
- record は少なくとも次を表現できる。
- `schema_version`
- `id`
- `extract_run_id`
- record-level `source`
- `kind`
- `claim`
- `why_useful`
- `staleness` / invalidation hint
- bounded `evidence[]`
- `source_refs[]`
- evidence は extract が選んだ bounded snippets のみを保持する。
- raw session log 全体や Overview 全体は保存しない。
- raw tool result 全文は保存しない。
- `source_refs` は evidence id / evidence kind / entry range など host-resolved anchor を保持する。
- extract run ごとの batch payload を staging record として保存しない。
- consolidation input は flat records を record ごとに列挙する。
- 旧 batch schema の読み込み互換は不要。
## 非目標
- `session-explore` feature / tools はこの Ticket では実装しない。
- `stage_candidate` / `finish_extraction` tool はこの Ticket では実装しない。
- staging resolution / disposition はこの Ticket では実装しない。
- Overview projection を staging に保存しない。
## 受け入れ条件
- flat candidate staging record を serialize / deserialize できる。
- 1 staging file が 1 candidate だけを表す。
- candidate kind が上記 taxonomy に制限されている。
- bounded evidence snippets と source refs を保持できる。
- raw session log / Overview 全体 / raw tool result 全文を schema が要求しない。
- consolidation input が flat records を candidate 単位で表示できる。
- relevant tests が追加または更新されている。
- `cargo test -p memory` または該当 crate の同等テストが通る。
- code 変更として `nix build .#yoi` が通る。

View File

@ -1,9 +0,0 @@
Implemented flat candidate staging record schema.
The memory extract staging path now treats staging as flat candidate records: one extract run can produce zero or more staging files, and each staging file is one consolidation decision unit. The transitional `write_extracted` path now accepts `candidates[]`, and the host expands each candidate into a flat `StagingRecord` with schema version, ids, source, kind, claim, usefulness, optional staleness, bounded evidence, and source refs.
Validation passed:
- `cargo fmt --check`
- `cargo test -p memory`
- `cargo test -p worker`
- `nix build .#yoi`

View File

@ -1,92 +0,0 @@
<!-- event: create author: "yoi ticket" at: 2026-07-17T18:07:40Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: decision author: hare at: 2026-07-17T18:10:00Z -->
## Decision
Created to replace the previous batch staging direction. This Ticket owns the new flat staging record schema: one candidate per staging record, with kinds `preference`, `working_assumption`, `constraint`, `decision`, `open_question`, and `lesson`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-17T19:48:08Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `yoi ticket` が queued にしました。
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-17T19:48:08Z from: queued to: inprogress reason: cli_state field: state -->
## State changed
State changed to `inprogress`.
---
<!-- event: implementation_report author: hare at: 2026-07-17T19:56:08Z -->
## Implementation report
Implemented flat candidate staging record schema.
Changes:
- Replaced extract payload with `candidates[]` based on narrow candidate kinds: `preference`, `working_assumption`, `constraint`, `decision`, `open_question`, `lesson`.
- Added flat `StagingRecord` schema with `schema_version`, `id`, `extract_run_id`, record-level `source`, `kind`, `claim`, `why_useful`, optional `staleness`, bounded `evidence[]`, and `source_refs[]`.
- Changed staging writer to write one JSON file per candidate. Empty payload writes no files.
- Updated transitional `write_extracted` tool and extract prompt to candidate extraction instead of activity-log extraction.
- Updated consolidation staging/input paths and tests to read/render flat records.
- Updated worker extract path to handle multiple staged candidate records per extract run.
Validation:
- `cargo fmt --check`
- `cargo test -p memory`
- `cargo test -p worker`
- `nix build .#yoi`
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-17T19:56:08Z from: inprogress to: done reason: cli_state field: state -->
## State changed
State changed to `done`.
---
<!-- event: state_changed author: hare at: 2026-07-17T19:56:08Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-17T19:56:08Z status: closed -->
## 完了
Implemented flat candidate staging record schema.
The memory extract staging path now treats staging as flat candidate records: one extract run can produce zero or more staging files, and each staging file is one consolidation decision unit. The transitional `write_extracted` path now accepts `candidates[]`, and the host expands each candidate into a flat `StagingRecord` with schema version, ids, source, kind, claim, usefulness, optional staleness, bounded evidence, and source refs.
Validation passed:
- `cargo fmt --check`
- `cargo test -p memory`
- `cargo test -p worker`
- `nix build .#yoi`
---

View File

@ -1,62 +0,0 @@
---
title: 'Internal Worker runnerを実装する'
state: 'closed'
created_at: '2026-07-17T20:47:11Z'
updated_at: '2026-07-17T20:58:47Z'
assignee: null
queued_by: 'yoi ticket'
queued_at: '2026-07-17T20:47:48Z'
---
## 背景
Memory extract は runtime 側の Worker から派生する internal worker として動かす。今後 `session-explore` feature 付き extract worker、compaction、consolidation なども同じ形で安全に動かせるよう、Worker 内部に isolated internal worker / sub-worker runner の標準経路を用意する。
Protocol に外部 API として載せる段階ではない。まず Worker 内部 API として実装し、後で session 永続化、progress event、cancellation、Protocol exposure を追加できる形にする。
## 実装方針
- Worker が internal worker の起動を所有する。
- Feature は internal worker に渡す tool / context を contribute するだけで、Feature 自体が Worker を spawn しない。
- internal worker は foreground Worker history を汚染しない。
- prompt / input / tool registry / usage capture / output collection を `InternalWorkerSpec` 的な構造にまとめる。
- 初期実装では既存 memory extract sub-engine をこの runner に載せ替える。
- session 永続化はこの Ticket では実装しないが、runner の引数や result が後で persistence metadata を持てる形にする。
## 実装要件
- Worker 内部に reusable internal worker runner を追加する。
- runner は少なくとも次を受け取れる。
- purpose / name。
- system prompt。
- initial user input。
- tool definitions / limited tool registry。
- optional usage capture。
- runner result は少なくとも次を返す。
- run result。
- captured usage。
- finish/error metadata。
- foreground history / canonical session log に internal prompt や harness messages を混ぜない。
- existing memory extract path を runner 経由にする。
- existing extract behavior を変えない。
- post-run threshold trigger のまま。
- `write_extracted` transitional path のまま。
- staging output は flat candidate records のまま。
- compaction / consolidation の載せ替えは非目標だが、後から同じ runner に寄せられる構造にする。
## 非目標
- Protocol method として external client から internal worker を起動できるようにしない。
- internal worker session persistence をこの Ticket で実装しない。
- `session-explore` feature や evidence tools はこの Ticket では実装しない。
- compaction / consolidation の full migration はこの Ticket では実装しない。
## 受け入れ条件
- reusable internal worker runner が存在する。
- memory extract がその runner 経由で動く。
- internal worker prompt/input が foreground history に永続化されない。
- tool surface は caller が渡した limited tool registry に閉じる。
- existing extract tests / memory tests / worker tests が通る。
- `cargo test -p memory``cargo test -p worker` が通る。
- code 変更として `nix build .#yoi` が通る。

View File

@ -1,9 +0,0 @@
Implemented reusable Worker-internal runner and migrated memory extract to use it.
The new runner provides a Worker-owned path for isolated internal LLM jobs with caller-supplied prompt, input, limited tools, cache key, max turns, and usage capture. Memory extract now uses this runner while preserving the existing trigger and staging behavior.
Validation passed:
- `cargo fmt --check`
- `cargo test -p memory`
- `cargo test -p worker`
- `nix build .#yoi`

View File

@ -1,82 +0,0 @@
<!-- event: create author: "yoi ticket" at: 2026-07-17T20:47:11Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-17T20:47:48Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `yoi ticket` が queued にしました。
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-17T20:47:48Z from: queued to: inprogress reason: cli_state field: state -->
## State changed
State changed to `inprogress`.
---
<!-- event: implementation_report author: hare at: 2026-07-17T20:58:47Z -->
## Implementation report
Implemented reusable Worker-internal runner and migrated memory extract to use it.
Changes:
- Added `crates/worker/src/internal_worker.rs` with `InternalWorkerSpec`, `InternalWorkerRunResult`, `InternalWorkerRunError`, and `run_internal_worker`.
- The runner owns isolated Engine creation, limited tool registration, cache key / max turns, usage capture, and foreground-history isolation by construction.
- Updated memory extract path to run through the internal runner with only the extract tool surface supplied by the caller.
- Kept existing extract behavior: post-run threshold trigger, transitional `write_extracted` path, and flat staging output.
- Left persistence / Protocol exposure / session-explore feature for later Tickets.
Validation:
- `cargo fmt --check`
- `cargo test -p memory`
- `cargo test -p worker`
- `nix build .#yoi`
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-17T20:58:47Z from: inprogress to: done reason: cli_state field: state -->
## State changed
State changed to `done`.
---
<!-- event: state_changed author: hare at: 2026-07-17T20:58:47Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-17T20:58:47Z status: closed -->
## 完了
Implemented reusable Worker-internal runner and migrated memory extract to use it.
The new runner provides a Worker-owned path for isolated internal LLM jobs with caller-supplied prompt, input, limited tools, cache key, max turns, and usage capture. Memory extract now uses this runner while preserving the existing trigger and staging behavior.
Validation passed:
- `cargo fmt --check`
- `cargo test -p memory`
- `cargo test -p worker`
- `nix build .#yoi`
---

View File

@ -1,4 +1,5 @@
すでにシステムのドッグフーディングに成功しているが、一旦安定した旧バージョンで、ブラウザ/TUI Client/backend/runtimeの分離とチームスペースとしてのworkspaceを作るObjectiveを進めている。
すでにシステムのドッグフーディングに成功しており、改善・機能追加のフェーズになっている。
随所の細かい仕様を詰めながら実装を進めている。
## このシステムに置ける設計要旨

17
Cargo.lock generated
View File

@ -5742,6 +5742,7 @@ dependencies = [
"uuid",
"wasmtime",
"wat",
"workflow",
"yoi-plugin-pdk",
]
@ -5771,6 +5772,22 @@ dependencies = [
"worker",
]
[[package]]
name = "workflow"
version = "0.1.0"
dependencies = [
"chrono",
"lint-common",
"manifest",
"memory",
"serde",
"serde_json",
"serde_yaml",
"tempfile",
"thiserror 2.0.18",
"tracing",
]
[[package]]
name = "writeable"
version = "0.6.3"

View File

@ -21,6 +21,7 @@ members = [
"crates/memory",
"crates/ticket",
"crates/project-record",
"crates/workflow",
"crates/workspace-server",
"tests/e2e",
]
@ -45,6 +46,7 @@ default-members = [
"crates/memory",
"crates/ticket",
"crates/project-record",
"crates/workflow",
"crates/workspace-server",
]

View File

@ -165,6 +165,7 @@ pub struct TicketRoleLaunchPlan {
pub role: TicketRole,
pub worker_name: String,
pub profile: String,
pub workflow: String,
pub launch_prompt_ref: Option<String>,
pub run_segments: Vec<Segment>,
}
@ -304,6 +305,7 @@ pub fn plan_ticket_role_launch_with_config(
}
let role_config = config.role_launch_config(context.role)?;
let profile = role_config.profile.as_str().to_string();
let workflow = role_config.workflow.as_str().to_string();
let launch_prompt_ref = role_config
.launch_prompt
.as_ref()
@ -334,10 +336,14 @@ pub fn plan_ticket_role_launch_with_config(
role: context.role,
worker_name,
profile,
workflow: workflow.clone(),
launch_prompt_ref,
run_segments: vec![Segment::Text {
run_segments: vec![
Segment::WorkflowInvoke { slug: workflow },
Segment::Text {
content: format!("\n\n{prompt}"),
}],
},
],
})
}
@ -753,6 +759,7 @@ mod tests {
role: TicketRole::Intake,
worker_name: "ticket-intake".to_string(),
profile: "project:intake".to_string(),
workflow: "ticket-intake-workflow".to_string(),
launch_prompt_ref: None,
run_segments: vec![Segment::Text {
content: "intake request".to_string(),
@ -987,6 +994,7 @@ profile = "builtin:default"
.unwrap();
assert_eq!(intake.role, TicketRole::Intake);
assert_eq!(intake.profile, TicketRole::Intake.default_profile());
assert_eq!(intake.workflow, TicketRole::Intake.default_workflow());
let orchestrator = plan_ticket_role_launch(TicketRoleLaunchContext::new(
temp.path(),
@ -998,6 +1006,10 @@ profile = "builtin:default"
orchestrator.profile,
TicketRole::Orchestrator.default_profile()
);
assert_eq!(
orchestrator.workflow,
TicketRole::Orchestrator.default_workflow()
);
}
#[test]
@ -1026,6 +1038,7 @@ profile = "builtin:default"
[ticket.roles.reviewer]
profile = "builtin:default"
launch_prompt = "$workspace/ticket/reviewer/launch"
workflow = "ticket-review-workflow"
"#,
);
let mut context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Reviewer);
@ -1038,13 +1051,18 @@ launch_prompt = "$workspace/ticket/reviewer/launch"
assert_eq!(plan.worker_name, "reviewer-fixed");
assert_eq!(plan.profile, "builtin:default");
assert_eq!(plan.workflow, "ticket-review-workflow");
assert_eq!(
plan.launch_prompt_ref.as_deref(),
Some("$workspace/ticket/reviewer/launch")
);
assert!(matches!(&plan.run_segments[0], Segment::Text { .. }));
assert!(matches!(
&plan.run_segments[0],
Segment::WorkflowInvoke { slug } if slug == "ticket-review-workflow"
));
assert!(!text.contains("Configured launch_prompt"));
assert!(!text.contains("$workspace/ticket/reviewer/launch"));
assert!(!text.contains("Workflow: ticket-review-workflow"));
assert!(!text.contains("Profile selector: builtin:default"));
assert!(!text.contains("Role: reviewer"));
assert!(!text.contains("system_instruction"));
@ -1115,6 +1133,8 @@ launch_prompt = "$workspace/ticket/reviewer/launch"
assert!(orchestrator_text.contains("Route to implementation after planning sync."));
assert!(orchestrator_text.contains("cargo check --workspace --all-targets"));
assert!(!orchestrator_text.contains("state = inprogress"));
assert!(!orchestrator_text.contains("worktree-workflow"));
assert!(!orchestrator_text.contains("multi-agent-workflow"));
assert!(!orchestrator_text.contains("root/original workspace reads"));
assert!(!orchestrator_text.contains("role_workspace_root"));
assert!(!orchestrator_text.contains("role_cwd"));

View File

@ -98,5 +98,5 @@ pub const COMPACT_DEFAULT_REFERENCE_COUNT: usize = 5;
pub const MEMORY_EXTRACT_WORKER_MAX_TURNS: Option<u32> = Some(8);
/// Default language used by memory extraction / consolidation workers for
/// durable memory text. See [`crate::MemoryConfig::language`].
/// durable memory and knowledge text. See [`crate::MemoryConfig::language`].
pub const MEMORY_LANGUAGE: &str = "English";

View File

@ -83,8 +83,8 @@ pub struct WorkerManifest {
/// `[feature.web].enabled = true`.
#[serde(default)]
pub web: Option<WebConfig>,
/// External Agent Skills (`SKILL.md`) candidate directories. Each entry
/// is a path to a skills *root* (i.e. a
/// External Agent Skills (`SKILL.md`) directories to ingest as
/// Workflows. Each entry is a path to a skills *root* (i.e. a
/// directory whose children are individual `<name>/SKILL.md` skill
/// bundles). Paths are resolved against the manifest's base
/// directory like other path fields. Absent ⇒ no skills loaded;
@ -379,7 +379,7 @@ pub struct MemoryConfig {
#[serde(default)]
pub workspace_root: Option<PathBuf>,
/// Maximum number of records returned by `MemoryQuery` /
/// `MemoryQuery` per call. `None` ⇒ tool default (20).
/// `KnowledgeQuery` per call. `None` ⇒ tool default (20).
#[serde(default)]
pub query_result_limit: Option<usize>,
/// Lines of context before and after each match in query excerpts.
@ -391,7 +391,7 @@ pub struct MemoryConfig {
#[serde(default)]
pub inject_summary: Option<bool>,
/// Language used by memory extraction / consolidation workers for durable
/// memory text. Free-form so workspaces can use names like
/// memory and knowledge text. Free-form so workspaces can use names like
/// `English`, `Japanese`, or locale tags. `None` ⇒
/// [`defaults::MEMORY_LANGUAGE`].
#[serde(default)]

View File

@ -2,13 +2,13 @@
## Role
`memory` owns generated memory, records, staging/consolidation mechanics, linting, and audit observations.
`memory` owns generated memory, Knowledge records, staging/consolidation mechanics, linting, and audit observations.
## Boundaries
Owns:
- memory/record parsing and validation
- memory/Knowledge record parsing and validation
- memory lint subcommand backend behavior
- staging and consolidation file mechanics
- audit log observation writes

View File

@ -321,6 +321,7 @@ pub fn snapshot_records(layout: &WorkspaceLayout) -> BTreeMap<String, RecordSnap
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());
snapshot_dir(&mut out, "knowledge", layout.knowledge_dir());
out
}

View File

@ -9,6 +9,8 @@
//! 3. Usage evidence report明示使用回数 + resident exposure cost
//! 4. 整理材料Linter Warn ベース、hard protection 判定はしない)
//!
//! 既存 `knowledge/*` 本文は埋めず、agent に `KnowledgeQuery` 経由で引かせる
//! 設計(`docs/plan/memory.md` §retrieval 経路 / §Consolidation の Knowledge アクセス)。
use std::fmt::Write;
@ -27,7 +29,7 @@ pub fn build_consolidate_input(
let mut out = String::new();
out.push_str(
"consolidation input. Run the integration step first \
(fold the staging activity logs into memory), then the \
(fold the staging activity logs into memory and knowledge), then the \
tidy step (clean up existing records). Use the memory tools for \
every write direct file writes are denied by the worker scope.\n\n",
);
@ -98,7 +100,7 @@ fn push_kind_records(out: &mut String, layout: &WorkspaceLayout, kind: RecordKin
let dir = match kind {
RecordKind::Decision => layout.decisions_dir(),
RecordKind::Request => layout.requests_dir(),
RecordKind::Summary => return,
RecordKind::Knowledge | RecordKind::Summary | RecordKind::Workflow => return,
};
let entries = match std::fs::read_dir(&dir) {
Ok(it) => it,
@ -133,13 +135,13 @@ fn push_kind_records(out: &mut String, layout: &WorkspaceLayout, kind: RecordKin
fn render_usage_report(report: &UsageReport) -> String {
if report.is_empty() {
return "(empty — no explicit memory usage events recorded yet. \
return "(empty — no explicit memory/knowledge usage events recorded yet. \
Treat this as lack of evidence, not proof that records are unused.)\n"
.to_string();
}
let json = serde_json::to_string_pretty(report).unwrap_or_else(|_| "{}".to_string());
format!(
"This report is evidence only. Do not make hard tidy-protection decisions from it alone.\n\n```json\n{json}\n```\n"
"This report is evidence only. Do not make hard Knowledge-creation or tidy-protection decisions from it alone.\n\n```json\n{json}\n```\n"
)
}
@ -206,11 +208,8 @@ pub fn render_tidy_hints(tidy: &TidyHints) -> String {
mod tests {
use super::*;
use crate::consolidate::tidy::{SimilarSlugCluster, SourcesOverflow};
use crate::extract::{
CandidateKind, ExtractedCandidate, ExtractedPayload, STAGING_SCHEMA_VERSION,
StagingEvidence, StagingRecord, write_staging,
};
use crate::schema::{EvidenceKind, SourceEvidenceRef, SourceRef};
use crate::extract::{ExtractedPayload, write_staging};
use crate::schema::SourceRef;
use chrono::Utc;
use std::path::Path;
@ -225,18 +224,6 @@ mod tests {
std::fs::write(p, content).unwrap();
}
fn candidate_payload(kind: CandidateKind, claim: &str) -> ExtractedPayload {
ExtractedPayload {
candidates: vec![ExtractedCandidate {
kind,
claim: claim.into(),
why_useful: "useful for consolidation".into(),
staleness: None,
evidence_ids: Vec::new(),
}],
}
}
#[test]
fn build_includes_all_sections_when_populated() {
let dir = tempfile::TempDir::new().unwrap();
@ -253,13 +240,13 @@ mod tests {
n = now()
),
);
let _written = write_staging(
let (_id, _) = write_staging(
&layout,
SourceRef {
segment_id: "s".into(),
range: [0, 1],
},
candidate_payload(CandidateKind::Preference, "Prefer concise tickets"),
ExtractedPayload::default(),
)
.unwrap();
let staging = crate::consolidate::staging::list_staging_entries(&layout);
@ -289,58 +276,7 @@ mod tests {
assert!(out.contains("Replaced decisions"));
assert!(out.contains("Sources overflow"));
assert!(out.contains("Similar slug clusters"));
assert!(out.contains("no explicit memory usage events"));
}
#[test]
fn staging_render_preserves_entry_source_refs() {
let dir = tempfile::TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
std::fs::create_dir_all(layout.staging_dir()).unwrap();
let id = uuid::Uuid::now_v7();
let record = StagingRecord {
schema_version: STAGING_SCHEMA_VERSION,
id: id.to_string(),
extract_run_id: "run-1".into(),
source: SourceRef {
segment_id: "segment-record".into(),
range: [0, 10],
},
kind: CandidateKind::Decision,
claim: "Keep flat staging records".into(),
why_useful: "consolidation input is lossless JSON".into(),
staleness: None,
evidence: vec![StagingEvidence {
id: "ev-1".into(),
kind: EvidenceKind::new(EvidenceKind::MESSAGE),
entry_range: Some([3, 4]),
excerpt: Some("bounded excerpt".into()),
summary: Some("bounded summary".into()),
}],
source_refs: vec![SourceEvidenceRef {
session_id: Some("session-1".into()),
segment_id: Some("segment-1".into()),
entry_range: Some([3, 4]),
evidence_id: Some("ev-1".into()),
evidence_kind: Some(EvidenceKind::new(EvidenceKind::MESSAGE)),
label: Some("user message".into()),
summary: Some("bounded summary".into()),
}],
};
std::fs::write(
layout.staging_dir().join(format!("{id}.json")),
serde_json::to_string_pretty(&record).unwrap(),
)
.unwrap();
let staging = crate::consolidate::staging::list_staging_entries(&layout);
let out = render_staging_records(&staging);
assert!(out.contains("source_refs"));
assert!(out.contains("session-1"));
assert!(out.contains("entry_range"));
assert!(out.contains("ev-1"));
assert!(out.contains("message"));
assert!(out.contains("no explicit memory/knowledge usage events"));
}
#[test]

View File

@ -200,7 +200,7 @@ fn pid_is_alive(_pid: u32) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use crate::extract::{CandidateKind, ExtractedCandidate, ExtractedPayload, write_staging};
use crate::extract::{ExtractedPayload, write_staging};
use crate::schema::SourceRef;
fn make_layout() -> (tempfile::TempDir, WorkspaceLayout) {
@ -210,18 +210,6 @@ mod tests {
(dir, layout)
}
fn candidate_payload(claim: &str) -> ExtractedPayload {
ExtractedPayload {
candidates: vec![ExtractedCandidate {
kind: CandidateKind::Lesson,
claim: claim.into(),
why_useful: "useful for test".into(),
staleness: None,
evidence_ids: Vec::new(),
}],
}
}
#[test]
fn acquire_writes_lock_file() {
let (_dir, layout) = make_layout();
@ -269,28 +257,24 @@ mod tests {
#[test]
fn release_drops_consumed_entries_and_unlinks_lock() {
let (_dir, layout) = make_layout();
let id_a = write_staging(
let (id_a, _) = write_staging(
&layout,
SourceRef {
segment_id: "s".into(),
range: [0, 0],
},
candidate_payload("a"),
ExtractedPayload::default(),
)
.unwrap()
.remove(0)
.id;
let id_b = write_staging(
.unwrap();
let (id_b, _) = write_staging(
&layout,
SourceRef {
segment_id: "s".into(),
range: [1, 1],
},
candidate_payload("b"),
ExtractedPayload::default(),
)
.unwrap()
.remove(0)
.id;
.unwrap();
let lock = StagingLock::acquire(&layout, std::process::id(), "worker", vec![id_a]).unwrap();
let lock_path = lock.path().to_path_buf();

View File

@ -1,19 +1,19 @@
//! consolidation: 統合 + 整理。
//!
//! extract が staging に残した活動ログを `memory/*`
//! extract が staging に残した活動ログを `memory/*` / `knowledge/*`
//! 統合し、続けて既存 record を `outdated | superseded | unused | noisy`
//! の観点で整理する disposable Engine を、Worker 側が組み立てるための
//! ヘルパー群を提供する。Worker は次の手順で sub-Engine を構築する:
//!
//! - [`build_consolidate_input`] を sub-Engine の最初の user 入力に
//! - memory 専用 Tool (read / write / edit) と memory 検索ツールを登録
//! - memory 専用 Tool (read / write / edit) と Knowledge / memory 検索ツールを登録
//! - [`StagingLock::acquire`] で並走防止 + consumed ID 確定
//! - sub-Engine run 完了後、[`StagingLock::release_with_cleanup`] で
//! consumed ID 分の staging のみ削除し、占有ファイルを解放
//!
//! system prompt は Worker の `PromptCatalog`
//! (`WorkerPrompt::MemoryConsolidationSystem`) で管理される。Usage report は
//! 判断材料として渡すだけで、ここでは protection の hard decision はしない
//! 判断材料として渡すだけで、ここでは Knowledge 化や protection の hard decision はしない
//! `docs/plan/memory.md` §Consolidation / 整理材料)。
mod input;

View File

@ -116,19 +116,11 @@ pub fn list_staging_entries_snapshot(layout: &WorkspaceLayout) -> StagingEntries
#[cfg(test)]
mod tests {
use super::*;
use crate::extract::{CandidateKind, ExtractedCandidate, ExtractedPayload, write_staging};
use crate::extract::{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 empty_payload() -> ExtractedPayload {
ExtractedPayload::default()
}
fn source(segment_id: &str, range: [u64; 2]) -> SourceRef {
@ -143,18 +135,9 @@ mod tests {
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 (id1, _) = write_staging(&layout, source("s", [0, 1]), empty_payload()).unwrap();
let (id2, _) = write_staging(&layout, source("s", [2, 3]), empty_payload()).unwrap();
let (id3, _) = write_staging(&layout, source("s", [4, 5]), empty_payload()).unwrap();
let entries = list_staging_entries(&layout);
let ids: Vec<Uuid> = entries.iter().map(|e| e.id).collect();
@ -165,13 +148,10 @@ mod tests {
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;
let (_id, _) = write_staging(&layout, source("s", [0, 1]), empty_payload()).unwrap();
// 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.
// old-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();
@ -198,7 +178,6 @@ mod tests {
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]

View File

@ -14,7 +14,9 @@
use std::collections::{BTreeMap, BTreeSet};
use crate::Slug;
use crate::schema::{DecisionFrontmatter, RequestFrontmatter, split_frontmatter};
use crate::schema::{
DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, split_frontmatter,
};
use crate::workspace::{RecordKind, WorkspaceLayout};
/// `sources` overflow を flag する閾値。`linter::warnings::SOURCES_OVERFLOW_THRESHOLD`
@ -67,6 +69,7 @@ pub fn collect_tidy_hints(layout: &WorkspaceLayout) -> TidyHints {
let decisions = read_kind_records(layout, RecordKind::Decision);
let requests = read_kind_records(layout, RecordKind::Request);
let knowledge = read_kind_records(layout, RecordKind::Knowledge);
for (slug, content) in &decisions {
let fm = parse_yaml::<DecisionFrontmatter>(content);
@ -96,18 +99,33 @@ pub fn collect_tidy_hints(layout: &WorkspaceLayout) -> TidyHints {
}
}
}
for (slug, content) in &knowledge {
if let Some(fm) = parse_yaml::<KnowledgeFrontmatter>(content) {
if fm.last_sources.len() > SOURCES_OVERFLOW_THRESHOLD {
hints.sources_overflow.push(SourcesOverflow {
kind: RecordKind::Knowledge,
slug: slug.clone(),
count: fm.last_sources.len(),
});
}
}
}
hints.sources_overflow.sort_by(|a, b| {
(a.kind.as_str(), a.slug.as_str()).cmp(&(b.kind.as_str(), b.slug.as_str()))
});
let decision_slugs: Vec<&str> = decisions.keys().map(|s| s.as_str()).collect();
let request_slugs: Vec<&str> = requests.keys().map(|s| s.as_str()).collect();
let knowledge_slugs: Vec<&str> = knowledge.keys().map(|s| s.as_str()).collect();
if let Some(c) = cluster_similar(&decision_slugs, RecordKind::Decision) {
hints.similar_slug_clusters.extend(c);
}
if let Some(c) = cluster_similar(&request_slugs, RecordKind::Request) {
hints.similar_slug_clusters.extend(c);
}
if let Some(c) = cluster_similar(&knowledge_slugs, RecordKind::Knowledge) {
hints.similar_slug_clusters.extend(c);
}
hints
.similar_slug_clusters
.sort_by(|a, b| (a.kind.as_str(), &a.slugs).cmp(&(b.kind.as_str(), &b.slugs)));
@ -115,13 +133,15 @@ pub fn collect_tidy_hints(layout: &WorkspaceLayout) -> TidyHints {
hints
}
/// `<root>/.yoi/memory/<kind>/*.md` を slug ごとに `(slug, full content)`
/// `<root>/.yoi/memory/<kind>/*.md` (Knowledge は
/// `<root>/.yoi/knowledge/*.md`) を slug ごとに `(slug, full content)`
/// 化して返す。
fn read_kind_records(layout: &WorkspaceLayout, kind: RecordKind) -> BTreeMap<String, String> {
let dir = match kind {
RecordKind::Decision => layout.decisions_dir(),
RecordKind::Request => layout.requests_dir(),
RecordKind::Summary => return BTreeMap::new(),
RecordKind::Knowledge => layout.knowledge_dir(),
RecordKind::Summary | RecordKind::Workflow => return BTreeMap::new(),
};
let mut out: BTreeMap<String, String> = BTreeMap::new();
let entries = match std::fs::read_dir(&dir) {

View File

@ -8,7 +8,7 @@ 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())]
#[error("path is not under the memory or knowledge tree: {}", .0.display())]
OutsideMemoryTree(PathBuf),
#[error("path is not absolute: {}", .0.display())]
RelativePath(PathBuf),
@ -56,6 +56,11 @@ pub enum LintError {
#[error("Decisions `status` must be one of open|resolved|replaced (got `{0}`)")]
InvalidStatus(String),
#[error(
"Knowledge with model_invokation: true cannot have description longer than {limit} chars (got {actual})"
)]
DescriptionTooLong { actual: usize, limit: usize },
#[error("body exceeds the size limit for this record kind: {actual} chars > {limit}")]
BodyTooLong { actual: usize, limit: usize },

View File

@ -11,8 +11,8 @@ use llm_engine::Item;
pub fn build_extract_input(items: &[Item]) -> String {
let mut out = String::new();
out.push_str(
"Extract memory candidates from the conversation slice below. \
Follow the system prompt's candidate schema strictly and call `write_extracted` once.\n\n",
"Extract activity logs from the conversation slice below. \
Follow the system prompt's schema strictly and call `write_extracted` once.\n\n",
);
out.push_str("## Conversation slice\n");
out.push_str(&render_items(items));

View File

@ -1,4 +1,4 @@
//! extract: memory candidate extraction.
//! extract: 活動抽出。
//!
//! 通常 Worker の post-run hook で発火する disposable Engine と、その
//! 出力を `<workspace>/.yoi/memory/_staging/<id>.json` に書き出す
@ -24,11 +24,10 @@ mod tool;
pub use input::build_extract_input;
pub use payload::{
CandidateKind, ExtractedCandidate, ExtractedPayload, STAGING_SCHEMA_VERSION, StagingEvidence,
StagingRecord,
AttemptEntry, DecisionEntry, DiscussionEntry, ExtractedPayload, RequestEntry, StagingRecord,
};
pub use pointer::{ExtractPointerPayload, fold_pointer};
pub use staging::{StagingWriteResult, write_staging};
pub use staging::{StagingError, write_staging};
pub use tool::{ExtractWorkerContext, write_extracted_tool};
/// session-store `LogEntry::Extension` で使う domain 名。

View File

@ -1,210 +1,88 @@
//! extract staging schema.
//! extract 抽出の出力 schema。
//!
//! Extract produces memory-candidate records, not activity-log batches. During
//! the transitional `write_extracted` path the model submits an
//! [`ExtractedPayload`] containing `candidates[]`; the host expands each
//! candidate into one [`StagingRecord`] and attaches record ids, extract-run ids,
//! record-level source, evidence snippets, and source anchors mechanically.
//! LLM は [`ExtractedPayload`] そのものsource 抜きを返し、Worker 側
//! ラッパーが [`StagingRecord`] に組み立てて staging へ書き出す。
//! source は機械付与する契約 (`docs/plan/memory.md` §Extract)。
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::schema::{EvidenceKind, SourceEvidenceRef, SourceRef};
use crate::schema::SourceRef;
/// Current flat staging schema version.
pub const STAGING_SCHEMA_VERSION: u32 = 2;
/// Candidate kinds that extract is allowed to stage.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum CandidateKind {
Preference,
WorkingAssumption,
Constraint,
Decision,
OpenQuestion,
Lesson,
}
impl CandidateKind {
pub fn as_str(&self) -> &'static str {
match self {
CandidateKind::Preference => "preference",
CandidateKind::WorkingAssumption => "working_assumption",
CandidateKind::Constraint => "constraint",
CandidateKind::Decision => "decision",
CandidateKind::OpenQuestion => "open_question",
CandidateKind::Lesson => "lesson",
}
}
}
/// Model-submitted candidate before host-side staging metadata is attached.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ExtractedCandidate {
/// Kind of candidate. This is intentionally a narrow taxonomy, not an
/// activity-log category.
pub kind: CandidateKind,
/// Concise candidate claim.
pub claim: String,
/// Why this may be useful for future work or consolidation.
pub why_useful: String,
/// Optional invalidation / staleness hint.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub staleness: Option<String>,
/// Evidence ids selected by the extract worker. The transitional
/// `write_extracted` path may leave this empty until `session-explore`
/// provides host-issued evidence ids.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub evidence_ids: Vec<String>,
}
/// Transitional extract output: zero or more flat candidates.
/// LLM が返す活動ログ候補の集合。すべて optional空配列は許容
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct ExtractedPayload {
#[serde(default)]
pub candidates: Vec<ExtractedCandidate>,
pub decisions: Vec<DecisionEntry>,
#[serde(default)]
pub discussions: Vec<DiscussionEntry>,
#[serde(default)]
pub attempts: Vec<AttemptEntry>,
#[serde(default)]
pub requests: Vec<RequestEntry>,
}
impl ExtractedPayload {
/// すべての配列が空であれば true。空ペイロードは
/// "Nothing to save" 扱いで staging への書き込みを省いてよい。
pub fn is_empty(&self) -> bool {
self.candidates.is_empty()
self.decisions.is_empty()
&& self.discussions.is_empty()
&& self.attempts.is_empty()
&& self.requests.is_empty()
}
}
/// Bounded evidence snippet copied into a flat staging record.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StagingEvidence {
pub id: String,
pub kind: EvidenceKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entry_range: Option<[u64; 2]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub excerpt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
/// 判断したこと(選択肢 + 選んだ + 根拠)。
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct DecisionEntry {
/// 検討された選択肢の列挙。
pub options: Vec<String>,
/// 採用された選択肢。
pub chosen: String,
/// 採用理由 / 根拠。
pub rationale: String,
}
/// One flat staging record. One record is one consolidation decision unit.
/// 議論したこと(トピック + 論点)。結論が出ていなくてもよい。
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct DiscussionEntry {
/// 議論の主題。
pub topic: String,
/// 主題の中で挙がった論点 / 観点。
pub points: Vec<String>,
}
/// 試したこと(試行 + 結果 + 成否)。
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AttemptEntry {
/// 何を試したか。
pub action: String,
/// 試した結果。
pub result: String,
/// 試行が目的に対して成功したか。失敗 / 部分成功も含めて bool で表現する。
pub succeeded: bool,
}
/// ユーザー submit の構造化要約。
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct RequestEntry {
/// ユーザーの意図 / ゴール。
pub intent: String,
/// 対象ファイル / モジュール / 機能(任意)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
/// 一文サマリ。
pub summary: String,
}
/// staging に書き出される 1 ファイル分のレコード。
///
/// `source` は Worker 側ラッパーが segment_id と log entry range を
/// 機械付与する。LLM はこのフィールドを見ない / 推論しない。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StagingRecord {
pub schema_version: u32,
pub id: String,
pub extract_run_id: String,
pub source: SourceRef,
pub kind: CandidateKind,
pub claim: String,
pub why_useful: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub staleness: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub evidence: Vec<StagingEvidence>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub source_refs: Vec<SourceEvidenceRef>,
}
impl StagingRecord {
pub fn from_candidate(
id: impl Into<String>,
extract_run_id: impl Into<String>,
source: SourceRef,
candidate: ExtractedCandidate,
evidence: Vec<StagingEvidence>,
source_refs: Vec<SourceEvidenceRef>,
) -> Self {
Self {
schema_version: STAGING_SCHEMA_VERSION,
id: id.into(),
extract_run_id: extract_run_id.into(),
source,
kind: candidate.kind,
claim: candidate.claim,
why_useful: candidate.why_useful,
staleness: candidate.staleness,
evidence,
source_refs,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn source() -> SourceRef {
SourceRef {
segment_id: "segment-record".into(),
range: [0, 20],
}
}
#[test]
fn extracted_payload_empty_when_no_candidates() {
assert!(ExtractedPayload::default().is_empty());
let payload = ExtractedPayload {
candidates: vec![ExtractedCandidate {
kind: CandidateKind::Decision,
claim: "Use flat staging records".into(),
why_useful: "Consolidation can resolve candidates independently".into(),
staleness: None,
evidence_ids: Vec::new(),
}],
};
assert!(!payload.is_empty());
}
#[test]
fn flat_staging_record_roundtrips() {
let evidence = StagingEvidence {
id: "E001".into(),
kind: EvidenceKind::new(EvidenceKind::MESSAGE),
entry_range: Some([10, 12]),
excerpt: Some("extract candidate taxonomy".into()),
summary: Some("User and assistant discussed staging kinds".into()),
};
let source_ref = SourceEvidenceRef {
segment_id: Some("segment-1".into()),
entry_range: Some([10, 12]),
evidence_id: Some("E001".into()),
evidence_kind: Some(EvidenceKind::new(EvidenceKind::MESSAGE)),
label: Some("design discussion".into()),
summary: Some("bounded host summary".into()),
..Default::default()
};
let candidate = ExtractedCandidate {
kind: CandidateKind::Constraint,
claim: "Extract stages candidates but does not write Memory".into(),
why_useful: "Preserves extract/consolidation boundary".into(),
staleness: Some("Revisit if the execution boundary changes".into()),
evidence_ids: vec!["E001".into()],
};
let record = StagingRecord::from_candidate(
"stg-1",
"run-1",
source(),
candidate,
vec![evidence],
vec![source_ref],
);
let json = serde_json::to_string_pretty(&record).unwrap();
assert!(json.contains("\"schema_version\": 2"));
assert!(json.contains("\"kind\": \"constraint\""));
assert!(json.contains("source_refs"));
assert!(json.contains("evidence"));
let parsed: StagingRecord = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.schema_version, STAGING_SCHEMA_VERSION);
assert_eq!(parsed.kind, CandidateKind::Constraint);
assert_eq!(parsed.evidence[0].id, "E001");
assert_eq!(parsed.source_refs[0].evidence_id.as_deref(), Some("E001"));
}
#[test]
fn candidate_kind_serializes_as_snake_case() {
let json = serde_json::to_string(&CandidateKind::WorkingAssumption).unwrap();
assert_eq!(json, "\"working_assumption\"");
let parsed: CandidateKind = serde_json::from_str("\"open_question\"").unwrap();
assert_eq!(parsed, CandidateKind::OpenQuestion);
}
#[serde(flatten)]
pub payload: ExtractedPayload,
}

View File

@ -1,12 +1,9 @@
//! extract staging writer.
//! `<workspace>/.yoi/memory/_staging/<id>.json` への書き出しヘルパー。
//!
//! 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.
//! 1 件 1 ファイル、UUIDv7 命名(短命なので衝突回避と順序を兼ねる)。
//! `source` を機械付与した [`StagingRecord`] 形式で保存する。
use std::fs;
use std::io;
use std::path::PathBuf;
use uuid::Uuid;
@ -15,108 +12,103 @@ use crate::extract::payload::{ExtractedPayload, StagingRecord};
use crate::schema::SourceRef;
use crate::workspace::WorkspaceLayout;
/// Filesystem result for a single staged candidate.
#[derive(Debug, Clone)]
pub struct StagingWriteResult {
pub id: Uuid,
pub path: PathBuf,
/// staging 書き出し時のエラー。
#[derive(Debug, thiserror::Error)]
pub enum StagingError {
#[error("failed to create staging dir {}: {source}", .path.display())]
CreateDir {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to write staging file {}: {source}", .path.display())]
Write {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to serialize staging record: {0}")]
Serialize(#[from] serde_json::Error),
}
/// Write one flat staging JSON file per extracted candidate.
/// `payload` を `source` で wrap して staging に書き出す。
///
/// Returns an empty vector when `payload` has no candidates.
/// 戻り値は割り当てられた staging file の (id, path)。`payload` が
/// 完全に空の場合は呼び出し側が事前に `is_empty()` で skip 推奨だが、
/// この関数は空でも正規に書き出す(仕様 §Extract で空配列許容と
/// 明記されており、書く / 書かないの判断は呼び出し側に委ねる)。
pub fn write_staging(
layout: &WorkspaceLayout,
source: SourceRef,
payload: ExtractedPayload,
) -> io::Result<Vec<StagingWriteResult>> {
if payload.candidates.is_empty() {
return Ok(Vec::new());
}
) -> Result<(Uuid, PathBuf), StagingError> {
let staging_dir = layout.staging_dir();
fs::create_dir_all(&staging_dir).map_err(|source| StagingError::CreateDir {
path: staging_dir.clone(),
source,
})?;
let dir = layout.staging_dir();
fs::create_dir_all(&dir)?;
let extract_run_id = Uuid::now_v7().to_string();
let mut written = Vec::with_capacity(payload.candidates.len());
for candidate in payload.candidates {
let id = Uuid::now_v7();
let record = StagingRecord::from_candidate(
id.to_string(),
extract_run_id.clone(),
source.clone(),
candidate,
Vec::new(),
Vec::new(),
);
let path = dir.join(format!("{}.json", id));
let bytes = serde_json::to_vec_pretty(&record).map_err(io::Error::other)?;
fs::write(&path, bytes)?;
written.push(StagingWriteResult { id, path });
}
Ok(written)
let path = staging_dir.join(format!("{id}.json"));
let record = StagingRecord { source, payload };
let json = serde_json::to_string_pretty(&record)?;
fs::write(&path, json).map_err(|source| StagingError::Write {
path: path.clone(),
source,
})?;
Ok((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(),
}
}
use crate::extract::payload::{DecisionEntry, ExtractedPayload};
#[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"),
],
fn writes_record_with_machine_attached_source() {
let tmp = tempfile::TempDir::new().unwrap();
let layout = WorkspaceLayout::new(tmp.path().to_path_buf());
let source = SourceRef {
segment_id: "sess-1".into(),
range: [3, 7],
};
let results = write_staging(&layout, source(), payload).unwrap();
assert_eq!(results.len(), 2);
assert_ne!(results[0].id, results[1].id);
let payload = ExtractedPayload {
decisions: vec![DecisionEntry {
options: vec!["a".into(), "b".into()],
chosen: "a".into(),
rationale: "shorter".into(),
}],
..Default::default()
};
let (id, path) = write_staging(&layout, source.clone(), payload).unwrap();
assert_eq!(path.parent().unwrap(), layout.staging_dir());
assert!(
path.file_name()
.unwrap()
.to_string_lossy()
.contains(&id.to_string())
);
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");
let written: StagingRecord =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(written.source.segment_id, "sess-1");
assert_eq!(written.source.range, [3, 7]);
assert_eq!(written.payload.decisions.len(), 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());
fn empty_payload_is_written_verbatim() {
let tmp = tempfile::TempDir::new().unwrap();
let layout = WorkspaceLayout::new(tmp.path().to_path_buf());
let source = SourceRef {
segment_id: "sess".into(),
range: [0, 0],
};
let (_, path) = write_staging(&layout, source, ExtractedPayload::default()).unwrap();
let written: StagingRecord =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert!(written.payload.is_empty());
}
}

View File

@ -12,10 +12,10 @@ use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use crate::extract::payload::ExtractedPayload;
const WRITE_EXTRACTED_DESCRIPTION: &str = "Submit extracted memory-candidate JSON for this slice. \
Pass an object with a `candidates` array. Each candidate must have `kind`, `claim`, and `why_useful`; \
`staleness` and `evidence_ids` are optional. Call this exactly once and end the turn. Do not include \
record ids, source anchors, session metadata, or free-form prose the wrapper attaches staging metadata mechanically.";
const WRITE_EXTRACTED_DESCRIPTION: &str = "Submit the final activity-log JSON for this slice. \
Pass an object with `decisions`, `discussions`, `attempts`, and `requests` arrays (any may be empty). \
Call this exactly once and end the turn. Do not include `source`, session metadata, or free-form prose \
the wrapper attaches provenance mechanically.";
/// extract sub-Engine の出力受け口。`ExtractedPayload` 1 件をホストする。
#[derive(Debug, Default)]
@ -63,8 +63,11 @@ impl Tool for WriteExtractedTool {
ToolError::InvalidArgument(format!("invalid write_extracted input: {e}"))
})?;
let summary = format!(
"Recorded memory candidates: candidates={}",
payload.candidates.len(),
"Recorded activity log: decisions={} discussions={} attempts={} requests={}",
payload.decisions.len(),
payload.discussions.len(),
payload.attempts.len(),
payload.requests.len(),
);
{
let mut guard = self
@ -113,17 +116,20 @@ mod tests {
let ctx = Arc::new(ExtractWorkerContext::new());
let tool: Arc<dyn Tool> = Arc::new(WriteExtractedTool { ctx: ctx.clone() });
let input = serde_json::json!({
"candidates": [{
"kind": "decision",
"claim": "Use flat staging",
"why_useful": "Consolidation can resolve candidates independently"
}]
"decisions": [{
"options": ["a", "b"],
"chosen": "a",
"rationale": "test"
}],
"discussions": [],
"attempts": [],
"requests": []
})
.to_string();
let out = tool.execute(&input, Default::default()).await.unwrap();
assert!(out.summary.contains("candidates=1"));
assert!(out.summary.contains("decisions=1"));
let payload = ctx.take_payload().unwrap();
assert_eq!(payload.candidates.len(), 1);
assert_eq!(payload.decisions.len(), 1);
assert_eq!(ctx.call_count(), 1);
}
@ -132,21 +138,22 @@ mod tests {
let ctx = Arc::new(ExtractWorkerContext::new());
let tool: Arc<dyn Tool> = Arc::new(WriteExtractedTool { ctx: ctx.clone() });
let first = serde_json::json!({"candidates": []}).to_string();
let first =
serde_json::json!({"decisions": [], "discussions": [], "attempts": [], "requests": []})
.to_string();
tool.execute(&first, Default::default()).await.unwrap();
let second = serde_json::json!({
"candidates": [{
"kind": "lesson",
"claim": "Validation should use Nix build",
"why_useful": "Packaging can fail independently"
}]
"decisions": [],
"discussions": [],
"attempts": [{"action": "x", "result": "ok", "succeeded": true}],
"requests": []
})
.to_string();
tool.execute(&second, Default::default()).await.unwrap();
let payload = ctx.take_payload().unwrap();
assert_eq!(payload.candidates.len(), 1);
assert_eq!(payload.attempts.len(), 1);
assert_eq!(ctx.call_count(), 2);
}

View File

@ -1,9 +1,10 @@
//! Memory subsystem: persistence layer for `memory/*` records.
//! Memory subsystem: persistence layer for `memory/*` and `knowledge/*` records.
//!
//! Self-contained: provides its own Tool implementations (read/write/edit)
//! that target `<workspace>/memory/` only, with a pre-write Linter built in.
//! Generic CRUD tools (in the `tools` crate) must not touch this directory —
//! Worker is responsible for denying it at the Scope level when memory is enabled.
//! that target `<workspace>/memory/` and `<workspace>/knowledge/` only,
//! with a pre-write Linter built in. Generic CRUD tools (in the `tools`
//! crate) must not touch these directories — Worker is responsible for
//! denying them at the Scope level when memory is enabled.
pub mod audit;
pub mod consolidate;
@ -21,7 +22,10 @@ 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 resident::{
ResidentKnowledgeEntry, collect_resident_knowledge, collect_resident_summary,
list_knowledge_slugs,
};
pub use scope::deny_write_rules;
pub use usage::{
UsageEvent, UsageEventKind, UsageRecordSnapshot, UsageReport, UsageReportRecord, UsageSource,

View File

@ -1,4 +1,4 @@
//! Walks `<workspace>/memory/{decisions,requests}/` to collect
//! Walks `<workspace>/memory/{decisions,requests}/` and `<workspace>/knowledge/` to collect
//! the slug set the linter needs for reference-integrity and
//! same-slug-duplication checks.
//!
@ -10,7 +10,9 @@ use std::io;
use std::path::Path;
use crate::Slug;
use crate::schema::{DecisionFrontmatter, RequestFrontmatter, split_frontmatter};
use crate::schema::{
DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, split_frontmatter,
};
use crate::workspace::{RecordKind, WorkspaceLayout};
/// Snapshot of every record currently on disk under the workspace.
@ -23,6 +25,7 @@ use crate::workspace::{RecordKind, WorkspaceLayout};
pub struct ExistingRecords {
decisions: HashMap<Slug, DecisionMeta>,
requests: HashSet<Slug>,
knowledge: HashSet<Slug>,
}
#[derive(Debug, Clone)]
@ -35,6 +38,8 @@ impl ExistingRecords {
match kind {
RecordKind::Decision => self.decisions.contains_key(slug),
RecordKind::Request => self.requests.contains(slug),
RecordKind::Knowledge => self.knowledge.contains(slug),
RecordKind::Workflow => false,
RecordKind::Summary => false,
}
}
@ -47,6 +52,8 @@ impl ExistingRecords {
match kind {
RecordKind::Decision => self.decisions.keys().collect(),
RecordKind::Request => self.requests.iter().collect(),
RecordKind::Knowledge => self.knowledge.iter().collect(),
RecordKind::Workflow => Vec::new(),
RecordKind::Summary => Vec::new(),
}
}
@ -68,6 +75,10 @@ pub fn scan_existing(layout: &WorkspaceLayout) -> io::Result<ExistingRecords> {
let _ = parse_silent::<RequestFrontmatter>(path);
out.requests.insert(slug);
})?;
scan_dir(&layout.knowledge_dir(), |path, slug| {
let _ = parse_silent::<KnowledgeFrontmatter>(path);
out.knowledge.insert(slug);
})?;
Ok(out)
}

View File

@ -42,6 +42,9 @@ fn parse_missing_field(msg: &str) -> Option<&'static str> {
"status",
"kind",
"description",
"model_invokation",
"user_invocable",
"last_sources",
"requires",
];
FIELDS.iter().copied().find(|n| *n == field_name)

View File

@ -7,7 +7,7 @@
//! collection back to the LLM as `ToolError::InvalidArgument`.
//!
//! Reference-integrity checks (`replaced_by` / `requires` existence,
//! cycle detection) walk the whole `memory/` tree
//! cycle detection) walk the whole `memory/` and `knowledge/` trees
//! each call. No caching; the trees are expected to be small.
mod existing;
@ -23,7 +23,8 @@ use serde::de::DeserializeOwned;
use crate::error::{LintError, LintWarning};
use crate::schema::{
DecisionFrontmatter, RequestFrontmatter, SummaryFrontmatter, split_frontmatter,
DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, SummaryFrontmatter,
split_frontmatter,
};
use crate::workspace::{ClassifiedPath, RecordKind, WorkspaceLayout};
@ -133,9 +134,15 @@ impl Linter {
RecordKind::Request => {
self.check_request(content, &classified, &mut report);
}
RecordKind::Knowledge => {
self.check_knowledge(content, &classified, &mut report);
}
RecordKind::Summary => {
self.check_kind::<SummaryFrontmatter>(content, &classified, &mut report);
}
RecordKind::Workflow => {
unreachable!("workflow paths are not classified by memory linter")
}
}
report
@ -203,6 +210,30 @@ impl Linter {
warnings::check_warnings_with_sources(parsed.body, fm.sources.len(), report);
}
fn check_knowledge(&self, content: &str, cp: &ClassifiedPath, report: &mut LintReport) {
let parsed = match parse_frontmatter::<KnowledgeFrontmatter>(content) {
Ok(p) => p,
Err(e) => {
report.push_error(e);
return;
}
};
let fm = parsed.frontmatter;
size::check_body::<KnowledgeFrontmatter>(parsed.body, report);
if fm.model_invokation
&& fm.description.chars().count() > crate::schema::KNOWLEDGE_DESCRIPTION_HARD_CAP
{
report.push_error(LintError::DescriptionTooLong {
actual: fm.description.chars().count(),
limit: crate::schema::KNOWLEDGE_DESCRIPTION_HARD_CAP,
});
}
warnings::check_warnings_with_sources(parsed.body, fm.last_sources.len(), report);
let _ = cp;
}
}
struct Parsed<'a, F> {
@ -329,6 +360,37 @@ mod tests {
)));
}
#[test]
fn knowledge_long_description_with_model_invokation_errors() {
let (dir, linter) = workspace();
let path = dir.path().join(".yoi/knowledge/foo.md");
let big_desc = "x".repeat(2000);
let content = format!(
"---\ncreated_at: {now}\nupdated_at: {now}\nkind: rule\ndescription: {big_desc}\nmodel_invokation: true\nuser_invocable: true\nlast_sources: []\n---\nbody\n",
now = iso_now()
);
let report = linter.lint(&path, &content, WriteMode::Create);
assert!(
report
.errors
.iter()
.any(|e| matches!(e, LintError::DescriptionTooLong { .. }))
);
}
#[test]
fn knowledge_long_description_without_model_invokation_ok() {
let (dir, linter) = workspace();
let path = dir.path().join(".yoi/knowledge/foo.md");
let big_desc = "x".repeat(2000);
let content = format!(
"---\ncreated_at: {now}\nupdated_at: {now}\nkind: rule\ndescription: {big_desc}\nmodel_invokation: false\nuser_invocable: true\nlast_sources: []\n---\nbody\n",
now = iso_now()
);
let report = linter.lint(&path, &content, WriteMode::Create);
assert!(!report.has_errors(), "got errors: {:?}", report.errors);
}
#[test]
fn summary_path_accepted() {
let (dir, linter) = workspace();

View File

@ -26,7 +26,7 @@ pub fn check_warnings_kindless(_cp: &ClassifiedPath, body: &str, _report: &mut L
// size:importance heuristic doesn't apply to a single rolling file.
}
/// For kinds with `sources`, consult
/// For kinds with `sources` (Decisions / Requests / Knowledge), 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();

View File

@ -2,17 +2,46 @@
//!
//! Surfaces used by the Worker system-prompt assembler:
//!
//! - [`collect_resident_knowledge`] — resident-injection candidates
//! (`model_invokation: true`) returned as `(slug, description)` pairs.
//! - [`collect_resident_summary`] — the body of
//! `<workspace>/.yoi/memory/summary.md` when it parses as a summary
//! record and has non-empty body.
//! - [`list_knowledge_slugs`] — every slug whose file parses, regardless
//! of `model_invokation`. Used by the Worker IPC layer to answer TUI `#`
//! completion (`model_invokation` is a resident-injection flag, not a
//! user-visibility flag).
//!
//! 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::schema::{KnowledgeFrontmatter, SummaryFrontmatter, split_frontmatter};
use crate::workspace::WorkspaceLayout;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResidentKnowledgeEntry {
pub slug: String,
pub description: String,
}
/// Walk `<workspace>/.yoi/knowledge/*.md` and return entries whose
/// frontmatter has `model_invokation: true`, sorted by slug. A missing
/// directory yields an empty vec.
pub fn collect_resident_knowledge(layout: &WorkspaceLayout) -> Vec<ResidentKnowledgeEntry> {
let mut out: Vec<ResidentKnowledgeEntry> = Vec::new();
walk_knowledge(layout, |slug, fm| {
if fm.model_invokation {
out.push(ResidentKnowledgeEntry {
slug,
description: fm.description,
});
}
});
out.sort_by(|a, b| a.slug.cmp(&b.slug));
out
}
/// 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.
@ -28,6 +57,52 @@ pub fn collect_resident_summary(layout: &WorkspaceLayout) -> Option<String> {
}
}
/// Walk `<workspace>/knowledge/*.md` and return every slug whose
/// frontmatter parses, sorted ascending. Does not filter on
/// `model_invokation`. A missing `knowledge/` directory yields an empty
/// vec.
pub fn list_knowledge_slugs(layout: &WorkspaceLayout) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
walk_knowledge(layout, |slug, _fm| out.push(slug));
out.sort();
out
}
fn walk_knowledge(layout: &WorkspaceLayout, mut visit: impl FnMut(String, KnowledgeFrontmatter)) {
let dir = layout.knowledge_dir();
let entries = match std::fs::read_dir(&dir) {
Ok(it) => it,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
let slug = match name.strip_suffix(".md") {
Some(s) => s.to_string(),
None => continue,
};
let raw = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => continue,
};
let (yaml, _body) = match split_frontmatter(&raw) {
Ok(v) => v,
Err(_) => continue,
};
let fm: KnowledgeFrontmatter = match serde_yaml::from_str(yaml) {
Ok(f) => f,
Err(_) => continue,
};
visit(slug, fm);
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -45,8 +120,25 @@ mod tests {
std::fs::write(path, content).unwrap();
}
fn write_knowledge(
dir: &Path,
slug: &str,
description: &str,
model_invokation: bool,
body: &str,
) {
let path = dir.join(".yoi/knowledge").join(format!("{slug}.md"));
let content = format!(
"---\ncreated_at: {n}\nupdated_at: {n}\nkind: policy\ndescription: \"{description}\"\nmodel_invokation: {flag}\nuser_invocable: true\nlast_sources: []\n---\n{body}",
n = now(),
flag = model_invokation,
);
std::fs::write(path, content).unwrap();
}
fn setup() -> (TempDir, WorkspaceLayout) {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".yoi/knowledge")).unwrap();
std::fs::create_dir_all(dir.path().join(".yoi/memory")).unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
(dir, layout)
@ -88,4 +180,98 @@ mod tests {
write_summary(dir.path(), " \n");
assert!(collect_resident_summary(&layout).is_none());
}
#[test]
fn missing_knowledge_dir_returns_empty() {
let dir = TempDir::new().unwrap();
// No knowledge/ directory at all.
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
assert!(collect_resident_knowledge(&layout).is_empty());
}
#[test]
fn picks_only_model_invokation_true() {
let (dir, layout) = setup();
write_knowledge(dir.path(), "alpha", "alpha desc", true, "body\n");
write_knowledge(dir.path(), "beta", "beta desc", false, "body\n");
write_knowledge(dir.path(), "gamma", "gamma desc", true, "body\n");
let got = collect_resident_knowledge(&layout);
assert_eq!(got.len(), 2);
assert_eq!(got[0].slug, "alpha");
assert_eq!(got[0].description, "alpha desc");
assert_eq!(got[1].slug, "gamma");
assert_eq!(got[1].description, "gamma desc");
}
#[test]
fn entries_are_sorted_by_slug() {
let (dir, layout) = setup();
write_knowledge(dir.path(), "zeta", "z", true, "");
write_knowledge(dir.path(), "alpha", "a", true, "");
write_knowledge(dir.path(), "mu", "m", true, "");
let got = collect_resident_knowledge(&layout);
let slugs: Vec<&str> = got.iter().map(|e| e.slug.as_str()).collect();
assert_eq!(slugs, vec!["alpha", "mu", "zeta"]);
}
#[test]
fn malformed_frontmatter_is_skipped() {
let (dir, layout) = setup();
write_knowledge(dir.path(), "good", "ok", true, "");
// Garbage in frontmatter — must be skipped, not panic.
std::fs::write(
dir.path().join(".yoi/knowledge/bad.md"),
"---\nthis is not yaml: : :\n---\nbody\n",
)
.unwrap();
let got = collect_resident_knowledge(&layout);
assert_eq!(got.len(), 1);
assert_eq!(got[0].slug, "good");
}
#[test]
fn non_md_files_ignored() {
let (dir, layout) = setup();
write_knowledge(dir.path(), "good", "ok", true, "");
std::fs::write(dir.path().join(".yoi/knowledge/note.txt"), "not markdown\n").unwrap();
let got = collect_resident_knowledge(&layout);
assert_eq!(got.len(), 1);
}
#[test]
fn list_slugs_missing_dir_returns_empty() {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
assert!(list_knowledge_slugs(&layout).is_empty());
}
#[test]
fn list_slugs_returns_all_regardless_of_model_invokation() {
let (dir, layout) = setup();
write_knowledge(dir.path(), "alpha", "a", true, "");
write_knowledge(dir.path(), "beta", "b", false, "");
write_knowledge(dir.path(), "gamma", "g", true, "");
let got = list_knowledge_slugs(&layout);
assert_eq!(got, vec!["alpha", "beta", "gamma"]);
}
#[test]
fn list_slugs_skips_malformed_and_non_md() {
let (dir, layout) = setup();
write_knowledge(dir.path(), "good", "ok", true, "");
std::fs::write(
dir.path().join(".yoi/knowledge/bad.md"),
"---\nthis is not yaml: : :\n---\nbody\n",
)
.unwrap();
std::fs::write(dir.path().join(".yoi/knowledge/note.txt"), "not markdown\n").unwrap();
let got = list_knowledge_slugs(&layout);
assert_eq!(got, vec!["good"]);
}
}

View File

@ -1,6 +1,5 @@
//! Common frontmatter helpers and shared types.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::error::LintError;
@ -9,94 +8,13 @@ pub use lint_common::Frontmatter;
/// Reference to a session-store entry range. Stored in `sources` /
/// `last_sources` arrays for traceability back to raw session logs.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceRef {
pub segment_id: String,
/// `[start_entry, end_entry]` inclusive range of session-store entry indices.
pub range: [u64; 2],
}
impl<'de> Deserialize<'de> for SourceRef {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct RawSourceRef {
#[serde(default)]
segment_id: Option<String>,
#[serde(default)]
session_id: Option<String>,
range: [u64; 2],
}
let raw = RawSourceRef::deserialize(deserializer)?;
let segment_id = raw
.segment_id
.or(raw.session_id)
.ok_or_else(|| serde::de::Error::missing_field("segment_id"))?;
Ok(SourceRef {
segment_id,
range: raw.range,
})
}
}
/// Extensible evidence kind tag used by staging source anchors.
///
/// Known values include `message`, `tool_call`, `tool_result`, `file_ref`,
/// `ticket_ref`, and `objective_ref`, but callers may use newer bounded tags
/// without changing the schema.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct EvidenceKind(pub String);
impl EvidenceKind {
pub const MESSAGE: &'static str = "message";
pub const TOOL_CALL: &'static str = "tool_call";
pub const TOOL_RESULT: &'static str = "tool_result";
pub const FILE_REF: &'static str = "file_ref";
pub const TICKET_REF: &'static str = "ticket_ref";
pub const OBJECTIVE_REF: &'static str = "objective_ref";
pub fn new(kind: impl Into<String>) -> Self {
Self(kind.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Host-resolved source/evidence metadata for an individual staging claim.
///
/// This deliberately stores only bounded anchor metadata: stable ids, entry
/// ranges, and short labels/summaries. It must not carry raw message bodies or
/// full tool result content.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct SourceEvidenceRef {
/// Stable session id when the anchor crosses or disambiguates segments.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
/// Session segment id containing the anchored log entries.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub segment_id: Option<String>,
/// `[start_entry, end_entry]` inclusive range of session-store entry indices.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entry_range: Option<[u64; 2]>,
/// Host-assigned evidence id within the referenced evidence set.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub evidence_id: Option<String>,
/// Extensible evidence kind tag.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub evidence_kind: Option<EvidenceKind>,
/// Short host-provided display label, not raw evidence content.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
/// Short host-provided summary, not raw evidence content.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
}
/// 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)

View File

@ -0,0 +1,33 @@
//! Knowledge frontmatter schema.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::schema::common::{Frontmatter, SourceRef};
/// Hard cap on `description` length when `model_invokation: true`.
/// Mirrors the agent-skills 1024-char rule for description that lives
/// in resident system-prompt budget.
pub const KNOWLEDGE_DESCRIPTION_HARD_CAP: usize = 1024;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct KnowledgeFrontmatter {
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub kind: String,
pub description: String,
pub model_invokation: bool,
pub user_invocable: bool,
pub last_sources: Vec<SourceRef>,
}
impl Frontmatter for KnowledgeFrontmatter {
const BODY_LIMIT: usize = 8000;
fn created_at(&self) -> Option<DateTime<Utc>> {
Some(self.created_at)
}
fn updated_at(&self) -> Option<DateTime<Utc>> {
Some(self.updated_at)
}
}

View File

@ -7,10 +7,12 @@
mod common;
mod decision;
mod knowledge;
mod request;
mod summary;
pub use common::{EvidenceKind, Frontmatter, SourceEvidenceRef, SourceRef, split_frontmatter};
pub use common::{Frontmatter, SourceRef, split_frontmatter};
pub use decision::{DecisionFrontmatter, DecisionStatus};
pub use knowledge::{KNOWLEDGE_DESCRIPTION_HARD_CAP, KnowledgeFrontmatter};
pub use request::RequestFrontmatter;
pub use summary::SummaryFrontmatter;

View File

@ -13,9 +13,14 @@ use manifest::{Permission, ScopeRule};
use crate::workspace::WorkspaceLayout;
/// Build a deny rule that strips Write permission from `<workspace>/.yoi/memory/`.
/// Build deny rules that strip Write permission from `<workspace>/memory/`
/// and `<workspace>/knowledge/`. Recursive — every descendant is capped at
/// Read for the generic tools.
pub fn deny_write_rules(layout: &WorkspaceLayout) -> Vec<ScopeRule> {
vec![deny_write(layout.memory_dir().as_path())]
vec![
deny_write(layout.memory_dir().as_path()),
deny_write(layout.knowledge_dir().as_path()),
]
}
fn deny_write(target: &Path) -> ScopeRule {
@ -32,12 +37,13 @@ mod tests {
use std::path::PathBuf;
#[test]
fn deny_targets_memory() {
fn deny_targets_memory_and_knowledge() {
let layout = WorkspaceLayout::new(PathBuf::from("/ws"));
let rules = deny_write_rules(&layout);
assert_eq!(rules.len(), 1);
assert_eq!(rules.len(), 2);
assert_eq!(rules[0].target, PathBuf::from("/ws/.yoi/memory"));
assert_eq!(rules[0].permission, Permission::Write);
assert!(rules[0].recursive);
assert_eq!(rules[1].target, PathBuf::from("/ws/.yoi/knowledge"));
}
}

View File

@ -1,4 +1,4 @@
//! `MemoryDelete` tool for removing memory records with audit logging.
//! `MemoryDelete` tool for removing memory / knowledge records with audit logging.
use std::sync::Arc;
@ -10,7 +10,7 @@ use crate::audit::{AuditStatus, RecordOperationAudit, append_record_operation, f
use crate::tool::MemoryToolKind;
use crate::workspace::WorkspaceLayout;
const DESCRIPTION: &str = "Delete an existing memory record selected by `kind` + `slug`. \
const DESCRIPTION: &str = "Delete an existing memory or knowledge record selected by `kind` + `slug`. \
For `summary` omit `slug`; for the others `slug` is required. The delete is audited and cannot target \
workflow or staging/log files.";

View File

@ -19,14 +19,14 @@ use crate::linter::{LintReport, Linter, WriteMode};
use crate::tool::MemoryToolKind;
use crate::workspace::WorkspaceLayout;
const DESCRIPTION: &str = "Replace a substring in an existing memory \
const DESCRIPTION: &str = "Replace a substring in an existing memory or knowledge \
record selected by `kind` + `slug`. By default `old_string` must be unique in the \
file; set `replace_all: true` to replace every occurrence. The resulting content \
is re-validated by the memory linter; failure leaves the file untouched.";
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct EditParams {
/// Record kind: `summary` | `decision` | `request`.
/// Record kind: `summary` | `decision` | `request` | `knowledge`.
kind: MemoryToolKind,
/// Slug. Required for everything except `summary`; forbidden for `summary`.
#[serde(default)]

View File

@ -22,7 +22,7 @@ use crate::Slug;
use crate::workspace::{RecordKind, WorkspaceLayout};
pub use edit::edit_tool;
pub use query::{QueryConfig, memory_query_tool};
pub use query::{QueryConfig, knowledge_query_tool, memory_query_tool};
pub use read::{read_tool, read_tool_with_usage};
pub use write::write_tool;
@ -34,6 +34,7 @@ pub enum MemoryToolKind {
Summary,
Decision,
Request,
Knowledge,
}
impl std::fmt::Display for MemoryToolKind {
@ -42,6 +43,7 @@ impl std::fmt::Display for MemoryToolKind {
Self::Summary => "summary",
Self::Decision => "decision",
Self::Request => "request",
Self::Knowledge => "knowledge",
})
}
}
@ -52,6 +54,7 @@ impl MemoryToolKind {
Self::Summary => "summary",
Self::Decision => "decision",
Self::Request => "request",
Self::Knowledge => "knowledge",
}
}
@ -60,6 +63,7 @@ impl MemoryToolKind {
Self::Summary => RecordKind::Summary,
Self::Decision => RecordKind::Decision,
Self::Request => RecordKind::Request,
Self::Knowledge => RecordKind::Knowledge,
}
}
@ -88,6 +92,7 @@ impl MemoryToolKind {
Ok(match other {
Self::Decision => layout.decision_path(&parsed),
Self::Request => layout.request_path(&parsed),
Self::Knowledge => layout.knowledge_path(&parsed),
Self::Summary => unreachable!(),
})
}

View File

@ -1,15 +1,17 @@
//! `MemoryQuery` tool.
//! `MemoryQuery` / `KnowledgeQuery` tools.
//!
//! Performs a case-insensitive substring scan over markdown record
//! Both perform a case-insensitive substring scan over markdown record
//! files. With a `query` set, returns `{slug, kind, ..., excerpt}` hits
//! with `excerpt_lines` lines of context around each match. With `query`
//! omitted, returns one entry per file (no excerpt) so the agent can
//! enumerate what records exist without knowing what's inside them.
//!
//! - `MemoryQuery` walks `.yoi/memory/{summary.md,decisions/,
//! requests/}`. `.yoi/memory/_staging/`,
//! requests/}`. `.yoi/workflow/`, `.yoi/memory/_staging/`,
//! `.yoi/memory/_usage/`, and `.yoi/memory/_logs/` are excluded
//! by construction.
//! - `KnowledgeQuery` walks `.yoi/knowledge/*.md` and supports a
//! `kind` filter against the Knowledge frontmatter's `kind` field.
//!
//! No derived index — the file tree is the source of truth and is
//! re-scanned per call. 出現順: within a file by line order, across
@ -23,6 +25,7 @@ use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use serde::{Deserialize, Serialize};
use crate::audit::{AuditStatus, RecordUsageAudit, append_record_usage};
use crate::schema::{KnowledgeFrontmatter, split_frontmatter};
use crate::workspace::WorkspaceLayout;
const DEFAULT_RESULT_LIMIT: usize = 20;
@ -35,6 +38,14 @@ when you don't yet know what's in there. Result count is capped (configurable vi
manifest's `[memory]` section). Use the returned `slug` + `kind` with MemoryRead to fetch \
the full record. Workflow and staging directories are not visible.";
const KNOWLEDGE_QUERY_DESCRIPTION: &str = "Inspect knowledge records. With `query` set, \
returns substring hits with line context; omit `query` to list every record (one entry \
per file, no excerpt). Optional `kind` filters by the Knowledge frontmatter's `kind` \
field; records whose frontmatter fails to parse are skipped when `kind` is given. Result \
count is capped (configurable via the manifest's `[memory]` section). Returns \
`{slug, kind, description, model_invokation, excerpt}` entries. Use the returned `slug` \
with MemoryRead (kind=knowledge) for the full record.";
/// Tunables passed in from the manifest.
#[derive(Debug, Clone, Copy)]
pub struct QueryConfig {
@ -74,6 +85,17 @@ struct MemoryQueryParams {
query: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct KnowledgeQueryParams {
/// Optional substring filter. Case-insensitive. Omit to list every
/// knowledge record under the query scope.
#[serde(default)]
query: Option<String>,
/// Optional filter on the Knowledge frontmatter's `kind` field.
#[serde(default)]
kind: Option<String>,
}
#[derive(Debug, Serialize)]
struct MemoryRecord {
slug: String,
@ -82,11 +104,26 @@ struct MemoryRecord {
excerpt: Option<String>,
}
#[derive(Debug, Serialize)]
struct KnowledgeRecord {
slug: String,
kind: Option<String>,
description: Option<String>,
model_invokation: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
excerpt: Option<String>,
}
struct MemoryQueryTool {
layout: WorkspaceLayout,
config: QueryConfig,
}
struct KnowledgeQueryTool {
layout: WorkspaceLayout,
config: QueryConfig,
}
#[async_trait]
impl Tool for MemoryQueryTool {
async fn execute(
@ -205,6 +242,123 @@ impl Tool for MemoryQueryTool {
}
}
#[async_trait]
impl Tool for KnowledgeQueryTool {
async fn execute(
&self,
input_json: &str,
_ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: KnowledgeQueryParams = serde_json::from_str(input_json).map_err(|e| {
ToolError::InvalidArgument(format!("invalid KnowledgeQuery input: {e}"))
})?;
let needle = match params.query.as_deref() {
Some(q) => match validate_query(q) {
Ok(q) => Some(q),
Err(err) => {
let _ = append_record_usage(
&self.layout,
RecordUsageAudit {
op: "query".to_string(),
status: AuditStatus::Failed,
kind: "knowledge".to_string(),
slug: None,
path: None,
query: params.query.clone(),
result_count: None,
reason: Some(err.to_string()),
},
);
return Err(err);
}
},
None => None,
};
let kind_filter = params.kind.as_deref();
let mut records: Vec<KnowledgeRecord> = Vec::new();
let limit = self.config.result_limit;
let ctx = self.config.excerpt_lines;
for (path, slug) in list_md_files(&self.layout.knowledge_dir()) {
if records.len() >= limit {
break;
}
let raw = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => continue,
};
let fm = parse_knowledge_frontmatter(&raw);
// kind filter applies to the frontmatter's kind field.
if let Some(filter) = kind_filter {
let matches = fm
.as_ref()
.map(|f| f.kind.as_str() == filter)
.unwrap_or(false);
if !matches {
continue;
}
}
let kind = fm.as_ref().map(|f| f.kind.clone());
let description = fm.as_ref().map(|f| f.description.clone());
let model_invokation = fm.as_ref().map(|f| f.model_invokation);
match needle.as_deref() {
Some(n) => {
scan_text(&raw, n, ctx, limit - records.len(), |excerpt| {
records.push(KnowledgeRecord {
slug: slug.clone(),
kind: kind.clone(),
description: description.clone(),
model_invokation,
excerpt: Some(excerpt),
});
});
}
None => {
records.push(KnowledgeRecord {
slug: slug.clone(),
kind,
description,
model_invokation,
excerpt: None,
});
}
}
}
let body = serde_json::to_string_pretty(&records)
.map_err(|e| ToolError::ExecutionFailed(format!("serialize records: {e}")))?;
let summary = match params.query.as_deref() {
Some(q) => format!("{} hit(s) for {q:?}", records.len()),
None => format!("{} record(s)", records.len()),
};
let _ = append_record_usage(
&self.layout,
RecordUsageAudit {
op: "query".to_string(),
status: AuditStatus::Success,
kind: "knowledge".to_string(),
slug: None,
path: None,
query: params.query.clone(),
result_count: Some(records.len()),
reason: if records.len() >= limit {
Some("result_limit_reached".to_string())
} else {
None
},
},
);
Ok(ToolOutput {
summary,
content: Some(body),
})
}
}
fn collect_memory_records(
path: &Path,
slug: &str,
@ -316,6 +470,14 @@ fn scan_text(
}
}
/// Best-effort frontmatter parse. Returns `None` if missing/malformed
/// — query still finds matches in the body even when the header is
/// broken.
fn parse_knowledge_frontmatter(raw: &str) -> Option<KnowledgeFrontmatter> {
let (yaml, _body) = split_frontmatter(raw).ok()?;
serde_yaml::from_str::<KnowledgeFrontmatter>(yaml).ok()
}
pub fn memory_query_tool(layout: WorkspaceLayout, config: QueryConfig) -> ToolDefinition {
Arc::new(move || {
let schema = schemars::schema_for!(MemoryQueryParams);
@ -331,6 +493,21 @@ pub fn memory_query_tool(layout: WorkspaceLayout, config: QueryConfig) -> ToolDe
})
}
pub fn knowledge_query_tool(layout: WorkspaceLayout, config: QueryConfig) -> ToolDefinition {
Arc::new(move || {
let schema = schemars::schema_for!(KnowledgeQueryParams);
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
let meta = ToolMeta::new("KnowledgeQuery")
.description(KNOWLEDGE_QUERY_DESCRIPTION)
.input_schema(schema_value);
let tool: Arc<dyn Tool> = Arc::new(KnowledgeQueryTool {
layout: layout.clone(),
config,
});
(meta, tool)
})
}
#[cfg(test)]
mod tests {
use super::*;
@ -347,6 +524,8 @@ mod tests {
std::fs::create_dir_all(dir.path().join(".yoi/memory/decisions")).unwrap();
std::fs::create_dir_all(dir.path().join(".yoi/memory/requests")).unwrap();
std::fs::create_dir_all(dir.path().join(".yoi/memory/_staging")).unwrap();
std::fs::create_dir_all(dir.path().join(".yoi/workflow")).unwrap();
std::fs::create_dir_all(dir.path().join(".yoi/knowledge")).unwrap();
(dir, layout)
}
@ -359,6 +538,19 @@ mod tests {
std::fs::write(path, content).unwrap();
}
fn write_knowledge(dir: &Path, slug: &str, kind: &str, description: &str, body: &str) {
let path = dir.join(".yoi/knowledge").join(format!("{slug}.md"));
let content = format!(
"---\ncreated_at: {n}\nupdated_at: {n}\nkind: {kind}\ndescription: \"{description}\"\nmodel_invokation: false\nuser_invocable: true\nlast_sources: []\n---\n{body}",
n = now()
);
std::fs::write(path, content).unwrap();
}
fn parse_records<T: for<'de> serde::Deserialize<'de>>(out: &ToolOutput) -> Vec<T> {
serde_json::from_str(out.content.as_ref().unwrap()).unwrap()
}
#[derive(Deserialize)]
struct OwnedMemoryRecord {
slug: String,
@ -367,10 +559,16 @@ mod tests {
excerpt: Option<String>,
}
fn parse_records(out: &ToolOutput) -> Vec<OwnedMemoryRecord> {
let text = out.content.as_ref().unwrap_or(&out.summary);
serde_json::from_str(text).unwrap()
#[derive(Deserialize)]
struct OwnedKnowledgeRecord {
slug: String,
kind: Option<String>,
description: Option<String>,
model_invokation: Option<bool>,
#[serde(default)]
excerpt: Option<String>,
}
#[tokio::test]
async fn memory_query_finds_decision_body() {
let (dir, layout) = setup();
@ -439,16 +637,47 @@ mod tests {
assert_eq!(records[0].kind, "summary");
}
#[tokio::test]
async fn memory_query_excludes_workflow_and_staging() {
let (dir, layout) = setup();
let wf = dir.path().join(".yoi/workflow/wf.md");
std::fs::write(&wf, "needle in workflow\n").unwrap();
let stg = dir.path().join(".yoi/memory/_staging/abc.json");
std::fs::write(&stg, "needle in staging\n").unwrap();
let (_, tool) = memory_query_tool(layout, QueryConfig::default())();
let inp = serde_json::json!({ "query": "needle" });
let out = tool
.execute(&inp.to_string(), Default::default())
.await
.unwrap();
let records: Vec<OwnedMemoryRecord> = parse_records(&out);
assert!(records.is_empty(), "got records: {:?}", out.content);
}
#[tokio::test]
async fn query_hits_do_not_log_usage() {
let (dir, layout) = setup();
write_decision(dir.path(), "alpha", "needle line\n");
write_knowledge(
dir.path(),
"policy",
"policy",
"needle desc",
"needle body\n",
);
let (_, memory_tool) = memory_query_tool(layout.clone(), QueryConfig::default())();
let (_, knowledge_tool) = knowledge_query_tool(layout.clone(), QueryConfig::default())();
let inp = serde_json::json!({ "query": "needle" });
memory_tool
.execute(&inp.to_string(), Default::default())
.await
.unwrap();
knowledge_tool
.execute(&inp.to_string(), Default::default())
.await
.unwrap();
let report = crate::usage::build_usage_report(&layout).unwrap();
assert!(report.records.is_empty());
@ -514,4 +743,116 @@ mod tests {
.unwrap_err();
assert!(matches!(err, ToolError::InvalidArgument(_)));
}
#[tokio::test]
async fn knowledge_query_returns_frontmatter_fields() {
let (dir, layout) = setup();
write_knowledge(
dir.path(),
"policy",
"policy",
"the policy doc",
"Ollama first\n",
);
let (_, tool) = knowledge_query_tool(layout, QueryConfig::default())();
let inp = serde_json::json!({ "query": "ollama" });
let out = tool
.execute(&inp.to_string(), Default::default())
.await
.unwrap();
let records: Vec<OwnedKnowledgeRecord> = parse_records(&out);
assert_eq!(records.len(), 1);
assert_eq!(records[0].slug, "policy");
assert_eq!(records[0].kind.as_deref(), Some("policy"));
assert_eq!(records[0].description.as_deref(), Some("the policy doc"));
assert_eq!(records[0].model_invokation, Some(false));
assert!(
records[0]
.excerpt
.as_deref()
.unwrap()
.to_lowercase()
.contains("ollama")
);
}
#[tokio::test]
async fn knowledge_query_without_query_lists_all_records() {
let (dir, layout) = setup();
write_knowledge(dir.path(), "p1", "policy", "d1", "body\n");
write_knowledge(dir.path(), "h1", "howto", "d2", "body\n");
let (_, tool) = knowledge_query_tool(layout, QueryConfig::default())();
let out = tool.execute("{}", Default::default()).await.unwrap();
let records: Vec<OwnedKnowledgeRecord> = parse_records(&out);
let mut slugs: Vec<&str> = records.iter().map(|r| r.slug.as_str()).collect();
slugs.sort();
assert_eq!(slugs, vec!["h1", "p1"]);
assert!(records.iter().all(|r| r.excerpt.is_none()));
}
#[tokio::test]
async fn knowledge_query_kind_filter() {
let (dir, layout) = setup();
write_knowledge(dir.path(), "p1", "policy", "d1", "needle\n");
write_knowledge(dir.path(), "h1", "howto", "d2", "needle\n");
let (_, tool) = knowledge_query_tool(layout, QueryConfig::default())();
let inp = serde_json::json!({ "query": "needle", "kind": "howto" });
let out = tool
.execute(&inp.to_string(), Default::default())
.await
.unwrap();
let records: Vec<OwnedKnowledgeRecord> = parse_records(&out);
assert_eq!(records.len(), 1);
assert_eq!(records[0].slug, "h1");
}
#[tokio::test]
async fn knowledge_query_kind_filter_works_without_query() {
let (dir, layout) = setup();
write_knowledge(dir.path(), "p1", "policy", "d1", "body\n");
write_knowledge(dir.path(), "h1", "howto", "d2", "body\n");
let (_, tool) = knowledge_query_tool(layout, QueryConfig::default())();
let inp = serde_json::json!({ "kind": "howto" });
let out = tool
.execute(&inp.to_string(), Default::default())
.await
.unwrap();
let records: Vec<OwnedKnowledgeRecord> = parse_records(&out);
assert_eq!(records.len(), 1);
assert_eq!(records[0].slug, "h1");
assert!(records[0].excerpt.is_none());
}
#[tokio::test]
async fn knowledge_query_searches_frontmatter_too() {
let (dir, layout) = setup();
write_knowledge(dir.path(), "p", "policy", "mentions xyzzy here", "body\n");
let (_, tool) = knowledge_query_tool(layout, QueryConfig::default())();
let inp = serde_json::json!({ "query": "xyzzy" });
let out = tool
.execute(&inp.to_string(), Default::default())
.await
.unwrap();
let records: Vec<OwnedKnowledgeRecord> = parse_records(&out);
assert_eq!(records.len(), 1);
assert_eq!(records[0].slug, "p");
}
#[tokio::test]
async fn knowledge_query_no_matches_returns_empty() {
let (dir, layout) = setup();
write_knowledge(dir.path(), "p", "policy", "d", "no match\n");
let (_, tool) = knowledge_query_tool(layout, QueryConfig::default())();
let inp = serde_json::json!({ "query": "absent" });
let out = tool
.execute(&inp.to_string(), Default::default())
.await
.unwrap();
let records: Vec<OwnedKnowledgeRecord> = parse_records(&out);
assert!(records.is_empty());
}
}

View File

@ -1,8 +1,8 @@
//! `MemoryRead` tool.
//!
//! Reads a memory record by `(kind, slug)`. Returns
//! Reads a memory or knowledge record by `(kind, slug)`. Returns
//! line-numbered content (1-based), like the generic Read tool. The
//! agent never names a path — `MemoryQuery` returns `{kind, slug, ...}`
//! agent never names a path — `Search` returns `{kind, slug, ...}`
//! and that pair feeds straight into Read.
use std::sync::Arc;
@ -16,8 +16,8 @@ use crate::tool::MemoryToolKind;
use crate::usage::{self, UsageSource};
use crate::workspace::WorkspaceLayout;
const DESCRIPTION: &str = "Read a memory record by `kind` + `slug`. \
`kind` is one of: summary, decision, request. \
const DESCRIPTION: &str = "Read a memory or knowledge record by `kind` + `slug`. \
`kind` is one of: summary, decision, request, knowledge. \
For `summary` omit `slug`; for the others `slug` is required. \
Returns line-numbered output (1-based).";
@ -25,7 +25,7 @@ const DEFAULT_LIMIT: usize = 2000;
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct ReadParams {
/// Record kind: `summary` | `decision` | `request`.
/// Record kind: `summary` | `decision` | `request` | `knowledge`.
kind: MemoryToolKind,
/// Slug. Required for everything except `summary`; forbidden for `summary`.
#[serde(default)]
@ -290,6 +290,22 @@ mod tests {
assert!(matches!(err, ToolError::InvalidArgument(_)));
}
#[tokio::test]
async fn knowledge_path_resolution() {
let (dir, layout) = setup();
let path = dir.path().join(".yoi/knowledge/policy.md");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "k\n").unwrap();
let (_, tool) = read_tool(layout)();
let inp = serde_json::json!({ "kind": "knowledge", "slug": "policy" });
let out = tool
.execute(&inp.to_string(), Default::default())
.await
.unwrap();
assert!(out.content.unwrap().contains("k"));
}
#[tokio::test]
async fn read_logs_explicit_use_when_usage_session_is_set() {
let (dir, layout) = setup();

View File

@ -1,6 +1,6 @@
//! `MemoryWrite` tool.
//!
//! Creates or overwrites a memory record by `(kind, slug)`.
//! Creates or overwrites a memory or knowledge record by `(kind, slug)`.
//! Pre-write Linter validates frontmatter, slug uniqueness (Create only),
//! reference integrity, size limits. On any
//! Linter error the tool returns `ToolError::InvalidArgument` with all
@ -19,14 +19,14 @@ use crate::linter::{LintReport, Linter, WriteMode};
use crate::tool::MemoryToolKind;
use crate::workspace::WorkspaceLayout;
const DESCRIPTION: &str = "Create or overwrite a memory record by \
`kind` + `slug`. `kind`: summary | decision | request. For `summary` \
const DESCRIPTION: &str = "Create or overwrite a memory or knowledge record by \
`kind` + `slug`. `kind`: summary | decision | request | knowledge. For `summary` \
omit `slug`. Frontmatter is validated before write; on validation failure no \
write occurs and every violation is returned in the error message.";
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct WriteParams {
/// Record kind: `summary` | `decision` | `request`.
/// Record kind: `summary` | `decision` | `request` | `knowledge`.
kind: MemoryToolKind,
/// Slug. Required for everything except `summary`; forbidden for `summary`.
#[serde(default)]

View File

@ -1,9 +1,9 @@
//! Workspace-local usage event log for memory records.
//! Workspace-local usage event log for memory / knowledge / workflow 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.
//! Knowledge candidates or tidy-protected records.
use std::collections::{BTreeMap, HashMap};
use std::fs::{self, OpenOptions};
@ -25,6 +25,8 @@ pub enum UsageEventKind {
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum UsageSource {
MemoryRead,
KnowledgeRef,
WorkflowInvoke,
ResidentInjection,
}
@ -32,6 +34,8 @@ impl UsageSource {
pub fn as_str(self) -> &'static str {
match self {
Self::MemoryRead => "MemoryRead",
Self::KnowledgeRef => "KnowledgeRef",
Self::WorkflowInvoke => "WorkflowInvoke",
Self::ResidentInjection => "ResidentInjection",
}
}
@ -212,6 +216,14 @@ fn record_path(
let slug = crate::Slug::parse(slug.to_string()).map_err(invalid_slug_error)?;
Ok(layout.request_path(&slug))
}
RecordKind::Workflow => {
let slug = crate::Slug::parse(slug.to_string()).map_err(invalid_slug_error)?;
Ok(layout.workflow_path(&slug))
}
RecordKind::Knowledge => {
let slug = crate::Slug::parse(slug.to_string()).map_err(invalid_slug_error)?;
Ok(layout.knowledge_path(&slug))
}
}
}
@ -307,7 +319,7 @@ mod tests {
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");
let knowledge = snapshot_record_from_bytes(RecordKind::Knowledge, "policy", b"abcdefgh");
append_use_event(
&layout,
@ -319,18 +331,18 @@ mod tests {
append_use_event(
&layout,
"session-a",
UsageSource::MemoryRead,
vec![request.clone()],
UsageSource::KnowledgeRef,
vec![knowledge.clone()],
)
.unwrap();
append_use_event(
&layout,
"session-b",
UsageSource::MemoryRead,
vec![request.clone()],
UsageSource::KnowledgeRef,
vec![knowledge.clone()],
)
.unwrap();
append_resident_exposure_event(&layout, "session-b", vec![request]).unwrap();
append_resident_exposure_event(&layout, "session-b", vec![knowledge]).unwrap();
let report = build_usage_report(&layout).unwrap();
let decision = report
@ -343,22 +355,22 @@ mod tests {
assert_eq!(decision.resident_exposure_count, 0);
assert!(decision.last_used_at.is_some());
let request = report
let knowledge = report
.records
.iter()
.find(|r| r.kind == "request" && r.slug == "policy")
.find(|r| r.kind == "knowledge" && 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);
assert_eq!(knowledge.use_count, 2);
assert_eq!(knowledge.source_breakdown["KnowledgeRef"], 2);
assert_eq!(knowledge.resident_exposure_count, 1);
assert_eq!(knowledge.estimated_tokens_per_injection, 2);
assert_eq!(knowledge.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");
let snapshot = snapshot_record_from_bytes(RecordKind::Knowledge, "policy", b"abcdefgh");
append_resident_exposure_event(&layout, "session", vec![snapshot]).unwrap();
let report = build_usage_report(&layout).unwrap();

View File

@ -3,8 +3,10 @@
//! `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:
//! such as workflow and generated durable memory. The trees inside it:
//!
//! - `<root>/.yoi/workflow/<slug>.md`
//! - `<root>/.yoi/knowledge/<slug>.md`
//! - `<root>/.yoi/memory/summary.md`
//! - `<root>/.yoi/memory/decisions/<slug>.md`
//! - `<root>/.yoi/memory/requests/<slug>.md`
@ -12,6 +14,9 @@
//! - `<root>/.yoi/memory/_logs/current.log` (append-only audit log)
//!
//! `memory/` is reserved for session-derived / generated state;
//! Workflows are human-managed and live one level up under
//! `.yoi/workflow/`.
//!
//! `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.
@ -25,6 +30,8 @@ use lint_common::RecordLintError;
const YOI_DIR: &str = ".yoi";
const MEMORY_DIR: &str = "memory";
const KNOWLEDGE_DIR: &str = "knowledge";
const WORKFLOW_DIR: &str = "workflow";
const SUMMARY_FILE: &str = "summary.md";
const DECISIONS_DIR: &str = "decisions";
const REQUESTS_DIR: &str = "requests";
@ -40,6 +47,8 @@ pub enum RecordKind {
Summary,
Decision,
Request,
Workflow,
Knowledge,
}
impl RecordKind {
@ -48,6 +57,8 @@ impl RecordKind {
Self::Summary => "summary",
Self::Decision => "decision",
Self::Request => "request",
Self::Workflow => "workflow",
Self::Knowledge => "knowledge",
}
}
}
@ -75,7 +86,7 @@ impl WorkspaceLayout {
/// An explicit `memory.workspace_root` is honored exactly. Without an
/// explicit root, resolution searches `default_root` and its ancestors for
/// the nearest `.yoi/memory` directory. This keeps child worktrees that
/// contain `.yoi` project records such as tickets from
/// contain `.yoi` project records such as tickets or workflows from
/// becoming independent memory roots merely because they contain `.yoi`.
///
/// If no memory marker exists, this falls back to `default_root` because
@ -106,6 +117,10 @@ impl WorkspaceLayout {
self.yoi_dir().join(MEMORY_DIR)
}
pub fn knowledge_dir(&self) -> PathBuf {
self.yoi_dir().join(KNOWLEDGE_DIR)
}
pub fn summary_path(&self) -> PathBuf {
self.memory_dir().join(SUMMARY_FILE)
}
@ -118,6 +133,11 @@ impl WorkspaceLayout {
self.memory_dir().join(REQUESTS_DIR)
}
/// Workflow directory: `<root>/.yoi/workflow/`.
pub fn workflow_dir(&self) -> PathBuf {
self.yoi_dir().join(WORKFLOW_DIR)
}
pub fn staging_dir(&self) -> PathBuf {
self.memory_dir().join(STAGING_DIR)
}
@ -150,8 +170,16 @@ impl WorkspaceLayout {
self.requests_dir().join(format!("{slug}.md"))
}
pub fn workflow_path(&self, slug: &Slug) -> PathBuf {
self.workflow_dir().join(format!("{slug}.md"))
}
pub fn knowledge_path(&self, slug: &Slug) -> PathBuf {
self.knowledge_dir().join(format!("{slug}.md"))
}
/// Classify a path under the memory tree. Returns `None` if the
/// path is not under `.yoi/memory/`
/// path is not under `.yoi/memory/` or `.yoi/knowledge/`
/// of this workspace, or if it lives in
/// `_staging/` / `_usage/` / `_logs/` (opaque subsystem-owned trees).
///
@ -161,7 +189,11 @@ impl WorkspaceLayout {
/// can surface it as a write violation.
pub fn classify(&self, path: &Path) -> Result<Option<ClassifiedPath>, LintError> {
let memory = self.memory_dir();
let knowledge = self.knowledge_dir();
if let Ok(rel) = path.strip_prefix(&knowledge) {
return Ok(Some(classify_kinded_md(rel, RecordKind::Knowledge, path)?));
}
let rel = match path.strip_prefix(&memory) {
Ok(r) => r,
Err(_) => return Ok(None),
@ -267,7 +299,24 @@ mod tests {
}
#[test]
fn staging_tree_is_opaque_to_classifier() {
fn classifies_knowledge() {
let cp = layout()
.classify(&PathBuf::from("/ws/.yoi/knowledge/x.md"))
.unwrap()
.unwrap();
assert_eq!(cp.kind, RecordKind::Knowledge);
}
#[test]
fn workflow_under_memory_is_invalid_path() {
let err = layout()
.classify(&PathBuf::from("/ws/.yoi/memory/workflow/wf.md"))
.unwrap_err();
assert!(matches!(err, LintError::InvalidPath(_)));
}
#[test]
fn staging_returns_none() {
assert!(
layout()
.classify(&PathBuf::from("/ws/.yoi/memory/_staging/abc.json"))
@ -365,6 +414,7 @@ mod tests {
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();
std::fs::create_dir_all(child.join(".yoi/workflow")).unwrap();
let cfg = manifest::MemoryConfig::default();
let layout = WorkspaceLayout::resolve(&cfg, &child);
@ -377,6 +427,7 @@ mod tests {
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(workspace.join(".yoi/workflow")).unwrap();
std::fs::create_dir_all(&child).unwrap();
assert_eq!(find_memory_marker_root(&child), None);

View File

@ -70,7 +70,9 @@ pub enum Method {
///
/// Reply is sent on the same socket as `Event::Completions` (not
/// broadcast). The IPC server handles this directly and writes
/// the response straight back to the requesting socket.
/// the response straight back to the requesting socket. Empty
/// results for resolvers that are not yet wired up
/// (Knowledge / Workflow).
ListCompletions {
kind: CompletionKind,
prefix: String,
@ -171,7 +173,7 @@ impl WorkerEvent {
/// `Method::Run` and `Event::UserMessage` carry `Vec<Segment>`. Dumb
/// clients (CLI piping, scripts) only need to produce a single
/// `Segment::Text`; richer clients (TUI / GUI) construct typed atoms
/// (paste chips, file refs) and
/// (paste chips, file refs, knowledge refs, workflow invocations) and
/// send them through directly so the Worker side never has to re-parse a
/// flattened string.
///
@ -201,6 +203,10 @@ pub enum Segment {
/// `[Dir: <path>]` listings; the flattened user text keeps the literal
/// `@<path>` placeholder either way.
FileRef { path: String },
/// `#<slug>` Knowledge reference (see `docs/plan/memory.md`).
KnowledgeRef { slug: String },
/// `/<slug>` Workflow invocation (see `docs/plan/workflow.md`).
WorkflowInvoke { slug: String },
/// Unknown variant from a newer client. Worker treats this as an
/// unresolved input — surfaces an alert and inserts a placeholder.
/// Round-trip is lossy: re-serializing yields `{"kind":"unknown"}`.
@ -219,8 +225,9 @@ impl Segment {
/// to surface user-visible alerts for unresolved refs should do so
/// alongside this call (Worker does so at submit time).
///
/// Sigil-prefixed variants (`FileRef`) flatten back to their literal
/// sigil form (`@<path>`) when converted to text.
/// Sigil-prefixed variants (`FileRef` / `KnowledgeRef` / `WorkflowInvoke`)
/// flatten back to their literal sigil form (`@<path>`, `#<slug>`,
/// `/<slug>`) — matching what the user originally typed. Resolved
/// content (e.g. file body or shallow directory listing for `FileRef`) is
/// delivered as separate `Item::system_message`s adjacent to the user
/// message; the resolution itself is the caller's job. `Unknown` falls back to
@ -235,7 +242,17 @@ impl Segment {
out.push('@');
out.push_str(path);
}
Segment::Unknown => {}
Segment::KnowledgeRef { slug } => {
out.push('#');
out.push_str(slug);
}
Segment::WorkflowInvoke { slug } => {
out.push('/');
out.push_str(slug);
}
Segment::Unknown => {
out.push_str("[unknown input segment]");
}
}
}
out
@ -278,7 +295,8 @@ pub enum Event {
///
/// Carries the JSON form of `session_store::SystemItem`. Covers
/// `Method::Notify` echoes, child-Worker lifecycle events from
/// `Method::WorkerEvent`, `@<path>` resolution payloads, and any future
/// `Method::WorkerEvent`, `@<path>` / `#<slug>` / `/<slug>`
/// resolution payloads, and any future agent-side injection kind.
/// Clients dispatch on the `kind` tag for typed rendering instead
/// of parsing free-text prefixes like `[Notification] …` or
/// `[File: …]`.
@ -589,17 +607,26 @@ pub enum AlertSource {
/// Kind of completion requested by `Method::ListCompletions`.
///
/// Mirrors the completion prefix sigil: `@` → `File`.
/// Mirrors the TUI prefix sigils: `@` → `File`, `#` → `Knowledge`,
/// `/` → `Workflow`. Knowledge and Workflow resolvers are currently
/// stubs (always reply with empty `entries`); the wire shape is
/// nailed down here so the TUI side can ship without waiting for
/// the memory / workflow tickets.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum CompletionKind {
File,
Knowledge,
Workflow,
}
/// One completion candidate for a prefix query.
/// One candidate returned in `Event::Completions::entries`.
///
/// `value` is a path (file kind).
/// `value` is a path (file kind) or a slug (knowledge / workflow).
/// `is_dir` is meaningful only for the file kind — it lets the TUI
/// keep a trailing `/` after a directory selection so the user can
/// drill in without re-typing the prefix.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct CompletionEntry {
@ -1163,16 +1190,16 @@ mod tests {
#[test]
fn event_completions_format_and_default_is_dir() {
let event = Event::Completions {
kind: CompletionKind::File,
kind: CompletionKind::Workflow,
entries: vec![CompletionEntry {
value: "src/main.rs".into(),
value: "clear".into(),
is_dir: false,
}],
};
let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["event"], "completions");
assert_eq!(parsed["data"]["kind"], "file");
assert_eq!(parsed["data"]["kind"], "workflow");
assert_eq!(parsed["data"]["entries"][0]["value"], "clear");
// is_dir defaults to false on inbound payloads that omit it.

View File

@ -74,7 +74,7 @@ pub enum LogEntry {
/// User input accepted at submit time. Carries the original typed
/// `Vec<Segment>` so clients can re-render typed atoms (paste chips,
/// file refs) on segment restore.
/// file/knowledge refs, workflow invocations) on segment restore.
/// Replay flattens these into a `Item::user_message` for the worker
/// history; the worker layer never sees segments directly.
UserInput { ts: u64, segments: Vec<Segment> },
@ -88,8 +88,8 @@ pub enum LogEntry {
ToolResult { ts: u64, item: LoggedItem },
/// One typed agent-injected system item: notification, child-Worker
/// lifecycle event, `@<path>` / `/<slug>` resolution payload. Each
/// `SystemItem` carries kind metadata that the LLM
/// lifecycle event, `@<path>` / `#<slug>` / `/<slug>` resolution
/// payload. Each `SystemItem` carries kind metadata that the LLM
/// itself never sees (the LLM gets `Item::system_message` with the
/// item's denormalised `body`), but live clients and replay paths
/// dispatch on `kind` for typed rendering.

View File

@ -2,7 +2,7 @@
//!
//! Items in worker history with `role:system` are never produced by the
//! LLM — they are always inserted by the Worker itself (notifications,
//! file ref resolutions, child-worker lifecycle events,
//! file/knowledge/workflow ref resolutions, child-worker lifecycle events,
//! future `<system-reminder>` tags, …). [`SystemItem`] carries the
//! typed shape of each such injection so clients can dispatch on
//! `kind` instead of parsing text prefixes like `[Notification] …` or
@ -106,7 +106,7 @@ fn render_system_reminder(body: &str) -> String {
///
/// Each variant carries the kind-specific raw data clients use for
/// typed rendering (`Notification.message`, `WorkerEvent.event`, file
/// path / identifier / etc.), plus a pre-rendered
/// path / knowledge slug / workflow slug / etc.), plus a pre-rendered
/// `body` (where applicable) that is the exact `role:system` text the
/// LLM actually saw at commit time. `body` is denormalised so that
/// segment log replay reconstructs worker history byte-identical to
@ -139,21 +139,14 @@ pub enum SystemItem {
/// byte-identical to what was sent.
FileAttachment { path: String, body: String },
/// Explicit Agent Skill activation. `body` is the exact LLM-facing
/// Skill text committed before any subsequent LLM request can use it.
SkillActivation { name: String, body: String },
/// `#<slug>` Knowledge reference resolution. `body` is the
/// rendered text the LLM saw (Worker composes the `[Knowledge: …]`
/// header + body).
Knowledge { slug: String, body: String },
/// Historical persisted Knowledge reference resolution. Knowledge is no
/// longer active, so restored sessions ignore this item instead of replaying
/// archived record text into model context.
#[serde(rename = "knowledge")]
LegacyKnowledgeIgnored { slug: String, body: String },
/// Compatibility sink for pre-removal persisted `kind: "workflow"`
/// system items. These entries are intentionally not replayed as
/// authority-bearing context.
#[serde(rename = "workflow")]
LegacyIgnored { slug: String },
/// `/<slug>` Workflow invocation. `body` is the workflow's
/// prompt body materialized into the LLM context.
Workflow { slug: String, body: String },
/// Task-management inactivity reminder inserted before an LLM request.
/// `source` is the policy that produced this durable reminder; `body` is
@ -178,11 +171,8 @@ impl SystemItem {
SystemItem::Notification { body, .. } => body.clone(),
SystemItem::WorkerEvent { body, .. } => body.clone(),
SystemItem::FileAttachment { body, .. } => body.clone(),
SystemItem::SkillActivation { body, .. } => body.clone(),
SystemItem::LegacyKnowledgeIgnored { .. } => String::new(),
SystemItem::LegacyIgnored { slug } => {
format!("Ignored legacy procedure item: /{slug}")
}
SystemItem::Knowledge { body, .. } => body.clone(),
SystemItem::Workflow { body, .. } => body.clone(),
SystemItem::TaskReminder { body, .. } => body.clone(),
SystemItem::Interrupt { body } => body.clone(),
}
@ -201,9 +191,8 @@ impl SystemItem {
SystemItem::Notification { .. } => "notification",
SystemItem::WorkerEvent { .. } => "worker_event",
SystemItem::FileAttachment { .. } => "file_attachment",
SystemItem::SkillActivation { .. } => "skill_activation",
SystemItem::LegacyKnowledgeIgnored { .. } => "legacy_knowledge_ignored",
SystemItem::LegacyIgnored { .. } => "legacy_ignored",
SystemItem::Knowledge { .. } => "knowledge",
SystemItem::Workflow { .. } => "workflow",
SystemItem::TaskReminder { .. } => "task_reminder",
SystemItem::Interrupt { .. } => "interrupt",
}
@ -317,17 +306,6 @@ mod tests {
}
}
#[test]
fn legacy_procedure_system_item_is_ignored_on_replay() {
let parsed: SystemItem =
serde_json::from_str(r#"{"kind":"workflow","slug":"old-flow","body":"legacy body"}"#)
.unwrap();
assert_eq!(
parsed.history_text(),
"Ignored legacy procedure item: /old-flow"
);
}
#[test]
fn round_trip_via_json() {
let item = SystemItem::FileAttachment {

View File

@ -49,8 +49,9 @@ pub fn ticket_config_scaffold() -> String {
);
for role in TicketRole::ALL {
out.push_str(&format!(
"\n[ticket.roles.{role}]\nprofile = \"{}\"\n",
"\n[ticket.roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"\n",
role.default_profile(),
role.default_workflow()
));
}
out
@ -348,6 +349,10 @@ impl TicketConfig {
pub fn launch_prompt_for(&self, role: TicketRole) -> Option<&PromptRef> {
self.role(role).launch_prompt.as_ref()
}
pub fn workflow_for(&self, role: TicketRole) -> &WorkflowRef {
&self.role(role).workflow
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -432,6 +437,14 @@ impl TicketRole {
}
}
pub fn default_workflow(self) -> &'static str {
match self {
Self::Intake => "ticket-intake-workflow",
Self::Orchestrator => "ticket-orchestrator-routing",
Self::Coder | Self::Reviewer => "multi-agent-workflow",
}
}
pub fn default_profile(self) -> &'static str {
match self {
Self::Intake => "builtin:intake",
@ -563,13 +576,15 @@ pub enum TicketRoleLaunchConfigError {
pub struct TicketRoleConfig {
pub profile: ProfileSelectorRef,
pub launch_prompt: Option<PromptRef>,
pub workflow: WorkflowRef,
}
impl TicketRoleConfig {
pub fn default_for_role(_role: TicketRole) -> Self {
pub fn default_for_role(role: TicketRole) -> Self {
Self {
profile: ProfileSelectorRef::inherit(),
launch_prompt: None,
workflow: WorkflowRef::from_static(role.default_workflow()),
}
}
}
@ -670,6 +685,45 @@ impl AsRef<str> for PromptRef {
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
pub struct WorkflowRef(String);
impl WorkflowRef {
pub fn new(value: impl Into<String>) -> Result<Self, String> {
normalized_non_empty(value, "workflow ref").map(Self)
}
pub fn from_static(value: &'static str) -> Self {
Self(value.to_string())
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl<'de> Deserialize<'de> for WorkflowRef {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::new(value).map_err(serde::de::Error::custom)
}
}
impl fmt::Display for WorkflowRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl AsRef<str> for WorkflowRef {
fn as_ref(&self) -> &str {
self.as_str()
}
}
fn normalized_non_empty(value: impl Into<String>, label: &str) -> Result<String, String> {
let value = value.into();
let trimmed = value.trim();
@ -876,13 +930,18 @@ struct RawTicketRoleConfig {
profile: Option<ProfileSelectorRef>,
#[serde(default)]
launch_prompt: Option<PromptRef>,
#[serde(default)]
workflow: Option<WorkflowRef>,
}
impl RawTicketRoleConfig {
fn resolve(self, _role: TicketRole) -> TicketRoleConfig {
fn resolve(self, role: TicketRole) -> TicketRoleConfig {
TicketRoleConfig {
profile: self.profile.unwrap_or_else(ProfileSelectorRef::inherit),
launch_prompt: self.launch_prompt,
workflow: self
.workflow
.unwrap_or_else(|| WorkflowRef::from_static(role.default_workflow())),
}
}
}
@ -940,6 +999,7 @@ mod tests {
let role_config = config.role(role);
assert_eq!(role_config.profile.as_str(), "inherit");
assert!(role_config.launch_prompt.is_none());
assert_eq!(role_config.workflow.as_str(), role.default_workflow());
}
}
@ -1048,18 +1108,22 @@ worktree_name = "custom-orchestrator"
[ticket.roles.intake]
profile = "project:intake"
launch_prompt = "$workspace/ticket/intake/launch"
workflow = "ticket-intake-workflow"
[ticket.roles.orchestrator]
profile = "project:orchestrator"
launch_prompt = "$workspace/ticket/orchestrator/launch"
workflow = "ticket-orchestrator-routing"
[ticket.roles.coder]
profile = "inherit"
launch_prompt = "$workspace/ticket/coder/launch"
workflow = "multi-agent-workflow"
[ticket.roles.reviewer]
profile = "project:reviewer"
launch_prompt = "$workspace/ticket/reviewer/launch"
workflow = "multi-agent-workflow"
"#,
);
@ -1097,6 +1161,10 @@ launch_prompt = "$workspace/ticket/reviewer/launch"
.as_str(),
"$workspace/ticket/reviewer/launch"
);
assert_eq!(
config.workflow_for(TicketRole::Reviewer).as_str(),
"multi-agent-workflow"
);
}
#[test]
@ -1115,8 +1183,9 @@ launch_prompt = "$workspace/ticket/reviewer/launch"
for role in TicketRole::ALL {
assert!(scaffold.contains(&format!("[ticket.roles.{role}]")));
assert!(scaffold.contains(&format!(
"[ticket.roles.{role}]\nprofile = \"{}\"",
role.default_profile()
"[ticket.roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"",
role.default_profile(),
role.default_workflow()
)));
}
assert!(!scaffold.contains("[ticket.roles.investigator]"));
@ -1142,6 +1211,7 @@ launch_prompt = "$workspace/ticket/reviewer/launch"
for role in TicketRole::ALL {
let role_config = config.role_launch_config(role).unwrap();
assert_eq!(role_config.profile.as_str(), role.default_profile());
assert_eq!(role_config.workflow.as_str(), role.default_workflow());
}
}
@ -1160,6 +1230,7 @@ profile = "project:coder"
let coder = config.role(TicketRole::Coder);
assert_eq!(coder.profile.as_str(), "project:coder");
assert!(coder.launch_prompt.is_none());
assert_eq!(coder.workflow.as_str(), "multi-agent-workflow");
assert_eq!(config.profile_for(TicketRole::Reviewer).as_str(), "inherit");
}
@ -1248,6 +1319,7 @@ branch = "orchestration/panel:bad"
temp.path(),
r#"
[roles.orchestrator]
workflow = "ticket-orchestrator-routing"
"#,
);
@ -1314,6 +1386,7 @@ profile = "inherit"
r#"
[roles.investigator]
profile = "builtin:default"
workflow = "ticket-orchestrator-routing"
"#,
);

View File

@ -154,7 +154,7 @@ fn base_tool_description(name: &str) -> &'static str {
/// Build the model-visible Ticket tool description for a configured Ticket backend.
///
/// `record_language` is the durable Ticket record/tool-body language, distinct from
/// worker response language and Memory language. Keeping this on the tool
/// worker response language and Memory/Knowledge language. Keeping this on the tool
/// surface ensures every Ticket-capable Worker sees the policy without hidden context
/// injection or role-launch-only prose.
pub fn ticket_tool_description(name: &str, record_language: Option<&str>) -> String {
@ -162,7 +162,7 @@ pub fn ticket_tool_description(name: &str, record_language: Option<&str>) -> Str
if let Some(language) = record_language.filter(|language| !language.trim().is_empty()) {
description.push_str("\n\nTicket record language: ");
description.push_str(language.trim());
description.push_str(". Use this language for durable Ticket record and Ticket tool body text, including Ticket item bodies, thread comments/plans/decisions/implementation reports, reviews, resolutions, intake summaries, and orchestration plan notes. This policy is distinct from worker.language for normal prose and memory.language for Memory. Preserve protocol literals, file paths, commands, logs, identifiers, and quoted external text when translation would reduce fidelity.");
description.push_str(". Use this language for durable Ticket record and Ticket tool body text, including Ticket item bodies, thread comments/plans/decisions/implementation reports, reviews, resolutions, intake summaries, and orchestration plan notes. This policy is distinct from worker.language for normal prose and memory.language for Memory/Knowledge. Preserve protocol literals, file paths, commands, logs, identifiers, and quoted external text when translation would reduce fidelity.");
}
description
}

View File

@ -595,6 +595,8 @@ impl App {
self.input_history.cancel_browse();
match kind {
CompletionKind::File => self.input.replace_with_file_ref(start, value),
CompletionKind::Knowledge => self.input.replace_with_knowledge_ref(start, value),
CompletionKind::Workflow => self.input.replace_with_workflow_invoke(start, value),
}
self.completion = None;
true
@ -606,6 +608,8 @@ impl App {
/// suggestion" — partial typing like `@README.` followed by
/// Enter should chip when the popup is on `README.md`.
///
/// Files (and Knowledge / Workflow entries, which have no dir
/// concept) chipify here. Directory file entries return `false`
/// so the caller can fall through to `apply_completion_text`
/// for drill-in — chip-ifying a directory on Enter would strand
/// the user with no way to inspect children.
@ -626,6 +630,8 @@ impl App {
self.input_history.cancel_browse();
match kind {
CompletionKind::File => self.input.replace_with_file_ref(start, value),
CompletionKind::Knowledge => self.input.replace_with_knowledge_ref(start, value),
CompletionKind::Workflow => self.input.replace_with_workflow_invoke(start, value),
}
self.completion = None;
true
@ -2163,14 +2169,13 @@ impl App {
self.blocks.push(Block::WorkerEvent { event });
}
session_store::SystemItem::FileAttachment { body, .. }
| session_store::SystemItem::SkillActivation { body, .. }
| session_store::SystemItem::Knowledge { body, .. }
| session_store::SystemItem::Workflow { body, .. }
| session_store::SystemItem::TaskReminder { body, .. }
| session_store::SystemItem::Interrupt { body } => {
self.task_store.apply_system_message_text(&body);
self.blocks.push(Block::SystemMessage { text: body });
}
session_store::SystemItem::LegacyIgnored { .. } => {}
session_store::SystemItem::LegacyKnowledgeIgnored { .. } => {}
}
}
@ -2967,6 +2972,24 @@ mod completion_flow_tests {
assert!(app.completion.is_some());
}
#[test]
fn outdated_completions_event_is_dropped() {
let mut app = App::new("test".into());
for c in "@x".chars() {
app.insert_char(c);
}
let _ = app.refresh_completion();
// Reply for a different kind shouldn't overwrite state.
app.handle_worker_event(Event::Completions {
kind: CompletionKind::Workflow,
entries: vec![CompletionEntry {
value: "stale".into(),
is_dir: false,
}],
});
assert!(app.completion.as_ref().unwrap().entries.is_empty());
}
#[test]
fn committed_user_message_survives_fresh_segment_rotation() {
let mut app = App::new("test".into());
@ -3303,7 +3326,7 @@ mod completion_flow_tests {
}
#[test]
fn live_legacy_workflow_system_item_is_ignored() {
fn live_system_item_workflow_appends_system_message_block() {
let mut app = App::new("test".into());
let item = serde_json::json!({
"kind": "workflow",
@ -3312,7 +3335,10 @@ mod completion_flow_tests {
});
app.handle_worker_event(Event::SystemItem { item });
assert!(app.blocks.is_empty());
assert!(matches!(
app.blocks.as_slice(),
[Block::SystemMessage { text }] if text == "[Workflow /build]\nRun the build"
));
}
#[test]
@ -3564,11 +3590,12 @@ mod completion_flow_tests {
```json\n{\n \"tasks\": [\n {\n \"taskid\": 4,\n \
\"status\": \"inprogress\",\n \"subject\": \"from snapshot\",\n \
\"description\": \"d\"\n }\n ]\n}\n```\n";
// Snapshot text injected through an active system item kind; legacy
// workflow items are intentionally ignored and must not carry active state.
// Snapshot text injected as a workflow body (kind doesn't matter
// for task-store parsing, only the text contents do).
app.handle_worker_event(Event::SystemItem {
item: serde_json::json!({
"kind": "task_reminder",
"kind": "workflow",
"slug": "task-snapshot",
"body": snapshot,
}),
});
@ -3677,6 +3704,15 @@ mod completion_flow_tests {
Segment::Text {
content: " and ".into(),
},
Segment::KnowledgeRef {
slug: "design-note".into(),
},
Segment::Text {
content: " then ".into(),
},
Segment::WorkflowInvoke {
slug: "review".into(),
},
Segment::Paste {
id: 1,
chars: 13,

View File

@ -4949,7 +4949,7 @@ fn orchestrator_queue_notification_message(
) -> String {
let title = ticket.title.replace(['\r', '\n'], " ");
format!(
"Workspace Dashboard Queue for Ticket `{}`, title `{}`: human authorized Orchestrator routing; this is not an unattended scheduler. Read the Ticket and inspect current Orchestrator workspace state. If unblocked, record routing and transition state queued -> inprogress before any worktree/SpawnWorker implementation side effects. After inprogress acceptance, create the delegated implementation worktree with tracked `.yoi` project records visible and generated/local/runtime/log/lock/secret-like `.yoi` paths excluded, then run sibling coder/reviewer Workers through typed Ticket role launch surfaces. After reviewer approval and blocker resolution, integrate the implementation branch into the orchestration branch automatically, validate in the Orchestrator worktree, record the outcome, and clean up only child implementation worktrees/branches. Do not read, write, validate, merge, clean up, or run git operations in the root/original workspace. If blocked, record a concise reason and leave the Ticket queued or return it to planning with the missing-information reason.",
"Workspace Dashboard Queue for Ticket `{}`, title `{}`: human authorized Orchestrator routing; this is not an unattended scheduler. Read the Ticket and inspect current Orchestrator workspace state. If unblocked, record routing and transition state queued -> inprogress before any worktree/SpawnWorker implementation side effects. After inprogress acceptance, use worktree-workflow for `.worktree/<task-name>` creation with tracked `.yoi` project records visible and `.yoi/memory` plus local/runtime/log/lock/secret-like `.yoi` paths excluded, then use multi-agent-workflow to run sibling coder/reviewer Workers (coder narrow child-worktree write scope, reviewer read-only by default). After reviewer approval and blocker resolution, integrate the implementation branch into the orchestration branch automatically, validate in the Orchestrator worktree, record the outcome, and clean up only child implementation worktrees/branches. Do not read, write, validate, merge, clean up, or run git operations in the root/original workspace. If blocked, record a concise reason and leave the Ticket queued or return it to planning with the missing-information reason.",
ticket.id,
title.trim()
)

View File

@ -825,10 +825,18 @@ fn ticket_queue_notification_message_carries_routing_contract() {
assert!(message.contains("transition state queued -> inprogress"));
assert!(message.contains("before any worktree/SpawnWorker implementation side effects"));
assert!(message.contains("After inprogress acceptance"));
assert!(message.contains("implementation worktree"));
assert!(message.contains("worktree-workflow"));
assert!(message.contains("`.worktree/<task-name>`"));
assert!(message.contains("tracked `.yoi` project records visible"));
assert!(message.contains("generated/local/runtime/log/lock/secret-like `.yoi` paths excluded"));
assert!(
message.contains(
"`.yoi/memory` plus local/runtime/log/lock/secret-like `.yoi` paths excluded"
)
);
assert!(message.contains("multi-agent-workflow"));
assert!(message.contains("sibling coder/reviewer Workers"));
assert!(message.contains("coder narrow child-worktree write scope"));
assert!(message.contains("reviewer read-only by default"));
assert!(message.contains(
"integrate the implementation branch into the orchestration branch automatically"
));
@ -2783,6 +2791,7 @@ fn dashboard_ticket_intake_finish_success_clears_composer_and_reports_pod() {
role: TicketRole::Intake,
worker_name: "intake-worker".to_string(),
profile: "builtin:default".to_string(),
workflow: "ticket-intake-workflow".to_string(),
launch_prompt_ref: None,
run_segments: vec![],
},

View File

@ -46,11 +46,37 @@ impl FileRefAtom {
}
}
/// `#<slug>` chip — confirmed completion of a Knowledge reference.
#[derive(Debug, Clone)]
pub struct KnowledgeRefAtom {
pub slug: String,
}
impl KnowledgeRefAtom {
pub fn label(&self) -> String {
format!("#{}", self.slug)
}
}
/// `/<slug>` chip — confirmed completion of a Workflow invocation.
#[derive(Debug, Clone)]
pub struct WorkflowInvokeAtom {
pub slug: String,
}
impl WorkflowInvokeAtom {
pub fn label(&self) -> String {
format!("/{}", self.slug)
}
}
#[derive(Debug, Clone)]
pub enum Atom {
Char(char),
Paste(PasteRef),
FileRef(FileRefAtom),
KnowledgeRef(KnowledgeRefAtom),
WorkflowInvoke(WorkflowInvokeAtom),
}
impl Atom {
@ -61,6 +87,8 @@ impl Atom {
Atom::Char(_) => None,
Atom::Paste(p) => Some((Style::default().fg(Color::Magenta), p.label())),
Atom::FileRef(r) => Some((Style::default().fg(Color::Cyan), r.label())),
Atom::KnowledgeRef(r) => Some((Style::default().fg(Color::Green), r.label())),
Atom::WorkflowInvoke(r) => Some((Style::default().fg(Color::Yellow), r.label())),
}
}
}
@ -69,7 +97,8 @@ impl Atom {
enum AtomClass {
Word(WordKind),
Sep,
/// Indivisible chip — paste / file ref. Word motion treats one chip as one unit; deletion
/// Indivisible chip — paste / file ref / knowledge ref / workflow
/// invocation. Word motion treats one chip as one unit; deletion
/// removes the whole atom.
Chip,
}
@ -89,7 +118,9 @@ enum WordKind {
fn atom_class(atom: &Atom) -> AtomClass {
match atom {
Atom::Char(c) => char_class(*c),
Atom::Paste(_) | Atom::FileRef(_) => AtomClass::Chip,
Atom::Paste(_) | Atom::FileRef(_) | Atom::KnowledgeRef(_) | Atom::WorkflowInvoke(_) => {
AtomClass::Chip
}
}
}
@ -181,6 +212,15 @@ impl InputBuffer {
self.atoms
.push(Atom::FileRef(FileRefAtom { path: path.clone() }));
}
protocol::Segment::KnowledgeRef { slug } => {
self.atoms
.push(Atom::KnowledgeRef(KnowledgeRefAtom { slug: slug.clone() }));
}
protocol::Segment::WorkflowInvoke { slug } => {
self.atoms.push(Atom::WorkflowInvoke(WorkflowInvokeAtom {
slug: slug.clone(),
}));
}
protocol::Segment::Unknown => {
self.atoms
.extend("[unknown input segment]".chars().map(Atom::Char));
@ -208,6 +248,8 @@ impl InputBuffer {
Atom::Char(c) => text.push(*c),
Atom::Paste(paste) => text.push_str(&paste.content),
Atom::FileRef(file) => text.push_str(&file.path),
Atom::KnowledgeRef(knowledge) => text.push_str(&knowledge.slug),
Atom::WorkflowInvoke(workflow) => text.push_str(&workflow.slug),
}
}
text
@ -235,7 +277,7 @@ impl InputBuffer {
}
/// Replace `atoms[start..self.cursor]` (the in-flight `@<typed>` /
/// active `@<typed>` file token) with the corresponding chip atom
/// `#<typed>` / `/<typed>` token) with the corresponding chip atom
/// and place the cursor right after the chip. Used by the completion
/// confirm path.
pub fn replace_with_file_ref(&mut self, start: usize, path: String) {
@ -245,6 +287,20 @@ impl InputBuffer {
self.cursor = start + 1;
}
pub fn replace_with_knowledge_ref(&mut self, start: usize, slug: String) {
self.atoms.drain(start..self.cursor);
self.atoms
.insert(start, Atom::KnowledgeRef(KnowledgeRefAtom { slug }));
self.cursor = start + 1;
}
pub fn replace_with_workflow_invoke(&mut self, start: usize, slug: String) {
self.atoms.drain(start..self.cursor);
self.atoms
.insert(start, Atom::WorkflowInvoke(WorkflowInvokeAtom { slug }));
self.cursor = start + 1;
}
/// Replace `atoms[start..self.cursor]` with the chars of `text`,
/// leaving cursor at the end of the inserted run. Used by the Tab
/// completion path: the popup-selected entry is inserted as raw
@ -260,15 +316,15 @@ impl InputBuffer {
self.cursor = idx;
}
/// If the cursor is currently inside a `@<typed>` /
/// If the cursor is currently inside a `@<typed>` / `#<typed>` /
/// `/<typed>` token that satisfies the trigger rules, return the
/// kind, the index of the leading sigil atom, and the typed text
/// after the sigil (sigil itself excluded).
///
/// Trigger rules:
/// - The sigil (`@` / `#`) must be preceded by start-of-input,
/// - The sigil (`@` / `#` / `/`) must be preceded by start-of-input,
/// whitespace, or another chip atom — otherwise this is normal
/// text (e.g. the `/` in `src/main.rs` is not a completion trigger).
/// text (e.g. the `/` in `src/main.rs` is not a workflow trigger).
/// - Whitespace, newlines and chip atoms invalidate an in-flight
/// token — `@foo /` closes the `@foo` candidate as soon as the
/// space lands.
@ -285,7 +341,8 @@ impl InputBuffer {
}
let kind = match c {
'@' => Some(protocol::CompletionKind::File),
'#' => Some(protocol::CompletionKind::Knowledge),
'/' => Some(protocol::CompletionKind::Workflow),
_ => None,
};
if let Some(k) = kind {
@ -454,7 +511,7 @@ impl InputBuffer {
/// Build the typed `Vec<Segment>` sent over the protocol. Adjacent
/// `Atom::Char`s are concatenated into a single `Segment::Text`; each
/// chip atom (`Paste` / `FileRef`)
/// chip atom (`Paste` / `FileRef` / `KnowledgeRef` / `WorkflowInvoke`)
/// becomes a standalone `Segment` so that clients re-rendering an
/// `Event::UserMessage` see the same indivisible chip rather than a
/// flattened string.
@ -484,6 +541,18 @@ impl InputBuffer {
path: r.path.clone(),
});
}
Atom::KnowledgeRef(r) => {
flush_text(&mut buf, &mut out);
out.push(protocol::Segment::KnowledgeRef {
slug: r.slug.clone(),
});
}
Atom::WorkflowInvoke(r) => {
flush_text(&mut buf, &mut out);
out.push(protocol::Segment::WorkflowInvoke {
slug: r.slug.clone(),
});
}
}
}
if !buf.is_empty() {
@ -924,6 +993,34 @@ mod submit_segments_tests {
assert!(matches!(&segs[0], Segment::Text { content } if content == "see "));
assert!(matches!(&segs[1], Segment::FileRef { path } if path == "src/main.rs"));
}
#[test]
fn knowledge_and_workflow_chips_emit_typed_segments() {
let mut buf = InputBuffer::new();
for c in "#r".chars() {
buf.insert_char(c);
}
buf.replace_with_knowledge_ref(0, "rust-style".into());
buf.insert_char(' ');
for c in "/p".chars() {
buf.insert_char(c);
}
buf.replace_with_workflow_invoke(2, "plan".into());
let segs = buf.submit_segments();
assert_eq!(segs.len(), 3);
match &segs[0] {
Segment::KnowledgeRef { slug } => assert_eq!(slug, "rust-style"),
other => panic!("expected KnowledgeRef, got {other:?}"),
}
match &segs[1] {
Segment::Text { content } => assert_eq!(content, " "),
other => panic!("expected Text, got {other:?}"),
}
match &segs[2] {
Segment::WorkflowInvoke { slug } => assert_eq!(slug, "plan"),
other => panic!("expected WorkflowInvoke, got {other:?}"),
}
}
}
#[cfg(test)]
@ -958,7 +1055,7 @@ mod completion_prefix_tests {
}
#[test]
fn slash_inside_path_is_not_a_completion_trigger() {
fn slash_inside_path_is_not_a_workflow_trigger() {
// After `@src/m`, the only valid trigger is `@`, not the `/`.
let buf = buf_from("@src/m");
let (kind, start, prefix) = buf.pending_completion_prefix().unwrap();
@ -996,6 +1093,22 @@ mod completion_prefix_tests {
assert_eq!(prefix, "sr");
}
#[test]
fn hash_sigil_triggers_knowledge_completion() {
let buf = buf_from("#abc");
let (kind, _, prefix) = buf.pending_completion_prefix().unwrap();
assert_eq!(kind, CompletionKind::Knowledge);
assert_eq!(prefix, "abc");
}
#[test]
fn slash_at_start_triggers_workflow_completion() {
let buf = buf_from("/cl");
let (kind, _, prefix) = buf.pending_completion_prefix().unwrap();
assert_eq!(kind, CompletionKind::Workflow);
assert_eq!(prefix, "cl");
}
#[test]
fn newline_before_cursor_invalidates_trigger() {
let buf = buf_from("@a\nbc");
@ -1194,7 +1307,10 @@ mod word_motion_tests {
for a in &buf.atoms {
match a {
Atom::Char(c) => out.push(*c),
Atom::Paste(_) | Atom::FileRef(_) => out.push_str("<P>"),
Atom::Paste(_)
| Atom::FileRef(_)
| Atom::KnowledgeRef(_)
| Atom::WorkflowInvoke(_) => out.push_str("<P>"),
}
}
out

View File

@ -965,7 +965,7 @@ fn push_padded_lines(lines: &mut Vec<Line<'static>>, text: &str, kind: MessageKi
/// Render `Block::UserMessage` from typed segments. Each non-text
/// segment renders as a one-piece chip whose colour matches the input
/// area's chip presentation (paste = magenta, `@` file = cyan,
/// `/` workflow = yellow), so the user
/// `#` knowledge = green, `/` workflow = yellow), so the user
/// recognises their own typed atoms in the scrollback.
fn render_user_message(
lines: &mut Vec<Line<'static>>,
@ -1097,6 +1097,10 @@ fn chip_span_for(seg: &Segment, fallback: Style) -> (Style, String) {
format!("[Clipboard #{id} | {chars} chars, {line_count} lines]"),
),
Segment::FileRef { path } => (Style::default().fg(Color::Cyan), format!("@{path}")),
Segment::KnowledgeRef { slug } => (Style::default().fg(Color::Green), format!("#{slug}")),
Segment::WorkflowInvoke { slug } => {
(Style::default().fg(Color::Yellow), format!("/{slug}"))
}
Segment::Unknown => (fallback, "[unknown segment]".to_owned()),
}
}
@ -1111,6 +1115,8 @@ fn segment_display_text(seg: &Segment) -> String {
id, chars, lines, ..
} => format!("[Clipboard #{id} | {chars} chars, {lines} lines]"),
Segment::FileRef { path } => format!("@{path}"),
Segment::KnowledgeRef { slug } => format!("#{slug}"),
Segment::WorkflowInvoke { slug } => format!("/{slug}"),
Segment::Unknown => "[unknown segment]".to_owned(),
}
}

View File

@ -34,6 +34,7 @@ libc = { workspace = true }
schemars = { workspace = true }
ticket = { workspace = true }
memory = { workspace = true }
workflow-crate = { package = "workflow", path = "../workflow" }
uuid = { workspace = true, features = ["v7"] }
session-metrics = { workspace = true }
arc-swap = "1.9.1"

View File

@ -0,0 +1,736 @@
//! Durable active workflow invocation state.
//!
//! Workflow bodies are resolved at invocation time and snapshotted here. The
//! snapshot, not whatever resource version is installed later, is the procedural
//! authority that survives compaction for the currently governed task.
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use llm_engine::Item;
use llm_engine::tool::{
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use session_store::{LogEntry, SystemItem, segment_log};
pub const DOMAIN: &str = "worker.active_workflows";
pub const REHYDRATION_MESSAGE_PREFIX: &str = "[Active workflow snapshot]";
pub const INACTIVE_MESSAGE_PREFIX: &str = "[Active workflow state]";
const SCHEMA_VERSION: u32 = 1;
pub type LogEntryCommitter = Arc<dyn Fn(LogEntry) + Send + Sync>;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActiveWorkflowSnapshot {
pub schema_version: u32,
pub workflows: Vec<ActiveWorkflowRecord>,
}
impl Default for ActiveWorkflowSnapshot {
fn default() -> Self {
Self {
schema_version: SCHEMA_VERSION,
workflows: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActiveWorkflowRecord {
pub slug: String,
pub status: ActiveWorkflowStatus,
pub invocation: WorkflowInvocationInfo,
pub task_scope: String,
pub body_snapshot_policy: WorkflowBodySnapshotPolicy,
pub guidance_snapshot: String,
pub obligations: Vec<String>,
pub checkpoints: Vec<WorkflowCheckpoint>,
pub updated_at_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completion: Option<WorkflowCompletionInfo>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ActiveWorkflowStatus {
Active,
Completed,
Cancelled,
}
impl std::fmt::Display for ActiveWorkflowStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Self::Active => "active",
Self::Completed => "completed",
Self::Cancelled => "cancelled",
};
f.write_str(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkflowInvocationInfo {
pub source: WorkflowInvocationSource,
pub invoked_at_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkflowInvocationSource {
UserWorkflowInvokeSegment,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkflowBodySnapshotPolicy {
SnapshottedAtInvocation,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkflowCheckpoint {
pub label: String,
pub status: WorkflowCheckpointStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkflowCheckpointStatus {
Open,
Done,
Cancelled,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkflowCompletionInfo {
pub completed_at_ms: u64,
pub reason: String,
}
#[derive(Debug, Clone, Default)]
pub struct ActiveWorkflowStore {
inner: Arc<Mutex<ActiveWorkflowSnapshot>>,
}
impl ActiveWorkflowStore {
pub fn new() -> Self {
Self::default()
}
pub fn snapshot(&self) -> ActiveWorkflowSnapshot {
self.inner.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
pub fn replace_with(&self, snapshot: ActiveWorkflowSnapshot) {
*self.inner.lock().unwrap_or_else(|e| e.into_inner()) = snapshot;
}
pub fn active_records(&self) -> Vec<ActiveWorkflowRecord> {
self.snapshot()
.workflows
.into_iter()
.filter(|record| record.status == ActiveWorkflowStatus::Active)
.collect()
}
pub fn activate_from_system_items(
&self,
items: &[SystemItem],
task_scope: String,
invoked_at_ms: u64,
) -> bool {
let mut grouped: BTreeMap<String, Vec<String>> = BTreeMap::new();
for item in items {
if let SystemItem::Workflow { slug, body } = item {
grouped.entry(slug.clone()).or_default().push(body.clone());
}
}
if grouped.is_empty() {
return false;
}
let mut snapshot = self.snapshot();
snapshot.schema_version = SCHEMA_VERSION;
for (slug, bodies) in grouped {
let guidance_snapshot = bodies.join("\n\n---\n\n");
let obligations = extract_obligations(&guidance_snapshot);
let checkpoints = obligations
.iter()
.take(32)
.map(|label| WorkflowCheckpoint {
label: label.clone(),
status: WorkflowCheckpointStatus::Open,
})
.collect();
let record = ActiveWorkflowRecord {
slug: slug.clone(),
status: ActiveWorkflowStatus::Active,
invocation: WorkflowInvocationInfo {
source: WorkflowInvocationSource::UserWorkflowInvokeSegment,
invoked_at_ms,
},
task_scope: truncate_chars(&task_scope, 2_000),
body_snapshot_policy: WorkflowBodySnapshotPolicy::SnapshottedAtInvocation,
guidance_snapshot,
obligations,
checkpoints,
updated_at_ms: invoked_at_ms,
completion: None,
};
upsert_record(&mut snapshot.workflows, record);
}
self.replace_with(snapshot);
true
}
pub fn set_status(
&self,
slug: &str,
status: ActiveWorkflowStatus,
reason: String,
now_ms: u64,
) -> Result<ActiveWorkflowRecord, String> {
let mut snapshot = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let record = snapshot
.workflows
.iter_mut()
.find(|record| record.slug == slug)
.ok_or_else(|| format!("active workflow `{slug}` not found"))?;
record.status = status;
record.updated_at_ms = now_ms;
record.completion = Some(WorkflowCompletionInfo {
completed_at_ms: now_ms,
reason,
});
for checkpoint in &mut record.checkpoints {
checkpoint.status = match status {
ActiveWorkflowStatus::Active => WorkflowCheckpointStatus::Open,
ActiveWorkflowStatus::Completed => WorkflowCheckpointStatus::Done,
ActiveWorkflowStatus::Cancelled => WorkflowCheckpointStatus::Cancelled,
};
}
Ok(record.clone())
}
pub fn snapshot_text(&self) -> Option<String> {
let active = self.active_records();
(!active.is_empty()).then(|| render_snapshot_text(&active))
}
pub fn rehydration_message(&self) -> Option<String> {
let active = self.active_records();
(!active.is_empty()).then(|| render_rehydration_message(&active))
}
pub fn sanitize_context(&self, context: &mut Vec<Item>) -> usize {
let removed = strip_rehydration_messages(context);
if let Some(message) = self.rehydration_message() {
context.push(Item::system_message(message));
} else if removed > 0 || context.iter().any(has_active_workflow_hint) {
context.push(Item::system_message(inactive_workflow_message()));
}
removed
}
pub fn extension_entry(&self) -> LogEntry {
LogEntry::Extension {
ts: segment_log::now_millis(),
domain: DOMAIN.into(),
payload: serde_json::to_value(self.snapshot())
.expect("ActiveWorkflowSnapshot is always JSON-serializable"),
}
}
pub fn restore_from_history_and_extensions(
&self,
_history: &[Item],
extensions: &[(String, serde_json::Value)],
) {
let (snapshot, diagnostics) = fold_extensions(extensions);
for diagnostic in diagnostics {
tracing::warn!(diagnostic, "failed to restore active workflow state");
}
self.replace_with(snapshot);
}
}
pub fn fold_extensions(
extensions: &[(String, serde_json::Value)],
) -> (ActiveWorkflowSnapshot, Vec<String>) {
let mut latest = None;
let mut diagnostics = Vec::new();
for (domain, payload) in extensions {
if domain != DOMAIN {
continue;
}
match serde_json::from_value::<ActiveWorkflowSnapshot>(payload.clone()) {
Ok(snapshot) if snapshot.schema_version == SCHEMA_VERSION => latest = Some(snapshot),
Ok(snapshot) => {
latest = None;
diagnostics.push(format!(
"unsupported active workflow schema_version {}",
snapshot.schema_version
));
}
Err(err) => {
latest = None;
diagnostics.push(format!("corrupt active workflow payload: {err}"));
}
}
}
(latest.unwrap_or_default(), diagnostics)
}
pub fn strip_rehydration_messages(items: &mut Vec<Item>) -> usize {
let before = items.len();
items.retain(|item| !is_rehydration_message(item));
before - items.len()
}
pub fn is_rehydration_message(item: &Item) -> bool {
item_system_text(item)
.map(|text| text.trim_start().starts_with(REHYDRATION_MESSAGE_PREFIX))
.unwrap_or(false)
}
fn has_active_workflow_hint(item: &Item) -> bool {
item_system_text(item)
.map(|text| {
text.contains("Active Workflow Invocation State")
|| text.contains("ActiveWorkflowStore:")
|| text.contains(REHYDRATION_MESSAGE_PREFIX)
})
.unwrap_or(false)
}
fn item_system_text(item: &Item) -> Option<String> {
match item {
Item::Message { role, content, .. } if *role == llm_engine::Role::System => Some(
content
.iter()
.map(|part| part.as_text())
.collect::<String>(),
),
_ => None,
}
}
fn inactive_workflow_message() -> String {
format!(
"{INACTIVE_MESSAGE_PREFIX}\n\n\
No currently valid active workflow invocation state is active. Ignore older compacted \
history or summaries that appear to describe active workflow obligations; only validated \
typed `{DOMAIN}` records with status `active` establish active workflow guidance."
)
}
pub fn active_workflow_tools(
store: ActiveWorkflowStore,
committer: Option<LogEntryCommitter>,
) -> Vec<ToolDefinition> {
vec![
list_tool(store.clone()),
status_tool(
store.clone(),
ActiveWorkflowStatus::Completed,
committer.clone(),
),
status_tool(store, ActiveWorkflowStatus::Cancelled, committer),
]
}
fn list_tool(store: ActiveWorkflowStore) -> ToolDefinition {
Arc::new(move || {
(
ToolMeta::new("ActiveWorkflowList")
.description("List durable active workflow invocations and their status")
.input_schema(
json!({"type":"object","properties":{},"additionalProperties":false}),
),
Arc::new(ActiveWorkflowListTool {
store: store.clone(),
}) as Arc<dyn Tool>,
)
})
}
fn status_tool(
store: ActiveWorkflowStore,
status: ActiveWorkflowStatus,
committer: Option<LogEntryCommitter>,
) -> ToolDefinition {
let name = match status {
ActiveWorkflowStatus::Completed => "ActiveWorkflowComplete",
ActiveWorkflowStatus::Cancelled => "ActiveWorkflowCancel",
ActiveWorkflowStatus::Active => unreachable!("active status tool is not exposed"),
};
let description = match status {
ActiveWorkflowStatus::Completed => {
"Mark an active workflow as completed when its governed task is finished"
}
ActiveWorkflowStatus::Cancelled => {
"Cancel an active workflow when the governed task is explicitly abandoned"
}
ActiveWorkflowStatus::Active => unreachable!("active status tool is not exposed"),
};
let store_for_tool = store.clone();
let committer_for_tool = committer.clone();
Arc::new(move || {
(
ToolMeta::new(name)
.description(description)
.input_schema(json!({
"type":"object",
"properties":{
"slug":{"type":"string","description":"Workflow slug to update"},
"reason":{"type":"string","description":"Brief completion/cancellation reason"}
},
"required":["slug"],
"additionalProperties":false
})),
Arc::new(ActiveWorkflowStatusTool {
store: store_for_tool.clone(),
status,
committer: committer_for_tool.clone(),
}) as Arc<dyn Tool>,
)
})
}
struct ActiveWorkflowListTool {
store: ActiveWorkflowStore,
}
#[async_trait]
impl Tool for ActiveWorkflowListTool {
async fn execute(
&self,
_input_json: &str,
_ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let snapshot = self.store.snapshot();
let content = serde_json::to_string_pretty(&snapshot)
.map_err(|err| ToolError::Internal(err.to_string()))?;
let active = snapshot
.workflows
.iter()
.filter(|record| record.status == ActiveWorkflowStatus::Active)
.count();
Ok(ToolOutput {
summary: format!(
"ActiveWorkflowStore: {} workflow(s), {active} active",
snapshot.workflows.len()
),
content: Some(content),
})
}
}
struct ActiveWorkflowStatusTool {
store: ActiveWorkflowStore,
status: ActiveWorkflowStatus,
committer: Option<LogEntryCommitter>,
}
#[async_trait]
impl Tool for ActiveWorkflowStatusTool {
async fn execute(
&self,
input_json: &str,
_ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: WorkflowStatusParams = serde_json::from_str(input_json)
.map_err(|err| ToolError::InvalidArgument(err.to_string()))?;
let reason = params.reason.unwrap_or_else(|| self.status.to_string());
let record = self
.store
.set_status(&params.slug, self.status, reason, segment_log::now_millis())
.map_err(ToolError::InvalidArgument)?;
if let Some(committer) = &self.committer {
committer(self.store.extension_entry());
}
let content = serde_json::to_string_pretty(&record)
.map_err(|err| ToolError::Internal(err.to_string()))?;
Ok(ToolOutput {
summary: format!("workflow {} marked {}", record.slug, record.status),
content: Some(content),
})
}
}
#[derive(Debug, Deserialize)]
struct WorkflowStatusParams {
slug: String,
#[serde(default)]
reason: Option<String>,
}
fn upsert_record(records: &mut Vec<ActiveWorkflowRecord>, record: ActiveWorkflowRecord) {
if let Some(existing) = records
.iter_mut()
.find(|existing| existing.slug == record.slug)
{
*existing = record;
} else {
records.push(record);
}
}
fn extract_obligations(body: &str) -> Vec<String> {
let mut obligations = Vec::new();
for line in body.lines() {
let trimmed = line.trim();
let candidate = trimmed
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
.or_else(|| trimmed.strip_prefix(""))
.unwrap_or(trimmed);
let lower = candidate.to_ascii_lowercase();
let looks_obligating = lower.contains("must")
|| lower.contains("require")
|| lower.contains("obligation")
|| lower.contains("review")
|| lower.contains("merge")
|| lower.contains("close")
|| lower.contains("report")
|| lower.contains("handoff");
if looks_obligating && !candidate.is_empty() {
obligations.push(truncate_chars(candidate, 240));
}
if obligations.len() >= 32 {
break;
}
}
if obligations.is_empty() {
obligations
.push("Follow the snapshotted workflow body until completion or cancellation".into());
}
obligations
}
fn render_snapshot_text(records: &[ActiveWorkflowRecord]) -> String {
let json = serde_json::to_string_pretty(&ActiveWorkflowSnapshot {
schema_version: SCHEMA_VERSION,
workflows: records.to_vec(),
})
.unwrap_or_else(|_| String::from("{\"schema_version\":1,\"workflows\":[]}"));
format!(
"ActiveWorkflowStore: {} active workflow(s)\n\n```json\n{}\n```",
records.len(),
json
)
}
fn render_rehydration_message(records: &[ActiveWorkflowRecord]) -> String {
let mut out = format!(
"{REHYDRATION_MESSAGE_PREFIX}\n\n\
The following workflow invocation state is durable state carried across compaction. \
Continue to follow each active workflow's snapshotted guidance until the governed task \
is completed with ActiveWorkflowComplete or explicitly cancelled with ActiveWorkflowCancel. \
Missing or obsolete workflow resources must not replace these invocation snapshots.\n"
);
for record in records {
out.push_str(&format!(
"\n## /{} ({})\n- invoked_at_ms: {}\n- invocation_source: {:?}\n- body_snapshot_policy: {:?}\n- task_scope: {}\n\n### Current obligations/checkpoints\n",
record.slug,
record.status,
record.invocation.invoked_at_ms,
record.invocation.source,
record.body_snapshot_policy,
record.task_scope.replace('\n', " "),
));
for checkpoint in &record.checkpoints {
out.push_str(&format!(
"- [{}] {}\n",
checkpoint.status_label(),
checkpoint.label
));
}
out.push_str("\n### Snapshotted workflow guidance\n");
out.push_str(record.guidance_snapshot.trim_end());
out.push_str("\n");
}
out
}
impl WorkflowCheckpoint {
fn status_label(&self) -> &'static str {
match self.status {
WorkflowCheckpointStatus::Open => "open",
WorkflowCheckpointStatus::Done => "done",
WorkflowCheckpointStatus::Cancelled => "cancelled",
}
}
}
fn truncate_chars(text: &str, max_chars: usize) -> String {
let mut out = String::new();
for (idx, ch) in text.chars().enumerate() {
if idx >= max_chars {
out.push('…');
return out;
}
out.push(ch);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn store_with_active_workflow() -> ActiveWorkflowStore {
let store = ActiveWorkflowStore::new();
assert!(store.activate_from_system_items(
&[SystemItem::Workflow {
slug: "multi-agent-workflow".into(),
body: "# Multi-agent workflow\n- Delegate implementation to coder.\n- Require external review before merge.\n- Close the Ticket after merge and report evidence.\n".into(),
}],
"/multi-agent-workflow implement ticket".into(),
42,
));
store
}
fn active_extension(store: &ActiveWorkflowStore) -> (String, serde_json::Value) {
(
DOMAIN.to_string(),
serde_json::to_value(store.snapshot()).expect("snapshot json"),
)
}
#[test]
fn active_workflow_guidance_carries_merge_close_obligations() {
let store = store_with_active_workflow();
let msg = store.rehydration_message().unwrap();
assert!(msg.contains("multi-agent-workflow"));
assert!(msg.contains("external review before merge"));
assert!(msg.contains("Close the Ticket after merge"));
assert!(msg.contains("Snapshotted workflow guidance"));
}
#[test]
fn compacted_rehydration_message_is_removed_when_typed_state_missing_or_invalid() {
for extensions in [
Vec::new(),
vec![(DOMAIN.to_string(), json!({"schema_version":"bad"}))],
vec![(
DOMAIN.to_string(),
json!({"schema_version":999,"workflows":[]}),
)],
] {
let original = store_with_active_workflow();
let stale_message = original.rehydration_message().unwrap();
let mut context = vec![
Item::system_message(stale_message),
Item::user_message("continue"),
];
let restored = ActiveWorkflowStore::new();
restored.restore_from_history_and_extensions(&context, &extensions);
let removed = restored.sanitize_context(&mut context);
assert_eq!(removed, 1);
assert!(restored.active_records().is_empty());
assert!(!context.iter().any(is_rehydration_message));
}
}
#[test]
fn completion_or_cancellation_suppresses_old_compacted_guidance() {
for status in [
ActiveWorkflowStatus::Completed,
ActiveWorkflowStatus::Cancelled,
] {
let store = store_with_active_workflow();
let stale_message = store.rehydration_message().unwrap();
let mut context = vec![
Item::system_message(stale_message),
Item::user_message("continue"),
];
store
.set_status("multi-agent-workflow", status, status.to_string(), 84)
.expect("workflow exists");
let removed = store.sanitize_context(&mut context);
assert_eq!(removed, 1);
assert!(!context.iter().any(is_rehydration_message));
}
}
#[test]
fn unmatched_status_tool_calls_do_not_mutate_restored_state() {
let store = store_with_active_workflow();
let extensions = vec![active_extension(&store)];
let history = vec![
Item::tool_call(
"call-1",
"ActiveWorkflowCancel",
json!({"slug":"multi-agent-workflow","reason":"not durable"}).to_string(),
),
Item::tool_result_error("call-1", "error: failed"),
];
let restored = ActiveWorkflowStore::new();
restored.restore_from_history_and_extensions(&history, &extensions);
assert_eq!(restored.active_records().len(), 1);
assert_eq!(
restored.snapshot().workflows[0].status,
ActiveWorkflowStatus::Active
);
}
#[tokio::test]
async fn status_tool_persists_typed_extension_on_success() {
let store = store_with_active_workflow();
let committed = Arc::new(Mutex::new(Vec::<LogEntry>::new()));
let committed_for_tool = committed.clone();
let tools = active_workflow_tools(
store.clone(),
Some(Arc::new(move |entry| {
committed_for_tool
.lock()
.expect("committed entries mutex poisoned")
.push(entry);
})),
);
let (_, tool) = tools[1]();
tool.execute(
&json!({"slug":"multi-agent-workflow","reason":"review complete"}).to_string(),
ToolExecutionContext::default(),
)
.await
.expect("status tool succeeds");
let committed = committed.lock().expect("committed entries mutex poisoned");
let LogEntry::Extension {
domain, payload, ..
} = committed.last().expect("extension committed")
else {
panic!("expected typed active workflow extension");
};
assert_eq!(domain, DOMAIN);
let snapshot: ActiveWorkflowSnapshot = serde_json::from_value(payload.clone()).unwrap();
assert_eq!(
snapshot.workflows[0].status,
ActiveWorkflowStatus::Completed
);
}
#[test]
fn corrupt_extension_fails_closed_with_diagnostic() {
let entries = vec![(DOMAIN.to_string(), json!({"schema_version":"bad"}))];
let (snapshot, diagnostics) = fold_extensions(&entries);
assert!(snapshot.workflows.is_empty());
assert_eq!(diagnostics.len(), 1);
}
}

View File

@ -108,6 +108,24 @@ impl WorkerHandle {
is_dir: c.is_dir,
})
.collect(),
protocol::CompletionKind::Knowledge => self
.shared_state
.list_knowledge_completions(prefix)
.into_iter()
.map(|c| protocol::CompletionEntry {
value: c.slug,
is_dir: false,
})
.collect(),
protocol::CompletionKind::Workflow => self
.shared_state
.list_workflow_completions(prefix)
.into_iter()
.map(|c| protocol::CompletionEntry {
value: c.slug,
is_dir: false,
})
.collect(),
}
}
@ -322,6 +340,20 @@ impl WorkerController {
if let Some(fs_for_view) = fs_for_view {
shared_state.set_fs_view(crate::fs_view::WorkerFsView::new(fs_for_view));
}
shared_state.set_workflows(
worker
.workflow_completions()
.into_iter()
.map(|slug| crate::shared_state::WorkflowCandidate { slug })
.collect(),
);
shared_state.set_knowledge(
worker
.knowledge_completions()
.into_iter()
.map(|slug| crate::shared_state::KnowledgeCandidate { slug })
.collect(),
);
runtime_dir.write_manifest(&manifest_toml).await?;
runtime_dir.write_status(&shared_state).await?;
@ -736,7 +768,7 @@ where
// Memory tools require both explicit feature exposure and memory storage
// configuration. This keeps resident-memory config separate from the
// model-visible Memory* tool surface.
// model-visible Memory*/Knowledge* tool surface.
if feature_config.memory.enabled {
let mem = memory_config.as_ref().ok_or_else(|| {
std::io::Error::new(
@ -759,7 +791,8 @@ where
worker.register_tool(memory::tool::write_tool(layout.clone()));
worker.register_tool(memory::tool::edit_tool(layout.clone()));
worker.register_tool(memory::tool::delete_tool(layout.clone()));
worker.register_tool(memory::tool::memory_query_tool(layout, query_cfg));
worker.register_tool(memory::tool::memory_query_tool(layout.clone(), query_cfg));
worker.register_tool(memory::tool::knowledge_query_tool(layout, query_cfg));
}
// Worker-orchestration tools (SpawnWorker + the four comm tools) share
@ -1551,6 +1584,7 @@ fn worker_error_code(e: &WorkerError) -> ErrorCode {
_ => ErrorCode::Internal,
},
WorkerError::Provider(_) => ErrorCode::ProviderError,
WorkerError::WorkflowResolve(_) => ErrorCode::InvalidRequest,
_ => ErrorCode::Internal,
}
}

View File

@ -1064,6 +1064,8 @@ fn discovery_error_to_tool_error(error: WorkerDiscoveryError) -> ToolError {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use manifest::{Permission, ScopeRule};
use protocol::stream::JsonLineWriter;
use protocol::{Alert, AlertLevel, AlertSource};
@ -1075,7 +1077,6 @@ mod tests {
use tokio::net::UnixListener;
use crate::runtime::dir::RuntimeDir;
use crate::runtime::worker_allocation::test_util::RuntimeDirSandbox;
#[derive(Clone)]
struct FailTargetPeerStore {
@ -1117,13 +1118,18 @@ mod tests {
}
}
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[tokio::test(flavor = "current_thread")]
async fn state_backed_visibility_and_restore_planning() {
let _env = ENV_LOCK.lock().unwrap();
let root = TempDir::new().unwrap();
let store_dir = root.path().join("store");
let runtime_base = root.path().join("runtime");
std::fs::create_dir_all(&runtime_base).unwrap();
let _runtime_sandbox = RuntimeDirSandbox::new(&runtime_base);
unsafe {
std::env::set_var("YOI_RUNTIME_DIR", &runtime_base);
}
let store = FsWorkerStore::new(&store_dir).unwrap();
let session_id = new_session_id();

View File

@ -1,92 +0,0 @@
//! Reusable runner for Worker-internal LLM jobs.
//!
//! Internal workers are isolated model/tool runs owned by the foreground
//! [`Worker`](crate::Worker). They do not append their harness prompt/input to
//! the foreground session history. Callers provide a deliberately limited tool
//! surface and decide how to apply the result.
use std::sync::{Arc, Mutex};
use llm_engine::llm_client::client::LlmClient;
use llm_engine::llm_client::event::UsageEvent;
use llm_engine::tool::ToolDefinition;
use llm_engine::{Engine, EngineError};
/// Specification for a single internal worker run.
pub(crate) struct InternalWorkerSpec {
/// Stable purpose label for audit/debug/persistence metadata.
pub purpose: &'static str,
/// System prompt for the isolated engine.
pub system_prompt: String,
/// Initial user input for the isolated engine.
pub input: String,
/// Client used by the internal engine.
pub client: Box<dyn LlmClient>,
/// Optional prompt-cache key.
pub cache_key: Option<String>,
/// Optional turn limit for the internal engine.
pub max_turns: Option<u32>,
/// Deliberately limited tool surface for this internal run.
pub tools: Vec<ToolDefinition>,
}
/// Result metadata for an internal worker run.
#[derive(Debug, Clone, Default)]
pub(crate) struct InternalWorkerRunResult {
pub purpose: &'static str,
/// Last usage event observed for this internal run.
pub usage: Option<UsageEvent>,
}
/// Error metadata for an internal worker run.
#[derive(Debug)]
pub(crate) struct InternalWorkerRunError {
pub purpose: &'static str,
pub source: EngineError,
/// Last usage event observed before the failure, if any.
pub usage: Option<UsageEvent>,
}
/// Run an isolated internal worker engine.
pub(crate) async fn run_internal_worker(
spec: InternalWorkerSpec,
) -> Result<InternalWorkerRunResult, InternalWorkerRunError> {
let InternalWorkerSpec {
purpose,
system_prompt,
input,
client,
cache_key,
max_turns,
tools,
} = spec;
let mut worker = Engine::new(client).system_prompt(system_prompt);
worker.set_cache_key(cache_key);
worker.set_max_turns(max_turns);
worker.register_tools(tools);
let usage_capture = Arc::new(Mutex::new(None));
let usage_capture_for_worker = usage_capture.clone();
worker.on_usage(move |event| {
*usage_capture_for_worker
.lock()
.expect("internal worker usage capture poisoned") = Some(event.clone());
});
let run_result = worker.run(input).await;
let usage = usage_capture
.lock()
.expect("internal worker usage capture poisoned")
.clone();
match run_result {
Ok(_output) => Ok(InternalWorkerRunResult { purpose, usage }),
Err(source) => Err(InternalWorkerRunError {
purpose,
source,
usage,
}),
}
}

View File

@ -22,6 +22,7 @@ use llm_engine::tool::ToolOutput;
use tracing::info;
use tracing::warn;
use crate::active_workflow::ActiveWorkflowStore;
use crate::compact::state::CompactState;
use crate::compact::usage_tracker::UsageTracker;
use session_store::SystemItem;
@ -71,6 +72,10 @@ pub(crate) struct WorkerInterceptor {
/// worker. `None` in tests / `Worker::new` paths where no writer is
/// attached.
log_writer: Option<Arc<dyn SystemItemCommitter>>,
/// Active workflow state is durable typed Worker state. The interceptor
/// regenerates request-local workflow guidance from this store and strips
/// any stale compacted-history copies before each model request.
active_workflows: ActiveWorkflowStore,
/// Next turn index assigned by `on_prompt_submit`.
next_turn_index: AtomicUsize,
/// Tool calls observed in the current turn (reset on each new prompt).
@ -86,6 +91,7 @@ impl WorkerInterceptor {
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
prompts: Arc<PromptCatalog>,
log_writer: Option<Arc<dyn SystemItemCommitter>>,
active_workflows: ActiveWorkflowStore,
) -> Self {
Self {
registry,
@ -96,6 +102,7 @@ impl WorkerInterceptor {
pending_attachments,
prompts,
log_writer,
active_workflows,
next_turn_index: AtomicUsize::new(0),
tool_calls_this_turn: AtomicUsize::new(0),
}
@ -234,6 +241,8 @@ impl Interceptor for WorkerInterceptor {
}
async fn pre_llm_request(&self, context: &mut Vec<Item>) -> PreRequestAction {
self.active_workflows.sanitize_context(context);
let initial_tokens = self.estimated_tokens(context);
if self.request_threshold_exceeded(initial_tokens, context) {
return PreRequestAction::Yield;
@ -527,6 +536,7 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
@ -559,6 +569,7 @@ mod tests {
Some(Arc::new(RecordingSystemItemCommitter {
committed: Arc::clone(&committed),
})),
ActiveWorkflowStore::new(),
);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
@ -595,6 +606,7 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
)
.with_usage_tracker(usage_tracker);
let mut ctx = ctx_items;
@ -620,6 +632,7 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
@ -661,6 +674,7 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
@ -688,6 +702,7 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
@ -709,6 +724,7 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let mut ctx: Vec<Item> = Vec::new();
let action = interceptor.pre_llm_request(&mut ctx).await;
@ -737,6 +753,7 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
Some(committer),
ActiveWorkflowStore::new(),
);
let mut ctx: Vec<Item> = Vec::new();
@ -784,6 +801,7 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let mut ctx: Vec<Item> = Vec::new();
@ -841,6 +859,7 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let mut info = task_tool_call_info("TaskList", serde_json::json!({"scope": "all"}));
@ -888,6 +907,7 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let info = task_tool_call_info("TaskList", serde_json::json!({}));
let mut result_info = ToolResultInfo {
@ -937,6 +957,7 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let history = vec![Item::user_message("hi"), Item::assistant_message("done")];
@ -971,6 +992,7 @@ mod tests {
Some(Arc::new(RecordingSystemItemCommitter {
committed: Arc::clone(&committed),
})),
ActiveWorkflowStore::new(),
)
.with_usage_tracker(Arc::clone(&usage_tracker));
@ -1030,6 +1052,7 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let items = interceptor.pending_history_appends().await;
@ -1067,6 +1090,7 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let mut ctx: Vec<Item> = vec![Item::user_message("hi")];
let action = interceptor.pre_llm_request(&mut ctx).await;
@ -1097,6 +1121,7 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let mut ctx: Vec<Item> = Vec::new();
let action = interceptor.pre_llm_request(&mut ctx).await;

View File

@ -1,3 +1,4 @@
pub mod active_workflow;
pub mod compact;
pub mod controller;
pub mod discovery;
@ -13,10 +14,9 @@ pub mod runtime;
pub mod segment_log_sink;
pub mod shared_state;
mod shutdown_after_idle;
pub mod skill;
pub mod spawn;
pub mod workflow;
mod internal_worker;
mod interrupt_prep;
mod permission;
mod ticket_event_notify;

View File

@ -83,6 +83,15 @@ pub enum WorkerPrompt {
/// AGENTS.md section when memory is enabled, summary injection is enabled,
/// and `memory/summary.md` has a valid non-empty body.
ResidentMemorySummarySection,
/// Trailing `## Resident knowledge` section, appended after the
/// resident memory summary when memory is enabled, Knowledge resident
/// injection is enabled, and at least one `knowledge/*` record advertises
/// `model_invokation: true`.
ResidentKnowledgeSection,
/// Trailing `## Resident workflows` section, appended after resident
/// knowledge when Workflow resident injection is enabled and at least one
/// workflow advertises `model_invokation: true`.
ResidentWorkflowsSection,
/// Trailing Worker orchestration guidance, appended when registered tools
/// include Worker-management capabilities.
WorkerOrchestrationGuidanceSection,
@ -105,6 +114,8 @@ impl WorkerPrompt {
Self::WorkingBoundariesSection => "working_boundaries_section",
Self::AgentsMdSection => "agents_md_section",
Self::ResidentMemorySummarySection => "resident_memory_summary_section",
Self::ResidentKnowledgeSection => "resident_knowledge_section",
Self::ResidentWorkflowsSection => "resident_workflows_section",
Self::WorkerOrchestrationGuidanceSection => "worker_orchestration_guidance_section",
Self::TicketEventCompanionNotice => "ticket_event_companion_notice",
Self::SpawnWorkerToolDescription => "spawn_worker_tool_description",
@ -124,6 +135,8 @@ impl WorkerPrompt {
WorkerPrompt::WorkingBoundariesSection,
WorkerPrompt::AgentsMdSection,
WorkerPrompt::ResidentMemorySummarySection,
WorkerPrompt::ResidentKnowledgeSection,
WorkerPrompt::ResidentWorkflowsSection,
WorkerPrompt::WorkerOrchestrationGuidanceSection,
WorkerPrompt::TicketEventCompanionNotice,
WorkerPrompt::SpawnWorkerToolDescription,
@ -139,6 +152,8 @@ impl WorkerPrompt {
"working_boundaries_section",
"agents_md_section",
"resident_memory_summary_section",
"resident_knowledge_section",
"resident_workflows_section",
"worker_orchestration_guidance_section",
"ticket_event_companion_notice",
"spawn_worker_tool_description",
@ -376,6 +391,34 @@ impl PromptCatalog {
)
}
/// Render `WorkerPrompt::ResidentKnowledgeSection` with `{{ entries }}`
/// (a pre-formatted list block authored by the caller).
pub fn resident_knowledge_section(
&self,
entries: &str,
knowledge_query_available: bool,
memory_read_available: bool,
) -> Result<String, CatalogError> {
use std::collections::BTreeMap;
let mut m: BTreeMap<&'static str, Value> = BTreeMap::new();
m.insert("entries", Value::from(entries));
m.insert(
"knowledge_query_available",
Value::from(knowledge_query_available),
);
m.insert("memory_read_available", Value::from(memory_read_available));
self.render(WorkerPrompt::ResidentKnowledgeSection, Value::from(m))
}
/// Render `WorkerPrompt::ResidentWorkflowsSection` with `{{ entries }}`
/// (a pre-formatted list block authored by the caller).
pub fn resident_workflows_section(&self, entries: &str) -> Result<String, CatalogError> {
self.render(
WorkerPrompt::ResidentWorkflowsSection,
single("entries", entries),
)
}
/// Render `WorkerPrompt::WorkerOrchestrationGuidanceSection` (no inputs).
pub fn worker_orchestration_guidance_section(&self) -> Result<String, CatalogError> {
self.render(
@ -527,6 +570,7 @@ mod tests {
let extract = cat.memory_extract_system("Japanese").unwrap();
let consolidate = cat.memory_consolidation_system("Japanese").unwrap();
for rendered in [compact, extract, consolidate] {
assert!(!rendered.contains("### Memory and knowledge"));
assert!(!rendered.contains("Do not query memory every turn"));
assert!(!rendered.contains("Strong lookup triggers include"));
}
@ -706,7 +750,7 @@ compact_system = "PREFIX\n{% include \"$yoi/internal/compact_system\" %}"
assert!(rendered.contains("Do not use `sleep` or polling loops"));
assert!(rendered.contains("worktree state, diff, and test results"));
assert!(rendered.contains("not scheduler or auto-maintain authorization"));
assert!(rendered.contains("bypass user/Ticket authorization"));
assert!(rendered.contains("bypass user/workflow authorization"));
}
#[test]

View File

@ -21,9 +21,11 @@ use std::sync::Arc;
use chrono::{DateTime, SecondsFormat, Utc};
use manifest::Scope;
use memory::ResidentKnowledgeEntry;
use minijinja::value::Value;
use minijinja::{Environment, ErrorKind, UndefinedBehavior};
use thiserror::Error;
use workflow_crate::ResidentWorkflowEntry;
use crate::prompt::catalog::{CatalogError, PromptCatalog};
use crate::prompt::loader::{LoaderError, PromptLoader, PromptRef};
@ -124,6 +126,8 @@ impl SystemPromptTemplate {
ctx.scope,
ctx.agents_md.as_deref(),
ctx.resident_summary,
ctx.resident_knowledge,
ctx.resident_workflows,
ToolCapabilities::from_tool_names(&ctx.tool_names),
)
}
@ -157,6 +161,15 @@ pub struct SystemPromptContext<'a> {
/// frontmatter stripped. `None` disables the resident summary section;
/// empty strings are ignored by the trailing-section formatter.
pub resident_summary: Option<&'a str>,
/// Resident-injection candidates from `<workspace>/knowledge/*` whose
/// frontmatter has `model_invokation: true`. `None` disables the
/// section entirely (memory disabled, or a consolidation worker that opts
/// out); `Some(&[])` also yields no section.
pub resident_knowledge: Option<&'a [ResidentKnowledgeEntry]>,
/// Resident workflow descriptions from `<workspace>/.yoi/workflow/*`
/// whose frontmatter has `model_invokation: true`. `None` disables the
/// section; consolidation workers opt out together with resident Knowledge.
pub resident_workflows: Option<&'a [ResidentWorkflowEntry]>,
/// Catalog used to render the fixed trailing section headers.
/// Passed by reference so callers do not give up ownership across
/// the short-lived render borrow.
@ -201,6 +214,7 @@ impl<'a> SystemPromptContext<'a> {
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
struct ToolCapabilities {
memory_query: bool,
knowledge_query: bool,
memory_read: bool,
memory_write: bool,
memory_edit: bool,
@ -219,6 +233,7 @@ impl ToolCapabilities {
for name in names {
match name.as_str() {
"MemoryQuery" => capabilities.memory_query = true,
"KnowledgeQuery" => capabilities.knowledge_query = true,
"MemoryRead" => capabilities.memory_read = true,
"MemoryWrite" => capabilities.memory_write = true,
"MemoryEdit" => capabilities.memory_edit = true,
@ -244,7 +259,7 @@ impl ToolCapabilities {
}
fn memory_any(self) -> bool {
self.memory_records()
self.memory_records() || self.knowledge_query
}
fn memory_mutation(self) -> bool {
@ -265,6 +280,7 @@ impl ToolCapabilities {
map.insert("memory_any", Value::from(self.memory_any()));
map.insert("memory_records", Value::from(self.memory_records()));
map.insert("memory_query", Value::from(self.memory_query));
map.insert("knowledge_query", Value::from(self.knowledge_query));
map.insert("memory_read", Value::from(self.memory_read));
map.insert("memory_write", Value::from(self.memory_write));
map.insert("memory_edit", Value::from(self.memory_edit));
@ -287,6 +303,8 @@ fn append_trailing_section(
scope: &Scope,
agents_md: Option<&str>,
resident_summary: Option<&str>,
resident_knowledge: Option<&[ResidentKnowledgeEntry]>,
resident_workflows: Option<&[ResidentWorkflowEntry]>,
tool_capabilities: ToolCapabilities,
) -> Result<String, SystemPromptError> {
let mut out = String::with_capacity(body.len() + 256);
@ -314,6 +332,28 @@ fn append_trailing_section(
out.push('\n');
}
}
if let Some(entries) = resident_knowledge {
if !entries.is_empty() {
out.push('\n');
let formatted = format_resident_knowledge_entries(entries);
let section = prompts.resident_knowledge_section(
&formatted,
tool_capabilities.knowledge_query,
tool_capabilities.memory_read,
)?;
out.push_str(section.trim_end_matches(&['\n', ' '][..]));
out.push('\n');
}
}
if let Some(entries) = resident_workflows {
if !entries.is_empty() {
out.push('\n');
let formatted = format_resident_workflow_entries(entries);
let section = prompts.resident_workflows_section(&formatted)?;
out.push_str(section.trim_end_matches(&['\n', ' '][..]));
out.push('\n');
}
}
if tool_capabilities.worker_management() {
out.push('\n');
let section = prompts.worker_orchestration_guidance_section()?;
@ -328,6 +368,44 @@ fn append_trailing_section(
Ok(out)
}
/// `- <slug>: <description>` per line. Description newlines are folded
/// to spaces so a single entry stays on one row in the rendered prompt.
fn format_resident_knowledge_entries(entries: &[ResidentKnowledgeEntry]) -> String {
format_resident_entries(
entries
.iter()
.map(|e| (e.slug.as_str(), e.description.as_str())),
)
}
fn format_resident_workflow_entries(entries: &[ResidentWorkflowEntry]) -> String {
format_resident_entries(
entries
.iter()
.map(|e| (e.slug.as_str(), e.description.as_str())),
)
}
fn format_resident_entries<'a>(entries: impl Iterator<Item = (&'a str, &'a str)>) -> String {
let mut out = String::new();
for (i, (slug, description)) in entries.enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str("- ");
out.push_str(slug);
out.push_str(": ");
for ch in description.chars() {
if ch == '\n' || ch == '\r' {
out.push(' ');
} else {
out.push(ch);
}
}
}
out
}
/// Bridge used by [`Worker::ensure_system_prompt_materialized`] so tests
/// can construct a synthetic context without going through a full Worker.
#[doc(hidden)]
@ -372,6 +450,8 @@ mod tests {
tool_names: tools,
agents_md,
resident_summary: None,
resident_knowledge: None,
resident_workflows: None,
prompts: test_prompts(),
}
}
@ -389,11 +469,17 @@ mod tests {
tool_names: Vec::new(),
agents_md: None,
resident_summary: summary,
resident_knowledge: None,
resident_workflows: None,
prompts: test_prompts(),
}
}
fn ctx_with_resident<'a>(cwd: &'a Path, scope: &'a Scope) -> SystemPromptContext<'a> {
fn ctx_with_resident<'a>(
cwd: &'a Path,
scope: &'a Scope,
resident: &'a [ResidentKnowledgeEntry],
) -> SystemPromptContext<'a> {
SystemPromptContext {
now: fixed_now(),
cwd: cwd.display().to_string().into(),
@ -402,6 +488,27 @@ mod tests {
tool_names: Vec::new(),
agents_md: None,
resident_summary: None,
resident_knowledge: Some(resident),
resident_workflows: None,
prompts: test_prompts(),
}
}
fn ctx_with_resident_workflows<'a>(
cwd: &'a Path,
scope: &'a Scope,
resident: &'a [ResidentWorkflowEntry],
) -> SystemPromptContext<'a> {
SystemPromptContext {
now: fixed_now(),
cwd: cwd.display().to_string().into(),
language: manifest::defaults::WORKER_LANGUAGE,
scope,
tool_names: Vec::new(),
agents_md: None,
resident_summary: None,
resident_knowledge: None,
resident_workflows: Some(resident),
prompts: test_prompts(),
}
}
@ -409,6 +516,7 @@ mod tests {
fn memory_tool_names() -> Vec<String> {
[
"MemoryQuery",
"KnowledgeQuery",
"MemoryRead",
"MemoryWrite",
"MemoryEdit",
@ -460,8 +568,8 @@ mod tests {
.render(&ctx(dir.path(), &scope, memory_tool_names(), None))
.unwrap();
// Builtin default body must expose the tool and language policies.
assert!(rendered.contains("### Memory"));
assert!(rendered.contains("small targeted `MemoryQuery`"));
assert!(rendered.contains("### Memory and knowledge"));
assert!(rendered.contains("small targeted `MemoryQuery` / `KnowledgeQuery`"));
assert!(rendered.contains("Strong lookup triggers include"));
assert!(rendered.contains("MemoryRead(kind=summary)"));
assert!(rendered.contains("Do not query memory every turn"));
@ -488,8 +596,9 @@ mod tests {
))
.unwrap();
assert!(!rendered.contains("### Memory"));
assert!(!rendered.contains("### Memory and knowledge"));
assert!(!rendered.contains("MemoryQuery"));
assert!(!rendered.contains("KnowledgeQuery"));
assert!(!rendered.contains("MemoryRead"));
assert!(!rendered.contains("MemoryWrite"));
assert!(!rendered.contains("MemoryEdit"));
@ -513,9 +622,10 @@ mod tests {
))
.unwrap();
assert!(rendered.contains("### Memory"));
assert!(rendered.contains("### Memory and knowledge"));
assert!(rendered.contains("small targeted `MemoryQuery`"));
assert!(rendered.contains("MemoryRead(kind=summary)"));
assert!(!rendered.contains("KnowledgeQuery"));
assert!(!rendered.contains("MemoryWrite"));
assert!(!rendered.contains("MemoryEdit"));
assert!(!rendered.contains("MemoryDelete"));
@ -542,7 +652,7 @@ mod tests {
assert!(rendered.contains("Do not use `sleep` or polling loops"));
assert!(rendered.contains("worktree state, diff, and test results"));
assert!(rendered.contains("not scheduler or auto-maintain authorization"));
assert!(rendered.contains("bypass user/Ticket authorization"));
assert!(rendered.contains("bypass user/workflow authorization"));
}
#[test]
@ -774,4 +884,110 @@ mod tests {
.unwrap();
assert!(!rendered.contains("Resident memory summary"));
}
#[test]
fn trailing_section_omits_resident_knowledge_when_none() {
let (_tmp, loader) = user_loader_with("body.md", "BODY");
let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap();
let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path());
let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap();
assert!(!rendered.contains("Resident knowledge"));
}
#[test]
fn trailing_section_omits_resident_knowledge_when_empty_slice() {
let (_tmp, loader) = user_loader_with("body.md", "BODY");
let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap();
let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path());
let rendered = tmpl
.render(&ctx_with_resident(dir.path(), &scope, &[]))
.unwrap();
assert!(!rendered.contains("Resident knowledge"));
}
#[test]
fn trailing_section_renders_resident_knowledge_entries() {
let (_tmp, loader) = user_loader_with("body.md", "BODY");
let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap();
let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path());
let entries = vec![
ResidentKnowledgeEntry {
slug: "alpha".into(),
description: "first record".into(),
},
ResidentKnowledgeEntry {
slug: "beta".into(),
description: "second record\nwith newline".into(),
},
];
let rendered = tmpl
.render(&ctx_with_resident(dir.path(), &scope, &entries))
.unwrap();
assert!(rendered.contains("## Resident knowledge"));
assert!(rendered.contains("- alpha: first record"));
// Newline in description is folded to a space (one entry per line).
assert!(rendered.contains("- beta: second record with newline"));
assert!(!rendered.contains("KnowledgeQuery"));
assert!(!rendered.contains("MemoryRead"));
// Resident section sits *after* the working-boundaries header.
let pos_boundaries = rendered.find("## Working boundaries").unwrap();
let pos_resident = rendered.find("## Resident knowledge").unwrap();
assert!(pos_resident > pos_boundaries);
}
#[test]
fn trailing_section_mentions_resident_knowledge_tools_when_available() {
let (_tmp, loader) = user_loader_with("body.md", "BODY");
let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap();
let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path());
let entries = [ResidentKnowledgeEntry {
slug: "alpha".into(),
description: "first record".into(),
}];
let mut context = ctx_with_resident(dir.path(), &scope, &entries);
context.tool_names = memory_tool_names();
let rendered = tmpl.render(&context).unwrap();
assert!(rendered.contains("## Resident knowledge"));
assert!(rendered.contains("KnowledgeQuery / MemoryRead"));
}
#[test]
fn trailing_section_renders_resident_workflows() {
let (_tmp, loader) = user_loader_with("body.md", "BODY");
let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap();
let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path());
let workflows = [ResidentWorkflowEntry {
slug: "resident-flow".to_string(),
description: "workflow resident desc\nwith newline".to_string(),
}];
let rendered = tmpl
.render(&ctx_with_resident_workflows(dir.path(), &scope, &workflows))
.unwrap();
assert!(rendered.contains("## Resident workflows"));
assert!(rendered.contains("- resident-flow: workflow resident desc with newline"));
let pos_boundaries = rendered.find("## Working boundaries").unwrap();
let pos_resident = rendered.find("## Resident workflows").unwrap();
assert!(pos_resident > pos_boundaries);
}
#[test]
fn trailing_section_omits_empty_resident_workflows() {
let (_tmp, loader) = user_loader_with("body.md", "BODY");
let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap();
let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path());
let workflows: [ResidentWorkflowEntry; 0] = [];
let rendered = tmpl
.render(&ctx_with_resident_workflows(dir.path(), &scope, &workflows))
.unwrap();
assert!(!rendered.contains("Resident workflows"));
}
}

View File

@ -12,7 +12,7 @@ mod mutate;
mod table;
#[cfg(test)]
pub(crate) mod test_util;
mod test_util;
pub use conflict::{
ConflictOwner, find_conflict_owner, find_conflict_owners, is_within_effective_write,

View File

@ -6,6 +6,16 @@ use session_store::SegmentId;
use crate::fs_view::WorkerFsView;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkflowCandidate {
pub slug: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KnowledgeCandidate {
pub slug: String,
}
/// Shared state between WorkerController and runtime directory.
///
/// Controller updates this in-memory; RuntimeDir writes the status
@ -14,8 +24,8 @@ use crate::fs_view::WorkerFsView;
/// History and typed user-segment mirrors used to live here so the
/// IPC layer could answer `Method::GetHistory`. Those reads now go
/// directly through the session-log sink (`Event::Snapshot` +
/// live events), so this struct holds only status, identity,
/// greeting, and filesystem completion lookup hubs.
/// `Event::Entry`), so this struct holds only status, identity,
/// greeting, and completion lookup hubs.
pub struct WorkerSharedState {
pub worker_name: String,
pub segment_id: SegmentId,
@ -29,6 +39,8 @@ pub struct WorkerSharedState {
/// (only relevant for unit tests that build a `WorkerSharedState`
/// directly without spinning up a controller).
fs_view: OnceLock<WorkerFsView>,
workflows: OnceLock<Vec<WorkflowCandidate>>,
knowledge: OnceLock<Vec<KnowledgeCandidate>>,
}
impl WorkerSharedState {
@ -45,6 +57,8 @@ impl WorkerSharedState {
greeting,
status: RwLock::new(WorkerStatus::Idle),
fs_view: OnceLock::new(),
workflows: OnceLock::new(),
knowledge: OnceLock::new(),
}
}
@ -60,6 +74,40 @@ impl WorkerSharedState {
self.fs_view.get()
}
pub fn set_workflows(&self, workflows: Vec<WorkflowCandidate>) {
let _ = self.workflows.set(workflows);
}
pub fn list_workflow_completions(&self, prefix: &str) -> Vec<WorkflowCandidate> {
self.workflows
.get()
.map(|items| {
items
.iter()
.filter(|candidate| candidate.slug.starts_with(prefix))
.cloned()
.collect()
})
.unwrap_or_default()
}
pub fn set_knowledge(&self, knowledge: Vec<KnowledgeCandidate>) {
let _ = self.knowledge.set(knowledge);
}
pub fn list_knowledge_completions(&self, prefix: &str) -> Vec<KnowledgeCandidate> {
self.knowledge
.get()
.map(|items| {
items
.iter()
.filter(|candidate| candidate.slug.starts_with(prefix))
.cloned()
.collect()
})
.unwrap_or_default()
}
pub fn set_status(&self, status: WorkerStatus) {
if let Ok(mut s) = self.status.write() {
*s = status;
@ -141,4 +189,35 @@ mod tests {
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["state"], "running");
}
#[test]
fn knowledge_completions_empty_when_unset() {
let state = test_state();
assert!(state.list_knowledge_completions("").is_empty());
assert!(state.list_knowledge_completions("foo").is_empty());
}
#[test]
fn knowledge_completions_filter_by_prefix() {
let state = test_state();
state.set_knowledge(vec![
KnowledgeCandidate {
slug: "alpha".into(),
},
KnowledgeCandidate {
slug: "alphabet".into(),
},
KnowledgeCandidate {
slug: "beta".into(),
},
]);
let all = state.list_knowledge_completions("");
assert_eq!(all.len(), 3);
let alpha = state.list_knowledge_completions("alpha");
assert_eq!(
alpha.iter().map(|c| c.slug.as_str()).collect::<Vec<_>>(),
vec!["alpha", "alphabet"]
);
assert!(state.list_knowledge_completions("zzz").is_empty());
}
}

View File

@ -1,238 +0,0 @@
use serde::{Deserialize, Serialize};
use crate::worker::WorkspaceClient;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SkillDiagnosticSeverity {
Error,
Warning,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillDiagnostic {
pub severity: SkillDiagnosticSeverity,
pub code: String,
pub message: String,
/// Path-free authority/provenance label such as `builtin:foo` or `workspace:foo`.
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
}
impl SkillDiagnostic {
pub fn error(
code: impl Into<String>,
message: impl Into<String>,
source: Option<String>,
) -> Self {
Self {
severity: SkillDiagnosticSeverity::Error,
code: code.into(),
message: message.into(),
source,
}
}
pub fn warning(
code: impl Into<String>,
message: impl Into<String>,
source: Option<String>,
) -> Self {
Self {
severity: SkillDiagnosticSeverity::Warning,
code: code.into(),
message: message.into(),
source,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SkillSourceKind {
Builtin,
Workspace,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillProvenance {
pub kind: SkillSourceKind,
/// Stable path-free id: `builtin:<name>` or `workspace:<name>`.
pub id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillResourceRef {
pub kind: String,
/// Skill-relative resource name/path. Never an absolute filesystem path.
pub name: String,
pub supported: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub diagnostic: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillCatalogEntry {
pub name: String,
pub description: String,
pub provenance: SkillProvenance,
#[serde(default)]
pub overrides: Vec<SkillProvenance>,
#[serde(default)]
pub diagnostics: Vec<SkillDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillCatalogResponse {
/// Authority label for diagnostics; callers must not interpret it as a path.
pub authority: String,
#[serde(default)]
pub entries: Vec<SkillCatalogEntry>,
#[serde(default)]
pub diagnostics: Vec<SkillDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillDetailResponse {
pub name: String,
pub description: String,
pub provenance: SkillProvenance,
#[serde(default)]
pub overrides: Vec<SkillProvenance>,
#[serde(default)]
pub diagnostics: Vec<SkillDiagnostic>,
/// Full SKILL.md contents. This is intentionally omitted from catalog responses.
pub body: String,
#[serde(default)]
pub allowed_tools: Vec<String>,
/// Explicitly documents that allowed-tools is parsed only as an experimental hint.
pub allowed_tools_status: String,
#[serde(default)]
pub resources: Vec<SkillResourceRef>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillActivationResponse {
pub name: String,
pub provenance: SkillProvenance,
#[serde(default)]
pub diagnostics: Vec<SkillDiagnostic>,
/// Full SKILL.md contents to append to Worker history on explicit activation.
pub body: String,
}
#[derive(Debug, thiserror::Error)]
pub enum SkillClientError {
#[error("workspace client is unavailable: {0}")]
Unavailable(String),
#[error("workspace client kind `{0}` does not expose direct Skill HTTP operations")]
UnsupportedClient(String),
#[error("Skill request failed: {0}")]
Request(#[from] reqwest::Error),
#[error("Skill API response JSON is invalid: {0}")]
Json(#[from] serde_json::Error),
#[error("Skill API returned HTTP {status}: {body}")]
Http {
status: reqwest::StatusCode,
body: String,
},
#[error("invalid Skill API base URL: {0}")]
InvalidBaseUrl(String),
}
impl WorkspaceClient {
pub fn list_skills(&self) -> Result<SkillCatalogResponse, SkillClientError> {
self.get_skill_json("skills")
}
pub fn read_skill(&self, name: &str) -> Result<SkillDetailResponse, SkillClientError> {
self.get_skill_json(&format!("skills/{name}"))
}
pub fn activate_skill(&self, name: &str) -> Result<SkillActivationResponse, SkillClientError> {
self.get_skill_json(&format!("skills/{name}/activate"))
}
fn get_skill_json<T: for<'de> Deserialize<'de>>(
&self,
path: &str,
) -> Result<T, SkillClientError> {
let Self::Http {
workspace_id,
base_url,
} = self
else {
return match self {
Self::Available { kind } => Err(SkillClientError::UnsupportedClient(kind.clone())),
Self::Unavailable { reason } => Err(SkillClientError::Unavailable(reason.clone())),
Self::Http { .. } => unreachable!(),
};
};
if base_url.trim().is_empty() {
return Err(SkillClientError::InvalidBaseUrl(base_url.clone()));
}
let base = base_url.trim_end_matches('/');
let url = format!("{base}/api/w/{workspace_id}/{path}");
let response = reqwest::blocking::Client::new().get(url).send()?;
let status = response.status();
let body = response.text()?;
if !status.is_success() {
return Err(SkillClientError::Http { status, body });
}
Ok(serde_json::from_str(&body)?)
}
}
#[cfg(test)]
mod tests {
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::thread;
use super::*;
#[test]
fn http_workspace_client_fetches_skill_catalog_from_backend_endpoint() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut request_line = String::new();
reader.read_line(&mut request_line).unwrap();
assert!(request_line.starts_with("GET /api/w/ws-1/skills HTTP/1.1"));
loop {
let mut line = String::new();
reader.read_line(&mut line).unwrap();
if line == "\r\n" || line.is_empty() {
break;
}
}
let body = serde_json::json!({
"authority": "workspace-backend-skills-v0",
"entries": [{
"name": "triage-errors",
"description": "Use when triaging errors.",
"provenance": { "kind": "workspace", "id": "workspace:triage-errors" },
"overrides": [],
"diagnostics": []
}],
"diagnostics": []
})
.to_string();
write!(
stream,
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
body.len(),
body
)
.unwrap();
});
let client = WorkspaceClient::http("ws-1", format!("http://{addr}"));
let catalog = client.list_skills().unwrap();
assert_eq!(catalog.entries[0].name, "triage-errors");
assert_eq!(catalog.entries[0].provenance.id, "workspace:triage-errors");
handle.join().unwrap();
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,261 @@
//! Worker-side Workflow resolver.
//!
//! Turns `Segment::WorkflowInvoke { slug }` into system-message attachments:
//! dependency Knowledge bodies first, then the Workflow body. Resolution is
//! strict for explicit user invocations: missing workflows, non-user-invocable
//! workflows, and missing Knowledge requirements are returned as errors before
//! the turn is handed to the Engine.
use std::fmt;
use llm_engine::Item;
use memory::WorkspaceLayout;
use memory::schema::split_frontmatter;
use workflow_crate::{Slug, WorkflowRegistry};
#[derive(Debug)]
pub enum WorkflowResolveError {
InvalidSlug(workflow_crate::WorkflowLintError),
NotFound {
slug: String,
},
NotUserInvocable {
slug: String,
},
KnowledgeNotFound {
workflow: String,
slug: String,
},
KnowledgeRead {
workflow: String,
slug: String,
source: std::io::Error,
},
KnowledgeFrontmatter {
workflow: String,
slug: String,
source: memory::LintError,
},
}
impl fmt::Display for WorkflowResolveError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidSlug(e) => write!(f, "invalid workflow slug: {e}"),
Self::NotFound { slug } => write!(f, "workflow /{slug} is not registered"),
Self::NotUserInvocable { slug } => {
write!(f, "workflow /{slug} is not user-invocable")
}
Self::KnowledgeNotFound { workflow, slug } => write!(
f,
"workflow /{workflow} requires missing Knowledge slug `{slug}`"
),
Self::KnowledgeRead {
workflow,
slug,
source,
} => write!(
f,
"workflow /{workflow} could not read required Knowledge `{slug}`: {source}"
),
Self::KnowledgeFrontmatter {
workflow,
slug,
source,
} => write!(
f,
"workflow /{workflow} required Knowledge `{slug}` has invalid frontmatter: {source}"
),
}
}
}
impl std::error::Error for WorkflowResolveError {}
struct BuiltinKnowledgeResource {
slug: &'static str,
content: &'static str,
}
const BUILTIN_KNOWLEDGE: &[BuiltinKnowledgeResource] = &[BuiltinKnowledgeResource {
slug: "workflow-resource-boundary",
content: include_str!("../../../../resources/knowledge/workflow-resource-boundary.md"),
}];
fn builtin_knowledge(slug: &Slug) -> Option<&'static str> {
BUILTIN_KNOWLEDGE
.iter()
.find(|resource| resource.slug == slug.as_str())
.map(|resource| resource.content)
}
fn read_required_knowledge(
workflow: &Slug,
layout: &WorkspaceLayout,
req: &Slug,
) -> Result<(String, &'static str), WorkflowResolveError> {
let path = layout.knowledge_dir().join(format!("{req}.md"));
match std::fs::read_to_string(&path) {
Ok(raw) => Ok((raw, "workspace")),
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
if let Some(raw) = builtin_knowledge(req) {
Ok((raw.to_string(), "builtin"))
} else {
Err(WorkflowResolveError::KnowledgeNotFound {
workflow: workflow.to_string(),
slug: req.to_string(),
})
}
}
Err(source) => Err(WorkflowResolveError::KnowledgeRead {
workflow: workflow.to_string(),
slug: req.to_string(),
source,
}),
}
}
pub fn resolve_workflow_invocation(
registry: &WorkflowRegistry,
layout: &WorkspaceLayout,
raw_slug: &str,
) -> Result<Vec<Item>, WorkflowResolveError> {
let slug = Slug::parse(raw_slug.to_string())
.map_err(|source| WorkflowResolveError::InvalidSlug(source.into()))?;
let record = registry
.get(&slug)
.ok_or_else(|| WorkflowResolveError::NotFound {
slug: raw_slug.to_string(),
})?;
if !record.user_invocable {
return Err(WorkflowResolveError::NotUserInvocable {
slug: raw_slug.to_string(),
});
}
let mut out = Vec::new();
for req in &record.requires {
let (raw, knowledge_source) = read_required_knowledge(&slug, layout, req)?;
let (_yaml, body) = split_frontmatter(&raw).map_err(|source| {
WorkflowResolveError::KnowledgeFrontmatter {
workflow: slug.to_string(),
slug: req.to_string(),
source,
}
})?;
out.push(Item::system_message(format!(
"[Workflow /{} requires Knowledge #{} from {}]\n{}",
slug,
req,
knowledge_source,
body.trim_end()
)));
}
out.push(Item::system_message(format!(
"[Workflow /{} from {}]\n{}",
slug,
record.source.label(),
record.body.trim_end()
)));
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn write(path: &std::path::Path, content: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, content).unwrap();
}
fn setup() -> (TempDir, WorkspaceLayout, WorkflowRegistry) {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
write(
&dir.path().join(".yoi/knowledge/policy.md"),
"---\ncreated_at: 2026-01-01T00:00:00Z\nupdated_at: 2026-01-01T00:00:00Z\nkind: policy\ndescription: p\nmodel_invokation: false\nuser_invocable: true\nlast_sources: []\n---\npolicy body\n",
);
write(
&dir.path().join(".yoi/workflow/run-it.md"),
"---\ndescription: run\nrequires: [policy]\n---\nworkflow body\n",
);
let registry = workflow_crate::load_workflows(&layout).unwrap();
(dir, layout, registry)
}
#[test]
fn resolves_requires_before_workflow_body() {
let (_dir, layout, registry) = setup();
let items = resolve_workflow_invocation(&registry, &layout, "run-it").unwrap();
assert_eq!(items.len(), 2);
let first = format!("{:?}", items[0]);
let second = format!("{:?}", items[1]);
assert!(first.contains("Knowledge #policy"));
assert!(first.contains("policy body"));
assert!(second.contains("[Workflow /run-it from workspace workflow]"));
assert!(second.contains("workflow body"));
}
#[test]
fn builtin_workflow_uses_builtin_required_knowledge_when_workspace_missing() {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
let registry = workflow_crate::load_workflows(&layout).unwrap();
let items =
resolve_workflow_invocation(&registry, &layout, "ticket-intake-workflow").unwrap();
let first = format!("{:?}", items[0]);
let second = format!("{:?}", items[1]);
assert!(first.contains("Knowledge #workflow-resource-boundary from builtin"));
assert!(first.contains("Builtin workflow resources live under"));
assert!(second.contains("[Workflow /ticket-intake-workflow from builtin workflow]"));
}
#[test]
fn workspace_knowledge_overrides_builtin_required_knowledge() {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
write(
&dir.path()
.join(".yoi/knowledge/workflow-resource-boundary.md"),
"---\ncreated_at: 2026-01-01T00:00:00Z\nupdated_at: 2026-01-01T00:00:00Z\nkind: policy\ndescription: p\nmodel_invokation: false\nuser_invocable: true\nlast_sources: []\n---\nworkspace override knowledge\n",
);
let registry = workflow_crate::load_workflows(&layout).unwrap();
let items =
resolve_workflow_invocation(&registry, &layout, "ticket-intake-workflow").unwrap();
let first = format!("{:?}", items[0]);
assert!(first.contains("Knowledge #workflow-resource-boundary from workspace"));
assert!(first.contains("workspace override knowledge"));
}
#[test]
fn user_invocable_false_errors() {
let (dir, layout, _registry) = setup();
write(
&dir.path().join(".yoi/workflow/hidden.md"),
"---\ndescription: hidden\nuser_invocable: false\n---\nbody\n",
);
let registry = workflow_crate::load_workflows(&layout).unwrap();
let err = resolve_workflow_invocation(&registry, &layout, "hidden").unwrap_err();
assert!(matches!(err, WorkflowResolveError::NotUserInvocable { .. }));
}
#[test]
fn missing_required_knowledge_errors() {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
write(
&dir.path().join(".yoi/workflow/bad.md"),
"---\ndescription: bad\nrequires: [ghost]\n---\nbody\n",
);
let registry = workflow_crate::load_workflows(&layout).unwrap();
let err = resolve_workflow_invocation(&registry, &layout, "bad").unwrap_err();
assert!(matches!(
err,
WorkflowResolveError::KnowledgeNotFound { .. }
));
}
}

Some files were not shown because too many files have changed in this diff Show More