merge: sync orchestration before queue 00001KXNYXNM6
This commit is contained in:
commit
a9c86297aa
|
|
@ -1,8 +1,8 @@
|
||||||
---
|
---
|
||||||
title: 'Remove workflow tracking and workflow resources'
|
title: 'Remove workflow tracking and workflow resources'
|
||||||
state: 'inprogress'
|
state: 'closed'
|
||||||
created_at: '2026-07-15T19:02:13Z'
|
created_at: '2026-07-15T19:02:13Z'
|
||||||
updated_at: '2026-07-15T21:25:42Z'
|
updated_at: '2026-07-15T21:39:23Z'
|
||||||
assignee: null
|
assignee: null
|
||||||
queued_by: 'workspace-panel'
|
queued_by: 'workspace-panel'
|
||||||
queued_at: '2026-07-15T19:54:36Z'
|
queued_at: '2026-07-15T19:54:36Z'
|
||||||
|
|
|
||||||
41
.yoi/tickets/00001KXKJGYGD/resolution.md
Normal file
41
.yoi/tickets/00001KXKJGYGD/resolution.md
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
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。
|
||||||
|
|
@ -327,3 +327,160 @@ Required fix:
|
||||||
- Update the test fixture to use a non-legacy active SystemItem kind or otherwise align it with workflow ignore semantics。
|
- 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。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
{"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"}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
---
|
---
|
||||||
title: 'Implement Agent Skills support'
|
title: 'Implement Agent Skills support'
|
||||||
state: 'queued'
|
state: 'closed'
|
||||||
created_at: '2026-07-15T19:43:46Z'
|
created_at: '2026-07-15T19:43:46Z'
|
||||||
updated_at: '2026-07-15T21:26:15Z'
|
updated_at: '2026-07-16T00:15:05Z'
|
||||||
assignee: null
|
assignee: null
|
||||||
queued_by: 'workspace-panel'
|
queued_by: 'workspace-panel'
|
||||||
queued_at: '2026-07-15T21:26:15Z'
|
queued_at: '2026-07-15T21:26:15Z'
|
||||||
|
|
|
||||||
52
.yoi/tickets/00001KXKMX0QM/resolution.md
Normal file
52
.yoi/tickets/00001KXKMX0QM/resolution.md
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
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。
|
||||||
|
|
@ -134,4 +134,398 @@ Marked ready by `yoi ticket state`.
|
||||||
Ticket を `workspace-panel` が queued にしました。
|
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 Ticket’s 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。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
{"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-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-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"}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
---
|
---
|
||||||
title: 'Remove Knowledge support'
|
title: 'Remove Knowledge support'
|
||||||
state: 'queued'
|
state: 'closed'
|
||||||
created_at: '2026-07-15T20:04:08Z'
|
created_at: '2026-07-15T20:04:08Z'
|
||||||
updated_at: '2026-07-15T20:27:51Z'
|
updated_at: '2026-07-15T23:00:18Z'
|
||||||
assignee: null
|
assignee: null
|
||||||
queued_by: 'workspace-panel'
|
queued_by: 'workspace-panel'
|
||||||
queued_at: '2026-07-15T20:27:02Z'
|
queued_at: '2026-07-15T20:27:02Z'
|
||||||
|
|
|
||||||
41
.yoi/tickets/00001KXKP2A71/resolution.md
Normal file
41
.yoi/tickets/00001KXKP2A71/resolution.md
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
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。
|
||||||
|
|
@ -65,3 +65,436 @@ Escalate if:
|
||||||
- Human explicitly wants Knowledge removal and Workflow removal combined in one branch/worktree, or accepts parallel conflict/review-boundary risk。
|
- 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。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
|
||||||
17
Cargo.lock
generated
17
Cargo.lock
generated
|
|
@ -5742,7 +5742,6 @@ dependencies = [
|
||||||
"uuid",
|
"uuid",
|
||||||
"wasmtime",
|
"wasmtime",
|
||||||
"wat",
|
"wat",
|
||||||
"workflow",
|
|
||||||
"yoi-plugin-pdk",
|
"yoi-plugin-pdk",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -5772,22 +5771,6 @@ dependencies = [
|
||||||
"worker",
|
"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]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "writeable"
|
||||||
version = "0.6.3"
|
version = "0.6.3"
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ members = [
|
||||||
"crates/memory",
|
"crates/memory",
|
||||||
"crates/ticket",
|
"crates/ticket",
|
||||||
"crates/project-record",
|
"crates/project-record",
|
||||||
"crates/workflow",
|
|
||||||
"crates/workspace-server",
|
"crates/workspace-server",
|
||||||
"tests/e2e",
|
"tests/e2e",
|
||||||
]
|
]
|
||||||
|
|
@ -46,7 +45,6 @@ default-members = [
|
||||||
"crates/memory",
|
"crates/memory",
|
||||||
"crates/ticket",
|
"crates/ticket",
|
||||||
"crates/project-record",
|
"crates/project-record",
|
||||||
"crates/workflow",
|
|
||||||
"crates/workspace-server",
|
"crates/workspace-server",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -165,7 +165,6 @@ pub struct TicketRoleLaunchPlan {
|
||||||
pub role: TicketRole,
|
pub role: TicketRole,
|
||||||
pub worker_name: String,
|
pub worker_name: String,
|
||||||
pub profile: String,
|
pub profile: String,
|
||||||
pub workflow: String,
|
|
||||||
pub launch_prompt_ref: Option<String>,
|
pub launch_prompt_ref: Option<String>,
|
||||||
pub run_segments: Vec<Segment>,
|
pub run_segments: Vec<Segment>,
|
||||||
}
|
}
|
||||||
|
|
@ -305,7 +304,6 @@ pub fn plan_ticket_role_launch_with_config(
|
||||||
}
|
}
|
||||||
let role_config = config.role_launch_config(context.role)?;
|
let role_config = config.role_launch_config(context.role)?;
|
||||||
let profile = role_config.profile.as_str().to_string();
|
let profile = role_config.profile.as_str().to_string();
|
||||||
let workflow = role_config.workflow.as_str().to_string();
|
|
||||||
let launch_prompt_ref = role_config
|
let launch_prompt_ref = role_config
|
||||||
.launch_prompt
|
.launch_prompt
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|
@ -336,14 +334,10 @@ pub fn plan_ticket_role_launch_with_config(
|
||||||
role: context.role,
|
role: context.role,
|
||||||
worker_name,
|
worker_name,
|
||||||
profile,
|
profile,
|
||||||
workflow: workflow.clone(),
|
|
||||||
launch_prompt_ref,
|
launch_prompt_ref,
|
||||||
run_segments: vec![
|
run_segments: vec![Segment::Text {
|
||||||
Segment::WorkflowInvoke { slug: workflow },
|
|
||||||
Segment::Text {
|
|
||||||
content: format!("\n\n{prompt}"),
|
content: format!("\n\n{prompt}"),
|
||||||
},
|
}],
|
||||||
],
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -759,7 +753,6 @@ mod tests {
|
||||||
role: TicketRole::Intake,
|
role: TicketRole::Intake,
|
||||||
worker_name: "ticket-intake".to_string(),
|
worker_name: "ticket-intake".to_string(),
|
||||||
profile: "project:intake".to_string(),
|
profile: "project:intake".to_string(),
|
||||||
workflow: "ticket-intake-workflow".to_string(),
|
|
||||||
launch_prompt_ref: None,
|
launch_prompt_ref: None,
|
||||||
run_segments: vec![Segment::Text {
|
run_segments: vec![Segment::Text {
|
||||||
content: "intake request".to_string(),
|
content: "intake request".to_string(),
|
||||||
|
|
@ -994,7 +987,6 @@ profile = "builtin:default"
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(intake.role, TicketRole::Intake);
|
assert_eq!(intake.role, TicketRole::Intake);
|
||||||
assert_eq!(intake.profile, TicketRole::Intake.default_profile());
|
assert_eq!(intake.profile, TicketRole::Intake.default_profile());
|
||||||
assert_eq!(intake.workflow, TicketRole::Intake.default_workflow());
|
|
||||||
|
|
||||||
let orchestrator = plan_ticket_role_launch(TicketRoleLaunchContext::new(
|
let orchestrator = plan_ticket_role_launch(TicketRoleLaunchContext::new(
|
||||||
temp.path(),
|
temp.path(),
|
||||||
|
|
@ -1006,10 +998,6 @@ profile = "builtin:default"
|
||||||
orchestrator.profile,
|
orchestrator.profile,
|
||||||
TicketRole::Orchestrator.default_profile()
|
TicketRole::Orchestrator.default_profile()
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
orchestrator.workflow,
|
|
||||||
TicketRole::Orchestrator.default_workflow()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1038,7 +1026,6 @@ profile = "builtin:default"
|
||||||
[ticket.roles.reviewer]
|
[ticket.roles.reviewer]
|
||||||
profile = "builtin:default"
|
profile = "builtin:default"
|
||||||
launch_prompt = "$workspace/ticket/reviewer/launch"
|
launch_prompt = "$workspace/ticket/reviewer/launch"
|
||||||
workflow = "ticket-review-workflow"
|
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
let mut context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Reviewer);
|
let mut context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Reviewer);
|
||||||
|
|
@ -1051,18 +1038,13 @@ workflow = "ticket-review-workflow"
|
||||||
|
|
||||||
assert_eq!(plan.worker_name, "reviewer-fixed");
|
assert_eq!(plan.worker_name, "reviewer-fixed");
|
||||||
assert_eq!(plan.profile, "builtin:default");
|
assert_eq!(plan.profile, "builtin:default");
|
||||||
assert_eq!(plan.workflow, "ticket-review-workflow");
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plan.launch_prompt_ref.as_deref(),
|
plan.launch_prompt_ref.as_deref(),
|
||||||
Some("$workspace/ticket/reviewer/launch")
|
Some("$workspace/ticket/reviewer/launch")
|
||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(&plan.run_segments[0], Segment::Text { .. }));
|
||||||
&plan.run_segments[0],
|
|
||||||
Segment::WorkflowInvoke { slug } if slug == "ticket-review-workflow"
|
|
||||||
));
|
|
||||||
assert!(!text.contains("Configured launch_prompt"));
|
assert!(!text.contains("Configured launch_prompt"));
|
||||||
assert!(!text.contains("$workspace/ticket/reviewer/launch"));
|
assert!(!text.contains("$workspace/ticket/reviewer/launch"));
|
||||||
assert!(!text.contains("Workflow: ticket-review-workflow"));
|
|
||||||
assert!(!text.contains("Profile selector: builtin:default"));
|
assert!(!text.contains("Profile selector: builtin:default"));
|
||||||
assert!(!text.contains("Role: reviewer"));
|
assert!(!text.contains("Role: reviewer"));
|
||||||
assert!(!text.contains("system_instruction"));
|
assert!(!text.contains("system_instruction"));
|
||||||
|
|
@ -1133,8 +1115,6 @@ workflow = "ticket-review-workflow"
|
||||||
assert!(orchestrator_text.contains("Route to implementation after planning sync."));
|
assert!(orchestrator_text.contains("Route to implementation after planning sync."));
|
||||||
assert!(orchestrator_text.contains("cargo check --workspace --all-targets"));
|
assert!(orchestrator_text.contains("cargo check --workspace --all-targets"));
|
||||||
assert!(!orchestrator_text.contains("state = inprogress"));
|
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("root/original workspace reads"));
|
||||||
assert!(!orchestrator_text.contains("role_workspace_root"));
|
assert!(!orchestrator_text.contains("role_workspace_root"));
|
||||||
assert!(!orchestrator_text.contains("role_cwd"));
|
assert!(!orchestrator_text.contains("role_cwd"));
|
||||||
|
|
|
||||||
|
|
@ -98,5 +98,5 @@ pub const COMPACT_DEFAULT_REFERENCE_COUNT: usize = 5;
|
||||||
pub const MEMORY_EXTRACT_WORKER_MAX_TURNS: Option<u32> = Some(8);
|
pub const MEMORY_EXTRACT_WORKER_MAX_TURNS: Option<u32> = Some(8);
|
||||||
|
|
||||||
/// Default language used by memory extraction / consolidation workers for
|
/// Default language used by memory extraction / consolidation workers for
|
||||||
/// durable memory and knowledge text. See [`crate::MemoryConfig::language`].
|
/// durable memory text. See [`crate::MemoryConfig::language`].
|
||||||
pub const MEMORY_LANGUAGE: &str = "English";
|
pub const MEMORY_LANGUAGE: &str = "English";
|
||||||
|
|
|
||||||
|
|
@ -83,8 +83,8 @@ pub struct WorkerManifest {
|
||||||
/// `[feature.web].enabled = true`.
|
/// `[feature.web].enabled = true`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub web: Option<WebConfig>,
|
pub web: Option<WebConfig>,
|
||||||
/// External Agent Skills (`SKILL.md`) directories to ingest as
|
/// External Agent Skills (`SKILL.md`) candidate directories. Each entry
|
||||||
/// Workflows. Each entry is a path to a skills *root* (i.e. a
|
/// is a path to a skills *root* (i.e. a
|
||||||
/// directory whose children are individual `<name>/SKILL.md` skill
|
/// directory whose children are individual `<name>/SKILL.md` skill
|
||||||
/// bundles). Paths are resolved against the manifest's base
|
/// bundles). Paths are resolved against the manifest's base
|
||||||
/// directory like other path fields. Absent ⇒ no skills loaded;
|
/// directory like other path fields. Absent ⇒ no skills loaded;
|
||||||
|
|
@ -379,7 +379,7 @@ pub struct MemoryConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub workspace_root: Option<PathBuf>,
|
pub workspace_root: Option<PathBuf>,
|
||||||
/// Maximum number of records returned by `MemoryQuery` /
|
/// Maximum number of records returned by `MemoryQuery` /
|
||||||
/// `KnowledgeQuery` per call. `None` ⇒ tool default (20).
|
/// `MemoryQuery` per call. `None` ⇒ tool default (20).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub query_result_limit: Option<usize>,
|
pub query_result_limit: Option<usize>,
|
||||||
/// Lines of context before and after each match in query excerpts.
|
/// Lines of context before and after each match in query excerpts.
|
||||||
|
|
@ -391,7 +391,7 @@ pub struct MemoryConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub inject_summary: Option<bool>,
|
pub inject_summary: Option<bool>,
|
||||||
/// Language used by memory extraction / consolidation workers for durable
|
/// Language used by memory extraction / consolidation workers for durable
|
||||||
/// memory and knowledge text. Free-form so workspaces can use names like
|
/// memory text. Free-form so workspaces can use names like
|
||||||
/// `English`, `Japanese`, or locale tags. `None` ⇒
|
/// `English`, `Japanese`, or locale tags. `None` ⇒
|
||||||
/// [`defaults::MEMORY_LANGUAGE`].
|
/// [`defaults::MEMORY_LANGUAGE`].
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
|
|
||||||
## Role
|
## Role
|
||||||
|
|
||||||
`memory` owns generated memory, Knowledge records, staging/consolidation mechanics, linting, and audit observations.
|
`memory` owns generated memory, records, staging/consolidation mechanics, linting, and audit observations.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
Owns:
|
Owns:
|
||||||
|
|
||||||
- memory/Knowledge record parsing and validation
|
- memory/record parsing and validation
|
||||||
- memory lint subcommand backend behavior
|
- memory lint subcommand backend behavior
|
||||||
- staging and consolidation file mechanics
|
- staging and consolidation file mechanics
|
||||||
- audit log observation writes
|
- audit log observation writes
|
||||||
|
|
|
||||||
|
|
@ -321,7 +321,6 @@ pub fn snapshot_records(layout: &WorkspaceLayout) -> BTreeMap<String, RecordSnap
|
||||||
snapshot_one(&mut out, "summary", "summary", layout.summary_path());
|
snapshot_one(&mut out, "summary", "summary", layout.summary_path());
|
||||||
snapshot_dir(&mut out, "decision", layout.decisions_dir());
|
snapshot_dir(&mut out, "decision", layout.decisions_dir());
|
||||||
snapshot_dir(&mut out, "request", layout.requests_dir());
|
snapshot_dir(&mut out, "request", layout.requests_dir());
|
||||||
snapshot_dir(&mut out, "knowledge", layout.knowledge_dir());
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,6 @@
|
||||||
//! 3. Usage evidence report(明示使用回数 + resident exposure cost)
|
//! 3. Usage evidence report(明示使用回数 + resident exposure cost)
|
||||||
//! 4. 整理材料(Linter Warn ベース、hard protection 判定はしない)
|
//! 4. 整理材料(Linter Warn ベース、hard protection 判定はしない)
|
||||||
//!
|
//!
|
||||||
//! 既存 `knowledge/*` 本文は埋めず、agent に `KnowledgeQuery` 経由で引かせる
|
|
||||||
//! 設計(`docs/plan/memory.md` §retrieval 経路 / §Consolidation の Knowledge アクセス)。
|
|
||||||
|
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
|
|
||||||
|
|
@ -29,7 +27,7 @@ pub fn build_consolidate_input(
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
out.push_str(
|
out.push_str(
|
||||||
"consolidation input. Run the integration step first \
|
"consolidation input. Run the integration step first \
|
||||||
(fold the staging activity logs into memory and knowledge), then the \
|
(fold the staging activity logs into memory), then the \
|
||||||
tidy step (clean up existing records). Use the memory tools for \
|
tidy step (clean up existing records). Use the memory tools for \
|
||||||
every write — direct file writes are denied by the worker scope.\n\n",
|
every write — direct file writes are denied by the worker scope.\n\n",
|
||||||
);
|
);
|
||||||
|
|
@ -100,7 +98,7 @@ fn push_kind_records(out: &mut String, layout: &WorkspaceLayout, kind: RecordKin
|
||||||
let dir = match kind {
|
let dir = match kind {
|
||||||
RecordKind::Decision => layout.decisions_dir(),
|
RecordKind::Decision => layout.decisions_dir(),
|
||||||
RecordKind::Request => layout.requests_dir(),
|
RecordKind::Request => layout.requests_dir(),
|
||||||
RecordKind::Knowledge | RecordKind::Summary | RecordKind::Workflow => return,
|
RecordKind::Summary => return,
|
||||||
};
|
};
|
||||||
let entries = match std::fs::read_dir(&dir) {
|
let entries = match std::fs::read_dir(&dir) {
|
||||||
Ok(it) => it,
|
Ok(it) => it,
|
||||||
|
|
@ -135,13 +133,13 @@ fn push_kind_records(out: &mut String, layout: &WorkspaceLayout, kind: RecordKin
|
||||||
|
|
||||||
fn render_usage_report(report: &UsageReport) -> String {
|
fn render_usage_report(report: &UsageReport) -> String {
|
||||||
if report.is_empty() {
|
if report.is_empty() {
|
||||||
return "(empty — no explicit memory/knowledge usage events recorded yet. \
|
return "(empty — no explicit memory usage events recorded yet. \
|
||||||
Treat this as lack of evidence, not proof that records are unused.)\n"
|
Treat this as lack of evidence, not proof that records are unused.)\n"
|
||||||
.to_string();
|
.to_string();
|
||||||
}
|
}
|
||||||
let json = serde_json::to_string_pretty(report).unwrap_or_else(|_| "{}".to_string());
|
let json = serde_json::to_string_pretty(report).unwrap_or_else(|_| "{}".to_string());
|
||||||
format!(
|
format!(
|
||||||
"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"
|
"This report is evidence only. Do not make hard tidy-protection decisions from it alone.\n\n```json\n{json}\n```\n"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -276,7 +274,7 @@ mod tests {
|
||||||
assert!(out.contains("Replaced decisions"));
|
assert!(out.contains("Replaced decisions"));
|
||||||
assert!(out.contains("Sources overflow"));
|
assert!(out.contains("Sources overflow"));
|
||||||
assert!(out.contains("Similar slug clusters"));
|
assert!(out.contains("Similar slug clusters"));
|
||||||
assert!(out.contains("no explicit memory/knowledge usage events"));
|
assert!(out.contains("no explicit memory usage events"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
//! consolidation: 統合 + 整理。
|
//! consolidation: 統合 + 整理。
|
||||||
//!
|
//!
|
||||||
//! extract が staging に残した活動ログを `memory/*` / `knowledge/*` に
|
//! extract が staging に残した活動ログを `memory/*` に
|
||||||
//! 統合し、続けて既存 record を `outdated | superseded | unused | noisy`
|
//! 統合し、続けて既存 record を `outdated | superseded | unused | noisy`
|
||||||
//! の観点で整理する disposable Engine を、Worker 側が組み立てるための
|
//! の観点で整理する disposable Engine を、Worker 側が組み立てるための
|
||||||
//! ヘルパー群を提供する。Worker は次の手順で sub-Engine を構築する:
|
//! ヘルパー群を提供する。Worker は次の手順で sub-Engine を構築する:
|
||||||
//!
|
//!
|
||||||
//! - [`build_consolidate_input`] を sub-Engine の最初の user 入力に
|
//! - [`build_consolidate_input`] を sub-Engine の最初の user 入力に
|
||||||
//! - memory 専用 Tool (read / write / edit) と Knowledge / memory 検索ツールを登録
|
//! - memory 専用 Tool (read / write / edit) と memory 検索ツールを登録
|
||||||
//! - [`StagingLock::acquire`] で並走防止 + consumed ID 確定
|
//! - [`StagingLock::acquire`] で並走防止 + consumed ID 確定
|
||||||
//! - sub-Engine run 完了後、[`StagingLock::release_with_cleanup`] で
|
//! - sub-Engine run 完了後、[`StagingLock::release_with_cleanup`] で
|
||||||
//! consumed ID 分の staging のみ削除し、占有ファイルを解放
|
//! consumed ID 分の staging のみ削除し、占有ファイルを解放
|
||||||
//!
|
//!
|
||||||
//! system prompt は Worker の `PromptCatalog`
|
//! system prompt は Worker の `PromptCatalog`
|
||||||
//! (`WorkerPrompt::MemoryConsolidationSystem`) で管理される。Usage report は
|
//! (`WorkerPrompt::MemoryConsolidationSystem`) で管理される。Usage report は
|
||||||
//! 判断材料として渡すだけで、ここでは Knowledge 化や protection の hard decision はしない
|
//! 判断材料として渡すだけで、ここでは protection の hard decision はしない
|
||||||
//! (`docs/plan/memory.md` §Consolidation / 整理材料)。
|
//! (`docs/plan/memory.md` §Consolidation / 整理材料)。
|
||||||
|
|
||||||
mod input;
|
mod input;
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,7 @@
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
use crate::Slug;
|
use crate::Slug;
|
||||||
use crate::schema::{
|
use crate::schema::{DecisionFrontmatter, RequestFrontmatter, split_frontmatter};
|
||||||
DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, split_frontmatter,
|
|
||||||
};
|
|
||||||
use crate::workspace::{RecordKind, WorkspaceLayout};
|
use crate::workspace::{RecordKind, WorkspaceLayout};
|
||||||
|
|
||||||
/// `sources` overflow を flag する閾値。`linter::warnings::SOURCES_OVERFLOW_THRESHOLD`
|
/// `sources` overflow を flag する閾値。`linter::warnings::SOURCES_OVERFLOW_THRESHOLD`
|
||||||
|
|
@ -69,7 +67,6 @@ pub fn collect_tidy_hints(layout: &WorkspaceLayout) -> TidyHints {
|
||||||
|
|
||||||
let decisions = read_kind_records(layout, RecordKind::Decision);
|
let decisions = read_kind_records(layout, RecordKind::Decision);
|
||||||
let requests = read_kind_records(layout, RecordKind::Request);
|
let requests = read_kind_records(layout, RecordKind::Request);
|
||||||
let knowledge = read_kind_records(layout, RecordKind::Knowledge);
|
|
||||||
|
|
||||||
for (slug, content) in &decisions {
|
for (slug, content) in &decisions {
|
||||||
let fm = parse_yaml::<DecisionFrontmatter>(content);
|
let fm = parse_yaml::<DecisionFrontmatter>(content);
|
||||||
|
|
@ -99,33 +96,18 @@ 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| {
|
hints.sources_overflow.sort_by(|a, b| {
|
||||||
(a.kind.as_str(), a.slug.as_str()).cmp(&(b.kind.as_str(), b.slug.as_str()))
|
(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 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 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) {
|
if let Some(c) = cluster_similar(&decision_slugs, RecordKind::Decision) {
|
||||||
hints.similar_slug_clusters.extend(c);
|
hints.similar_slug_clusters.extend(c);
|
||||||
}
|
}
|
||||||
if let Some(c) = cluster_similar(&request_slugs, RecordKind::Request) {
|
if let Some(c) = cluster_similar(&request_slugs, RecordKind::Request) {
|
||||||
hints.similar_slug_clusters.extend(c);
|
hints.similar_slug_clusters.extend(c);
|
||||||
}
|
}
|
||||||
if let Some(c) = cluster_similar(&knowledge_slugs, RecordKind::Knowledge) {
|
|
||||||
hints.similar_slug_clusters.extend(c);
|
|
||||||
}
|
|
||||||
hints
|
hints
|
||||||
.similar_slug_clusters
|
.similar_slug_clusters
|
||||||
.sort_by(|a, b| (a.kind.as_str(), &a.slugs).cmp(&(b.kind.as_str(), &b.slugs)));
|
.sort_by(|a, b| (a.kind.as_str(), &a.slugs).cmp(&(b.kind.as_str(), &b.slugs)));
|
||||||
|
|
@ -133,15 +115,13 @@ pub fn collect_tidy_hints(layout: &WorkspaceLayout) -> TidyHints {
|
||||||
hints
|
hints
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `<root>/.yoi/memory/<kind>/*.md` (Knowledge は
|
/// `<root>/.yoi/memory/<kind>/*.md` を slug ごとに `(slug, full content)`
|
||||||
/// `<root>/.yoi/knowledge/*.md`) を slug ごとに `(slug, full content)`
|
|
||||||
/// 化して返す。
|
/// 化して返す。
|
||||||
fn read_kind_records(layout: &WorkspaceLayout, kind: RecordKind) -> BTreeMap<String, String> {
|
fn read_kind_records(layout: &WorkspaceLayout, kind: RecordKind) -> BTreeMap<String, String> {
|
||||||
let dir = match kind {
|
let dir = match kind {
|
||||||
RecordKind::Decision => layout.decisions_dir(),
|
RecordKind::Decision => layout.decisions_dir(),
|
||||||
RecordKind::Request => layout.requests_dir(),
|
RecordKind::Request => layout.requests_dir(),
|
||||||
RecordKind::Knowledge => layout.knowledge_dir(),
|
RecordKind::Summary => return BTreeMap::new(),
|
||||||
RecordKind::Summary | RecordKind::Workflow => return BTreeMap::new(),
|
|
||||||
};
|
};
|
||||||
let mut out: BTreeMap<String, String> = BTreeMap::new();
|
let mut out: BTreeMap<String, String> = BTreeMap::new();
|
||||||
let entries = match std::fs::read_dir(&dir) {
|
let entries = match std::fs::read_dir(&dir) {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use thiserror::Error;
|
||||||
/// Top-level error for memory operations that don't fit the lint flow.
|
/// Top-level error for memory operations that don't fit the lint flow.
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum MemoryError {
|
pub enum MemoryError {
|
||||||
#[error("path is not under the memory or knowledge tree: {}", .0.display())]
|
#[error("path is not under the memory tree: {}", .0.display())]
|
||||||
OutsideMemoryTree(PathBuf),
|
OutsideMemoryTree(PathBuf),
|
||||||
#[error("path is not absolute: {}", .0.display())]
|
#[error("path is not absolute: {}", .0.display())]
|
||||||
RelativePath(PathBuf),
|
RelativePath(PathBuf),
|
||||||
|
|
@ -56,11 +56,6 @@ pub enum LintError {
|
||||||
#[error("Decisions `status` must be one of open|resolved|replaced (got `{0}`)")]
|
#[error("Decisions `status` must be one of open|resolved|replaced (got `{0}`)")]
|
||||||
InvalidStatus(String),
|
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}")]
|
#[error("body exceeds the size limit for this record kind: {actual} chars > {limit}")]
|
||||||
BodyTooLong { actual: usize, limit: usize },
|
BodyTooLong { actual: usize, limit: usize },
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
//! Memory subsystem: persistence layer for `memory/*` and `knowledge/*` records.
|
//! Memory subsystem: persistence layer for `memory/*` records.
|
||||||
//!
|
//!
|
||||||
//! Self-contained: provides its own Tool implementations (read/write/edit)
|
//! Self-contained: provides its own Tool implementations (read/write/edit)
|
||||||
//! that target `<workspace>/memory/` and `<workspace>/knowledge/` only,
|
//! that target `<workspace>/memory/` only, with a pre-write Linter built in.
|
||||||
//! with a pre-write Linter built in. Generic CRUD tools (in the `tools`
|
//! Generic CRUD tools (in the `tools` crate) must not touch this directory —
|
||||||
//! crate) must not touch these directories — Worker is responsible for
|
//! Worker is responsible for denying it at the Scope level when memory is enabled.
|
||||||
//! denying them at the Scope level when memory is enabled.
|
|
||||||
|
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
pub mod consolidate;
|
pub mod consolidate;
|
||||||
|
|
@ -22,10 +21,7 @@ pub use error::{LintError, LintWarning, MemoryError};
|
||||||
pub use extract::ExtractPointerPayload;
|
pub use extract::ExtractPointerPayload;
|
||||||
pub use lint_common::{RecordLintError, Slug, is_valid_slug};
|
pub use lint_common::{RecordLintError, Slug, is_valid_slug};
|
||||||
pub use linter::{LintReport, Linter};
|
pub use linter::{LintReport, Linter};
|
||||||
pub use resident::{
|
pub use resident::collect_resident_summary;
|
||||||
ResidentKnowledgeEntry, collect_resident_knowledge, collect_resident_summary,
|
|
||||||
list_knowledge_slugs,
|
|
||||||
};
|
|
||||||
pub use scope::deny_write_rules;
|
pub use scope::deny_write_rules;
|
||||||
pub use usage::{
|
pub use usage::{
|
||||||
UsageEvent, UsageEventKind, UsageRecordSnapshot, UsageReport, UsageReportRecord, UsageSource,
|
UsageEvent, UsageEventKind, UsageRecordSnapshot, UsageReport, UsageReportRecord, UsageSource,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
//! Walks `<workspace>/memory/{decisions,requests}/` and `<workspace>/knowledge/` to collect
|
//! Walks `<workspace>/memory/{decisions,requests}/` to collect
|
||||||
//! the slug set the linter needs for reference-integrity and
|
//! the slug set the linter needs for reference-integrity and
|
||||||
//! same-slug-duplication checks.
|
//! same-slug-duplication checks.
|
||||||
//!
|
//!
|
||||||
|
|
@ -10,9 +10,7 @@ use std::io;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use crate::Slug;
|
use crate::Slug;
|
||||||
use crate::schema::{
|
use crate::schema::{DecisionFrontmatter, RequestFrontmatter, split_frontmatter};
|
||||||
DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, split_frontmatter,
|
|
||||||
};
|
|
||||||
use crate::workspace::{RecordKind, WorkspaceLayout};
|
use crate::workspace::{RecordKind, WorkspaceLayout};
|
||||||
|
|
||||||
/// Snapshot of every record currently on disk under the workspace.
|
/// Snapshot of every record currently on disk under the workspace.
|
||||||
|
|
@ -25,7 +23,6 @@ use crate::workspace::{RecordKind, WorkspaceLayout};
|
||||||
pub struct ExistingRecords {
|
pub struct ExistingRecords {
|
||||||
decisions: HashMap<Slug, DecisionMeta>,
|
decisions: HashMap<Slug, DecisionMeta>,
|
||||||
requests: HashSet<Slug>,
|
requests: HashSet<Slug>,
|
||||||
knowledge: HashSet<Slug>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -38,8 +35,6 @@ impl ExistingRecords {
|
||||||
match kind {
|
match kind {
|
||||||
RecordKind::Decision => self.decisions.contains_key(slug),
|
RecordKind::Decision => self.decisions.contains_key(slug),
|
||||||
RecordKind::Request => self.requests.contains(slug),
|
RecordKind::Request => self.requests.contains(slug),
|
||||||
RecordKind::Knowledge => self.knowledge.contains(slug),
|
|
||||||
RecordKind::Workflow => false,
|
|
||||||
RecordKind::Summary => false,
|
RecordKind::Summary => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -52,8 +47,6 @@ impl ExistingRecords {
|
||||||
match kind {
|
match kind {
|
||||||
RecordKind::Decision => self.decisions.keys().collect(),
|
RecordKind::Decision => self.decisions.keys().collect(),
|
||||||
RecordKind::Request => self.requests.iter().collect(),
|
RecordKind::Request => self.requests.iter().collect(),
|
||||||
RecordKind::Knowledge => self.knowledge.iter().collect(),
|
|
||||||
RecordKind::Workflow => Vec::new(),
|
|
||||||
RecordKind::Summary => Vec::new(),
|
RecordKind::Summary => Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -75,10 +68,6 @@ pub fn scan_existing(layout: &WorkspaceLayout) -> io::Result<ExistingRecords> {
|
||||||
let _ = parse_silent::<RequestFrontmatter>(path);
|
let _ = parse_silent::<RequestFrontmatter>(path);
|
||||||
out.requests.insert(slug);
|
out.requests.insert(slug);
|
||||||
})?;
|
})?;
|
||||||
scan_dir(&layout.knowledge_dir(), |path, slug| {
|
|
||||||
let _ = parse_silent::<KnowledgeFrontmatter>(path);
|
|
||||||
out.knowledge.insert(slug);
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,6 @@ fn parse_missing_field(msg: &str) -> Option<&'static str> {
|
||||||
"status",
|
"status",
|
||||||
"kind",
|
"kind",
|
||||||
"description",
|
"description",
|
||||||
"model_invokation",
|
|
||||||
"user_invocable",
|
|
||||||
"last_sources",
|
|
||||||
"requires",
|
"requires",
|
||||||
];
|
];
|
||||||
FIELDS.iter().copied().find(|n| *n == field_name)
|
FIELDS.iter().copied().find(|n| *n == field_name)
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
//! collection back to the LLM as `ToolError::InvalidArgument`.
|
//! collection back to the LLM as `ToolError::InvalidArgument`.
|
||||||
//!
|
//!
|
||||||
//! Reference-integrity checks (`replaced_by` / `requires` existence,
|
//! Reference-integrity checks (`replaced_by` / `requires` existence,
|
||||||
//! cycle detection) walk the whole `memory/` and `knowledge/` trees
|
//! cycle detection) walk the whole `memory/` tree
|
||||||
//! each call. No caching; the trees are expected to be small.
|
//! each call. No caching; the trees are expected to be small.
|
||||||
|
|
||||||
mod existing;
|
mod existing;
|
||||||
|
|
@ -23,8 +23,7 @@ use serde::de::DeserializeOwned;
|
||||||
|
|
||||||
use crate::error::{LintError, LintWarning};
|
use crate::error::{LintError, LintWarning};
|
||||||
use crate::schema::{
|
use crate::schema::{
|
||||||
DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, SummaryFrontmatter,
|
DecisionFrontmatter, RequestFrontmatter, SummaryFrontmatter, split_frontmatter,
|
||||||
split_frontmatter,
|
|
||||||
};
|
};
|
||||||
use crate::workspace::{ClassifiedPath, RecordKind, WorkspaceLayout};
|
use crate::workspace::{ClassifiedPath, RecordKind, WorkspaceLayout};
|
||||||
|
|
||||||
|
|
@ -134,15 +133,9 @@ impl Linter {
|
||||||
RecordKind::Request => {
|
RecordKind::Request => {
|
||||||
self.check_request(content, &classified, &mut report);
|
self.check_request(content, &classified, &mut report);
|
||||||
}
|
}
|
||||||
RecordKind::Knowledge => {
|
|
||||||
self.check_knowledge(content, &classified, &mut report);
|
|
||||||
}
|
|
||||||
RecordKind::Summary => {
|
RecordKind::Summary => {
|
||||||
self.check_kind::<SummaryFrontmatter>(content, &classified, &mut report);
|
self.check_kind::<SummaryFrontmatter>(content, &classified, &mut report);
|
||||||
}
|
}
|
||||||
RecordKind::Workflow => {
|
|
||||||
unreachable!("workflow paths are not classified by memory linter")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
report
|
report
|
||||||
|
|
@ -210,30 +203,6 @@ impl Linter {
|
||||||
|
|
||||||
warnings::check_warnings_with_sources(parsed.body, fm.sources.len(), report);
|
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> {
|
struct Parsed<'a, F> {
|
||||||
|
|
@ -360,37 +329,6 @@ 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]
|
#[test]
|
||||||
fn summary_path_accepted() {
|
fn summary_path_accepted() {
|
||||||
let (dir, linter) = workspace();
|
let (dir, linter) = workspace();
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// size:importance heuristic doesn't apply to a single rolling file.
|
||||||
}
|
}
|
||||||
|
|
||||||
/// For kinds with `sources` (Decisions / Requests / Knowledge), consult
|
/// For kinds with `sources`, consult
|
||||||
/// both the body length and the sources count.
|
/// both the body length and the sources count.
|
||||||
pub fn check_warnings_with_sources(body: &str, source_count: usize, report: &mut LintReport) {
|
pub fn check_warnings_with_sources(body: &str, source_count: usize, report: &mut LintReport) {
|
||||||
let chars = body.chars().count();
|
let chars = body.chars().count();
|
||||||
|
|
|
||||||
|
|
@ -2,46 +2,17 @@
|
||||||
//!
|
//!
|
||||||
//! Surfaces used by the Worker system-prompt assembler:
|
//! 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
|
//! - [`collect_resident_summary`] — the body of
|
||||||
//! `<workspace>/.yoi/memory/summary.md` when it parses as a summary
|
//! `<workspace>/.yoi/memory/summary.md` when it parses as a summary
|
||||||
//! record and has non-empty body.
|
//! 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
|
//! Files that fail to read or parse are skipped silently — the Linter
|
||||||
//! enforces shape on write, so a malformed file here means external
|
//! enforces shape on write, so a malformed file here means external
|
||||||
//! tampering and we'd rather degrade than panic.
|
//! tampering and we'd rather degrade than panic.
|
||||||
|
|
||||||
use crate::schema::{KnowledgeFrontmatter, SummaryFrontmatter, split_frontmatter};
|
use crate::schema::{SummaryFrontmatter, split_frontmatter};
|
||||||
use crate::workspace::WorkspaceLayout;
|
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
|
/// Read `<workspace>/.yoi/memory/summary.md` for resident prompt
|
||||||
/// injection. Returns only the markdown body (frontmatter stripped), and
|
/// injection. Returns only the markdown body (frontmatter stripped), and
|
||||||
/// degrades to `None` for missing, unreadable, malformed, or empty records.
|
/// degrades to `None` for missing, unreadable, malformed, or empty records.
|
||||||
|
|
@ -57,52 +28,6 @@ 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -120,25 +45,8 @@ mod tests {
|
||||||
std::fs::write(path, content).unwrap();
|
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) {
|
fn setup() -> (TempDir, WorkspaceLayout) {
|
||||||
let dir = TempDir::new().unwrap();
|
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();
|
std::fs::create_dir_all(dir.path().join(".yoi/memory")).unwrap();
|
||||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
||||||
(dir, layout)
|
(dir, layout)
|
||||||
|
|
@ -180,98 +88,4 @@ mod tests {
|
||||||
write_summary(dir.path(), " \n");
|
write_summary(dir.path(), " \n");
|
||||||
assert!(collect_resident_summary(&layout).is_none());
|
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"]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
//! 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -7,12 +7,10 @@
|
||||||
|
|
||||||
mod common;
|
mod common;
|
||||||
mod decision;
|
mod decision;
|
||||||
mod knowledge;
|
|
||||||
mod request;
|
mod request;
|
||||||
mod summary;
|
mod summary;
|
||||||
|
|
||||||
pub use common::{Frontmatter, SourceRef, split_frontmatter};
|
pub use common::{Frontmatter, SourceRef, split_frontmatter};
|
||||||
pub use decision::{DecisionFrontmatter, DecisionStatus};
|
pub use decision::{DecisionFrontmatter, DecisionStatus};
|
||||||
pub use knowledge::{KNOWLEDGE_DESCRIPTION_HARD_CAP, KnowledgeFrontmatter};
|
|
||||||
pub use request::RequestFrontmatter;
|
pub use request::RequestFrontmatter;
|
||||||
pub use summary::SummaryFrontmatter;
|
pub use summary::SummaryFrontmatter;
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,9 @@ use manifest::{Permission, ScopeRule};
|
||||||
|
|
||||||
use crate::workspace::WorkspaceLayout;
|
use crate::workspace::WorkspaceLayout;
|
||||||
|
|
||||||
/// Build deny rules that strip Write permission from `<workspace>/memory/`
|
/// Build a deny rule that strips Write permission from `<workspace>/.yoi/memory/`.
|
||||||
/// and `<workspace>/knowledge/`. Recursive — every descendant is capped at
|
|
||||||
/// Read for the generic tools.
|
|
||||||
pub fn deny_write_rules(layout: &WorkspaceLayout) -> Vec<ScopeRule> {
|
pub fn deny_write_rules(layout: &WorkspaceLayout) -> Vec<ScopeRule> {
|
||||||
vec![
|
vec![deny_write(layout.memory_dir().as_path())]
|
||||||
deny_write(layout.memory_dir().as_path()),
|
|
||||||
deny_write(layout.knowledge_dir().as_path()),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn deny_write(target: &Path) -> ScopeRule {
|
fn deny_write(target: &Path) -> ScopeRule {
|
||||||
|
|
@ -37,13 +32,12 @@ mod tests {
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deny_targets_memory_and_knowledge() {
|
fn deny_targets_memory() {
|
||||||
let layout = WorkspaceLayout::new(PathBuf::from("/ws"));
|
let layout = WorkspaceLayout::new(PathBuf::from("/ws"));
|
||||||
let rules = deny_write_rules(&layout);
|
let rules = deny_write_rules(&layout);
|
||||||
assert_eq!(rules.len(), 2);
|
assert_eq!(rules.len(), 1);
|
||||||
assert_eq!(rules[0].target, PathBuf::from("/ws/.yoi/memory"));
|
assert_eq!(rules[0].target, PathBuf::from("/ws/.yoi/memory"));
|
||||||
assert_eq!(rules[0].permission, Permission::Write);
|
assert_eq!(rules[0].permission, Permission::Write);
|
||||||
assert!(rules[0].recursive);
|
assert!(rules[0].recursive);
|
||||||
assert_eq!(rules[1].target, PathBuf::from("/ws/.yoi/knowledge"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
//! `MemoryDelete` tool for removing memory / knowledge records with audit logging.
|
//! `MemoryDelete` tool for removing memory records with audit logging.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
|
@ -10,7 +10,7 @@ use crate::audit::{AuditStatus, RecordOperationAudit, append_record_operation, f
|
||||||
use crate::tool::MemoryToolKind;
|
use crate::tool::MemoryToolKind;
|
||||||
use crate::workspace::WorkspaceLayout;
|
use crate::workspace::WorkspaceLayout;
|
||||||
|
|
||||||
const DESCRIPTION: &str = "Delete an existing memory or knowledge record selected by `kind` + `slug`. \
|
const DESCRIPTION: &str = "Delete an existing memory record selected by `kind` + `slug`. \
|
||||||
For `summary` omit `slug`; for the others `slug` is required. The delete is audited and cannot target \
|
For `summary` omit `slug`; for the others `slug` is required. The delete is audited and cannot target \
|
||||||
workflow or staging/log files.";
|
workflow or staging/log files.";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,14 @@ use crate::linter::{LintReport, Linter, WriteMode};
|
||||||
use crate::tool::MemoryToolKind;
|
use crate::tool::MemoryToolKind;
|
||||||
use crate::workspace::WorkspaceLayout;
|
use crate::workspace::WorkspaceLayout;
|
||||||
|
|
||||||
const DESCRIPTION: &str = "Replace a substring in an existing memory or knowledge \
|
const DESCRIPTION: &str = "Replace a substring in an existing memory \
|
||||||
record selected by `kind` + `slug`. By default `old_string` must be unique in the \
|
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 \
|
file; set `replace_all: true` to replace every occurrence. The resulting content \
|
||||||
is re-validated by the memory linter; failure leaves the file untouched.";
|
is re-validated by the memory linter; failure leaves the file untouched.";
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
struct EditParams {
|
struct EditParams {
|
||||||
/// Record kind: `summary` | `decision` | `request` | `knowledge`.
|
/// Record kind: `summary` | `decision` | `request`.
|
||||||
kind: MemoryToolKind,
|
kind: MemoryToolKind,
|
||||||
/// Slug. Required for everything except `summary`; forbidden for `summary`.
|
/// Slug. Required for everything except `summary`; forbidden for `summary`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ use crate::Slug;
|
||||||
use crate::workspace::{RecordKind, WorkspaceLayout};
|
use crate::workspace::{RecordKind, WorkspaceLayout};
|
||||||
|
|
||||||
pub use edit::edit_tool;
|
pub use edit::edit_tool;
|
||||||
pub use query::{QueryConfig, knowledge_query_tool, memory_query_tool};
|
pub use query::{QueryConfig, memory_query_tool};
|
||||||
pub use read::{read_tool, read_tool_with_usage};
|
pub use read::{read_tool, read_tool_with_usage};
|
||||||
pub use write::write_tool;
|
pub use write::write_tool;
|
||||||
|
|
||||||
|
|
@ -34,7 +34,6 @@ pub enum MemoryToolKind {
|
||||||
Summary,
|
Summary,
|
||||||
Decision,
|
Decision,
|
||||||
Request,
|
Request,
|
||||||
Knowledge,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for MemoryToolKind {
|
impl std::fmt::Display for MemoryToolKind {
|
||||||
|
|
@ -43,7 +42,6 @@ impl std::fmt::Display for MemoryToolKind {
|
||||||
Self::Summary => "summary",
|
Self::Summary => "summary",
|
||||||
Self::Decision => "decision",
|
Self::Decision => "decision",
|
||||||
Self::Request => "request",
|
Self::Request => "request",
|
||||||
Self::Knowledge => "knowledge",
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -54,7 +52,6 @@ impl MemoryToolKind {
|
||||||
Self::Summary => "summary",
|
Self::Summary => "summary",
|
||||||
Self::Decision => "decision",
|
Self::Decision => "decision",
|
||||||
Self::Request => "request",
|
Self::Request => "request",
|
||||||
Self::Knowledge => "knowledge",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,7 +60,6 @@ impl MemoryToolKind {
|
||||||
Self::Summary => RecordKind::Summary,
|
Self::Summary => RecordKind::Summary,
|
||||||
Self::Decision => RecordKind::Decision,
|
Self::Decision => RecordKind::Decision,
|
||||||
Self::Request => RecordKind::Request,
|
Self::Request => RecordKind::Request,
|
||||||
Self::Knowledge => RecordKind::Knowledge,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,7 +88,6 @@ impl MemoryToolKind {
|
||||||
Ok(match other {
|
Ok(match other {
|
||||||
Self::Decision => layout.decision_path(&parsed),
|
Self::Decision => layout.decision_path(&parsed),
|
||||||
Self::Request => layout.request_path(&parsed),
|
Self::Request => layout.request_path(&parsed),
|
||||||
Self::Knowledge => layout.knowledge_path(&parsed),
|
|
||||||
Self::Summary => unreachable!(),
|
Self::Summary => unreachable!(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,15 @@
|
||||||
//! `MemoryQuery` / `KnowledgeQuery` tools.
|
//! `MemoryQuery` tool.
|
||||||
//!
|
//!
|
||||||
//! Both perform a case-insensitive substring scan over markdown record
|
//! Performs a case-insensitive substring scan over markdown record
|
||||||
//! files. With a `query` set, returns `{slug, kind, ..., excerpt}` hits
|
//! files. With a `query` set, returns `{slug, kind, ..., excerpt}` hits
|
||||||
//! with `excerpt_lines` lines of context around each match. With `query`
|
//! with `excerpt_lines` lines of context around each match. With `query`
|
||||||
//! omitted, returns one entry per file (no excerpt) so the agent can
|
//! omitted, returns one entry per file (no excerpt) so the agent can
|
||||||
//! enumerate what records exist without knowing what's inside them.
|
//! enumerate what records exist without knowing what's inside them.
|
||||||
//!
|
//!
|
||||||
//! - `MemoryQuery` walks `.yoi/memory/{summary.md,decisions/,
|
//! - `MemoryQuery` walks `.yoi/memory/{summary.md,decisions/,
|
||||||
//! requests/}`. `.yoi/workflow/`, `.yoi/memory/_staging/`,
|
//! requests/}`. `.yoi/memory/_staging/`,
|
||||||
//! `.yoi/memory/_usage/`, and `.yoi/memory/_logs/` are excluded
|
//! `.yoi/memory/_usage/`, and `.yoi/memory/_logs/` are excluded
|
||||||
//! by construction.
|
//! 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
|
//! No derived index — the file tree is the source of truth and is
|
||||||
//! re-scanned per call. 出現順: within a file by line order, across
|
//! re-scanned per call. 出現順: within a file by line order, across
|
||||||
|
|
@ -25,7 +23,6 @@ use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::audit::{AuditStatus, RecordUsageAudit, append_record_usage};
|
use crate::audit::{AuditStatus, RecordUsageAudit, append_record_usage};
|
||||||
use crate::schema::{KnowledgeFrontmatter, split_frontmatter};
|
|
||||||
use crate::workspace::WorkspaceLayout;
|
use crate::workspace::WorkspaceLayout;
|
||||||
|
|
||||||
const DEFAULT_RESULT_LIMIT: usize = 20;
|
const DEFAULT_RESULT_LIMIT: usize = 20;
|
||||||
|
|
@ -38,14 +35,6 @@ 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 \
|
manifest's `[memory]` section). Use the returned `slug` + `kind` with MemoryRead to fetch \
|
||||||
the full record. Workflow and staging directories are not visible.";
|
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.
|
/// Tunables passed in from the manifest.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct QueryConfig {
|
pub struct QueryConfig {
|
||||||
|
|
@ -85,17 +74,6 @@ struct MemoryQueryParams {
|
||||||
query: Option<String>,
|
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)]
|
#[derive(Debug, Serialize)]
|
||||||
struct MemoryRecord {
|
struct MemoryRecord {
|
||||||
slug: String,
|
slug: String,
|
||||||
|
|
@ -104,26 +82,11 @@ struct MemoryRecord {
|
||||||
excerpt: Option<String>,
|
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 {
|
struct MemoryQueryTool {
|
||||||
layout: WorkspaceLayout,
|
layout: WorkspaceLayout,
|
||||||
config: QueryConfig,
|
config: QueryConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct KnowledgeQueryTool {
|
|
||||||
layout: WorkspaceLayout,
|
|
||||||
config: QueryConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for MemoryQueryTool {
|
impl Tool for MemoryQueryTool {
|
||||||
async fn execute(
|
async fn execute(
|
||||||
|
|
@ -242,123 +205,6 @@ 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(
|
fn collect_memory_records(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
slug: &str,
|
slug: &str,
|
||||||
|
|
@ -470,14 +316,6 @@ 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 {
|
pub fn memory_query_tool(layout: WorkspaceLayout, config: QueryConfig) -> ToolDefinition {
|
||||||
Arc::new(move || {
|
Arc::new(move || {
|
||||||
let schema = schemars::schema_for!(MemoryQueryParams);
|
let schema = schemars::schema_for!(MemoryQueryParams);
|
||||||
|
|
@ -493,21 +331,6 @@ 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -524,8 +347,6 @@ mod tests {
|
||||||
std::fs::create_dir_all(dir.path().join(".yoi/memory/decisions")).unwrap();
|
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/requests")).unwrap();
|
||||||
std::fs::create_dir_all(dir.path().join(".yoi/memory/_staging")).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)
|
(dir, layout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -538,19 +359,6 @@ mod tests {
|
||||||
std::fs::write(path, content).unwrap();
|
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)]
|
#[derive(Deserialize)]
|
||||||
struct OwnedMemoryRecord {
|
struct OwnedMemoryRecord {
|
||||||
slug: String,
|
slug: String,
|
||||||
|
|
@ -559,16 +367,10 @@ mod tests {
|
||||||
excerpt: Option<String>,
|
excerpt: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
fn parse_records(out: &ToolOutput) -> Vec<OwnedMemoryRecord> {
|
||||||
struct OwnedKnowledgeRecord {
|
let text = out.content.as_ref().unwrap_or(&out.summary);
|
||||||
slug: String,
|
serde_json::from_str(text).unwrap()
|
||||||
kind: Option<String>,
|
|
||||||
description: Option<String>,
|
|
||||||
model_invokation: Option<bool>,
|
|
||||||
#[serde(default)]
|
|
||||||
excerpt: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn memory_query_finds_decision_body() {
|
async fn memory_query_finds_decision_body() {
|
||||||
let (dir, layout) = setup();
|
let (dir, layout) = setup();
|
||||||
|
|
@ -637,47 +439,16 @@ mod tests {
|
||||||
assert_eq!(records[0].kind, "summary");
|
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]
|
#[tokio::test]
|
||||||
async fn query_hits_do_not_log_usage() {
|
async fn query_hits_do_not_log_usage() {
|
||||||
let (dir, layout) = setup();
|
let (dir, layout) = setup();
|
||||||
write_decision(dir.path(), "alpha", "needle line\n");
|
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 (_, 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" });
|
let inp = serde_json::json!({ "query": "needle" });
|
||||||
memory_tool
|
memory_tool
|
||||||
.execute(&inp.to_string(), Default::default())
|
.execute(&inp.to_string(), Default::default())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
knowledge_tool
|
|
||||||
.execute(&inp.to_string(), Default::default())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let report = crate::usage::build_usage_report(&layout).unwrap();
|
let report = crate::usage::build_usage_report(&layout).unwrap();
|
||||||
assert!(report.records.is_empty());
|
assert!(report.records.is_empty());
|
||||||
|
|
@ -743,116 +514,4 @@ mod tests {
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
//! `MemoryRead` tool.
|
//! `MemoryRead` tool.
|
||||||
//!
|
//!
|
||||||
//! Reads a memory or knowledge record by `(kind, slug)`. Returns
|
//! Reads a memory record by `(kind, slug)`. Returns
|
||||||
//! line-numbered content (1-based), like the generic Read tool. The
|
//! line-numbered content (1-based), like the generic Read tool. The
|
||||||
//! agent never names a path — `Search` returns `{kind, slug, ...}`
|
//! agent never names a path — `MemoryQuery` returns `{kind, slug, ...}`
|
||||||
//! and that pair feeds straight into Read.
|
//! and that pair feeds straight into Read.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -16,8 +16,8 @@ use crate::tool::MemoryToolKind;
|
||||||
use crate::usage::{self, UsageSource};
|
use crate::usage::{self, UsageSource};
|
||||||
use crate::workspace::WorkspaceLayout;
|
use crate::workspace::WorkspaceLayout;
|
||||||
|
|
||||||
const DESCRIPTION: &str = "Read a memory or knowledge record by `kind` + `slug`. \
|
const DESCRIPTION: &str = "Read a memory record by `kind` + `slug`. \
|
||||||
`kind` is one of: summary, decision, request, knowledge. \
|
`kind` is one of: summary, decision, request. \
|
||||||
For `summary` omit `slug`; for the others `slug` is required. \
|
For `summary` omit `slug`; for the others `slug` is required. \
|
||||||
Returns line-numbered output (1-based).";
|
Returns line-numbered output (1-based).";
|
||||||
|
|
||||||
|
|
@ -25,7 +25,7 @@ const DEFAULT_LIMIT: usize = 2000;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
struct ReadParams {
|
struct ReadParams {
|
||||||
/// Record kind: `summary` | `decision` | `request` | `knowledge`.
|
/// Record kind: `summary` | `decision` | `request`.
|
||||||
kind: MemoryToolKind,
|
kind: MemoryToolKind,
|
||||||
/// Slug. Required for everything except `summary`; forbidden for `summary`.
|
/// Slug. Required for everything except `summary`; forbidden for `summary`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
@ -290,22 +290,6 @@ mod tests {
|
||||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
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]
|
#[tokio::test]
|
||||||
async fn read_logs_explicit_use_when_usage_session_is_set() {
|
async fn read_logs_explicit_use_when_usage_session_is_set() {
|
||||||
let (dir, layout) = setup();
|
let (dir, layout) = setup();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! `MemoryWrite` tool.
|
//! `MemoryWrite` tool.
|
||||||
//!
|
//!
|
||||||
//! Creates or overwrites a memory or knowledge record by `(kind, slug)`.
|
//! Creates or overwrites a memory record by `(kind, slug)`.
|
||||||
//! Pre-write Linter validates frontmatter, slug uniqueness (Create only),
|
//! Pre-write Linter validates frontmatter, slug uniqueness (Create only),
|
||||||
//! reference integrity, size limits. On any
|
//! reference integrity, size limits. On any
|
||||||
//! Linter error the tool returns `ToolError::InvalidArgument` with all
|
//! 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::tool::MemoryToolKind;
|
||||||
use crate::workspace::WorkspaceLayout;
|
use crate::workspace::WorkspaceLayout;
|
||||||
|
|
||||||
const DESCRIPTION: &str = "Create or overwrite a memory or knowledge record by \
|
const DESCRIPTION: &str = "Create or overwrite a memory record by \
|
||||||
`kind` + `slug`. `kind`: summary | decision | request | knowledge. For `summary` \
|
`kind` + `slug`. `kind`: summary | decision | request. For `summary` \
|
||||||
omit `slug`. Frontmatter is validated before write; on validation failure no \
|
omit `slug`. Frontmatter is validated before write; on validation failure no \
|
||||||
write occurs and every violation is returned in the error message.";
|
write occurs and every violation is returned in the error message.";
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
struct WriteParams {
|
struct WriteParams {
|
||||||
/// Record kind: `summary` | `decision` | `request` | `knowledge`.
|
/// Record kind: `summary` | `decision` | `request`.
|
||||||
kind: MemoryToolKind,
|
kind: MemoryToolKind,
|
||||||
/// Slug. Required for everything except `summary`; forbidden for `summary`.
|
/// Slug. Required for everything except `summary`; forbidden for `summary`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
//! Workspace-local usage event log for memory / knowledge / workflow records.
|
//! Workspace-local usage event log for memory records.
|
||||||
//!
|
//!
|
||||||
//! The log is append-only JSONL under the workspace's `.yoi/` tree. It is
|
//! The log is append-only JSONL under the workspace's `.yoi/` tree. It is
|
||||||
//! intentionally evidence-only: aggregation reports explicit context reads and
|
//! intentionally evidence-only: aggregation reports explicit context reads and
|
||||||
//! resident exposure cost telemetry, but it does not classify records as
|
//! resident exposure cost telemetry, but it does not classify records as
|
||||||
//! Knowledge candidates or tidy-protected records.
|
//! tidy-protected records.
|
||||||
|
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::fs::{self, OpenOptions};
|
use std::fs::{self, OpenOptions};
|
||||||
|
|
@ -25,8 +25,6 @@ pub enum UsageEventKind {
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
pub enum UsageSource {
|
pub enum UsageSource {
|
||||||
MemoryRead,
|
MemoryRead,
|
||||||
KnowledgeRef,
|
|
||||||
WorkflowInvoke,
|
|
||||||
ResidentInjection,
|
ResidentInjection,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,8 +32,6 @@ impl UsageSource {
|
||||||
pub fn as_str(self) -> &'static str {
|
pub fn as_str(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::MemoryRead => "MemoryRead",
|
Self::MemoryRead => "MemoryRead",
|
||||||
Self::KnowledgeRef => "KnowledgeRef",
|
|
||||||
Self::WorkflowInvoke => "WorkflowInvoke",
|
|
||||||
Self::ResidentInjection => "ResidentInjection",
|
Self::ResidentInjection => "ResidentInjection",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -216,14 +212,6 @@ fn record_path(
|
||||||
let slug = crate::Slug::parse(slug.to_string()).map_err(invalid_slug_error)?;
|
let slug = crate::Slug::parse(slug.to_string()).map_err(invalid_slug_error)?;
|
||||||
Ok(layout.request_path(&slug))
|
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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -319,7 +307,7 @@ mod tests {
|
||||||
fn aggregates_use_and_resident_exposure_separately() {
|
fn aggregates_use_and_resident_exposure_separately() {
|
||||||
let (_dir, layout) = setup();
|
let (_dir, layout) = setup();
|
||||||
let decision = snapshot_record_from_bytes(RecordKind::Decision, "alpha", b"abcd");
|
let decision = snapshot_record_from_bytes(RecordKind::Decision, "alpha", b"abcd");
|
||||||
let knowledge = snapshot_record_from_bytes(RecordKind::Knowledge, "policy", b"abcdefgh");
|
let request = snapshot_record_from_bytes(RecordKind::Request, "policy", b"abcdefgh");
|
||||||
|
|
||||||
append_use_event(
|
append_use_event(
|
||||||
&layout,
|
&layout,
|
||||||
|
|
@ -331,18 +319,18 @@ mod tests {
|
||||||
append_use_event(
|
append_use_event(
|
||||||
&layout,
|
&layout,
|
||||||
"session-a",
|
"session-a",
|
||||||
UsageSource::KnowledgeRef,
|
UsageSource::MemoryRead,
|
||||||
vec![knowledge.clone()],
|
vec![request.clone()],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
append_use_event(
|
append_use_event(
|
||||||
&layout,
|
&layout,
|
||||||
"session-b",
|
"session-b",
|
||||||
UsageSource::KnowledgeRef,
|
UsageSource::MemoryRead,
|
||||||
vec![knowledge.clone()],
|
vec![request.clone()],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
append_resident_exposure_event(&layout, "session-b", vec![knowledge]).unwrap();
|
append_resident_exposure_event(&layout, "session-b", vec![request]).unwrap();
|
||||||
|
|
||||||
let report = build_usage_report(&layout).unwrap();
|
let report = build_usage_report(&layout).unwrap();
|
||||||
let decision = report
|
let decision = report
|
||||||
|
|
@ -355,22 +343,22 @@ mod tests {
|
||||||
assert_eq!(decision.resident_exposure_count, 0);
|
assert_eq!(decision.resident_exposure_count, 0);
|
||||||
assert!(decision.last_used_at.is_some());
|
assert!(decision.last_used_at.is_some());
|
||||||
|
|
||||||
let knowledge = report
|
let request = report
|
||||||
.records
|
.records
|
||||||
.iter()
|
.iter()
|
||||||
.find(|r| r.kind == "knowledge" && r.slug == "policy")
|
.find(|r| r.kind == "request" && r.slug == "policy")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(knowledge.use_count, 2);
|
assert_eq!(request.use_count, 2);
|
||||||
assert_eq!(knowledge.source_breakdown["KnowledgeRef"], 2);
|
assert_eq!(request.source_breakdown["MemoryRead"], 2);
|
||||||
assert_eq!(knowledge.resident_exposure_count, 1);
|
assert_eq!(request.resident_exposure_count, 1);
|
||||||
assert_eq!(knowledge.estimated_tokens_per_injection, 2);
|
assert_eq!(request.estimated_tokens_per_injection, 2);
|
||||||
assert_eq!(knowledge.estimated_total_resident_exposure_tokens, 2);
|
assert_eq!(request.estimated_total_resident_exposure_tokens, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resident_only_record_does_not_increment_use_count() {
|
fn resident_only_record_does_not_increment_use_count() {
|
||||||
let (_dir, layout) = setup();
|
let (_dir, layout) = setup();
|
||||||
let snapshot = snapshot_record_from_bytes(RecordKind::Knowledge, "policy", b"abcdefgh");
|
let snapshot = snapshot_record_from_bytes(RecordKind::Decision, "policy", b"abcdefgh");
|
||||||
append_resident_exposure_event(&layout, "session", vec![snapshot]).unwrap();
|
append_resident_exposure_event(&layout, "session", vec![snapshot]).unwrap();
|
||||||
|
|
||||||
let report = build_usage_report(&layout).unwrap();
|
let report = build_usage_report(&layout).unwrap();
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,8 @@
|
||||||
//! `WorkspaceLayout` carries the root used by the memory subsystem.
|
//! `WorkspaceLayout` carries the root used by the memory subsystem.
|
||||||
//! All yoi-managed memory content lives under the conventional
|
//! All yoi-managed memory content lives under the conventional
|
||||||
//! `<root>/.yoi/` subdirectory — alongside workspace project records
|
//! `<root>/.yoi/` subdirectory — alongside workspace project records
|
||||||
//! such as workflow and generated durable memory. The trees inside it:
|
//! 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/summary.md`
|
||||||
//! - `<root>/.yoi/memory/decisions/<slug>.md`
|
//! - `<root>/.yoi/memory/decisions/<slug>.md`
|
||||||
//! - `<root>/.yoi/memory/requests/<slug>.md`
|
//! - `<root>/.yoi/memory/requests/<slug>.md`
|
||||||
|
|
@ -14,9 +12,6 @@
|
||||||
//! - `<root>/.yoi/memory/_logs/current.log` (append-only audit log)
|
//! - `<root>/.yoi/memory/_logs/current.log` (append-only audit log)
|
||||||
//!
|
//!
|
||||||
//! `memory/` is reserved for session-derived / generated state;
|
//! `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
|
//! `memory.workspace_root` pins this root explicitly. Without an explicit
|
||||||
//! root, resolution searches upward from the Worker pwd for a `.yoi/memory`
|
//! root, resolution searches upward from the Worker pwd for a `.yoi/memory`
|
||||||
//! marker; `.yoi` project records alone are not a memory marker.
|
//! marker; `.yoi` project records alone are not a memory marker.
|
||||||
|
|
@ -30,8 +25,6 @@ use lint_common::RecordLintError;
|
||||||
|
|
||||||
const YOI_DIR: &str = ".yoi";
|
const YOI_DIR: &str = ".yoi";
|
||||||
const MEMORY_DIR: &str = "memory";
|
const MEMORY_DIR: &str = "memory";
|
||||||
const KNOWLEDGE_DIR: &str = "knowledge";
|
|
||||||
const WORKFLOW_DIR: &str = "workflow";
|
|
||||||
const SUMMARY_FILE: &str = "summary.md";
|
const SUMMARY_FILE: &str = "summary.md";
|
||||||
const DECISIONS_DIR: &str = "decisions";
|
const DECISIONS_DIR: &str = "decisions";
|
||||||
const REQUESTS_DIR: &str = "requests";
|
const REQUESTS_DIR: &str = "requests";
|
||||||
|
|
@ -47,8 +40,6 @@ pub enum RecordKind {
|
||||||
Summary,
|
Summary,
|
||||||
Decision,
|
Decision,
|
||||||
Request,
|
Request,
|
||||||
Workflow,
|
|
||||||
Knowledge,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RecordKind {
|
impl RecordKind {
|
||||||
|
|
@ -57,8 +48,6 @@ impl RecordKind {
|
||||||
Self::Summary => "summary",
|
Self::Summary => "summary",
|
||||||
Self::Decision => "decision",
|
Self::Decision => "decision",
|
||||||
Self::Request => "request",
|
Self::Request => "request",
|
||||||
Self::Workflow => "workflow",
|
|
||||||
Self::Knowledge => "knowledge",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -86,7 +75,7 @@ impl WorkspaceLayout {
|
||||||
/// An explicit `memory.workspace_root` is honored exactly. Without an
|
/// An explicit `memory.workspace_root` is honored exactly. Without an
|
||||||
/// explicit root, resolution searches `default_root` and its ancestors for
|
/// explicit root, resolution searches `default_root` and its ancestors for
|
||||||
/// the nearest `.yoi/memory` directory. This keeps child worktrees that
|
/// the nearest `.yoi/memory` directory. This keeps child worktrees that
|
||||||
/// contain `.yoi` project records such as tickets or workflows from
|
/// contain `.yoi` project records such as tickets from
|
||||||
/// becoming independent memory roots merely because they contain `.yoi`.
|
/// becoming independent memory roots merely because they contain `.yoi`.
|
||||||
///
|
///
|
||||||
/// If no memory marker exists, this falls back to `default_root` because
|
/// If no memory marker exists, this falls back to `default_root` because
|
||||||
|
|
@ -117,10 +106,6 @@ impl WorkspaceLayout {
|
||||||
self.yoi_dir().join(MEMORY_DIR)
|
self.yoi_dir().join(MEMORY_DIR)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn knowledge_dir(&self) -> PathBuf {
|
|
||||||
self.yoi_dir().join(KNOWLEDGE_DIR)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn summary_path(&self) -> PathBuf {
|
pub fn summary_path(&self) -> PathBuf {
|
||||||
self.memory_dir().join(SUMMARY_FILE)
|
self.memory_dir().join(SUMMARY_FILE)
|
||||||
}
|
}
|
||||||
|
|
@ -133,11 +118,6 @@ impl WorkspaceLayout {
|
||||||
self.memory_dir().join(REQUESTS_DIR)
|
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 {
|
pub fn staging_dir(&self) -> PathBuf {
|
||||||
self.memory_dir().join(STAGING_DIR)
|
self.memory_dir().join(STAGING_DIR)
|
||||||
}
|
}
|
||||||
|
|
@ -170,16 +150,8 @@ impl WorkspaceLayout {
|
||||||
self.requests_dir().join(format!("{slug}.md"))
|
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
|
/// Classify a path under the memory tree. Returns `None` if the
|
||||||
/// path is not under `.yoi/memory/` or `.yoi/knowledge/`
|
/// path is not under `.yoi/memory/`
|
||||||
/// of this workspace, or if it lives in
|
/// of this workspace, or if it lives in
|
||||||
/// `_staging/` / `_usage/` / `_logs/` (opaque subsystem-owned trees).
|
/// `_staging/` / `_usage/` / `_logs/` (opaque subsystem-owned trees).
|
||||||
///
|
///
|
||||||
|
|
@ -189,11 +161,7 @@ impl WorkspaceLayout {
|
||||||
/// can surface it as a write violation.
|
/// can surface it as a write violation.
|
||||||
pub fn classify(&self, path: &Path) -> Result<Option<ClassifiedPath>, LintError> {
|
pub fn classify(&self, path: &Path) -> Result<Option<ClassifiedPath>, LintError> {
|
||||||
let memory = self.memory_dir();
|
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) {
|
let rel = match path.strip_prefix(&memory) {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(_) => return Ok(None),
|
Err(_) => return Ok(None),
|
||||||
|
|
@ -299,24 +267,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classifies_knowledge() {
|
fn staging_tree_is_opaque_to_classifier() {
|
||||||
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!(
|
assert!(
|
||||||
layout()
|
layout()
|
||||||
.classify(&PathBuf::from("/ws/.yoi/memory/_staging/abc.json"))
|
.classify(&PathBuf::from("/ws/.yoi/memory/_staging/abc.json"))
|
||||||
|
|
@ -414,7 +365,6 @@ mod tests {
|
||||||
let child = workspace.join(".worktree/child");
|
let child = workspace.join(".worktree/child");
|
||||||
std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap();
|
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/tickets")).unwrap();
|
||||||
std::fs::create_dir_all(child.join(".yoi/workflow")).unwrap();
|
|
||||||
|
|
||||||
let cfg = manifest::MemoryConfig::default();
|
let cfg = manifest::MemoryConfig::default();
|
||||||
let layout = WorkspaceLayout::resolve(&cfg, &child);
|
let layout = WorkspaceLayout::resolve(&cfg, &child);
|
||||||
|
|
@ -427,7 +377,6 @@ mod tests {
|
||||||
let workspace = tmp.path().join("workspace");
|
let workspace = tmp.path().join("workspace");
|
||||||
let child = workspace.join("child");
|
let child = workspace.join("child");
|
||||||
std::fs::create_dir_all(workspace.join(".yoi/tickets")).unwrap();
|
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();
|
std::fs::create_dir_all(&child).unwrap();
|
||||||
|
|
||||||
assert_eq!(find_memory_marker_root(&child), None);
|
assert_eq!(find_memory_marker_root(&child), None);
|
||||||
|
|
|
||||||
|
|
@ -70,9 +70,7 @@ pub enum Method {
|
||||||
///
|
///
|
||||||
/// Reply is sent on the same socket as `Event::Completions` (not
|
/// Reply is sent on the same socket as `Event::Completions` (not
|
||||||
/// broadcast). The IPC server handles this directly and writes
|
/// broadcast). The IPC server handles this directly and writes
|
||||||
/// the response straight back to the requesting socket. Empty
|
/// the response straight back to the requesting socket.
|
||||||
/// results for resolvers that are not yet wired up
|
|
||||||
/// (Knowledge / Workflow).
|
|
||||||
ListCompletions {
|
ListCompletions {
|
||||||
kind: CompletionKind,
|
kind: CompletionKind,
|
||||||
prefix: String,
|
prefix: String,
|
||||||
|
|
@ -173,7 +171,7 @@ impl WorkerEvent {
|
||||||
/// `Method::Run` and `Event::UserMessage` carry `Vec<Segment>`. Dumb
|
/// `Method::Run` and `Event::UserMessage` carry `Vec<Segment>`. Dumb
|
||||||
/// clients (CLI piping, scripts) only need to produce a single
|
/// clients (CLI piping, scripts) only need to produce a single
|
||||||
/// `Segment::Text`; richer clients (TUI / GUI) construct typed atoms
|
/// `Segment::Text`; richer clients (TUI / GUI) construct typed atoms
|
||||||
/// (paste chips, file refs, knowledge refs, workflow invocations) and
|
/// (paste chips, file refs) and
|
||||||
/// send them through directly so the Worker side never has to re-parse a
|
/// send them through directly so the Worker side never has to re-parse a
|
||||||
/// flattened string.
|
/// flattened string.
|
||||||
///
|
///
|
||||||
|
|
@ -203,10 +201,6 @@ pub enum Segment {
|
||||||
/// `[Dir: <path>]` listings; the flattened user text keeps the literal
|
/// `[Dir: <path>]` listings; the flattened user text keeps the literal
|
||||||
/// `@<path>` placeholder either way.
|
/// `@<path>` placeholder either way.
|
||||||
FileRef { path: String },
|
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
|
/// Unknown variant from a newer client. Worker treats this as an
|
||||||
/// unresolved input — surfaces an alert and inserts a placeholder.
|
/// unresolved input — surfaces an alert and inserts a placeholder.
|
||||||
/// Round-trip is lossy: re-serializing yields `{"kind":"unknown"}`.
|
/// Round-trip is lossy: re-serializing yields `{"kind":"unknown"}`.
|
||||||
|
|
@ -225,9 +219,8 @@ impl Segment {
|
||||||
/// to surface user-visible alerts for unresolved refs should do so
|
/// to surface user-visible alerts for unresolved refs should do so
|
||||||
/// alongside this call (Worker does so at submit time).
|
/// alongside this call (Worker does so at submit time).
|
||||||
///
|
///
|
||||||
/// Sigil-prefixed variants (`FileRef` / `KnowledgeRef` / `WorkflowInvoke`)
|
/// Sigil-prefixed variants (`FileRef`) flatten back to their literal
|
||||||
/// flatten back to their literal sigil form (`@<path>`, `#<slug>`,
|
/// sigil form (`@<path>`) when converted to text.
|
||||||
/// `/<slug>`) — matching what the user originally typed. Resolved
|
|
||||||
/// content (e.g. file body or shallow directory listing for `FileRef`) is
|
/// content (e.g. file body or shallow directory listing for `FileRef`) is
|
||||||
/// delivered as separate `Item::system_message`s adjacent to the user
|
/// delivered as separate `Item::system_message`s adjacent to the user
|
||||||
/// message; the resolution itself is the caller's job. `Unknown` falls back to
|
/// message; the resolution itself is the caller's job. `Unknown` falls back to
|
||||||
|
|
@ -242,17 +235,7 @@ impl Segment {
|
||||||
out.push('@');
|
out.push('@');
|
||||||
out.push_str(path);
|
out.push_str(path);
|
||||||
}
|
}
|
||||||
Segment::KnowledgeRef { slug } => {
|
Segment::Unknown => {}
|
||||||
out.push('#');
|
|
||||||
out.push_str(slug);
|
|
||||||
}
|
|
||||||
Segment::WorkflowInvoke { slug } => {
|
|
||||||
out.push('/');
|
|
||||||
out.push_str(slug);
|
|
||||||
}
|
|
||||||
Segment::Unknown => {
|
|
||||||
out.push_str("[unknown input segment]");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
|
|
@ -295,8 +278,7 @@ pub enum Event {
|
||||||
///
|
///
|
||||||
/// Carries the JSON form of `session_store::SystemItem`. Covers
|
/// Carries the JSON form of `session_store::SystemItem`. Covers
|
||||||
/// `Method::Notify` echoes, child-Worker lifecycle events from
|
/// `Method::Notify` echoes, child-Worker lifecycle events from
|
||||||
/// `Method::WorkerEvent`, `@<path>` / `#<slug>` / `/<slug>`
|
/// `Method::WorkerEvent`, `@<path>` resolution payloads, and any future
|
||||||
/// resolution payloads, and any future agent-side injection kind.
|
|
||||||
/// Clients dispatch on the `kind` tag for typed rendering instead
|
/// Clients dispatch on the `kind` tag for typed rendering instead
|
||||||
/// of parsing free-text prefixes like `[Notification] …` or
|
/// of parsing free-text prefixes like `[Notification] …` or
|
||||||
/// `[File: …]`.
|
/// `[File: …]`.
|
||||||
|
|
@ -607,26 +589,17 @@ pub enum AlertSource {
|
||||||
|
|
||||||
/// Kind of completion requested by `Method::ListCompletions`.
|
/// Kind of completion requested by `Method::ListCompletions`.
|
||||||
///
|
///
|
||||||
/// Mirrors the TUI prefix sigils: `@` → `File`, `#` → `Knowledge`,
|
/// Mirrors the completion prefix sigil: `@` → `File`.
|
||||||
/// `/` → `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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum CompletionKind {
|
pub enum CompletionKind {
|
||||||
File,
|
File,
|
||||||
Knowledge,
|
|
||||||
Workflow,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One candidate returned in `Event::Completions::entries`.
|
/// One completion candidate for a prefix query.
|
||||||
///
|
///
|
||||||
/// `value` is a path (file kind) or a slug (knowledge / workflow).
|
/// `value` is a path (file kind).
|
||||||
/// `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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct CompletionEntry {
|
pub struct CompletionEntry {
|
||||||
|
|
@ -1190,16 +1163,16 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn event_completions_format_and_default_is_dir() {
|
fn event_completions_format_and_default_is_dir() {
|
||||||
let event = Event::Completions {
|
let event = Event::Completions {
|
||||||
kind: CompletionKind::Workflow,
|
kind: CompletionKind::File,
|
||||||
entries: vec![CompletionEntry {
|
entries: vec![CompletionEntry {
|
||||||
value: "clear".into(),
|
value: "src/main.rs".into(),
|
||||||
is_dir: false,
|
is_dir: false,
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&event).unwrap();
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(parsed["event"], "completions");
|
assert_eq!(parsed["event"], "completions");
|
||||||
assert_eq!(parsed["data"]["kind"], "workflow");
|
assert_eq!(parsed["data"]["kind"], "file");
|
||||||
assert_eq!(parsed["data"]["entries"][0]["value"], "clear");
|
assert_eq!(parsed["data"]["entries"][0]["value"], "clear");
|
||||||
|
|
||||||
// is_dir defaults to false on inbound payloads that omit it.
|
// is_dir defaults to false on inbound payloads that omit it.
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ pub enum LogEntry {
|
||||||
|
|
||||||
/// User input accepted at submit time. Carries the original typed
|
/// User input accepted at submit time. Carries the original typed
|
||||||
/// `Vec<Segment>` so clients can re-render typed atoms (paste chips,
|
/// `Vec<Segment>` so clients can re-render typed atoms (paste chips,
|
||||||
/// file/knowledge refs, workflow invocations) on segment restore.
|
/// file refs) on segment restore.
|
||||||
/// Replay flattens these into a `Item::user_message` for the worker
|
/// Replay flattens these into a `Item::user_message` for the worker
|
||||||
/// history; the worker layer never sees segments directly.
|
/// history; the worker layer never sees segments directly.
|
||||||
UserInput { ts: u64, segments: Vec<Segment> },
|
UserInput { ts: u64, segments: Vec<Segment> },
|
||||||
|
|
@ -88,8 +88,8 @@ pub enum LogEntry {
|
||||||
ToolResult { ts: u64, item: LoggedItem },
|
ToolResult { ts: u64, item: LoggedItem },
|
||||||
|
|
||||||
/// One typed agent-injected system item: notification, child-Worker
|
/// One typed agent-injected system item: notification, child-Worker
|
||||||
/// lifecycle event, `@<path>` / `#<slug>` / `/<slug>` resolution
|
/// lifecycle event, `@<path>` / `/<slug>` resolution payload. Each
|
||||||
/// payload. Each `SystemItem` carries kind metadata that the LLM
|
/// `SystemItem` carries kind metadata that the LLM
|
||||||
/// itself never sees (the LLM gets `Item::system_message` with the
|
/// itself never sees (the LLM gets `Item::system_message` with the
|
||||||
/// item's denormalised `body`), but live clients and replay paths
|
/// item's denormalised `body`), but live clients and replay paths
|
||||||
/// dispatch on `kind` for typed rendering.
|
/// dispatch on `kind` for typed rendering.
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
//!
|
//!
|
||||||
//! Items in worker history with `role:system` are never produced by the
|
//! Items in worker history with `role:system` are never produced by the
|
||||||
//! LLM — they are always inserted by the Worker itself (notifications,
|
//! LLM — they are always inserted by the Worker itself (notifications,
|
||||||
//! file/knowledge/workflow ref resolutions, child-worker lifecycle events,
|
//! file ref resolutions, child-worker lifecycle events,
|
||||||
//! future `<system-reminder>` tags, …). [`SystemItem`] carries the
|
//! future `<system-reminder>` tags, …). [`SystemItem`] carries the
|
||||||
//! typed shape of each such injection so clients can dispatch on
|
//! typed shape of each such injection so clients can dispatch on
|
||||||
//! `kind` instead of parsing text prefixes like `[Notification] …` or
|
//! `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
|
/// Each variant carries the kind-specific raw data clients use for
|
||||||
/// typed rendering (`Notification.message`, `WorkerEvent.event`, file
|
/// typed rendering (`Notification.message`, `WorkerEvent.event`, file
|
||||||
/// path / knowledge slug / workflow slug / etc.), plus a pre-rendered
|
/// path / identifier / etc.), plus a pre-rendered
|
||||||
/// `body` (where applicable) that is the exact `role:system` text the
|
/// `body` (where applicable) that is the exact `role:system` text the
|
||||||
/// LLM actually saw at commit time. `body` is denormalised so that
|
/// LLM actually saw at commit time. `body` is denormalised so that
|
||||||
/// segment log replay reconstructs worker history byte-identical to
|
/// segment log replay reconstructs worker history byte-identical to
|
||||||
|
|
@ -139,14 +139,21 @@ pub enum SystemItem {
|
||||||
/// byte-identical to what was sent.
|
/// byte-identical to what was sent.
|
||||||
FileAttachment { path: String, body: String },
|
FileAttachment { path: String, body: String },
|
||||||
|
|
||||||
/// `#<slug>` Knowledge reference resolution. `body` is the
|
/// Explicit Agent Skill activation. `body` is the exact LLM-facing
|
||||||
/// rendered text the LLM saw (Worker composes the `[Knowledge: …]`
|
/// Skill text committed before any subsequent LLM request can use it.
|
||||||
/// header + body).
|
SkillActivation { name: String, body: String },
|
||||||
Knowledge { slug: String, body: String },
|
|
||||||
|
|
||||||
/// `/<slug>` Workflow invocation. `body` is the workflow's
|
/// Historical persisted Knowledge reference resolution. Knowledge is no
|
||||||
/// prompt body materialized into the LLM context.
|
/// longer active, so restored sessions ignore this item instead of replaying
|
||||||
Workflow { slug: String, body: String },
|
/// 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 },
|
||||||
|
|
||||||
/// Task-management inactivity reminder inserted before an LLM request.
|
/// Task-management inactivity reminder inserted before an LLM request.
|
||||||
/// `source` is the policy that produced this durable reminder; `body` is
|
/// `source` is the policy that produced this durable reminder; `body` is
|
||||||
|
|
@ -171,8 +178,11 @@ impl SystemItem {
|
||||||
SystemItem::Notification { body, .. } => body.clone(),
|
SystemItem::Notification { body, .. } => body.clone(),
|
||||||
SystemItem::WorkerEvent { body, .. } => body.clone(),
|
SystemItem::WorkerEvent { body, .. } => body.clone(),
|
||||||
SystemItem::FileAttachment { body, .. } => body.clone(),
|
SystemItem::FileAttachment { body, .. } => body.clone(),
|
||||||
SystemItem::Knowledge { body, .. } => body.clone(),
|
SystemItem::SkillActivation { body, .. } => body.clone(),
|
||||||
SystemItem::Workflow { body, .. } => body.clone(),
|
SystemItem::LegacyKnowledgeIgnored { .. } => String::new(),
|
||||||
|
SystemItem::LegacyIgnored { slug } => {
|
||||||
|
format!("Ignored legacy procedure item: /{slug}")
|
||||||
|
}
|
||||||
SystemItem::TaskReminder { body, .. } => body.clone(),
|
SystemItem::TaskReminder { body, .. } => body.clone(),
|
||||||
SystemItem::Interrupt { body } => body.clone(),
|
SystemItem::Interrupt { body } => body.clone(),
|
||||||
}
|
}
|
||||||
|
|
@ -191,8 +201,9 @@ impl SystemItem {
|
||||||
SystemItem::Notification { .. } => "notification",
|
SystemItem::Notification { .. } => "notification",
|
||||||
SystemItem::WorkerEvent { .. } => "worker_event",
|
SystemItem::WorkerEvent { .. } => "worker_event",
|
||||||
SystemItem::FileAttachment { .. } => "file_attachment",
|
SystemItem::FileAttachment { .. } => "file_attachment",
|
||||||
SystemItem::Knowledge { .. } => "knowledge",
|
SystemItem::SkillActivation { .. } => "skill_activation",
|
||||||
SystemItem::Workflow { .. } => "workflow",
|
SystemItem::LegacyKnowledgeIgnored { .. } => "legacy_knowledge_ignored",
|
||||||
|
SystemItem::LegacyIgnored { .. } => "legacy_ignored",
|
||||||
SystemItem::TaskReminder { .. } => "task_reminder",
|
SystemItem::TaskReminder { .. } => "task_reminder",
|
||||||
SystemItem::Interrupt { .. } => "interrupt",
|
SystemItem::Interrupt { .. } => "interrupt",
|
||||||
}
|
}
|
||||||
|
|
@ -306,6 +317,17 @@ 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]
|
#[test]
|
||||||
fn round_trip_via_json() {
|
fn round_trip_via_json() {
|
||||||
let item = SystemItem::FileAttachment {
|
let item = SystemItem::FileAttachment {
|
||||||
|
|
|
||||||
|
|
@ -49,9 +49,8 @@ pub fn ticket_config_scaffold() -> String {
|
||||||
);
|
);
|
||||||
for role in TicketRole::ALL {
|
for role in TicketRole::ALL {
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
"\n[ticket.roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"\n",
|
"\n[ticket.roles.{role}]\nprofile = \"{}\"\n",
|
||||||
role.default_profile(),
|
role.default_profile(),
|
||||||
role.default_workflow()
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
|
|
@ -349,10 +348,6 @@ impl TicketConfig {
|
||||||
pub fn launch_prompt_for(&self, role: TicketRole) -> Option<&PromptRef> {
|
pub fn launch_prompt_for(&self, role: TicketRole) -> Option<&PromptRef> {
|
||||||
self.role(role).launch_prompt.as_ref()
|
self.role(role).launch_prompt.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn workflow_for(&self, role: TicketRole) -> &WorkflowRef {
|
|
||||||
&self.role(role).workflow
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|
@ -437,14 +432,6 @@ 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 {
|
pub fn default_profile(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Intake => "builtin:intake",
|
Self::Intake => "builtin:intake",
|
||||||
|
|
@ -576,15 +563,13 @@ pub enum TicketRoleLaunchConfigError {
|
||||||
pub struct TicketRoleConfig {
|
pub struct TicketRoleConfig {
|
||||||
pub profile: ProfileSelectorRef,
|
pub profile: ProfileSelectorRef,
|
||||||
pub launch_prompt: Option<PromptRef>,
|
pub launch_prompt: Option<PromptRef>,
|
||||||
pub workflow: WorkflowRef,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TicketRoleConfig {
|
impl TicketRoleConfig {
|
||||||
pub fn default_for_role(role: TicketRole) -> Self {
|
pub fn default_for_role(_role: TicketRole) -> Self {
|
||||||
Self {
|
Self {
|
||||||
profile: ProfileSelectorRef::inherit(),
|
profile: ProfileSelectorRef::inherit(),
|
||||||
launch_prompt: None,
|
launch_prompt: None,
|
||||||
workflow: WorkflowRef::from_static(role.default_workflow()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -685,45 +670,6 @@ 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> {
|
fn normalized_non_empty(value: impl Into<String>, label: &str) -> Result<String, String> {
|
||||||
let value = value.into();
|
let value = value.into();
|
||||||
let trimmed = value.trim();
|
let trimmed = value.trim();
|
||||||
|
|
@ -930,18 +876,13 @@ struct RawTicketRoleConfig {
|
||||||
profile: Option<ProfileSelectorRef>,
|
profile: Option<ProfileSelectorRef>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
launch_prompt: Option<PromptRef>,
|
launch_prompt: Option<PromptRef>,
|
||||||
#[serde(default)]
|
|
||||||
workflow: Option<WorkflowRef>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RawTicketRoleConfig {
|
impl RawTicketRoleConfig {
|
||||||
fn resolve(self, role: TicketRole) -> TicketRoleConfig {
|
fn resolve(self, _role: TicketRole) -> TicketRoleConfig {
|
||||||
TicketRoleConfig {
|
TicketRoleConfig {
|
||||||
profile: self.profile.unwrap_or_else(ProfileSelectorRef::inherit),
|
profile: self.profile.unwrap_or_else(ProfileSelectorRef::inherit),
|
||||||
launch_prompt: self.launch_prompt,
|
launch_prompt: self.launch_prompt,
|
||||||
workflow: self
|
|
||||||
.workflow
|
|
||||||
.unwrap_or_else(|| WorkflowRef::from_static(role.default_workflow())),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -999,7 +940,6 @@ mod tests {
|
||||||
let role_config = config.role(role);
|
let role_config = config.role(role);
|
||||||
assert_eq!(role_config.profile.as_str(), "inherit");
|
assert_eq!(role_config.profile.as_str(), "inherit");
|
||||||
assert!(role_config.launch_prompt.is_none());
|
assert!(role_config.launch_prompt.is_none());
|
||||||
assert_eq!(role_config.workflow.as_str(), role.default_workflow());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1108,22 +1048,18 @@ worktree_name = "custom-orchestrator"
|
||||||
[ticket.roles.intake]
|
[ticket.roles.intake]
|
||||||
profile = "project:intake"
|
profile = "project:intake"
|
||||||
launch_prompt = "$workspace/ticket/intake/launch"
|
launch_prompt = "$workspace/ticket/intake/launch"
|
||||||
workflow = "ticket-intake-workflow"
|
|
||||||
|
|
||||||
[ticket.roles.orchestrator]
|
[ticket.roles.orchestrator]
|
||||||
profile = "project:orchestrator"
|
profile = "project:orchestrator"
|
||||||
launch_prompt = "$workspace/ticket/orchestrator/launch"
|
launch_prompt = "$workspace/ticket/orchestrator/launch"
|
||||||
workflow = "ticket-orchestrator-routing"
|
|
||||||
|
|
||||||
[ticket.roles.coder]
|
[ticket.roles.coder]
|
||||||
profile = "inherit"
|
profile = "inherit"
|
||||||
launch_prompt = "$workspace/ticket/coder/launch"
|
launch_prompt = "$workspace/ticket/coder/launch"
|
||||||
workflow = "multi-agent-workflow"
|
|
||||||
|
|
||||||
[ticket.roles.reviewer]
|
[ticket.roles.reviewer]
|
||||||
profile = "project:reviewer"
|
profile = "project:reviewer"
|
||||||
launch_prompt = "$workspace/ticket/reviewer/launch"
|
launch_prompt = "$workspace/ticket/reviewer/launch"
|
||||||
workflow = "multi-agent-workflow"
|
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1161,10 +1097,6 @@ workflow = "multi-agent-workflow"
|
||||||
.as_str(),
|
.as_str(),
|
||||||
"$workspace/ticket/reviewer/launch"
|
"$workspace/ticket/reviewer/launch"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
config.workflow_for(TicketRole::Reviewer).as_str(),
|
|
||||||
"multi-agent-workflow"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1183,9 +1115,8 @@ workflow = "multi-agent-workflow"
|
||||||
for role in TicketRole::ALL {
|
for role in TicketRole::ALL {
|
||||||
assert!(scaffold.contains(&format!("[ticket.roles.{role}]")));
|
assert!(scaffold.contains(&format!("[ticket.roles.{role}]")));
|
||||||
assert!(scaffold.contains(&format!(
|
assert!(scaffold.contains(&format!(
|
||||||
"[ticket.roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"",
|
"[ticket.roles.{role}]\nprofile = \"{}\"",
|
||||||
role.default_profile(),
|
role.default_profile()
|
||||||
role.default_workflow()
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
assert!(!scaffold.contains("[ticket.roles.investigator]"));
|
assert!(!scaffold.contains("[ticket.roles.investigator]"));
|
||||||
|
|
@ -1211,7 +1142,6 @@ workflow = "multi-agent-workflow"
|
||||||
for role in TicketRole::ALL {
|
for role in TicketRole::ALL {
|
||||||
let role_config = config.role_launch_config(role).unwrap();
|
let role_config = config.role_launch_config(role).unwrap();
|
||||||
assert_eq!(role_config.profile.as_str(), role.default_profile());
|
assert_eq!(role_config.profile.as_str(), role.default_profile());
|
||||||
assert_eq!(role_config.workflow.as_str(), role.default_workflow());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1230,7 +1160,6 @@ profile = "project:coder"
|
||||||
let coder = config.role(TicketRole::Coder);
|
let coder = config.role(TicketRole::Coder);
|
||||||
assert_eq!(coder.profile.as_str(), "project:coder");
|
assert_eq!(coder.profile.as_str(), "project:coder");
|
||||||
assert!(coder.launch_prompt.is_none());
|
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");
|
assert_eq!(config.profile_for(TicketRole::Reviewer).as_str(), "inherit");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1319,7 +1248,6 @@ branch = "orchestration/panel:bad"
|
||||||
temp.path(),
|
temp.path(),
|
||||||
r#"
|
r#"
|
||||||
[roles.orchestrator]
|
[roles.orchestrator]
|
||||||
workflow = "ticket-orchestrator-routing"
|
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1386,7 +1314,6 @@ profile = "inherit"
|
||||||
r#"
|
r#"
|
||||||
[roles.investigator]
|
[roles.investigator]
|
||||||
profile = "builtin:default"
|
profile = "builtin:default"
|
||||||
workflow = "ticket-orchestrator-routing"
|
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@ fn base_tool_description(name: &str) -> &'static str {
|
||||||
/// Build the model-visible Ticket tool description for a configured Ticket backend.
|
/// Build the model-visible Ticket tool description for a configured Ticket backend.
|
||||||
///
|
///
|
||||||
/// `record_language` is the durable Ticket record/tool-body language, distinct from
|
/// `record_language` is the durable Ticket record/tool-body language, distinct from
|
||||||
/// worker response language and Memory/Knowledge language. Keeping this on the tool
|
/// worker response language and Memory language. Keeping this on the tool
|
||||||
/// surface ensures every Ticket-capable Worker sees the policy without hidden context
|
/// surface ensures every Ticket-capable Worker sees the policy without hidden context
|
||||||
/// injection or role-launch-only prose.
|
/// injection or role-launch-only prose.
|
||||||
pub fn ticket_tool_description(name: &str, record_language: Option<&str>) -> String {
|
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()) {
|
if let Some(language) = record_language.filter(|language| !language.trim().is_empty()) {
|
||||||
description.push_str("\n\nTicket record language: ");
|
description.push_str("\n\nTicket record language: ");
|
||||||
description.push_str(language.trim());
|
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/Knowledge. 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. Preserve protocol literals, file paths, commands, logs, identifiers, and quoted external text when translation would reduce fidelity.");
|
||||||
}
|
}
|
||||||
description
|
description
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -595,8 +595,6 @@ impl App {
|
||||||
self.input_history.cancel_browse();
|
self.input_history.cancel_browse();
|
||||||
match kind {
|
match kind {
|
||||||
CompletionKind::File => self.input.replace_with_file_ref(start, value),
|
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;
|
self.completion = None;
|
||||||
true
|
true
|
||||||
|
|
@ -608,8 +606,6 @@ impl App {
|
||||||
/// suggestion" — partial typing like `@README.` followed by
|
/// suggestion" — partial typing like `@README.` followed by
|
||||||
/// Enter should chip when the popup is on `README.md`.
|
/// 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`
|
/// so the caller can fall through to `apply_completion_text`
|
||||||
/// for drill-in — chip-ifying a directory on Enter would strand
|
/// for drill-in — chip-ifying a directory on Enter would strand
|
||||||
/// the user with no way to inspect children.
|
/// the user with no way to inspect children.
|
||||||
|
|
@ -630,8 +626,6 @@ impl App {
|
||||||
self.input_history.cancel_browse();
|
self.input_history.cancel_browse();
|
||||||
match kind {
|
match kind {
|
||||||
CompletionKind::File => self.input.replace_with_file_ref(start, value),
|
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;
|
self.completion = None;
|
||||||
true
|
true
|
||||||
|
|
@ -2169,13 +2163,14 @@ impl App {
|
||||||
self.blocks.push(Block::WorkerEvent { event });
|
self.blocks.push(Block::WorkerEvent { event });
|
||||||
}
|
}
|
||||||
session_store::SystemItem::FileAttachment { body, .. }
|
session_store::SystemItem::FileAttachment { body, .. }
|
||||||
| session_store::SystemItem::Knowledge { body, .. }
|
| session_store::SystemItem::SkillActivation { body, .. }
|
||||||
| session_store::SystemItem::Workflow { body, .. }
|
|
||||||
| session_store::SystemItem::TaskReminder { body, .. }
|
| session_store::SystemItem::TaskReminder { body, .. }
|
||||||
| session_store::SystemItem::Interrupt { body } => {
|
| session_store::SystemItem::Interrupt { body } => {
|
||||||
self.task_store.apply_system_message_text(&body);
|
self.task_store.apply_system_message_text(&body);
|
||||||
self.blocks.push(Block::SystemMessage { text: body });
|
self.blocks.push(Block::SystemMessage { text: body });
|
||||||
}
|
}
|
||||||
|
session_store::SystemItem::LegacyIgnored { .. } => {}
|
||||||
|
session_store::SystemItem::LegacyKnowledgeIgnored { .. } => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2972,24 +2967,6 @@ mod completion_flow_tests {
|
||||||
assert!(app.completion.is_some());
|
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]
|
#[test]
|
||||||
fn committed_user_message_survives_fresh_segment_rotation() {
|
fn committed_user_message_survives_fresh_segment_rotation() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
|
|
@ -3326,7 +3303,7 @@ mod completion_flow_tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn live_system_item_workflow_appends_system_message_block() {
|
fn live_legacy_workflow_system_item_is_ignored() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
let item = serde_json::json!({
|
let item = serde_json::json!({
|
||||||
"kind": "workflow",
|
"kind": "workflow",
|
||||||
|
|
@ -3335,10 +3312,7 @@ mod completion_flow_tests {
|
||||||
});
|
});
|
||||||
app.handle_worker_event(Event::SystemItem { item });
|
app.handle_worker_event(Event::SystemItem { item });
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(app.blocks.is_empty());
|
||||||
app.blocks.as_slice(),
|
|
||||||
[Block::SystemMessage { text }] if text == "[Workflow /build]\nRun the build"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -3590,12 +3564,11 @@ mod completion_flow_tests {
|
||||||
```json\n{\n \"tasks\": [\n {\n \"taskid\": 4,\n \
|
```json\n{\n \"tasks\": [\n {\n \"taskid\": 4,\n \
|
||||||
\"status\": \"inprogress\",\n \"subject\": \"from snapshot\",\n \
|
\"status\": \"inprogress\",\n \"subject\": \"from snapshot\",\n \
|
||||||
\"description\": \"d\"\n }\n ]\n}\n```\n";
|
\"description\": \"d\"\n }\n ]\n}\n```\n";
|
||||||
// Snapshot text injected as a workflow body (kind doesn't matter
|
// Snapshot text injected through an active system item kind; legacy
|
||||||
// for task-store parsing, only the text contents do).
|
// workflow items are intentionally ignored and must not carry active state.
|
||||||
app.handle_worker_event(Event::SystemItem {
|
app.handle_worker_event(Event::SystemItem {
|
||||||
item: serde_json::json!({
|
item: serde_json::json!({
|
||||||
"kind": "workflow",
|
"kind": "task_reminder",
|
||||||
"slug": "task-snapshot",
|
|
||||||
"body": snapshot,
|
"body": snapshot,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
@ -3704,15 +3677,6 @@ mod completion_flow_tests {
|
||||||
Segment::Text {
|
Segment::Text {
|
||||||
content: " and ".into(),
|
content: " and ".into(),
|
||||||
},
|
},
|
||||||
Segment::KnowledgeRef {
|
|
||||||
slug: "design-note".into(),
|
|
||||||
},
|
|
||||||
Segment::Text {
|
|
||||||
content: " then ".into(),
|
|
||||||
},
|
|
||||||
Segment::WorkflowInvoke {
|
|
||||||
slug: "review".into(),
|
|
||||||
},
|
|
||||||
Segment::Paste {
|
Segment::Paste {
|
||||||
id: 1,
|
id: 1,
|
||||||
chars: 13,
|
chars: 13,
|
||||||
|
|
|
||||||
|
|
@ -4949,7 +4949,7 @@ fn orchestrator_queue_notification_message(
|
||||||
) -> String {
|
) -> String {
|
||||||
let title = ticket.title.replace(['\r', '\n'], " ");
|
let title = ticket.title.replace(['\r', '\n'], " ");
|
||||||
format!(
|
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, 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.",
|
"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.",
|
||||||
ticket.id,
|
ticket.id,
|
||||||
title.trim()
|
title.trim()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -825,18 +825,10 @@ fn ticket_queue_notification_message_carries_routing_contract() {
|
||||||
assert!(message.contains("transition state queued -> inprogress"));
|
assert!(message.contains("transition state queued -> inprogress"));
|
||||||
assert!(message.contains("before any worktree/SpawnWorker implementation side effects"));
|
assert!(message.contains("before any worktree/SpawnWorker implementation side effects"));
|
||||||
assert!(message.contains("After inprogress acceptance"));
|
assert!(message.contains("After inprogress acceptance"));
|
||||||
assert!(message.contains("worktree-workflow"));
|
assert!(message.contains("implementation worktree"));
|
||||||
assert!(message.contains("`.worktree/<task-name>`"));
|
|
||||||
assert!(message.contains("tracked `.yoi` project records visible"));
|
assert!(message.contains("tracked `.yoi` project records visible"));
|
||||||
assert!(
|
assert!(message.contains("generated/local/runtime/log/lock/secret-like `.yoi` paths excluded"));
|
||||||
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("sibling coder/reviewer Workers"));
|
||||||
assert!(message.contains("coder narrow child-worktree write scope"));
|
|
||||||
assert!(message.contains("reviewer read-only by default"));
|
|
||||||
assert!(message.contains(
|
assert!(message.contains(
|
||||||
"integrate the implementation branch into the orchestration branch automatically"
|
"integrate the implementation branch into the orchestration branch automatically"
|
||||||
));
|
));
|
||||||
|
|
@ -2791,7 +2783,6 @@ fn dashboard_ticket_intake_finish_success_clears_composer_and_reports_pod() {
|
||||||
role: TicketRole::Intake,
|
role: TicketRole::Intake,
|
||||||
worker_name: "intake-worker".to_string(),
|
worker_name: "intake-worker".to_string(),
|
||||||
profile: "builtin:default".to_string(),
|
profile: "builtin:default".to_string(),
|
||||||
workflow: "ticket-intake-workflow".to_string(),
|
|
||||||
launch_prompt_ref: None,
|
launch_prompt_ref: None,
|
||||||
run_segments: vec![],
|
run_segments: vec![],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -46,37 +46,11 @@ 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)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Atom {
|
pub enum Atom {
|
||||||
Char(char),
|
Char(char),
|
||||||
Paste(PasteRef),
|
Paste(PasteRef),
|
||||||
FileRef(FileRefAtom),
|
FileRef(FileRefAtom),
|
||||||
KnowledgeRef(KnowledgeRefAtom),
|
|
||||||
WorkflowInvoke(WorkflowInvokeAtom),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Atom {
|
impl Atom {
|
||||||
|
|
@ -87,8 +61,6 @@ impl Atom {
|
||||||
Atom::Char(_) => None,
|
Atom::Char(_) => None,
|
||||||
Atom::Paste(p) => Some((Style::default().fg(Color::Magenta), p.label())),
|
Atom::Paste(p) => Some((Style::default().fg(Color::Magenta), p.label())),
|
||||||
Atom::FileRef(r) => Some((Style::default().fg(Color::Cyan), r.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())),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -97,8 +69,7 @@ impl Atom {
|
||||||
enum AtomClass {
|
enum AtomClass {
|
||||||
Word(WordKind),
|
Word(WordKind),
|
||||||
Sep,
|
Sep,
|
||||||
/// Indivisible chip — paste / file ref / knowledge ref / workflow
|
/// Indivisible chip — paste / file ref. Word motion treats one chip as one unit; deletion
|
||||||
/// invocation. Word motion treats one chip as one unit; deletion
|
|
||||||
/// removes the whole atom.
|
/// removes the whole atom.
|
||||||
Chip,
|
Chip,
|
||||||
}
|
}
|
||||||
|
|
@ -118,9 +89,7 @@ enum WordKind {
|
||||||
fn atom_class(atom: &Atom) -> AtomClass {
|
fn atom_class(atom: &Atom) -> AtomClass {
|
||||||
match atom {
|
match atom {
|
||||||
Atom::Char(c) => char_class(*c),
|
Atom::Char(c) => char_class(*c),
|
||||||
Atom::Paste(_) | Atom::FileRef(_) | Atom::KnowledgeRef(_) | Atom::WorkflowInvoke(_) => {
|
Atom::Paste(_) | Atom::FileRef(_) => AtomClass::Chip,
|
||||||
AtomClass::Chip
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -212,15 +181,6 @@ impl InputBuffer {
|
||||||
self.atoms
|
self.atoms
|
||||||
.push(Atom::FileRef(FileRefAtom { path: path.clone() }));
|
.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 => {
|
protocol::Segment::Unknown => {
|
||||||
self.atoms
|
self.atoms
|
||||||
.extend("[unknown input segment]".chars().map(Atom::Char));
|
.extend("[unknown input segment]".chars().map(Atom::Char));
|
||||||
|
|
@ -248,8 +208,6 @@ impl InputBuffer {
|
||||||
Atom::Char(c) => text.push(*c),
|
Atom::Char(c) => text.push(*c),
|
||||||
Atom::Paste(paste) => text.push_str(&paste.content),
|
Atom::Paste(paste) => text.push_str(&paste.content),
|
||||||
Atom::FileRef(file) => text.push_str(&file.path),
|
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
|
text
|
||||||
|
|
@ -277,7 +235,7 @@ impl InputBuffer {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace `atoms[start..self.cursor]` (the in-flight `@<typed>` /
|
/// Replace `atoms[start..self.cursor]` (the in-flight `@<typed>` /
|
||||||
/// `#<typed>` / `/<typed>` token) with the corresponding chip atom
|
/// active `@<typed>` file token) with the corresponding chip atom
|
||||||
/// and place the cursor right after the chip. Used by the completion
|
/// and place the cursor right after the chip. Used by the completion
|
||||||
/// confirm path.
|
/// confirm path.
|
||||||
pub fn replace_with_file_ref(&mut self, start: usize, path: String) {
|
pub fn replace_with_file_ref(&mut self, start: usize, path: String) {
|
||||||
|
|
@ -287,20 +245,6 @@ impl InputBuffer {
|
||||||
self.cursor = start + 1;
|
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`,
|
/// Replace `atoms[start..self.cursor]` with the chars of `text`,
|
||||||
/// leaving cursor at the end of the inserted run. Used by the Tab
|
/// leaving cursor at the end of the inserted run. Used by the Tab
|
||||||
/// completion path: the popup-selected entry is inserted as raw
|
/// completion path: the popup-selected entry is inserted as raw
|
||||||
|
|
@ -316,15 +260,15 @@ impl InputBuffer {
|
||||||
self.cursor = idx;
|
self.cursor = idx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If the cursor is currently inside a `@<typed>` / `#<typed>` /
|
/// If the cursor is currently inside a `@<typed>` /
|
||||||
/// `/<typed>` token that satisfies the trigger rules, return the
|
/// `/<typed>` token that satisfies the trigger rules, return the
|
||||||
/// kind, the index of the leading sigil atom, and the typed text
|
/// kind, the index of the leading sigil atom, and the typed text
|
||||||
/// after the sigil (sigil itself excluded).
|
/// after the sigil (sigil itself excluded).
|
||||||
///
|
///
|
||||||
/// Trigger rules:
|
/// 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
|
/// whitespace, or another chip atom — otherwise this is normal
|
||||||
/// text (e.g. the `/` in `src/main.rs` is not a workflow trigger).
|
/// text (e.g. the `/` in `src/main.rs` is not a completion trigger).
|
||||||
/// - Whitespace, newlines and chip atoms invalidate an in-flight
|
/// - Whitespace, newlines and chip atoms invalidate an in-flight
|
||||||
/// token — `@foo /` closes the `@foo` candidate as soon as the
|
/// token — `@foo /` closes the `@foo` candidate as soon as the
|
||||||
/// space lands.
|
/// space lands.
|
||||||
|
|
@ -341,8 +285,7 @@ impl InputBuffer {
|
||||||
}
|
}
|
||||||
let kind = match c {
|
let kind = match c {
|
||||||
'@' => Some(protocol::CompletionKind::File),
|
'@' => Some(protocol::CompletionKind::File),
|
||||||
'#' => Some(protocol::CompletionKind::Knowledge),
|
|
||||||
'/' => Some(protocol::CompletionKind::Workflow),
|
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
if let Some(k) = kind {
|
if let Some(k) = kind {
|
||||||
|
|
@ -511,7 +454,7 @@ impl InputBuffer {
|
||||||
|
|
||||||
/// Build the typed `Vec<Segment>` sent over the protocol. Adjacent
|
/// Build the typed `Vec<Segment>` sent over the protocol. Adjacent
|
||||||
/// `Atom::Char`s are concatenated into a single `Segment::Text`; each
|
/// `Atom::Char`s are concatenated into a single `Segment::Text`; each
|
||||||
/// chip atom (`Paste` / `FileRef` / `KnowledgeRef` / `WorkflowInvoke`)
|
/// chip atom (`Paste` / `FileRef`)
|
||||||
/// becomes a standalone `Segment` so that clients re-rendering an
|
/// becomes a standalone `Segment` so that clients re-rendering an
|
||||||
/// `Event::UserMessage` see the same indivisible chip rather than a
|
/// `Event::UserMessage` see the same indivisible chip rather than a
|
||||||
/// flattened string.
|
/// flattened string.
|
||||||
|
|
@ -541,18 +484,6 @@ impl InputBuffer {
|
||||||
path: r.path.clone(),
|
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() {
|
if !buf.is_empty() {
|
||||||
|
|
@ -993,34 +924,6 @@ mod submit_segments_tests {
|
||||||
assert!(matches!(&segs[0], Segment::Text { content } if content == "see "));
|
assert!(matches!(&segs[0], Segment::Text { content } if content == "see "));
|
||||||
assert!(matches!(&segs[1], Segment::FileRef { path } if path == "src/main.rs"));
|
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)]
|
#[cfg(test)]
|
||||||
|
|
@ -1055,7 +958,7 @@ mod completion_prefix_tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn slash_inside_path_is_not_a_workflow_trigger() {
|
fn slash_inside_path_is_not_a_completion_trigger() {
|
||||||
// After `@src/m`, the only valid trigger is `@`, not the `/`.
|
// After `@src/m`, the only valid trigger is `@`, not the `/`.
|
||||||
let buf = buf_from("@src/m");
|
let buf = buf_from("@src/m");
|
||||||
let (kind, start, prefix) = buf.pending_completion_prefix().unwrap();
|
let (kind, start, prefix) = buf.pending_completion_prefix().unwrap();
|
||||||
|
|
@ -1093,22 +996,6 @@ mod completion_prefix_tests {
|
||||||
assert_eq!(prefix, "sr");
|
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]
|
#[test]
|
||||||
fn newline_before_cursor_invalidates_trigger() {
|
fn newline_before_cursor_invalidates_trigger() {
|
||||||
let buf = buf_from("@a\nbc");
|
let buf = buf_from("@a\nbc");
|
||||||
|
|
@ -1307,10 +1194,7 @@ mod word_motion_tests {
|
||||||
for a in &buf.atoms {
|
for a in &buf.atoms {
|
||||||
match a {
|
match a {
|
||||||
Atom::Char(c) => out.push(*c),
|
Atom::Char(c) => out.push(*c),
|
||||||
Atom::Paste(_)
|
Atom::Paste(_) | Atom::FileRef(_) => out.push_str("<P>"),
|
||||||
| Atom::FileRef(_)
|
|
||||||
| Atom::KnowledgeRef(_)
|
|
||||||
| Atom::WorkflowInvoke(_) => out.push_str("<P>"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// Render `Block::UserMessage` from typed segments. Each non-text
|
||||||
/// segment renders as a one-piece chip whose colour matches the input
|
/// segment renders as a one-piece chip whose colour matches the input
|
||||||
/// area's chip presentation (paste = magenta, `@` file = cyan,
|
/// area's chip presentation (paste = magenta, `@` file = cyan,
|
||||||
/// `#` knowledge = green, `/` workflow = yellow), so the user
|
/// `/` workflow = yellow), so the user
|
||||||
/// recognises their own typed atoms in the scrollback.
|
/// recognises their own typed atoms in the scrollback.
|
||||||
fn render_user_message(
|
fn render_user_message(
|
||||||
lines: &mut Vec<Line<'static>>,
|
lines: &mut Vec<Line<'static>>,
|
||||||
|
|
@ -1097,10 +1097,6 @@ fn chip_span_for(seg: &Segment, fallback: Style) -> (Style, String) {
|
||||||
format!("[Clipboard #{id} | {chars} chars, {line_count} lines]"),
|
format!("[Clipboard #{id} | {chars} chars, {line_count} lines]"),
|
||||||
),
|
),
|
||||||
Segment::FileRef { path } => (Style::default().fg(Color::Cyan), format!("@{path}")),
|
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()),
|
Segment::Unknown => (fallback, "[unknown segment]".to_owned()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1115,8 +1111,6 @@ fn segment_display_text(seg: &Segment) -> String {
|
||||||
id, chars, lines, ..
|
id, chars, lines, ..
|
||||||
} => format!("[Clipboard #{id} | {chars} chars, {lines} lines]"),
|
} => format!("[Clipboard #{id} | {chars} chars, {lines} lines]"),
|
||||||
Segment::FileRef { path } => format!("@{path}"),
|
Segment::FileRef { path } => format!("@{path}"),
|
||||||
Segment::KnowledgeRef { slug } => format!("#{slug}"),
|
|
||||||
Segment::WorkflowInvoke { slug } => format!("/{slug}"),
|
|
||||||
Segment::Unknown => "[unknown segment]".to_owned(),
|
Segment::Unknown => "[unknown segment]".to_owned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ libc = { workspace = true }
|
||||||
schemars = { workspace = true }
|
schemars = { workspace = true }
|
||||||
ticket = { workspace = true }
|
ticket = { workspace = true }
|
||||||
memory = { workspace = true }
|
memory = { workspace = true }
|
||||||
workflow-crate = { package = "workflow", path = "../workflow" }
|
|
||||||
uuid = { workspace = true, features = ["v7"] }
|
uuid = { workspace = true, features = ["v7"] }
|
||||||
session-metrics = { workspace = true }
|
session-metrics = { workspace = true }
|
||||||
arc-swap = "1.9.1"
|
arc-swap = "1.9.1"
|
||||||
|
|
|
||||||
|
|
@ -1,736 +0,0 @@
|
||||||
//! 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(¶ms.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -108,24 +108,6 @@ impl WorkerHandle {
|
||||||
is_dir: c.is_dir,
|
is_dir: c.is_dir,
|
||||||
})
|
})
|
||||||
.collect(),
|
.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(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -340,20 +322,6 @@ impl WorkerController {
|
||||||
if let Some(fs_for_view) = fs_for_view {
|
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_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_manifest(&manifest_toml).await?;
|
||||||
runtime_dir.write_status(&shared_state).await?;
|
runtime_dir.write_status(&shared_state).await?;
|
||||||
|
|
||||||
|
|
@ -768,7 +736,7 @@ where
|
||||||
|
|
||||||
// Memory tools require both explicit feature exposure and memory storage
|
// Memory tools require both explicit feature exposure and memory storage
|
||||||
// configuration. This keeps resident-memory config separate from the
|
// configuration. This keeps resident-memory config separate from the
|
||||||
// model-visible Memory*/Knowledge* tool surface.
|
// model-visible Memory* tool surface.
|
||||||
if feature_config.memory.enabled {
|
if feature_config.memory.enabled {
|
||||||
let mem = memory_config.as_ref().ok_or_else(|| {
|
let mem = memory_config.as_ref().ok_or_else(|| {
|
||||||
std::io::Error::new(
|
std::io::Error::new(
|
||||||
|
|
@ -791,8 +759,7 @@ where
|
||||||
worker.register_tool(memory::tool::write_tool(layout.clone()));
|
worker.register_tool(memory::tool::write_tool(layout.clone()));
|
||||||
worker.register_tool(memory::tool::edit_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::delete_tool(layout.clone()));
|
||||||
worker.register_tool(memory::tool::memory_query_tool(layout.clone(), query_cfg));
|
worker.register_tool(memory::tool::memory_query_tool(layout, query_cfg));
|
||||||
worker.register_tool(memory::tool::knowledge_query_tool(layout, query_cfg));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Worker-orchestration tools (SpawnWorker + the four comm tools) share
|
// Worker-orchestration tools (SpawnWorker + the four comm tools) share
|
||||||
|
|
@ -1584,7 +1551,6 @@ fn worker_error_code(e: &WorkerError) -> ErrorCode {
|
||||||
_ => ErrorCode::Internal,
|
_ => ErrorCode::Internal,
|
||||||
},
|
},
|
||||||
WorkerError::Provider(_) => ErrorCode::ProviderError,
|
WorkerError::Provider(_) => ErrorCode::ProviderError,
|
||||||
WorkerError::WorkflowResolve(_) => ErrorCode::InvalidRequest,
|
|
||||||
_ => ErrorCode::Internal,
|
_ => ErrorCode::Internal,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1064,8 +1064,6 @@ fn discovery_error_to_tool_error(error: WorkerDiscoveryError) -> ToolError {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::sync::Mutex;
|
|
||||||
|
|
||||||
use manifest::{Permission, ScopeRule};
|
use manifest::{Permission, ScopeRule};
|
||||||
use protocol::stream::JsonLineWriter;
|
use protocol::stream::JsonLineWriter;
|
||||||
use protocol::{Alert, AlertLevel, AlertSource};
|
use protocol::{Alert, AlertLevel, AlertSource};
|
||||||
|
|
@ -1077,6 +1075,7 @@ mod tests {
|
||||||
use tokio::net::UnixListener;
|
use tokio::net::UnixListener;
|
||||||
|
|
||||||
use crate::runtime::dir::RuntimeDir;
|
use crate::runtime::dir::RuntimeDir;
|
||||||
|
use crate::runtime::worker_allocation::test_util::RuntimeDirSandbox;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct FailTargetPeerStore {
|
struct FailTargetPeerStore {
|
||||||
|
|
@ -1118,18 +1117,13 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
async fn state_backed_visibility_and_restore_planning() {
|
async fn state_backed_visibility_and_restore_planning() {
|
||||||
let _env = ENV_LOCK.lock().unwrap();
|
|
||||||
let root = TempDir::new().unwrap();
|
let root = TempDir::new().unwrap();
|
||||||
let store_dir = root.path().join("store");
|
let store_dir = root.path().join("store");
|
||||||
let runtime_base = root.path().join("runtime");
|
let runtime_base = root.path().join("runtime");
|
||||||
std::fs::create_dir_all(&runtime_base).unwrap();
|
std::fs::create_dir_all(&runtime_base).unwrap();
|
||||||
unsafe {
|
let _runtime_sandbox = RuntimeDirSandbox::new(&runtime_base);
|
||||||
std::env::set_var("YOI_RUNTIME_DIR", &runtime_base);
|
|
||||||
}
|
|
||||||
|
|
||||||
let store = FsWorkerStore::new(&store_dir).unwrap();
|
let store = FsWorkerStore::new(&store_dir).unwrap();
|
||||||
let session_id = new_session_id();
|
let session_id = new_session_id();
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ use llm_engine::tool::ToolOutput;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
use crate::active_workflow::ActiveWorkflowStore;
|
|
||||||
use crate::compact::state::CompactState;
|
use crate::compact::state::CompactState;
|
||||||
use crate::compact::usage_tracker::UsageTracker;
|
use crate::compact::usage_tracker::UsageTracker;
|
||||||
use session_store::SystemItem;
|
use session_store::SystemItem;
|
||||||
|
|
@ -72,10 +71,6 @@ pub(crate) struct WorkerInterceptor {
|
||||||
/// worker. `None` in tests / `Worker::new` paths where no writer is
|
/// worker. `None` in tests / `Worker::new` paths where no writer is
|
||||||
/// attached.
|
/// attached.
|
||||||
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
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 assigned by `on_prompt_submit`.
|
||||||
next_turn_index: AtomicUsize,
|
next_turn_index: AtomicUsize,
|
||||||
/// Tool calls observed in the current turn (reset on each new prompt).
|
/// Tool calls observed in the current turn (reset on each new prompt).
|
||||||
|
|
@ -91,7 +86,6 @@ impl WorkerInterceptor {
|
||||||
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
|
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
|
||||||
prompts: Arc<PromptCatalog>,
|
prompts: Arc<PromptCatalog>,
|
||||||
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
||||||
active_workflows: ActiveWorkflowStore,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
registry,
|
registry,
|
||||||
|
|
@ -102,7 +96,6 @@ impl WorkerInterceptor {
|
||||||
pending_attachments,
|
pending_attachments,
|
||||||
prompts,
|
prompts,
|
||||||
log_writer,
|
log_writer,
|
||||||
active_workflows,
|
|
||||||
next_turn_index: AtomicUsize::new(0),
|
next_turn_index: AtomicUsize::new(0),
|
||||||
tool_calls_this_turn: AtomicUsize::new(0),
|
tool_calls_this_turn: AtomicUsize::new(0),
|
||||||
}
|
}
|
||||||
|
|
@ -241,8 +234,6 @@ impl Interceptor for WorkerInterceptor {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn pre_llm_request(&self, context: &mut Vec<Item>) -> PreRequestAction {
|
async fn pre_llm_request(&self, context: &mut Vec<Item>) -> PreRequestAction {
|
||||||
self.active_workflows.sanitize_context(context);
|
|
||||||
|
|
||||||
let initial_tokens = self.estimated_tokens(context);
|
let initial_tokens = self.estimated_tokens(context);
|
||||||
if self.request_threshold_exceeded(initial_tokens, context) {
|
if self.request_threshold_exceeded(initial_tokens, context) {
|
||||||
return PreRequestAction::Yield;
|
return PreRequestAction::Yield;
|
||||||
|
|
@ -536,7 +527,6 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
);
|
);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||||
|
|
@ -569,7 +559,6 @@ mod tests {
|
||||||
Some(Arc::new(RecordingSystemItemCommitter {
|
Some(Arc::new(RecordingSystemItemCommitter {
|
||||||
committed: Arc::clone(&committed),
|
committed: Arc::clone(&committed),
|
||||||
})),
|
})),
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
);
|
);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||||
|
|
@ -606,7 +595,6 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
)
|
)
|
||||||
.with_usage_tracker(usage_tracker);
|
.with_usage_tracker(usage_tracker);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
|
|
@ -632,7 +620,6 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
);
|
);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||||
|
|
@ -674,7 +661,6 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
);
|
);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||||
|
|
@ -702,7 +688,6 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
);
|
);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||||
|
|
@ -724,7 +709,6 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
);
|
);
|
||||||
let mut ctx: Vec<Item> = Vec::new();
|
let mut ctx: Vec<Item> = Vec::new();
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||||
|
|
@ -753,7 +737,6 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
Some(committer),
|
Some(committer),
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut ctx: Vec<Item> = Vec::new();
|
let mut ctx: Vec<Item> = Vec::new();
|
||||||
|
|
@ -801,7 +784,6 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut ctx: Vec<Item> = Vec::new();
|
let mut ctx: Vec<Item> = Vec::new();
|
||||||
|
|
@ -859,7 +841,6 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
);
|
);
|
||||||
let mut info = task_tool_call_info("TaskList", serde_json::json!({"scope": "all"}));
|
let mut info = task_tool_call_info("TaskList", serde_json::json!({"scope": "all"}));
|
||||||
|
|
||||||
|
|
@ -907,7 +888,6 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
);
|
);
|
||||||
let info = task_tool_call_info("TaskList", serde_json::json!({}));
|
let info = task_tool_call_info("TaskList", serde_json::json!({}));
|
||||||
let mut result_info = ToolResultInfo {
|
let mut result_info = ToolResultInfo {
|
||||||
|
|
@ -957,7 +937,6 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
);
|
);
|
||||||
let history = vec![Item::user_message("hi"), Item::assistant_message("done")];
|
let history = vec![Item::user_message("hi"), Item::assistant_message("done")];
|
||||||
|
|
||||||
|
|
@ -992,7 +971,6 @@ mod tests {
|
||||||
Some(Arc::new(RecordingSystemItemCommitter {
|
Some(Arc::new(RecordingSystemItemCommitter {
|
||||||
committed: Arc::clone(&committed),
|
committed: Arc::clone(&committed),
|
||||||
})),
|
})),
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
)
|
)
|
||||||
.with_usage_tracker(Arc::clone(&usage_tracker));
|
.with_usage_tracker(Arc::clone(&usage_tracker));
|
||||||
|
|
||||||
|
|
@ -1052,7 +1030,6 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let items = interceptor.pending_history_appends().await;
|
let items = interceptor.pending_history_appends().await;
|
||||||
|
|
@ -1090,7 +1067,6 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
);
|
);
|
||||||
let mut ctx: Vec<Item> = vec![Item::user_message("hi")];
|
let mut ctx: Vec<Item> = vec![Item::user_message("hi")];
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||||
|
|
@ -1121,7 +1097,6 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
);
|
);
|
||||||
let mut ctx: Vec<Item> = Vec::new();
|
let mut ctx: Vec<Item> = Vec::new();
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
pub mod active_workflow;
|
|
||||||
pub mod compact;
|
pub mod compact;
|
||||||
pub mod controller;
|
pub mod controller;
|
||||||
pub mod discovery;
|
pub mod discovery;
|
||||||
|
|
@ -14,8 +13,8 @@ pub mod runtime;
|
||||||
pub mod segment_log_sink;
|
pub mod segment_log_sink;
|
||||||
pub mod shared_state;
|
pub mod shared_state;
|
||||||
mod shutdown_after_idle;
|
mod shutdown_after_idle;
|
||||||
|
pub mod skill;
|
||||||
pub mod spawn;
|
pub mod spawn;
|
||||||
pub mod workflow;
|
|
||||||
|
|
||||||
mod interrupt_prep;
|
mod interrupt_prep;
|
||||||
mod permission;
|
mod permission;
|
||||||
|
|
|
||||||
|
|
@ -83,15 +83,6 @@ pub enum WorkerPrompt {
|
||||||
/// AGENTS.md section when memory is enabled, summary injection is enabled,
|
/// AGENTS.md section when memory is enabled, summary injection is enabled,
|
||||||
/// and `memory/summary.md` has a valid non-empty body.
|
/// and `memory/summary.md` has a valid non-empty body.
|
||||||
ResidentMemorySummarySection,
|
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
|
/// Trailing Worker orchestration guidance, appended when registered tools
|
||||||
/// include Worker-management capabilities.
|
/// include Worker-management capabilities.
|
||||||
WorkerOrchestrationGuidanceSection,
|
WorkerOrchestrationGuidanceSection,
|
||||||
|
|
@ -114,8 +105,6 @@ impl WorkerPrompt {
|
||||||
Self::WorkingBoundariesSection => "working_boundaries_section",
|
Self::WorkingBoundariesSection => "working_boundaries_section",
|
||||||
Self::AgentsMdSection => "agents_md_section",
|
Self::AgentsMdSection => "agents_md_section",
|
||||||
Self::ResidentMemorySummarySection => "resident_memory_summary_section",
|
Self::ResidentMemorySummarySection => "resident_memory_summary_section",
|
||||||
Self::ResidentKnowledgeSection => "resident_knowledge_section",
|
|
||||||
Self::ResidentWorkflowsSection => "resident_workflows_section",
|
|
||||||
Self::WorkerOrchestrationGuidanceSection => "worker_orchestration_guidance_section",
|
Self::WorkerOrchestrationGuidanceSection => "worker_orchestration_guidance_section",
|
||||||
Self::TicketEventCompanionNotice => "ticket_event_companion_notice",
|
Self::TicketEventCompanionNotice => "ticket_event_companion_notice",
|
||||||
Self::SpawnWorkerToolDescription => "spawn_worker_tool_description",
|
Self::SpawnWorkerToolDescription => "spawn_worker_tool_description",
|
||||||
|
|
@ -135,8 +124,6 @@ impl WorkerPrompt {
|
||||||
WorkerPrompt::WorkingBoundariesSection,
|
WorkerPrompt::WorkingBoundariesSection,
|
||||||
WorkerPrompt::AgentsMdSection,
|
WorkerPrompt::AgentsMdSection,
|
||||||
WorkerPrompt::ResidentMemorySummarySection,
|
WorkerPrompt::ResidentMemorySummarySection,
|
||||||
WorkerPrompt::ResidentKnowledgeSection,
|
|
||||||
WorkerPrompt::ResidentWorkflowsSection,
|
|
||||||
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
||||||
WorkerPrompt::TicketEventCompanionNotice,
|
WorkerPrompt::TicketEventCompanionNotice,
|
||||||
WorkerPrompt::SpawnWorkerToolDescription,
|
WorkerPrompt::SpawnWorkerToolDescription,
|
||||||
|
|
@ -152,8 +139,6 @@ impl WorkerPrompt {
|
||||||
"working_boundaries_section",
|
"working_boundaries_section",
|
||||||
"agents_md_section",
|
"agents_md_section",
|
||||||
"resident_memory_summary_section",
|
"resident_memory_summary_section",
|
||||||
"resident_knowledge_section",
|
|
||||||
"resident_workflows_section",
|
|
||||||
"worker_orchestration_guidance_section",
|
"worker_orchestration_guidance_section",
|
||||||
"ticket_event_companion_notice",
|
"ticket_event_companion_notice",
|
||||||
"spawn_worker_tool_description",
|
"spawn_worker_tool_description",
|
||||||
|
|
@ -391,34 +376,6 @@ 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).
|
/// Render `WorkerPrompt::WorkerOrchestrationGuidanceSection` (no inputs).
|
||||||
pub fn worker_orchestration_guidance_section(&self) -> Result<String, CatalogError> {
|
pub fn worker_orchestration_guidance_section(&self) -> Result<String, CatalogError> {
|
||||||
self.render(
|
self.render(
|
||||||
|
|
@ -570,7 +527,6 @@ mod tests {
|
||||||
let extract = cat.memory_extract_system("Japanese").unwrap();
|
let extract = cat.memory_extract_system("Japanese").unwrap();
|
||||||
let consolidate = cat.memory_consolidation_system("Japanese").unwrap();
|
let consolidate = cat.memory_consolidation_system("Japanese").unwrap();
|
||||||
for rendered in [compact, extract, consolidate] {
|
for rendered in [compact, extract, consolidate] {
|
||||||
assert!(!rendered.contains("### Memory and knowledge"));
|
|
||||||
assert!(!rendered.contains("Do not query memory every turn"));
|
assert!(!rendered.contains("Do not query memory every turn"));
|
||||||
assert!(!rendered.contains("Strong lookup triggers include"));
|
assert!(!rendered.contains("Strong lookup triggers include"));
|
||||||
}
|
}
|
||||||
|
|
@ -750,7 +706,7 @@ compact_system = "PREFIX\n{% include \"$yoi/internal/compact_system\" %}"
|
||||||
assert!(rendered.contains("Do not use `sleep` or polling loops"));
|
assert!(rendered.contains("Do not use `sleep` or polling loops"));
|
||||||
assert!(rendered.contains("worktree state, diff, and test results"));
|
assert!(rendered.contains("worktree state, diff, and test results"));
|
||||||
assert!(rendered.contains("not scheduler or auto-maintain authorization"));
|
assert!(rendered.contains("not scheduler or auto-maintain authorization"));
|
||||||
assert!(rendered.contains("bypass user/workflow authorization"));
|
assert!(rendered.contains("bypass user/Ticket authorization"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,9 @@ use std::sync::Arc;
|
||||||
|
|
||||||
use chrono::{DateTime, SecondsFormat, Utc};
|
use chrono::{DateTime, SecondsFormat, Utc};
|
||||||
use manifest::Scope;
|
use manifest::Scope;
|
||||||
use memory::ResidentKnowledgeEntry;
|
|
||||||
use minijinja::value::Value;
|
use minijinja::value::Value;
|
||||||
use minijinja::{Environment, ErrorKind, UndefinedBehavior};
|
use minijinja::{Environment, ErrorKind, UndefinedBehavior};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use workflow_crate::ResidentWorkflowEntry;
|
|
||||||
|
|
||||||
use crate::prompt::catalog::{CatalogError, PromptCatalog};
|
use crate::prompt::catalog::{CatalogError, PromptCatalog};
|
||||||
use crate::prompt::loader::{LoaderError, PromptLoader, PromptRef};
|
use crate::prompt::loader::{LoaderError, PromptLoader, PromptRef};
|
||||||
|
|
@ -126,8 +124,6 @@ impl SystemPromptTemplate {
|
||||||
ctx.scope,
|
ctx.scope,
|
||||||
ctx.agents_md.as_deref(),
|
ctx.agents_md.as_deref(),
|
||||||
ctx.resident_summary,
|
ctx.resident_summary,
|
||||||
ctx.resident_knowledge,
|
|
||||||
ctx.resident_workflows,
|
|
||||||
ToolCapabilities::from_tool_names(&ctx.tool_names),
|
ToolCapabilities::from_tool_names(&ctx.tool_names),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -161,15 +157,6 @@ pub struct SystemPromptContext<'a> {
|
||||||
/// frontmatter stripped. `None` disables the resident summary section;
|
/// frontmatter stripped. `None` disables the resident summary section;
|
||||||
/// empty strings are ignored by the trailing-section formatter.
|
/// empty strings are ignored by the trailing-section formatter.
|
||||||
pub resident_summary: Option<&'a str>,
|
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.
|
/// Catalog used to render the fixed trailing section headers.
|
||||||
/// Passed by reference so callers do not give up ownership across
|
/// Passed by reference so callers do not give up ownership across
|
||||||
/// the short-lived render borrow.
|
/// the short-lived render borrow.
|
||||||
|
|
@ -214,7 +201,6 @@ impl<'a> SystemPromptContext<'a> {
|
||||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
struct ToolCapabilities {
|
struct ToolCapabilities {
|
||||||
memory_query: bool,
|
memory_query: bool,
|
||||||
knowledge_query: bool,
|
|
||||||
memory_read: bool,
|
memory_read: bool,
|
||||||
memory_write: bool,
|
memory_write: bool,
|
||||||
memory_edit: bool,
|
memory_edit: bool,
|
||||||
|
|
@ -233,7 +219,6 @@ impl ToolCapabilities {
|
||||||
for name in names {
|
for name in names {
|
||||||
match name.as_str() {
|
match name.as_str() {
|
||||||
"MemoryQuery" => capabilities.memory_query = true,
|
"MemoryQuery" => capabilities.memory_query = true,
|
||||||
"KnowledgeQuery" => capabilities.knowledge_query = true,
|
|
||||||
"MemoryRead" => capabilities.memory_read = true,
|
"MemoryRead" => capabilities.memory_read = true,
|
||||||
"MemoryWrite" => capabilities.memory_write = true,
|
"MemoryWrite" => capabilities.memory_write = true,
|
||||||
"MemoryEdit" => capabilities.memory_edit = true,
|
"MemoryEdit" => capabilities.memory_edit = true,
|
||||||
|
|
@ -259,7 +244,7 @@ impl ToolCapabilities {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn memory_any(self) -> bool {
|
fn memory_any(self) -> bool {
|
||||||
self.memory_records() || self.knowledge_query
|
self.memory_records()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn memory_mutation(self) -> bool {
|
fn memory_mutation(self) -> bool {
|
||||||
|
|
@ -280,7 +265,6 @@ impl ToolCapabilities {
|
||||||
map.insert("memory_any", Value::from(self.memory_any()));
|
map.insert("memory_any", Value::from(self.memory_any()));
|
||||||
map.insert("memory_records", Value::from(self.memory_records()));
|
map.insert("memory_records", Value::from(self.memory_records()));
|
||||||
map.insert("memory_query", Value::from(self.memory_query));
|
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_read", Value::from(self.memory_read));
|
||||||
map.insert("memory_write", Value::from(self.memory_write));
|
map.insert("memory_write", Value::from(self.memory_write));
|
||||||
map.insert("memory_edit", Value::from(self.memory_edit));
|
map.insert("memory_edit", Value::from(self.memory_edit));
|
||||||
|
|
@ -303,8 +287,6 @@ fn append_trailing_section(
|
||||||
scope: &Scope,
|
scope: &Scope,
|
||||||
agents_md: Option<&str>,
|
agents_md: Option<&str>,
|
||||||
resident_summary: Option<&str>,
|
resident_summary: Option<&str>,
|
||||||
resident_knowledge: Option<&[ResidentKnowledgeEntry]>,
|
|
||||||
resident_workflows: Option<&[ResidentWorkflowEntry]>,
|
|
||||||
tool_capabilities: ToolCapabilities,
|
tool_capabilities: ToolCapabilities,
|
||||||
) -> Result<String, SystemPromptError> {
|
) -> Result<String, SystemPromptError> {
|
||||||
let mut out = String::with_capacity(body.len() + 256);
|
let mut out = String::with_capacity(body.len() + 256);
|
||||||
|
|
@ -332,28 +314,6 @@ fn append_trailing_section(
|
||||||
out.push('\n');
|
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() {
|
if tool_capabilities.worker_management() {
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
let section = prompts.worker_orchestration_guidance_section()?;
|
let section = prompts.worker_orchestration_guidance_section()?;
|
||||||
|
|
@ -368,44 +328,6 @@ fn append_trailing_section(
|
||||||
Ok(out)
|
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
|
/// Bridge used by [`Worker::ensure_system_prompt_materialized`] so tests
|
||||||
/// can construct a synthetic context without going through a full Worker.
|
/// can construct a synthetic context without going through a full Worker.
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
|
|
@ -450,8 +372,6 @@ mod tests {
|
||||||
tool_names: tools,
|
tool_names: tools,
|
||||||
agents_md,
|
agents_md,
|
||||||
resident_summary: None,
|
resident_summary: None,
|
||||||
resident_knowledge: None,
|
|
||||||
resident_workflows: None,
|
|
||||||
prompts: test_prompts(),
|
prompts: test_prompts(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -469,17 +389,11 @@ mod tests {
|
||||||
tool_names: Vec::new(),
|
tool_names: Vec::new(),
|
||||||
agents_md: None,
|
agents_md: None,
|
||||||
resident_summary: summary,
|
resident_summary: summary,
|
||||||
resident_knowledge: None,
|
|
||||||
resident_workflows: None,
|
|
||||||
prompts: test_prompts(),
|
prompts: test_prompts(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ctx_with_resident<'a>(
|
fn ctx_with_resident<'a>(cwd: &'a Path, scope: &'a Scope) -> SystemPromptContext<'a> {
|
||||||
cwd: &'a Path,
|
|
||||||
scope: &'a Scope,
|
|
||||||
resident: &'a [ResidentKnowledgeEntry],
|
|
||||||
) -> SystemPromptContext<'a> {
|
|
||||||
SystemPromptContext {
|
SystemPromptContext {
|
||||||
now: fixed_now(),
|
now: fixed_now(),
|
||||||
cwd: cwd.display().to_string().into(),
|
cwd: cwd.display().to_string().into(),
|
||||||
|
|
@ -488,27 +402,6 @@ mod tests {
|
||||||
tool_names: Vec::new(),
|
tool_names: Vec::new(),
|
||||||
agents_md: None,
|
agents_md: None,
|
||||||
resident_summary: 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(),
|
prompts: test_prompts(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -516,7 +409,6 @@ mod tests {
|
||||||
fn memory_tool_names() -> Vec<String> {
|
fn memory_tool_names() -> Vec<String> {
|
||||||
[
|
[
|
||||||
"MemoryQuery",
|
"MemoryQuery",
|
||||||
"KnowledgeQuery",
|
|
||||||
"MemoryRead",
|
"MemoryRead",
|
||||||
"MemoryWrite",
|
"MemoryWrite",
|
||||||
"MemoryEdit",
|
"MemoryEdit",
|
||||||
|
|
@ -568,8 +460,8 @@ mod tests {
|
||||||
.render(&ctx(dir.path(), &scope, memory_tool_names(), None))
|
.render(&ctx(dir.path(), &scope, memory_tool_names(), None))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// Builtin default body must expose the tool and language policies.
|
// Builtin default body must expose the tool and language policies.
|
||||||
assert!(rendered.contains("### Memory and knowledge"));
|
assert!(rendered.contains("### Memory"));
|
||||||
assert!(rendered.contains("small targeted `MemoryQuery` / `KnowledgeQuery`"));
|
assert!(rendered.contains("small targeted `MemoryQuery`"));
|
||||||
assert!(rendered.contains("Strong lookup triggers include"));
|
assert!(rendered.contains("Strong lookup triggers include"));
|
||||||
assert!(rendered.contains("MemoryRead(kind=summary)"));
|
assert!(rendered.contains("MemoryRead(kind=summary)"));
|
||||||
assert!(rendered.contains("Do not query memory every turn"));
|
assert!(rendered.contains("Do not query memory every turn"));
|
||||||
|
|
@ -596,9 +488,8 @@ mod tests {
|
||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(!rendered.contains("### Memory and knowledge"));
|
assert!(!rendered.contains("### Memory"));
|
||||||
assert!(!rendered.contains("MemoryQuery"));
|
assert!(!rendered.contains("MemoryQuery"));
|
||||||
assert!(!rendered.contains("KnowledgeQuery"));
|
|
||||||
assert!(!rendered.contains("MemoryRead"));
|
assert!(!rendered.contains("MemoryRead"));
|
||||||
assert!(!rendered.contains("MemoryWrite"));
|
assert!(!rendered.contains("MemoryWrite"));
|
||||||
assert!(!rendered.contains("MemoryEdit"));
|
assert!(!rendered.contains("MemoryEdit"));
|
||||||
|
|
@ -622,10 +513,9 @@ mod tests {
|
||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(rendered.contains("### Memory and knowledge"));
|
assert!(rendered.contains("### Memory"));
|
||||||
assert!(rendered.contains("small targeted `MemoryQuery`"));
|
assert!(rendered.contains("small targeted `MemoryQuery`"));
|
||||||
assert!(rendered.contains("MemoryRead(kind=summary)"));
|
assert!(rendered.contains("MemoryRead(kind=summary)"));
|
||||||
assert!(!rendered.contains("KnowledgeQuery"));
|
|
||||||
assert!(!rendered.contains("MemoryWrite"));
|
assert!(!rendered.contains("MemoryWrite"));
|
||||||
assert!(!rendered.contains("MemoryEdit"));
|
assert!(!rendered.contains("MemoryEdit"));
|
||||||
assert!(!rendered.contains("MemoryDelete"));
|
assert!(!rendered.contains("MemoryDelete"));
|
||||||
|
|
@ -652,7 +542,7 @@ mod tests {
|
||||||
assert!(rendered.contains("Do not use `sleep` or polling loops"));
|
assert!(rendered.contains("Do not use `sleep` or polling loops"));
|
||||||
assert!(rendered.contains("worktree state, diff, and test results"));
|
assert!(rendered.contains("worktree state, diff, and test results"));
|
||||||
assert!(rendered.contains("not scheduler or auto-maintain authorization"));
|
assert!(rendered.contains("not scheduler or auto-maintain authorization"));
|
||||||
assert!(rendered.contains("bypass user/workflow authorization"));
|
assert!(rendered.contains("bypass user/Ticket authorization"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -884,110 +774,4 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(!rendered.contains("Resident memory summary"));
|
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"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ mod mutate;
|
||||||
mod table;
|
mod table;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test_util;
|
pub(crate) mod test_util;
|
||||||
|
|
||||||
pub use conflict::{
|
pub use conflict::{
|
||||||
ConflictOwner, find_conflict_owner, find_conflict_owners, is_within_effective_write,
|
ConflictOwner, find_conflict_owner, find_conflict_owners, is_within_effective_write,
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,6 @@ use session_store::SegmentId;
|
||||||
|
|
||||||
use crate::fs_view::WorkerFsView;
|
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.
|
/// Shared state between WorkerController and runtime directory.
|
||||||
///
|
///
|
||||||
/// Controller updates this in-memory; RuntimeDir writes the status
|
/// Controller updates this in-memory; RuntimeDir writes the status
|
||||||
|
|
@ -24,8 +14,8 @@ pub struct KnowledgeCandidate {
|
||||||
/// History and typed user-segment mirrors used to live here so the
|
/// History and typed user-segment mirrors used to live here so the
|
||||||
/// IPC layer could answer `Method::GetHistory`. Those reads now go
|
/// IPC layer could answer `Method::GetHistory`. Those reads now go
|
||||||
/// directly through the session-log sink (`Event::Snapshot` +
|
/// directly through the session-log sink (`Event::Snapshot` +
|
||||||
/// `Event::Entry`), so this struct holds only status, identity,
|
/// live events), so this struct holds only status, identity,
|
||||||
/// greeting, and completion lookup hubs.
|
/// greeting, and filesystem completion lookup hubs.
|
||||||
pub struct WorkerSharedState {
|
pub struct WorkerSharedState {
|
||||||
pub worker_name: String,
|
pub worker_name: String,
|
||||||
pub segment_id: SegmentId,
|
pub segment_id: SegmentId,
|
||||||
|
|
@ -39,8 +29,6 @@ pub struct WorkerSharedState {
|
||||||
/// (only relevant for unit tests that build a `WorkerSharedState`
|
/// (only relevant for unit tests that build a `WorkerSharedState`
|
||||||
/// directly without spinning up a controller).
|
/// directly without spinning up a controller).
|
||||||
fs_view: OnceLock<WorkerFsView>,
|
fs_view: OnceLock<WorkerFsView>,
|
||||||
workflows: OnceLock<Vec<WorkflowCandidate>>,
|
|
||||||
knowledge: OnceLock<Vec<KnowledgeCandidate>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkerSharedState {
|
impl WorkerSharedState {
|
||||||
|
|
@ -57,8 +45,6 @@ impl WorkerSharedState {
|
||||||
greeting,
|
greeting,
|
||||||
status: RwLock::new(WorkerStatus::Idle),
|
status: RwLock::new(WorkerStatus::Idle),
|
||||||
fs_view: OnceLock::new(),
|
fs_view: OnceLock::new(),
|
||||||
workflows: OnceLock::new(),
|
|
||||||
knowledge: OnceLock::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,40 +60,6 @@ impl WorkerSharedState {
|
||||||
self.fs_view.get()
|
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) {
|
pub fn set_status(&self, status: WorkerStatus) {
|
||||||
if let Ok(mut s) = self.status.write() {
|
if let Ok(mut s) = self.status.write() {
|
||||||
*s = status;
|
*s = status;
|
||||||
|
|
@ -189,35 +141,4 @@ mod tests {
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(parsed["state"], "running");
|
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
238
crates/worker/src/skill.rs
Normal file
238
crates/worker/src/skill.rs
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
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
|
|
@ -1,261 +0,0 @@
|
||||||
//! 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(®istry, &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(®istry, &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(®istry, &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(®istry, &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(®istry, &layout, "bad").unwrap_err();
|
|
||||||
assert!(matches!(
|
|
||||||
err,
|
|
||||||
WorkflowResolveError::KnowledgeNotFound { .. }
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -267,20 +267,7 @@ async fn feature_flags_default_to_core_tool_surface_only() {
|
||||||
|
|
||||||
let request = wait_for_captured_request(&client_for_assert).await;
|
let request = wait_for_captured_request(&client_for_assert).await;
|
||||||
let names = request_tool_names(&request);
|
let names = request_tool_names(&request);
|
||||||
assert_eq!(
|
assert_eq!(names, vec!["Bash", "Edit", "Glob", "Grep", "Read", "Write"]);
|
||||||
names,
|
|
||||||
vec![
|
|
||||||
"ActiveWorkflowCancel",
|
|
||||||
"ActiveWorkflowComplete",
|
|
||||||
"ActiveWorkflowList",
|
|
||||||
"Bash",
|
|
||||||
"Edit",
|
|
||||||
"Glob",
|
|
||||||
"Grep",
|
|
||||||
"Read",
|
|
||||||
"Write"
|
|
||||||
]
|
|
||||||
);
|
|
||||||
assert!(!names.iter().any(|name| name == "TaskCreate"));
|
assert!(!names.iter().any(|name| name == "TaskCreate"));
|
||||||
assert!(!names.iter().any(|name| name == "WebSearch"));
|
assert!(!names.iter().any(|name| name == "WebSearch"));
|
||||||
assert!(!names.iter().any(|name| name == "SpawnWorker"));
|
assert!(!names.iter().any(|name| name == "SpawnWorker"));
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "workflow"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
|
||||||
lint-common = { workspace = true }
|
|
||||||
manifest = { workspace = true }
|
|
||||||
memory = { workspace = true }
|
|
||||||
serde = { workspace = true, features = ["derive"] }
|
|
||||||
serde_yaml = "0.9.34"
|
|
||||||
thiserror = { workspace = true }
|
|
||||||
tracing = { workspace = true }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tempfile = { workspace = true }
|
|
||||||
serde_json = { workspace = true }
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
# workflow
|
|
||||||
|
|
||||||
## Role
|
|
||||||
|
|
||||||
`workflow` owns project-authored workflow record parsing, validation, and invocation metadata.
|
|
||||||
|
|
||||||
## Boundaries
|
|
||||||
|
|
||||||
Owns:
|
|
||||||
|
|
||||||
- workflow file schema/linting
|
|
||||||
- workflow discovery from configured workflow locations
|
|
||||||
- typed workflow metadata used by runtime/tooling layers
|
|
||||||
|
|
||||||
Does not own:
|
|
||||||
|
|
||||||
- generated memory records (`memory`)
|
|
||||||
- Ticket file lifecycle (`crates/ticket`, `.yoi/tickets/`)
|
|
||||||
- Worker orchestration decisions (`worker`, workflows executed by agents)
|
|
||||||
- product CLI command shape (`yoi`)
|
|
||||||
|
|
||||||
## Design notes
|
|
||||||
|
|
||||||
Workflows are curated project assets. They should not be mixed with generated memory, and invoking a workflow should not bypass work item authority or scope policy.
|
|
||||||
|
|
||||||
## See also
|
|
||||||
|
|
||||||
- [`../../docs/development/workflows.md`](../../docs/development/workflows.md)
|
|
||||||
- [`../../docs/design/profiles-manifests-prompts.md`](../../docs/design/profiles-manifests-prompts.md)
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
//! Errors raised by Workflow loading and linting.
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use lint_common::RecordLintError;
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
/// A single Workflow linter violation.
|
|
||||||
#[derive(Debug, Clone, Error, PartialEq, Eq)]
|
|
||||||
pub enum WorkflowLintError {
|
|
||||||
#[error(transparent)]
|
|
||||||
Record(#[from] RecordLintError),
|
|
||||||
|
|
||||||
#[error("missing required frontmatter field: `{0}`")]
|
|
||||||
MissingField(&'static str),
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"Workflow with model_invokation: true cannot have description longer than {limit} chars (got {actual})"
|
|
||||||
)]
|
|
||||||
DescriptionTooLong { actual: usize, limit: usize },
|
|
||||||
|
|
||||||
#[error("body exceeds the Workflow size limit: {actual} chars > {limit}")]
|
|
||||||
BodyTooLong { actual: usize, limit: usize },
|
|
||||||
|
|
||||||
#[error("`{field}` references unknown {kind} slug `{slug}`")]
|
|
||||||
UnknownReference {
|
|
||||||
field: &'static str,
|
|
||||||
kind: &'static str,
|
|
||||||
slug: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
#[error("path is not a valid Workflow location: {}", .0.display())]
|
|
||||||
InvalidPath(PathBuf),
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
//! Workflow records, loading, Agent Skill ingestion, and human-edit linting.
|
|
||||||
|
|
||||||
mod error;
|
|
||||||
mod linter;
|
|
||||||
mod schema;
|
|
||||||
mod scope;
|
|
||||||
mod skill;
|
|
||||||
mod workflow;
|
|
||||||
|
|
||||||
pub use error::WorkflowLintError;
|
|
||||||
pub use lint_common::{RecordLintError, Slug, is_valid_slug};
|
|
||||||
pub use linter::{WorkflowLintReport, WorkflowLinter};
|
|
||||||
pub use schema::{WorkflowFrontmatter, split_frontmatter};
|
|
||||||
pub use scope::deny_write_rules;
|
|
||||||
pub use skill::{
|
|
||||||
SKILL_FILENAME, SkillParseError, SkillRecord, load_skills_from_dir, parse_skill_md,
|
|
||||||
};
|
|
||||||
pub use workflow::{
|
|
||||||
ResidentWorkflowEntry, ShadowedSkill, WORKFLOW_DESCRIPTION_HARD_CAP, WorkflowLoadError,
|
|
||||||
WorkflowRecord, WorkflowRegistry, WorkflowSource, load_workflows,
|
|
||||||
};
|
|
||||||
|
|
@ -1,226 +0,0 @@
|
||||||
//! Human-edit linter for Workflow files.
|
|
||||||
|
|
||||||
use std::collections::HashSet;
|
|
||||||
|
|
||||||
use memory::WorkspaceLayout;
|
|
||||||
|
|
||||||
use crate::{Slug, WorkflowLintError};
|
|
||||||
use lint_common::RecordLintError;
|
|
||||||
use serde::de::DeserializeOwned;
|
|
||||||
|
|
||||||
use crate::schema::{WORKFLOW_BODY_LIMIT, WorkflowFrontmatter, split_frontmatter};
|
|
||||||
use crate::workflow::WORKFLOW_DESCRIPTION_HARD_CAP;
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone)]
|
|
||||||
pub struct WorkflowLintReport {
|
|
||||||
pub errors: Vec<WorkflowLintError>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WorkflowLintReport {
|
|
||||||
pub fn has_errors(&self) -> bool {
|
|
||||||
!self.errors.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn push_error(&mut self, err: WorkflowLintError) {
|
|
||||||
self.errors.push(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct WorkflowLinter {
|
|
||||||
layout: WorkspaceLayout,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WorkflowLinter {
|
|
||||||
pub fn new(layout: WorkspaceLayout) -> Self {
|
|
||||||
Self { layout }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn layout(&self) -> &WorkspaceLayout {
|
|
||||||
&self.layout
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate a human-authored Workflow document.
|
|
||||||
///
|
|
||||||
/// Verifies frontmatter shape, body size, resident description size, and
|
|
||||||
/// that every `requires` slug points at an existing Knowledge record.
|
|
||||||
pub fn lint(&self, content: &str) -> WorkflowLintReport {
|
|
||||||
let mut report = WorkflowLintReport::default();
|
|
||||||
let parsed = match parse_frontmatter::<WorkflowFrontmatter>(content) {
|
|
||||||
Ok(parsed) => parsed,
|
|
||||||
Err(err) => {
|
|
||||||
report.push_error(err);
|
|
||||||
return report;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let body_chars = parsed.body.chars().count();
|
|
||||||
if body_chars > WORKFLOW_BODY_LIMIT {
|
|
||||||
report.push_error(WorkflowLintError::BodyTooLong {
|
|
||||||
actual: body_chars,
|
|
||||||
limit: WORKFLOW_BODY_LIMIT,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if parsed.frontmatter.model_invokation {
|
|
||||||
let actual = parsed.frontmatter.description.chars().count();
|
|
||||||
if actual > WORKFLOW_DESCRIPTION_HARD_CAP {
|
|
||||||
report.push_error(WorkflowLintError::DescriptionTooLong {
|
|
||||||
actual,
|
|
||||||
limit: WORKFLOW_DESCRIPTION_HARD_CAP,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let knowledge = match scan_knowledge_slugs(&self.layout) {
|
|
||||||
Ok(knowledge) => knowledge,
|
|
||||||
Err(err) => {
|
|
||||||
report.push_error(WorkflowLintError::Record(
|
|
||||||
RecordLintError::MalformedFrontmatter(format!(
|
|
||||||
"failed to scan existing Knowledge records: {err}"
|
|
||||||
)),
|
|
||||||
));
|
|
||||||
return report;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for slug in &parsed.frontmatter.requires {
|
|
||||||
if !knowledge.contains(slug) {
|
|
||||||
report.push_error(WorkflowLintError::UnknownReference {
|
|
||||||
field: "requires",
|
|
||||||
kind: "knowledge",
|
|
||||||
slug: slug.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
report
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Parsed<'a, F> {
|
|
||||||
frontmatter: F,
|
|
||||||
body: &'a str,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_frontmatter<F: DeserializeOwned>(
|
|
||||||
content: &str,
|
|
||||||
) -> Result<Parsed<'_, F>, WorkflowLintError> {
|
|
||||||
let (yaml, body) = split_frontmatter(content)?;
|
|
||||||
let frontmatter = serde_yaml::from_str::<F>(yaml).map_err(|err| {
|
|
||||||
let msg = err.to_string();
|
|
||||||
if let Some(field) = parse_missing_field(&msg) {
|
|
||||||
WorkflowLintError::MissingField(field)
|
|
||||||
} else {
|
|
||||||
WorkflowLintError::Record(RecordLintError::MalformedFrontmatter(msg))
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
Ok(Parsed { frontmatter, body })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_missing_field(msg: &str) -> Option<&'static str> {
|
|
||||||
let needle = "missing field `";
|
|
||||||
let start = msg.find(needle)? + needle.len();
|
|
||||||
let end = msg[start..].find('`')? + start;
|
|
||||||
match &msg[start..end] {
|
|
||||||
"description" => Some("description"),
|
|
||||||
"model_invokation" => Some("model_invokation"),
|
|
||||||
"user_invocable" => Some("user_invocable"),
|
|
||||||
"requires" => Some("requires"),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn scan_knowledge_slugs(layout: &WorkspaceLayout) -> std::io::Result<HashSet<Slug>> {
|
|
||||||
let mut out = HashSet::new();
|
|
||||||
let entries = match std::fs::read_dir(layout.knowledge_dir()) {
|
|
||||||
Ok(entries) => entries,
|
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(out),
|
|
||||||
Err(err) => return Err(err),
|
|
||||||
};
|
|
||||||
for entry in entries {
|
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
|
||||||
if !path.is_file() || path.extension().and_then(|s| s.to_str()) != Some("md") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if let Ok(slug) = Slug::parse(stem) {
|
|
||||||
out.insert(slug);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 workspace() -> (TempDir, WorkflowLinter) {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
|
||||||
(dir, WorkflowLinter::new(layout))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn workflow_lint_accepts_valid_file() {
|
|
||||||
let (dir, linter) = workspace();
|
|
||||||
write(
|
|
||||||
&dir.path().join(".yoi/knowledge/policy.md"),
|
|
||||||
"---\ndescription: p\n---\nbody",
|
|
||||||
);
|
|
||||||
let wf = "---\ndescription: run\nrequires: [policy]\n---\nbody";
|
|
||||||
let report = linter.lint(wf);
|
|
||||||
assert!(!report.has_errors(), "{:?}", report.errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn workflow_lint_rejects_missing_required_knowledge() {
|
|
||||||
let (_dir, linter) = workspace();
|
|
||||||
let wf = "---\ndescription: run\nrequires: [ghost]\n---\nbody";
|
|
||||||
let report = linter.lint(wf);
|
|
||||||
assert!(report.errors.iter().any(|err| matches!(
|
|
||||||
err,
|
|
||||||
WorkflowLintError::UnknownReference { field: "requires", kind: "knowledge", slug }
|
|
||||||
if slug == "ghost"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn workflow_lint_enforces_resident_description_cap() {
|
|
||||||
let (_dir, linter) = workspace();
|
|
||||||
let desc = "x".repeat(WORKFLOW_DESCRIPTION_HARD_CAP + 1);
|
|
||||||
let wf = format!("---\ndescription: {desc}\nmodel_invokation: true\n---\nbody");
|
|
||||||
let report = linter.lint(&wf);
|
|
||||||
assert!(
|
|
||||||
report
|
|
||||||
.errors
|
|
||||||
.iter()
|
|
||||||
.any(|err| matches!(err, WorkflowLintError::DescriptionTooLong { .. }))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn workflow_lint_enforces_body_limit() {
|
|
||||||
let (_dir, linter) = workspace();
|
|
||||||
let body = "x".repeat(WORKFLOW_BODY_LIMIT + 1);
|
|
||||||
let wf = format!("---\ndescription: run\n---\n{body}");
|
|
||||||
let report = linter.lint(&wf);
|
|
||||||
assert!(
|
|
||||||
report
|
|
||||||
.errors
|
|
||||||
.iter()
|
|
||||||
.any(|err| matches!(err, WorkflowLintError::BodyTooLong { .. }))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
//! Workflow frontmatter schema and frontmatter splitting helpers.
|
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use lint_common::Frontmatter;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::{Slug, WorkflowLintError};
|
|
||||||
|
|
||||||
pub const WORKFLOW_BODY_LIMIT: usize = 8000;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct WorkflowFrontmatter {
|
|
||||||
/// Workflows do not require timestamps in the MVP. Human-authored files
|
|
||||||
/// may carry them.
|
|
||||||
#[serde(default)]
|
|
||||||
pub updated_at: Option<DateTime<Utc>>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub created_at: Option<DateTime<Utc>>,
|
|
||||||
pub description: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub model_invokation: bool,
|
|
||||||
#[serde(default = "default_user_invocable")]
|
|
||||||
pub user_invocable: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub requires: Vec<Slug>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Frontmatter for WorkflowFrontmatter {
|
|
||||||
const BODY_LIMIT: usize = WORKFLOW_BODY_LIMIT;
|
|
||||||
|
|
||||||
fn created_at(&self) -> Option<DateTime<Utc>> {
|
|
||||||
self.created_at
|
|
||||||
}
|
|
||||||
|
|
||||||
fn updated_at(&self) -> Option<DateTime<Utc>> {
|
|
||||||
self.updated_at
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_user_invocable() -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Split a markdown document into `(yaml_frontmatter, body)`.
|
|
||||||
pub fn split_frontmatter(content: &str) -> Result<(&str, &str), WorkflowLintError> {
|
|
||||||
lint_common::split_frontmatter(content).map_err(Into::into)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use lint_common::RecordLintError;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn splits_simple() {
|
|
||||||
let doc = "---\nfoo: 1\n---\nbody here\n";
|
|
||||||
let (y, b) = split_frontmatter(doc).unwrap();
|
|
||||||
assert_eq!(y, "foo: 1\n");
|
|
||||||
assert_eq!(b, "body here\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_leading_delim_errors() {
|
|
||||||
let err = split_frontmatter("hello").unwrap_err();
|
|
||||||
assert!(matches!(
|
|
||||||
err,
|
|
||||||
WorkflowLintError::Record(RecordLintError::MissingFrontmatter)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_closing_delim_errors() {
|
|
||||||
let err = split_frontmatter("---\nfoo: 1\nno close\n").unwrap_err();
|
|
||||||
assert!(matches!(
|
|
||||||
err,
|
|
||||||
WorkflowLintError::Record(RecordLintError::MalformedFrontmatter(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn handles_empty_body() {
|
|
||||||
let doc = "---\nfoo: 1\n---\n";
|
|
||||||
let (_, b) = split_frontmatter(doc).unwrap();
|
|
||||||
assert_eq!(b, "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
//! Scope deny helpers for human-authored Workflow files.
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use manifest::{Permission, ScopeRule};
|
|
||||||
use memory::WorkspaceLayout;
|
|
||||||
|
|
||||||
/// Build deny rules that strip Write permission from
|
|
||||||
/// `<workspace>/.yoi/workflow/` for generic CRUD tools.
|
|
||||||
pub fn deny_write_rules(layout: &WorkspaceLayout) -> Vec<ScopeRule> {
|
|
||||||
vec![deny_write(layout.workflow_dir().as_path())]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn deny_write(target: &Path) -> ScopeRule {
|
|
||||||
ScopeRule {
|
|
||||||
target: target.to_path_buf(),
|
|
||||||
permission: Permission::Write,
|
|
||||||
recursive: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn deny_targets_workflow() {
|
|
||||||
let layout = WorkspaceLayout::new(PathBuf::from("/ws"));
|
|
||||||
let rules = deny_write_rules(&layout);
|
|
||||||
assert_eq!(rules.len(), 1);
|
|
||||||
assert_eq!(rules[0].target, PathBuf::from("/ws/.yoi/workflow"));
|
|
||||||
assert_eq!(rules[0].permission, Permission::Write);
|
|
||||||
assert!(rules[0].recursive);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,453 +0,0 @@
|
||||||
//! Agent Skills (`SKILL.md`) parser.
|
|
||||||
//!
|
|
||||||
//! Skills follow the [agentskills.io](https://agentskills.io/specification)
|
|
||||||
//! spec: a directory `<root>/<name>/` containing `SKILL.md` (YAML frontmatter
|
|
||||||
//! + Markdown body) and optional `scripts/` / `references/` / `assets/`
|
|
||||||
//! subdirectories. The body is procedural agent guidance; yoi ingests
|
|
||||||
//! it as a Workflow so `/<name>` resolves to it just like an internal
|
|
||||||
//! Workflow.
|
|
||||||
//!
|
|
||||||
//! Parsing is intentionally lenient at the directory-scan level — one
|
|
||||||
//! malformed SKILL.md emits `tracing::warn!` and is skipped, leaving sibling
|
|
||||||
//! skills loadable. Internal Workflows (`.yoi/workflow/<slug>.md`) keep
|
|
||||||
//! their hard-error semantics.
|
|
||||||
|
|
||||||
use std::io;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use lint_common::RecordLintError;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use thiserror::Error;
|
|
||||||
use tracing::warn;
|
|
||||||
|
|
||||||
use crate::schema::split_frontmatter;
|
|
||||||
use crate::workflow::{WORKFLOW_DESCRIPTION_HARD_CAP, WorkflowRecord, WorkflowSource};
|
|
||||||
use crate::{Slug, WorkflowLintError};
|
|
||||||
|
|
||||||
/// Filename within a skill directory carrying the frontmatter + body.
|
|
||||||
pub const SKILL_FILENAME: &str = "SKILL.md";
|
|
||||||
|
|
||||||
/// SKILL.md frontmatter as defined by the agent-skills spec.
|
|
||||||
///
|
|
||||||
/// Fields beyond `name` / `description` are accepted to be spec-compatible
|
|
||||||
/// but not used by yoi today: `license`, `compatibility`, and
|
|
||||||
/// `metadata` are documentary, while `allowed-tools` is recognised and
|
|
||||||
/// emits a warning until [`permission-extension-point.md`] lands.
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct SkillFrontmatter {
|
|
||||||
pub name: String,
|
|
||||||
pub description: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub license: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub compatibility: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub metadata: Option<serde_yaml::Value>,
|
|
||||||
#[serde(default, rename = "allowed-tools")]
|
|
||||||
pub allowed_tools: Option<serde_yaml::Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validated skill record. Constructed by [`parse_skill_md`] and converted
|
|
||||||
/// to a `WorkflowRecord` by the caller via the `Skill → Workflow`
|
|
||||||
/// projection in [`crate::WorkflowRecord`].
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct SkillRecord {
|
|
||||||
pub slug: Slug,
|
|
||||||
pub description: String,
|
|
||||||
pub body: String,
|
|
||||||
/// The skill directory (parent of `SKILL.md`). Carried so callers can
|
|
||||||
/// register `scripts/` / `references/` / `assets/` against the Worker's
|
|
||||||
/// scope.
|
|
||||||
pub dir: PathBuf,
|
|
||||||
/// Path to the `SKILL.md` file itself. Used as the resolved path on
|
|
||||||
/// the resulting `WorkflowRecord`.
|
|
||||||
pub skill_md_path: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SkillRecord {
|
|
||||||
/// Project this skill into a [`WorkflowRecord`]. Skill-sourced
|
|
||||||
/// Workflows are advertised resident (`model_invokation: true`,
|
|
||||||
/// matching the agentskills progressive-disclosure model), are
|
|
||||||
/// invocable as `/<slug>`, and carry no `requires` since the SKILL
|
|
||||||
/// spec has no Knowledge-dependency concept.
|
|
||||||
pub fn into_workflow_record(self, source: WorkflowSource) -> WorkflowRecord {
|
|
||||||
WorkflowRecord {
|
|
||||||
slug: self.slug,
|
|
||||||
description: self.description,
|
|
||||||
model_invokation: true,
|
|
||||||
user_invocable: true,
|
|
||||||
requires: Vec::new(),
|
|
||||||
body: self.body,
|
|
||||||
path: self.skill_md_path,
|
|
||||||
source,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum SkillParseError {
|
|
||||||
#[error("skill path has no parent directory: {}", .0.display())]
|
|
||||||
NoParentDir(PathBuf),
|
|
||||||
#[error("failed to read SKILL.md at {}: {source}", .path.display())]
|
|
||||||
ReadFile { path: PathBuf, source: io::Error },
|
|
||||||
#[error("invalid frontmatter in {}: {source}", .path.display())]
|
|
||||||
Frontmatter {
|
|
||||||
path: PathBuf,
|
|
||||||
#[source]
|
|
||||||
source: WorkflowLintError,
|
|
||||||
},
|
|
||||||
#[error(
|
|
||||||
"SKILL.md `name` `{name}` does not match its directory name `{dir_name}` (at {})",
|
|
||||||
.skill_md_path.display()
|
|
||||||
)]
|
|
||||||
NameDirMismatch {
|
|
||||||
name: String,
|
|
||||||
dir_name: String,
|
|
||||||
skill_md_path: PathBuf,
|
|
||||||
},
|
|
||||||
#[error("SKILL.md `name` is not a valid slug at {}: {source}", .skill_md_path.display())]
|
|
||||||
InvalidName {
|
|
||||||
skill_md_path: PathBuf,
|
|
||||||
#[source]
|
|
||||||
source: WorkflowLintError,
|
|
||||||
},
|
|
||||||
#[error("SKILL.md `description` must be non-empty (at {})", .skill_md_path.display())]
|
|
||||||
DescriptionEmpty { skill_md_path: PathBuf },
|
|
||||||
#[error(
|
|
||||||
"SKILL.md `description` length {actual} exceeds limit {limit} (at {})",
|
|
||||||
.skill_md_path.display()
|
|
||||||
)]
|
|
||||||
DescriptionTooLong {
|
|
||||||
skill_md_path: PathBuf,
|
|
||||||
actual: usize,
|
|
||||||
limit: usize,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a single `SKILL.md`. The directory name is taken from the parent
|
|
||||||
/// of `skill_md_path` and validated against the frontmatter `name`.
|
|
||||||
pub fn parse_skill_md(skill_md_path: &Path) -> Result<SkillRecord, SkillParseError> {
|
|
||||||
let dir = skill_md_path
|
|
||||||
.parent()
|
|
||||||
.map(|p| p.to_path_buf())
|
|
||||||
.ok_or_else(|| SkillParseError::NoParentDir(skill_md_path.to_path_buf()))?;
|
|
||||||
let dir_name = dir
|
|
||||||
.file_name()
|
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.ok_or_else(|| SkillParseError::NoParentDir(skill_md_path.to_path_buf()))?;
|
|
||||||
|
|
||||||
let raw =
|
|
||||||
std::fs::read_to_string(skill_md_path).map_err(|source| SkillParseError::ReadFile {
|
|
||||||
path: skill_md_path.to_path_buf(),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
let (yaml, body) = split_frontmatter(&raw).map_err(|source| SkillParseError::Frontmatter {
|
|
||||||
path: skill_md_path.to_path_buf(),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
warn_unknown_skill_fields(skill_md_path, yaml);
|
|
||||||
let frontmatter: SkillFrontmatter =
|
|
||||||
serde_yaml::from_str(yaml).map_err(|err| SkillParseError::Frontmatter {
|
|
||||||
path: skill_md_path.to_path_buf(),
|
|
||||||
source: WorkflowLintError::Record(RecordLintError::MalformedFrontmatter(
|
|
||||||
err.to_string(),
|
|
||||||
)),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if frontmatter.allowed_tools.is_some() {
|
|
||||||
warn!(
|
|
||||||
path = %skill_md_path.display(),
|
|
||||||
"SKILL.md `allowed-tools` is recognised but not yet enforced; ignoring"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let desc_chars = frontmatter.description.chars().count();
|
|
||||||
if desc_chars == 0 {
|
|
||||||
return Err(SkillParseError::DescriptionEmpty {
|
|
||||||
skill_md_path: skill_md_path.to_path_buf(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if desc_chars > WORKFLOW_DESCRIPTION_HARD_CAP {
|
|
||||||
return Err(SkillParseError::DescriptionTooLong {
|
|
||||||
skill_md_path: skill_md_path.to_path_buf(),
|
|
||||||
actual: desc_chars,
|
|
||||||
limit: WORKFLOW_DESCRIPTION_HARD_CAP,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if frontmatter.name != dir_name {
|
|
||||||
return Err(SkillParseError::NameDirMismatch {
|
|
||||||
name: frontmatter.name,
|
|
||||||
dir_name,
|
|
||||||
skill_md_path: skill_md_path.to_path_buf(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let slug = Slug::parse(frontmatter.name).map_err(|source| SkillParseError::InvalidName {
|
|
||||||
skill_md_path: skill_md_path.to_path_buf(),
|
|
||||||
source: source.into(),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(SkillRecord {
|
|
||||||
slug,
|
|
||||||
description: frontmatter.description,
|
|
||||||
body: body.to_string(),
|
|
||||||
dir,
|
|
||||||
skill_md_path: skill_md_path.to_path_buf(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scan a skills root for `<root>/<name>/SKILL.md`. Returns successfully
|
|
||||||
/// parsed skills; per-skill errors emit a `tracing::warn!` and are
|
|
||||||
/// skipped. A missing root is treated as zero skills, not an error —
|
|
||||||
/// callers can probe optional directories without pre-checking.
|
|
||||||
pub fn load_skills_from_dir(root: &Path) -> Vec<SkillRecord> {
|
|
||||||
let entries = match std::fs::read_dir(root) {
|
|
||||||
Ok(it) => it,
|
|
||||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return Vec::new(),
|
|
||||||
Err(err) => {
|
|
||||||
warn!(
|
|
||||||
dir = %root.display(),
|
|
||||||
error = %err,
|
|
||||||
"failed to read skills directory; treating as empty"
|
|
||||||
);
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut paths: Vec<PathBuf> = Vec::new();
|
|
||||||
for entry in entries {
|
|
||||||
let entry = match entry {
|
|
||||||
Ok(e) => e,
|
|
||||||
Err(err) => {
|
|
||||||
warn!(
|
|
||||||
dir = %root.display(),
|
|
||||||
error = %err,
|
|
||||||
"skill directory entry read error; skipping"
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let path = entry.path();
|
|
||||||
if !path.is_dir() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let skill_md = path.join(SKILL_FILENAME);
|
|
||||||
if skill_md.is_file() {
|
|
||||||
paths.push(skill_md);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
paths.sort();
|
|
||||||
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for path in paths {
|
|
||||||
match parse_skill_md(&path) {
|
|
||||||
Ok(record) => out.push(record),
|
|
||||||
Err(err) => warn!(path = %path.display(), error = %err, "SKILL.md skipped"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn warn_unknown_skill_fields(path: &Path, yaml: &str) {
|
|
||||||
let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(yaml) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(map) = value.as_mapping() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
for key in map.keys().filter_map(|k| k.as_str()) {
|
|
||||||
if !matches!(
|
|
||||||
key,
|
|
||||||
"name" | "description" | "license" | "compatibility" | "metadata" | "allowed-tools"
|
|
||||||
) {
|
|
||||||
warn!(path = %path.display(), field = key, "unknown SKILL.md frontmatter field ignored");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
fn write_skill(root: &Path, name: &str, frontmatter: &str, body: &str) -> PathBuf {
|
|
||||||
let dir = root.join(name);
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
let path = dir.join(SKILL_FILENAME);
|
|
||||||
std::fs::write(&path, format!("---\n{frontmatter}\n---\n{body}")).unwrap();
|
|
||||||
path
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_minimal_skill() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let path = write_skill(
|
|
||||||
dir.path(),
|
|
||||||
"do-thing",
|
|
||||||
"name: do-thing\ndescription: Do the thing",
|
|
||||||
"Step 1\nStep 2\n",
|
|
||||||
);
|
|
||||||
let record = parse_skill_md(&path).unwrap();
|
|
||||||
assert_eq!(record.slug.as_str(), "do-thing");
|
|
||||||
assert_eq!(record.description, "Do the thing");
|
|
||||||
assert_eq!(record.body, "Step 1\nStep 2\n");
|
|
||||||
assert_eq!(record.dir, dir.path().join("do-thing"));
|
|
||||||
assert_eq!(record.skill_md_path, path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn name_dir_mismatch_is_error() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let path = write_skill(
|
|
||||||
dir.path(),
|
|
||||||
"actual-dir",
|
|
||||||
"name: declared-name\ndescription: x",
|
|
||||||
"body",
|
|
||||||
);
|
|
||||||
let err = parse_skill_md(&path).unwrap_err();
|
|
||||||
assert!(matches!(err, SkillParseError::NameDirMismatch { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn invalid_slug_name_is_error() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let path = write_skill(
|
|
||||||
dir.path(),
|
|
||||||
"BAD-Caps",
|
|
||||||
"name: BAD-Caps\ndescription: x",
|
|
||||||
"body",
|
|
||||||
);
|
|
||||||
// Slug::parse rejects uppercase before the dir match check fires;
|
|
||||||
// either way the parse is rejected.
|
|
||||||
let err = parse_skill_md(&path).unwrap_err();
|
|
||||||
assert!(matches!(
|
|
||||||
err,
|
|
||||||
SkillParseError::InvalidName { .. } | SkillParseError::NameDirMismatch { .. }
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_description_is_error() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let path = write_skill(dir.path(), "x", "name: x\ndescription: \"\"", "body");
|
|
||||||
let err = parse_skill_md(&path).unwrap_err();
|
|
||||||
assert!(matches!(err, SkillParseError::DescriptionEmpty { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn description_at_cap_is_accepted() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let desc = "x".repeat(WORKFLOW_DESCRIPTION_HARD_CAP);
|
|
||||||
let path = write_skill(
|
|
||||||
dir.path(),
|
|
||||||
"x",
|
|
||||||
&format!("name: x\ndescription: {desc}"),
|
|
||||||
"body",
|
|
||||||
);
|
|
||||||
let record = parse_skill_md(&path).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
record.description.chars().count(),
|
|
||||||
WORKFLOW_DESCRIPTION_HARD_CAP
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn description_over_cap_is_error() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let desc = "x".repeat(WORKFLOW_DESCRIPTION_HARD_CAP + 1);
|
|
||||||
let path = write_skill(
|
|
||||||
dir.path(),
|
|
||||||
"x",
|
|
||||||
&format!("name: x\ndescription: {desc}"),
|
|
||||||
"body",
|
|
||||||
);
|
|
||||||
let err = parse_skill_md(&path).unwrap_err();
|
|
||||||
assert!(matches!(err, SkillParseError::DescriptionTooLong { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn missing_frontmatter_is_error() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let path = dir.path().join("x").join(SKILL_FILENAME);
|
|
||||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
||||||
std::fs::write(&path, "no frontmatter at all\n").unwrap();
|
|
||||||
let err = parse_skill_md(&path).unwrap_err();
|
|
||||||
assert!(matches!(err, SkillParseError::Frontmatter { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extra_frontmatter_fields_are_kept() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let path = write_skill(
|
|
||||||
dir.path(),
|
|
||||||
"x",
|
|
||||||
"name: x\ndescription: ok\nlicense: MIT\ncompatibility: claude-4\n\
|
|
||||||
metadata:\n team: foo\nallowed-tools:\n - Read",
|
|
||||||
"body",
|
|
||||||
);
|
|
||||||
let record = parse_skill_md(&path).unwrap();
|
|
||||||
assert_eq!(record.slug.as_str(), "x");
|
|
||||||
// allowed-tools triggers a warn, but parse succeeds.
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_skills_from_dir_skips_broken_and_keeps_good() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
write_skill(dir.path(), "good", "name: good\ndescription: ok", "body");
|
|
||||||
// Mismatch — should be skipped, not abort the scan.
|
|
||||||
write_skill(
|
|
||||||
dir.path(),
|
|
||||||
"bad-dir",
|
|
||||||
"name: declared-different\ndescription: ok",
|
|
||||||
"body",
|
|
||||||
);
|
|
||||||
// A bare file at the root (not a directory) is ignored.
|
|
||||||
std::fs::write(dir.path().join("stray.md"), "not a skill").unwrap();
|
|
||||||
|
|
||||||
let records = load_skills_from_dir(dir.path());
|
|
||||||
let slugs: Vec<&str> = records.iter().map(|r| r.slug.as_str()).collect();
|
|
||||||
assert_eq!(slugs, vec!["good"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_skills_from_dir_missing_root_is_empty() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let records = load_skills_from_dir(&dir.path().join("does-not-exist"));
|
|
||||||
assert!(records.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn into_workflow_record_uses_skill_defaults() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let path = write_skill(
|
|
||||||
dir.path(),
|
|
||||||
"x",
|
|
||||||
"name: x\ndescription: Project X",
|
|
||||||
"Steps\n",
|
|
||||||
);
|
|
||||||
let record = parse_skill_md(&path).unwrap();
|
|
||||||
let wf = record.into_workflow_record(WorkflowSource::Skill {
|
|
||||||
dir: dir.path().to_path_buf(),
|
|
||||||
});
|
|
||||||
assert_eq!(wf.slug.as_str(), "x");
|
|
||||||
assert_eq!(wf.description, "Project X");
|
|
||||||
assert!(wf.model_invokation);
|
|
||||||
assert!(wf.user_invocable);
|
|
||||||
assert!(wf.requires.is_empty());
|
|
||||||
assert_eq!(wf.body, "Steps\n");
|
|
||||||
assert!(matches!(wf.source, WorkflowSource::Skill { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_skills_from_dir_orders_deterministically() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
write_skill(dir.path(), "b", "name: b\ndescription: b", "");
|
|
||||||
write_skill(dir.path(), "a", "name: a\ndescription: a", "");
|
|
||||||
write_skill(dir.path(), "c", "name: c\ndescription: c", "");
|
|
||||||
let records = load_skills_from_dir(dir.path());
|
|
||||||
let slugs: Vec<&str> = records.iter().map(|r| r.slug.as_str()).collect();
|
|
||||||
assert_eq!(slugs, vec!["a", "b", "c"]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,626 +0,0 @@
|
||||||
//! Workflow loader and registry.
|
|
||||||
//!
|
|
||||||
//! Workflows live under `<workspace>/.yoi/workflow/<slug>.md`. They are
|
|
||||||
//! human-authored Markdown documents with YAML frontmatter. The loader is
|
|
||||||
//! intentionally strict about malformed records because Worker startup should
|
|
||||||
//! fail rather than silently ignoring a broken procedural instruction.
|
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use std::io;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use thiserror::Error;
|
|
||||||
use tracing::warn;
|
|
||||||
|
|
||||||
use crate::schema::{WorkflowFrontmatter, split_frontmatter};
|
|
||||||
use lint_common::RecordLintError;
|
|
||||||
use memory::WorkspaceLayout;
|
|
||||||
|
|
||||||
use crate::{Slug, WorkflowLintError};
|
|
||||||
|
|
||||||
/// Hard cap on Workflow descriptions that are advertised resident.
|
|
||||||
/// Mirrors agent-skills and resident Knowledge descriptions.
|
|
||||||
pub const WORKFLOW_DESCRIPTION_HARD_CAP: usize = 1024;
|
|
||||||
|
|
||||||
/// Origin of a [`WorkflowRecord`]. Used to break ties when the same slug
|
|
||||||
/// is provided by multiple sources: workspace-authored Workflows always
|
|
||||||
/// win over external skills.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum WorkflowSource {
|
|
||||||
Builtin,
|
|
||||||
/// `<workspace>/.yoi/workflow/<slug>.md`. Authored in-tree by
|
|
||||||
/// the project.
|
|
||||||
WorkspaceWorkflow,
|
|
||||||
/// SKILL.md ingested from a `[skills] directories` entry in the
|
|
||||||
/// manifest. `dir` is the skills root that contained
|
|
||||||
/// `<slug>/SKILL.md`.
|
|
||||||
Skill {
|
|
||||||
dir: PathBuf,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WorkflowSource {
|
|
||||||
/// Human-readable label used in shadow-notification messages.
|
|
||||||
pub fn label(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Builtin => "builtin workflow",
|
|
||||||
Self::WorkspaceWorkflow => "workspace workflow",
|
|
||||||
Self::Skill { .. } => "skill",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct WorkflowRecord {
|
|
||||||
pub slug: Slug,
|
|
||||||
pub description: String,
|
|
||||||
pub model_invokation: bool,
|
|
||||||
pub user_invocable: bool,
|
|
||||||
pub requires: Vec<Slug>,
|
|
||||||
/// Markdown body after the closing frontmatter delimiter.
|
|
||||||
pub body: String,
|
|
||||||
pub path: PathBuf,
|
|
||||||
/// Where this record was loaded from. Determines shadowing priority
|
|
||||||
/// when [`WorkflowRegistry::merge_skill`] encounters a slug
|
|
||||||
/// collision.
|
|
||||||
pub source: WorkflowSource,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returned by [`WorkflowRegistry::merge_skill`] when an incoming skill is
|
|
||||||
/// shadowed by an existing record (either an internal Workflow or a
|
|
||||||
/// higher-priority skill). Carries enough context for a `Notification` to
|
|
||||||
/// explain which side won.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ShadowedSkill {
|
|
||||||
pub slug: Slug,
|
|
||||||
pub kept_source: WorkflowSource,
|
|
||||||
pub kept_path: PathBuf,
|
|
||||||
pub shadowed_source: WorkflowSource,
|
|
||||||
pub shadowed_path: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ShadowedSkill {
|
|
||||||
/// One-line message for `Notification` payloads.
|
|
||||||
pub fn message(&self) -> String {
|
|
||||||
format!(
|
|
||||||
"skill /{slug} from {shadowed_label} ({shadowed_path}) was shadowed by existing {kept_label} ({kept_path})",
|
|
||||||
slug = self.slug,
|
|
||||||
shadowed_label = self.shadowed_source.label(),
|
|
||||||
shadowed_path = self.shadowed_path.display(),
|
|
||||||
kept_label = self.kept_source.label(),
|
|
||||||
kept_path = self.kept_path.display(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct WorkflowRegistry {
|
|
||||||
records: BTreeMap<Slug, WorkflowRecord>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WorkflowRegistry {
|
|
||||||
pub fn empty() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn len(&self) -> usize {
|
|
||||||
self.records.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.records.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get(&self, slug: &Slug) -> Option<&WorkflowRecord> {
|
|
||||||
self.records.get(slug)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn iter(&self) -> impl Iterator<Item = &WorkflowRecord> {
|
|
||||||
self.records.values()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn resident_entries(&self) -> Vec<ResidentWorkflowEntry> {
|
|
||||||
self.records
|
|
||||||
.values()
|
|
||||||
.filter(|record| record.model_invokation)
|
|
||||||
.map(|record| ResidentWorkflowEntry {
|
|
||||||
slug: record.slug.to_string(),
|
|
||||||
description: record.description.clone(),
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_user_invocable(&self, prefix: &str) -> Vec<String> {
|
|
||||||
self.records
|
|
||||||
.values()
|
|
||||||
.filter(|record| record.user_invocable && record.slug.as_str().starts_with(prefix))
|
|
||||||
.map(|record| record.slug.to_string())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert a skill-derived record. If an existing record (internal
|
|
||||||
/// Workflow or earlier-fed skill) already owns the slug, the
|
|
||||||
/// incoming record is dropped and a [`ShadowedSkill`] describing the
|
|
||||||
/// collision is returned. Callers feed records in priority order
|
|
||||||
/// (highest first); the registry is "first-insert wins" and does
|
|
||||||
/// not re-rank.
|
|
||||||
pub fn merge_skill(&mut self, record: WorkflowRecord) -> Option<ShadowedSkill> {
|
|
||||||
if let Some(existing) = self.records.get(&record.slug) {
|
|
||||||
return Some(ShadowedSkill {
|
|
||||||
slug: record.slug.clone(),
|
|
||||||
kept_source: existing.source.clone(),
|
|
||||||
kept_path: existing.path.clone(),
|
|
||||||
shadowed_source: record.source,
|
|
||||||
shadowed_path: record.path,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
self.records.insert(record.slug.clone(), record);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ResidentWorkflowEntry {
|
|
||||||
pub slug: String,
|
|
||||||
pub description: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum WorkflowLoadError {
|
|
||||||
#[error("failed to read workflow directory {}: {source}", .dir.display())]
|
|
||||||
ReadDir { dir: PathBuf, source: io::Error },
|
|
||||||
#[error("failed to read workflow file {}: {source}", .path.display())]
|
|
||||||
ReadFile { path: PathBuf, source: io::Error },
|
|
||||||
#[error("invalid workflow file name {}: {source}", .path.display())]
|
|
||||||
InvalidSlug {
|
|
||||||
path: PathBuf,
|
|
||||||
source: WorkflowLintError,
|
|
||||||
},
|
|
||||||
#[error("invalid workflow frontmatter in {}: {source}", .path.display())]
|
|
||||||
Frontmatter {
|
|
||||||
path: PathBuf,
|
|
||||||
source: WorkflowLintError,
|
|
||||||
},
|
|
||||||
#[error(
|
|
||||||
"Workflow {} with model_invokation: true cannot have description longer than {limit} chars (got {actual})",
|
|
||||||
.path.display()
|
|
||||||
)]
|
|
||||||
DescriptionTooLong {
|
|
||||||
path: PathBuf,
|
|
||||||
actual: usize,
|
|
||||||
limit: usize,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BuiltinWorkflowResource {
|
|
||||||
slug: &'static str,
|
|
||||||
content: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
const BUILTIN_WORKFLOWS: &[BuiltinWorkflowResource] = &[
|
|
||||||
BuiltinWorkflowResource {
|
|
||||||
slug: "ticket-intake-workflow",
|
|
||||||
content: include_str!("../../../resources/workflows/ticket-intake-workflow.md"),
|
|
||||||
},
|
|
||||||
BuiltinWorkflowResource {
|
|
||||||
slug: "ticket-orchestrator-routing",
|
|
||||||
content: include_str!("../../../resources/workflows/ticket-orchestrator-routing.md"),
|
|
||||||
},
|
|
||||||
BuiltinWorkflowResource {
|
|
||||||
slug: "multi-agent-workflow",
|
|
||||||
content: include_str!("../../../resources/workflows/multi-agent-workflow.md"),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
fn builtin_workflow_records() -> Result<BTreeMap<Slug, WorkflowRecord>, WorkflowLoadError> {
|
|
||||||
let mut records = BTreeMap::new();
|
|
||||||
for resource in BUILTIN_WORKFLOWS {
|
|
||||||
let path = PathBuf::from(format!("builtin:{}", resource.slug));
|
|
||||||
records.insert(
|
|
||||||
Slug::parse(resource.slug).map_err(|source| WorkflowLoadError::InvalidSlug {
|
|
||||||
path: path.clone(),
|
|
||||||
source: source.into(),
|
|
||||||
})?,
|
|
||||||
parse_workflow_record(
|
|
||||||
Slug::parse(resource.slug).map_err(|source| WorkflowLoadError::InvalidSlug {
|
|
||||||
path: path.clone(),
|
|
||||||
source: source.into(),
|
|
||||||
})?,
|
|
||||||
path,
|
|
||||||
WorkflowSource::Builtin,
|
|
||||||
resource.content,
|
|
||||||
)?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(records)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_workflow_record(
|
|
||||||
slug: Slug,
|
|
||||||
path: PathBuf,
|
|
||||||
source: WorkflowSource,
|
|
||||||
raw: &str,
|
|
||||||
) -> Result<WorkflowRecord, WorkflowLoadError> {
|
|
||||||
let (yaml, body) = split_frontmatter(raw).map_err(|source| WorkflowLoadError::Frontmatter {
|
|
||||||
path: path.clone(),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
warn_unknown_workflow_fields(&path, yaml);
|
|
||||||
let frontmatter: WorkflowFrontmatter =
|
|
||||||
serde_yaml::from_str(yaml).map_err(|err| WorkflowLoadError::Frontmatter {
|
|
||||||
path: path.clone(),
|
|
||||||
source: map_serde_workflow_error(err),
|
|
||||||
})?;
|
|
||||||
if frontmatter.model_invokation
|
|
||||||
&& frontmatter.description.chars().count() > WORKFLOW_DESCRIPTION_HARD_CAP
|
|
||||||
{
|
|
||||||
return Err(WorkflowLoadError::DescriptionTooLong {
|
|
||||||
path,
|
|
||||||
actual: frontmatter.description.chars().count(),
|
|
||||||
limit: WORKFLOW_DESCRIPTION_HARD_CAP,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(WorkflowRecord {
|
|
||||||
slug,
|
|
||||||
description: frontmatter.description,
|
|
||||||
model_invokation: frontmatter.model_invokation,
|
|
||||||
user_invocable: frontmatter.user_invocable,
|
|
||||||
requires: frontmatter.requires,
|
|
||||||
body: body.to_string(),
|
|
||||||
path,
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_workflows(layout: &WorkspaceLayout) -> Result<WorkflowRegistry, WorkflowLoadError> {
|
|
||||||
let mut records = builtin_workflow_records()?;
|
|
||||||
let dir = layout.workflow_dir();
|
|
||||||
let entries = match std::fs::read_dir(&dir) {
|
|
||||||
Ok(entries) => entries,
|
|
||||||
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
|
||||||
return Ok(WorkflowRegistry { records });
|
|
||||||
}
|
|
||||||
Err(source) => return Err(WorkflowLoadError::ReadDir { dir, source }),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut paths = Vec::new();
|
|
||||||
for entry in entries {
|
|
||||||
let entry = entry.map_err(|source| WorkflowLoadError::ReadDir {
|
|
||||||
dir: dir.clone(),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
let path = entry.path();
|
|
||||||
if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some("md") {
|
|
||||||
paths.push(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
paths.sort();
|
|
||||||
|
|
||||||
for path in paths {
|
|
||||||
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let slug =
|
|
||||||
Slug::parse(stem.to_string()).map_err(|source| WorkflowLoadError::InvalidSlug {
|
|
||||||
path: path.clone(),
|
|
||||||
source: source.into(),
|
|
||||||
})?;
|
|
||||||
if let Some(existing) = records.get(&slug) {
|
|
||||||
if !matches!(existing.source, WorkflowSource::Builtin) {
|
|
||||||
warn!(slug = %slug, path = %path.display(), "duplicate workflow slug encountered; keeping first record");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let raw = std::fs::read_to_string(&path).map_err(|source| WorkflowLoadError::ReadFile {
|
|
||||||
path: path.clone(),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
let record =
|
|
||||||
parse_workflow_record(slug.clone(), path, WorkflowSource::WorkspaceWorkflow, &raw)?;
|
|
||||||
records.insert(slug.clone(), record);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(WorkflowRegistry { records })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn warn_unknown_workflow_fields(path: &Path, yaml: &str) {
|
|
||||||
let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(yaml) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(map) = value.as_mapping() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
for key in map.keys().filter_map(|k| k.as_str()) {
|
|
||||||
if !matches!(
|
|
||||||
key,
|
|
||||||
"description"
|
|
||||||
| "model_invokation"
|
|
||||||
| "user_invocable"
|
|
||||||
| "requires"
|
|
||||||
| "created_at"
|
|
||||||
| "updated_at"
|
|
||||||
) {
|
|
||||||
warn!(path = %path.display(), field = key, "unknown workflow frontmatter field ignored");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_serde_workflow_error(err: serde_yaml::Error) -> WorkflowLintError {
|
|
||||||
let msg = err.to_string();
|
|
||||||
if let Some(field) = parse_missing_field(&msg) {
|
|
||||||
return WorkflowLintError::MissingField(field);
|
|
||||||
}
|
|
||||||
WorkflowLintError::Record(RecordLintError::MalformedFrontmatter(msg))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_missing_field(msg: &str) -> Option<&'static str> {
|
|
||||||
let needle = "missing field `";
|
|
||||||
let start = msg.find(needle)? + needle.len();
|
|
||||||
let end = msg[start..].find('`')? + start;
|
|
||||||
match &msg[start..end] {
|
|
||||||
"description" => Some("description"),
|
|
||||||
"model_invokation" => Some("model_invokation"),
|
|
||||||
"user_invocable" => Some("user_invocable"),
|
|
||||||
"requires" => Some("requires"),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
fn setup() -> (TempDir, WorkspaceLayout) {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
std::fs::create_dir_all(dir.path().join(".yoi/workflow")).unwrap();
|
|
||||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
|
||||||
(dir, layout)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_workflow(root: &Path, slug: &str, frontmatter: &str, body: &str) {
|
|
||||||
let path = root.join(".yoi/workflow").join(format!("{slug}.md"));
|
|
||||||
std::fs::write(path, format!("---\n{frontmatter}\n---\n{body}")).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn missing_directory_loads_builtin_registry() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
|
||||||
let got = load_workflows(&layout).unwrap();
|
|
||||||
assert!(got.get(&Slug::parse("ghost").unwrap()).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn loads_valid_workflow_with_default_flags() {
|
|
||||||
let (dir, layout) = setup();
|
|
||||||
write_workflow(dir.path(), "do-thing", "description: Do thing", "Step 1");
|
|
||||||
let got = load_workflows(&layout).unwrap();
|
|
||||||
let slug = Slug::parse("do-thing").unwrap();
|
|
||||||
let record = got.get(&slug).unwrap();
|
|
||||||
assert_eq!(record.description, "Do thing");
|
|
||||||
assert!(!record.model_invokation);
|
|
||||||
assert!(record.user_invocable);
|
|
||||||
assert!(record.requires.is_empty());
|
|
||||||
assert_eq!(record.body, "Step 1");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn model_invokation_uses_typo_field() {
|
|
||||||
let (dir, layout) = setup();
|
|
||||||
write_workflow(
|
|
||||||
dir.path(),
|
|
||||||
"auto",
|
|
||||||
"description: Auto\nmodel_invokation: true\nuser_invocable: false",
|
|
||||||
"Body",
|
|
||||||
);
|
|
||||||
let got = load_workflows(&layout).unwrap();
|
|
||||||
assert!(
|
|
||||||
got.resident_entries()
|
|
||||||
.iter()
|
|
||||||
.any(|entry| entry.slug == "auto")
|
|
||||||
);
|
|
||||||
assert!(!got.list_user_invocable("").contains(&"auto".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn workspace_workflow_overrides_builtin_by_slug() {
|
|
||||||
let (dir, layout) = setup();
|
|
||||||
write_workflow(
|
|
||||||
dir.path(),
|
|
||||||
"ticket-intake-workflow",
|
|
||||||
"description: Workspace intake\nmodel_invokation: false",
|
|
||||||
"workspace override body",
|
|
||||||
);
|
|
||||||
let got = load_workflows(&layout).unwrap();
|
|
||||||
let slug = Slug::parse("ticket-intake-workflow").unwrap();
|
|
||||||
let record = got.get(&slug).unwrap();
|
|
||||||
assert_eq!(record.source, WorkflowSource::WorkspaceWorkflow);
|
|
||||||
assert_eq!(record.description, "Workspace intake");
|
|
||||||
assert_eq!(record.body, "workspace override body");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn builtin_workflow_records_have_visible_provenance() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
|
||||||
let got = load_workflows(&layout).unwrap();
|
|
||||||
let slug = Slug::parse("multi-agent-workflow").unwrap();
|
|
||||||
let record = got.get(&slug).unwrap();
|
|
||||||
assert_eq!(record.source, WorkflowSource::Builtin);
|
|
||||||
assert_eq!(record.path, PathBuf::from("builtin:multi-agent-workflow"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn invalid_filename_is_hard_error() {
|
|
||||||
let (dir, layout) = setup();
|
|
||||||
write_workflow(dir.path(), "Bad", "description: Bad", "Body");
|
|
||||||
let err = load_workflows(&layout).unwrap_err();
|
|
||||||
assert!(matches!(err, WorkflowLoadError::InvalidSlug { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn missing_description_is_hard_error() {
|
|
||||||
let (dir, layout) = setup();
|
|
||||||
write_workflow(dir.path(), "bad", "model_invokation: false", "Body");
|
|
||||||
let err = load_workflows(&layout).unwrap_err();
|
|
||||||
assert!(matches!(err, WorkflowLoadError::Frontmatter { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn workflow_under_memory_is_ignored() {
|
|
||||||
// The legacy `.yoi/memory/workflow/` location is no longer
|
|
||||||
// a Workflow source. Files placed there must be ignored (the
|
|
||||||
// loader is rooted at `.yoi/workflow/` only).
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
|
||||||
let legacy = dir.path().join(".yoi/memory/workflow");
|
|
||||||
std::fs::create_dir_all(&legacy).unwrap();
|
|
||||||
std::fs::write(
|
|
||||||
legacy.join("ghost.md"),
|
|
||||||
"---\ndescription: ghost\n---\nbody\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let got = load_workflows(&layout).unwrap();
|
|
||||||
assert!(got.get(&Slug::parse("ghost").unwrap()).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn skill_record(slug: &str, path: &Path) -> WorkflowRecord {
|
|
||||||
WorkflowRecord {
|
|
||||||
slug: Slug::parse(slug).unwrap(),
|
|
||||||
description: format!("desc {slug}"),
|
|
||||||
model_invokation: true,
|
|
||||||
user_invocable: true,
|
|
||||||
requires: Vec::new(),
|
|
||||||
body: format!("body for {slug}"),
|
|
||||||
path: path.to_path_buf(),
|
|
||||||
source: WorkflowSource::Skill {
|
|
||||||
dir: path.parent().unwrap().parent().unwrap().to_path_buf(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn merge_skill_inserts_when_no_collision() {
|
|
||||||
let mut reg = WorkflowRegistry::empty();
|
|
||||||
let path = std::path::PathBuf::from("/tmp/skills/x/SKILL.md");
|
|
||||||
let shadow = reg.merge_skill(skill_record("x", &path));
|
|
||||||
assert!(shadow.is_none());
|
|
||||||
assert_eq!(reg.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn merge_skill_shadows_existing_workflow() {
|
|
||||||
let (dir, layout) = setup();
|
|
||||||
write_workflow(
|
|
||||||
dir.path(),
|
|
||||||
"shared",
|
|
||||||
"description: Internal",
|
|
||||||
"internal body",
|
|
||||||
);
|
|
||||||
let mut reg = load_workflows(&layout).unwrap();
|
|
||||||
let skill_path = dir
|
|
||||||
.path()
|
|
||||||
.join("user-skills")
|
|
||||||
.join("shared")
|
|
||||||
.join("SKILL.md");
|
|
||||||
std::fs::create_dir_all(skill_path.parent().unwrap()).unwrap();
|
|
||||||
std::fs::write(&skill_path, "ignored").unwrap();
|
|
||||||
let incoming = WorkflowRecord {
|
|
||||||
slug: Slug::parse("shared").unwrap(),
|
|
||||||
description: "From skill".into(),
|
|
||||||
model_invokation: true,
|
|
||||||
user_invocable: true,
|
|
||||||
requires: Vec::new(),
|
|
||||||
body: "skill body".into(),
|
|
||||||
path: skill_path.clone(),
|
|
||||||
source: WorkflowSource::Skill {
|
|
||||||
dir: dir.path().join("user-skills"),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let shadow = reg.merge_skill(incoming).expect("expected shadow");
|
|
||||||
assert_eq!(shadow.slug.as_str(), "shared");
|
|
||||||
assert!(matches!(
|
|
||||||
shadow.kept_source,
|
|
||||||
WorkflowSource::WorkspaceWorkflow
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
shadow.shadowed_source,
|
|
||||||
WorkflowSource::Skill { .. }
|
|
||||||
));
|
|
||||||
// The kept record is still the workspace workflow.
|
|
||||||
let kept = reg.get(&Slug::parse("shared").unwrap()).unwrap();
|
|
||||||
assert!(matches!(kept.source, WorkflowSource::WorkspaceWorkflow));
|
|
||||||
assert_eq!(kept.body, "internal body");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn merge_skill_first_fed_wins_on_collision() {
|
|
||||||
let mut reg = WorkflowRegistry::empty();
|
|
||||||
let first_path = std::path::PathBuf::from("/a/skills/x/SKILL.md");
|
|
||||||
let second_path = std::path::PathBuf::from("/b/skills/x/SKILL.md");
|
|
||||||
let first = WorkflowRecord {
|
|
||||||
slug: Slug::parse("x").unwrap(),
|
|
||||||
description: "first".into(),
|
|
||||||
model_invokation: true,
|
|
||||||
user_invocable: true,
|
|
||||||
requires: Vec::new(),
|
|
||||||
body: "first body".into(),
|
|
||||||
path: first_path.clone(),
|
|
||||||
source: WorkflowSource::Skill {
|
|
||||||
dir: std::path::PathBuf::from("/a/skills"),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let second = WorkflowRecord {
|
|
||||||
slug: Slug::parse("x").unwrap(),
|
|
||||||
description: "second".into(),
|
|
||||||
model_invokation: true,
|
|
||||||
user_invocable: true,
|
|
||||||
requires: Vec::new(),
|
|
||||||
body: "second body".into(),
|
|
||||||
path: second_path.clone(),
|
|
||||||
source: WorkflowSource::Skill {
|
|
||||||
dir: std::path::PathBuf::from("/b/skills"),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
// Caller is responsible for feeding in priority order; the
|
|
||||||
// registry just keeps whichever arrives first.
|
|
||||||
assert!(reg.merge_skill(first).is_none());
|
|
||||||
let shadow = reg
|
|
||||||
.merge_skill(second)
|
|
||||||
.expect("later-fed skill must shadow");
|
|
||||||
assert_eq!(shadow.kept_path, first_path);
|
|
||||||
assert!(matches!(shadow.kept_source, WorkflowSource::Skill { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shadow_message_is_human_readable() {
|
|
||||||
let s = ShadowedSkill {
|
|
||||||
slug: Slug::parse("x").unwrap(),
|
|
||||||
kept_source: WorkflowSource::WorkspaceWorkflow,
|
|
||||||
kept_path: std::path::PathBuf::from("/ws/.yoi/workflow/x.md"),
|
|
||||||
shadowed_source: WorkflowSource::Skill {
|
|
||||||
dir: std::path::PathBuf::from("/skills"),
|
|
||||||
},
|
|
||||||
shadowed_path: std::path::PathBuf::from("/skills/x/SKILL.md"),
|
|
||||||
};
|
|
||||||
let msg = s.message();
|
|
||||||
assert!(msg.contains("/x"));
|
|
||||||
assert!(msg.contains("workspace workflow"));
|
|
||||||
assert!(msg.contains("skill"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resident_description_cap_is_enforced() {
|
|
||||||
let (dir, layout) = setup();
|
|
||||||
let desc = "x".repeat(WORKFLOW_DESCRIPTION_HARD_CAP + 1);
|
|
||||||
write_workflow(
|
|
||||||
dir.path(),
|
|
||||||
"bad",
|
|
||||||
&format!("description: {desc}\nmodel_invokation: true"),
|
|
||||||
"Body",
|
|
||||||
);
|
|
||||||
let err = load_workflows(&layout).unwrap_err();
|
|
||||||
assert!(matches!(err, WorkflowLoadError::DescriptionTooLong { .. }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -14,6 +14,7 @@ pub mod records;
|
||||||
pub mod repositories;
|
pub mod repositories;
|
||||||
pub mod resource_broker;
|
pub mod resource_broker;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
|
pub mod skills;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
|
||||||
pub use config::{
|
pub use config::{
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ enum Command {
|
||||||
Init(InitOptions),
|
Init(InitOptions),
|
||||||
ConfigDefault,
|
ConfigDefault,
|
||||||
ConfigDiff(WorkspacePathOptions),
|
ConfigDiff(WorkspacePathOptions),
|
||||||
|
Skills(SkillsCommand),
|
||||||
Help,
|
Help,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,6 +37,13 @@ struct WorkspacePathOptions {
|
||||||
workspace: PathBuf,
|
workspace: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum SkillsCommand {
|
||||||
|
List(WorkspacePathOptions),
|
||||||
|
Lint(WorkspacePathOptions),
|
||||||
|
Show { workspace: PathBuf, name: String },
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct CliError(String);
|
struct CliError(String);
|
||||||
|
|
||||||
|
|
@ -65,6 +73,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
Command::Init(options) => run_init(options),
|
Command::Init(options) => run_init(options),
|
||||||
Command::ConfigDefault => run_config_default(),
|
Command::ConfigDefault => run_config_default(),
|
||||||
Command::ConfigDiff(options) => run_config_diff(options),
|
Command::ConfigDiff(options) => run_config_diff(options),
|
||||||
|
Command::Skills(command) => run_skills(command),
|
||||||
Command::Help => Ok(()),
|
Command::Help => Ok(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -84,6 +93,7 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
|
||||||
Ok(Command::Init(parse_init_options(rest)?))
|
Ok(Command::Init(parse_init_options(rest)?))
|
||||||
}
|
}
|
||||||
"config" => parse_config_command(rest),
|
"config" => parse_config_command(rest),
|
||||||
|
"skills" => parse_skills_command(rest),
|
||||||
"serve" => {
|
"serve" => {
|
||||||
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
||||||
print_serve_help();
|
print_serve_help();
|
||||||
|
|
@ -96,7 +106,7 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
|
||||||
Ok(Command::Help)
|
Ok(Command::Help)
|
||||||
}
|
}
|
||||||
other => Err(CliError(format!(
|
other => Err(CliError(format!(
|
||||||
"unknown command `{other}`; expected `init`, `config`, or `serve`"
|
"unknown command `{other}`; expected `init`, `config`, `skills`, or `serve`"
|
||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -123,6 +133,39 @@ fn run_config_diff(options: WorkspacePathOptions) -> Result<(), Box<dyn std::err
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_skills(command: SkillsCommand) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
match command {
|
||||||
|
SkillsCommand::List(options) => {
|
||||||
|
let catalog = yoi_workspace_server::skills::catalog(&options.workspace);
|
||||||
|
println!("{}", serde_json::to_string_pretty(&catalog)?);
|
||||||
|
}
|
||||||
|
SkillsCommand::Lint(options) => {
|
||||||
|
let catalog = yoi_workspace_server::skills::lint(&options.workspace);
|
||||||
|
println!("{}", serde_json::to_string_pretty(&catalog)?);
|
||||||
|
if catalog
|
||||||
|
.diagnostics
|
||||||
|
.iter()
|
||||||
|
.chain(
|
||||||
|
catalog
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.flat_map(|entry| entry.diagnostics.iter()),
|
||||||
|
)
|
||||||
|
.any(|diagnostic| {
|
||||||
|
diagnostic.severity == worker::skill::SkillDiagnosticSeverity::Error
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err(Box::new(CliError("Skill lint found errors".to_string())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SkillsCommand::Show { workspace, name } => {
|
||||||
|
let detail = yoi_workspace_server::skills::detail(&workspace, &name)?;
|
||||||
|
println!("{}", serde_json::to_string_pretty(&detail)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> {
|
async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let identity = WorkspaceIdentity::load_required(&options.workspace)?;
|
let identity = WorkspaceIdentity::load_required(&options.workspace)?;
|
||||||
let config_file = WorkspaceBackendConfigFile::load_for_workspace(&options.workspace)?;
|
let config_file = WorkspaceBackendConfigFile::load_for_workspace(&options.workspace)?;
|
||||||
|
|
@ -187,6 +230,37 @@ fn parse_config_command(args: &[String]) -> Result<Command, CliError> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_skills_command(args: &[String]) -> Result<Command, CliError> {
|
||||||
|
let Some((subcommand, rest)) = args.split_first() else {
|
||||||
|
print_skills_help();
|
||||||
|
return Ok(Command::Help);
|
||||||
|
};
|
||||||
|
match subcommand.as_str() {
|
||||||
|
"list" => Ok(Command::Skills(SkillsCommand::List(
|
||||||
|
parse_workspace_path_options(rest)?,
|
||||||
|
))),
|
||||||
|
"lint" => Ok(Command::Skills(SkillsCommand::Lint(
|
||||||
|
parse_workspace_path_options(rest)?,
|
||||||
|
))),
|
||||||
|
"show" => {
|
||||||
|
let Some((name, rest)) = rest.split_first() else {
|
||||||
|
return Err(CliError("skills show requires a Skill name".to_string()));
|
||||||
|
};
|
||||||
|
Ok(Command::Skills(SkillsCommand::Show {
|
||||||
|
workspace: parse_workspace_path_options(rest)?.workspace,
|
||||||
|
name: name.to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
"--help" | "-h" => {
|
||||||
|
print_skills_help();
|
||||||
|
Ok(Command::Help)
|
||||||
|
}
|
||||||
|
other => Err(CliError(format!(
|
||||||
|
"unknown skills subcommand `{other}`; expected `list`, `lint`, or `show`"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_workspace_path_options(args: &[String]) -> Result<WorkspacePathOptions, CliError> {
|
fn parse_workspace_path_options(args: &[String]) -> Result<WorkspacePathOptions, CliError> {
|
||||||
let mut workspace = std::env::current_dir()
|
let mut workspace = std::env::current_dir()
|
||||||
.map_err(|error| CliError(format!("failed to read current dir: {error}")))?;
|
.map_err(|error| CliError(format!("failed to read current dir: {error}")))?;
|
||||||
|
|
@ -333,7 +407,7 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
|
||||||
|
|
||||||
fn print_help() {
|
fn print_help() {
|
||||||
println!(
|
println!(
|
||||||
"yoi-workspace-server\n\nUsage:\n yoi-workspace-server init [OPTIONS]\n yoi-workspace-server config <COMMAND> [OPTIONS]\n yoi-workspace-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
|
"yoi-workspace-server\n\nUsage:\n yoi-workspace-server init [OPTIONS]\n yoi-workspace-server config <COMMAND> [OPTIONS]\n yoi-workspace-server skills <COMMAND> [OPTIONS]\n yoi-workspace-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -349,6 +423,12 @@ fn print_config_help() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn print_skills_help() {
|
||||||
|
println!(
|
||||||
|
"yoi-workspace-server skills\n\nUsage:\n yoi-workspace-server skills list [OPTIONS]\n yoi-workspace-server skills lint [OPTIONS]\n yoi-workspace-server skills show <NAME> [OPTIONS]\n\nDescription:\n Uses the Workspace backend Skill catalog/lint/detail authority. Catalog output is lightweight and omits full SKILL.md bodies; detail output includes the body. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace <PATH> Workspace root (defaults to cwd)\n -h, --help Print help"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn print_serve_help() {
|
fn print_serve_help() {
|
||||||
println!(
|
println!(
|
||||||
"yoi-workspace-server serve\n\nUsage:\n yoi-workspace-server serve [OPTIONS]\n\nDescription:\n Serves an already initialized Workspace. Run `yoi workspace init` first.\n\nOptions:\n --workspace <PATH> Workspace root containing .yoi project records (defaults to cwd)\n --db <PATH> SQLite database path (legacy dev override)\n --frontend <PATH> Static SPA build directory to serve (legacy dev override)\n --listen <ADDR> Listen address (legacy dev override; default 127.0.0.1:8787)\n -h, --help Print help"
|
"yoi-workspace-server serve\n\nUsage:\n yoi-workspace-server serve [OPTIONS]\n\nDescription:\n Serves an already initialized Workspace. Run `yoi workspace init` first.\n\nOptions:\n --workspace <PATH> Workspace root containing .yoi project records (defaults to cwd)\n --db <PATH> SQLite database path (legacy dev override)\n --frontend <PATH> Static SPA build directory to serve (legacy dev override)\n --listen <ADDR> Listen address (legacy dev override; default 127.0.0.1:8787)\n -h, --help Print help"
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ use crate::repositories::{
|
||||||
RepositoryRegistryReader, RepositorySummary,
|
RepositoryRegistryReader, RepositorySummary,
|
||||||
};
|
};
|
||||||
use crate::resource_broker::BackendResourceBroker;
|
use crate::resource_broker::BackendResourceBroker;
|
||||||
|
use crate::skills;
|
||||||
use crate::store::{
|
use crate::store::{
|
||||||
ControlPlaneStore, WorkdirRegistryRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord,
|
ControlPlaneStore, WorkdirRegistryRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord,
|
||||||
WorkspaceRecord,
|
WorkspaceRecord,
|
||||||
|
|
@ -330,6 +331,13 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
||||||
post(scoped_ticket_backend_operation),
|
post(scoped_ticket_backend_operation),
|
||||||
)
|
)
|
||||||
.route("/api/tickets/{id}", get(get_ticket))
|
.route("/api/tickets/{id}", get(get_ticket))
|
||||||
|
.route("/api/w/{workspace_id}/skills", get(scoped_list_skills))
|
||||||
|
.route("/api/w/{workspace_id}/skills/lint", get(scoped_lint_skills))
|
||||||
|
.route("/api/w/{workspace_id}/skills/{name}", get(scoped_get_skill))
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/skills/{name}/activate",
|
||||||
|
get(scoped_activate_skill),
|
||||||
|
)
|
||||||
.route("/api/w/{workspace_id}/tickets/{id}", get(scoped_get_ticket))
|
.route("/api/w/{workspace_id}/tickets/{id}", get(scoped_get_ticket))
|
||||||
.route("/api/objectives", get(list_objectives))
|
.route("/api/objectives", get(list_objectives))
|
||||||
.route(
|
.route(
|
||||||
|
|
@ -995,6 +1003,12 @@ struct ScopedRecordPath {
|
||||||
id: String,
|
id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ScopedSkillPath {
|
||||||
|
workspace_id: String,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct ScopedRepositoryPath {
|
struct ScopedRepositoryPath {
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
|
|
@ -1261,6 +1275,60 @@ async fn scoped_ticket_backend_operation(
|
||||||
Ok(Json(response))
|
Ok(Json(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn scoped_list_skills(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
) -> ApiResult<Json<worker::skill::SkillCatalogResponse>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
Ok(Json(skills::catalog(&api.config.workspace_root)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_lint_skills(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
) -> ApiResult<Json<worker::skill::SkillCatalogResponse>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
Ok(Json(skills::lint(&api.config.workspace_root)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_get_skill(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedSkillPath>,
|
||||||
|
) -> ApiResult<Json<worker::skill::SkillDetailResponse>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
skills::detail(&api.config.workspace_root, &path.name)
|
||||||
|
.map(Json)
|
||||||
|
.map_err(skill_api_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_activate_skill(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedSkillPath>,
|
||||||
|
) -> ApiResult<Json<worker::skill::SkillActivationResponse>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
skills::activation(&api.config.workspace_root, &path.name)
|
||||||
|
.map(Json)
|
||||||
|
.map_err(skill_api_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skill_api_error(error: skills::SkillError) -> ApiError {
|
||||||
|
match error {
|
||||||
|
skills::SkillError::NotFound(name) => ApiError::with_diagnostics(
|
||||||
|
Error::RuntimeOperationFailed {
|
||||||
|
runtime_id: "workspace".to_string(),
|
||||||
|
code: "skill_not_found".to_string(),
|
||||||
|
message: format!("unknown Skill `{name}`"),
|
||||||
|
},
|
||||||
|
vec![RuntimeDiagnostic {
|
||||||
|
code: "skill_not_found".to_string(),
|
||||||
|
severity: DiagnosticSeverity::Error,
|
||||||
|
message: format!("unknown Skill `{name}`"),
|
||||||
|
}],
|
||||||
|
),
|
||||||
|
skills::SkillError::Io(error) => ApiError::from(Error::Io(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn scoped_list_objectives(
|
async fn scoped_list_objectives(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
|
@ -5096,7 +5164,9 @@ impl IntoResponse for ApiError {
|
||||||
| Error::UnknownWorker { .. }
|
| Error::UnknownWorker { .. }
|
||||||
| Error::UnknownRepository(_)
|
| Error::UnknownRepository(_)
|
||||||
| Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND,
|
| Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND,
|
||||||
Error::Ticket(_) => StatusCode::NOT_FOUND,
|
Error::RuntimeOperationFailed { code, .. } if code == "skill_not_found" => {
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
}
|
||||||
Error::RuntimeCapabilityUnsupported { .. } => StatusCode::NOT_IMPLEMENTED,
|
Error::RuntimeCapabilityUnsupported { .. } => StatusCode::NOT_IMPLEMENTED,
|
||||||
Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => {
|
Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => {
|
||||||
StatusCode::NOT_FOUND
|
StatusCode::NOT_FOUND
|
||||||
|
|
@ -5846,6 +5916,51 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn skills_endpoints_use_workspace_backend_catalog_and_progressive_detail() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let skill_dir = dir.path().join(".yoi/skills/triage-errors");
|
||||||
|
fs::create_dir_all(&skill_dir).unwrap();
|
||||||
|
fs::write(
|
||||||
|
skill_dir.join("SKILL.md"),
|
||||||
|
"---\nname: triage-errors\ndescription: Use when triaging backend errors and choosing safe diagnostics.\n---\n\n# Triage Errors\n\nInspect logs before changing code.",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let api = test_api(dir.path()).await;
|
||||||
|
|
||||||
|
let Json(catalog) = scoped_list_skills(
|
||||||
|
State(api.clone()),
|
||||||
|
AxumPath(ScopedWorkspacePath {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|error| panic!("skill catalog failed: {}", error.error));
|
||||||
|
let entry = catalog
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.name == "triage-errors")
|
||||||
|
.expect("workspace Skill catalog entry");
|
||||||
|
assert_eq!(entry.provenance.id, "workspace:triage-errors");
|
||||||
|
assert!(
|
||||||
|
!serde_json::to_string(&catalog)
|
||||||
|
.unwrap()
|
||||||
|
.contains("# Triage Errors")
|
||||||
|
);
|
||||||
|
|
||||||
|
let Json(detail) = scoped_get_skill(
|
||||||
|
State(api),
|
||||||
|
AxumPath(ScopedSkillPath {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
|
name: "triage-errors".to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|error| panic!("skill detail failed: {}", error.error));
|
||||||
|
assert!(detail.body.contains("# Triage Errors"));
|
||||||
|
assert_eq!(detail.provenance.id, "workspace:triage-errors");
|
||||||
|
}
|
||||||
|
|
||||||
async fn test_api(workspace_root: impl Into<PathBuf>) -> WorkspaceApi {
|
async fn test_api(workspace_root: impl Into<PathBuf>) -> WorkspaceApi {
|
||||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||||
WorkspaceApi::new_with_execution_backend(
|
WorkspaceApi::new_with_execution_backend(
|
||||||
|
|
|
||||||
789
crates/workspace-server/src/skills.rs
Normal file
789
crates/workspace-server/src/skills.rs
Normal file
|
|
@ -0,0 +1,789 @@
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use worker::skill::{
|
||||||
|
SkillActivationResponse, SkillCatalogEntry, SkillCatalogResponse, SkillDetailResponse,
|
||||||
|
SkillDiagnostic, SkillDiagnosticSeverity, SkillProvenance, SkillResourceRef, SkillSourceKind,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BUILTIN_AGENT_SKILLS: &str = include_str!("../../../resources/skills/agent-skills/SKILL.md");
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum SkillError {
|
||||||
|
#[error("unknown Skill `{0}`")]
|
||||||
|
NotFound(String),
|
||||||
|
#[error("io error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct SkillSource {
|
||||||
|
source_kind: SkillSourceKind,
|
||||||
|
parent_name: String,
|
||||||
|
content: String,
|
||||||
|
resource_root: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ParsedSkill {
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
content: String,
|
||||||
|
allowed_tools: Vec<String>,
|
||||||
|
diagnostics: Vec<SkillDiagnostic>,
|
||||||
|
provenance: SkillProvenance,
|
||||||
|
resource_root: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
struct SkillFrontmatter {
|
||||||
|
name: Option<String>,
|
||||||
|
description: Option<String>,
|
||||||
|
license: Option<String>,
|
||||||
|
compatibility: Option<String>,
|
||||||
|
metadata: Option<BTreeMap<String, serde_yaml::Value>>,
|
||||||
|
#[serde(default)]
|
||||||
|
allowed_tools: Option<serde_yaml::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn catalog(workspace_root: &Path) -> SkillCatalogResponse {
|
||||||
|
let mut diagnostics = Vec::new();
|
||||||
|
let mut active = BTreeMap::<String, ParsedSkill>::new();
|
||||||
|
let mut builtin_by_name = BTreeMap::<String, ParsedSkill>::new();
|
||||||
|
|
||||||
|
for source in builtin_skill_sources() {
|
||||||
|
match parse_skill_source(source) {
|
||||||
|
Ok(skill) => {
|
||||||
|
builtin_by_name.insert(skill.name.clone(), skill.clone());
|
||||||
|
active.insert(skill.name.clone(), skill);
|
||||||
|
}
|
||||||
|
Err(errs) => diagnostics.extend(errs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let workspace_sources = match workspace_skill_sources(workspace_root) {
|
||||||
|
Ok(sources) => sources,
|
||||||
|
Err(error) => {
|
||||||
|
diagnostics.push(SkillDiagnostic::error(
|
||||||
|
"workspace_skill_read_failed",
|
||||||
|
format!("failed to read workspace Skills: {error}"),
|
||||||
|
Some("workspace:.yoi/skills".to_string()),
|
||||||
|
));
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for source in workspace_sources {
|
||||||
|
match parse_skill_source(source) {
|
||||||
|
Ok(skill) => {
|
||||||
|
let overrides = builtin_by_name
|
||||||
|
.get(&skill.name)
|
||||||
|
.map(|builtin| vec![builtin.provenance.clone()])
|
||||||
|
.unwrap_or_default();
|
||||||
|
if let Some(overridden) = overrides.first() {
|
||||||
|
diagnostics.push(SkillDiagnostic::warning(
|
||||||
|
"workspace_skill_overrides_builtin",
|
||||||
|
format!(
|
||||||
|
"workspace Skill `{}` overrides builtin Skill `{}`",
|
||||||
|
skill.name, overridden.id
|
||||||
|
),
|
||||||
|
Some(skill.provenance.id.clone()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut skill = skill;
|
||||||
|
if !overrides.is_empty() {
|
||||||
|
skill.diagnostics.push(SkillDiagnostic::warning(
|
||||||
|
"workspace_skill_overrides_builtin",
|
||||||
|
"workspace Skill has priority over the builtin Skill with the same name",
|
||||||
|
Some(skill.provenance.id.clone()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
active.insert(skill.name.clone(), skill);
|
||||||
|
}
|
||||||
|
Err(errs) => diagnostics.extend(errs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut entries = active
|
||||||
|
.into_values()
|
||||||
|
.map(|skill| {
|
||||||
|
let overrides = if matches!(&skill.provenance.kind, SkillSourceKind::Workspace) {
|
||||||
|
builtin_by_name
|
||||||
|
.get(&skill.name)
|
||||||
|
.map(|builtin| vec![builtin.provenance.clone()])
|
||||||
|
.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
SkillCatalogEntry {
|
||||||
|
name: skill.name,
|
||||||
|
description: skill.description,
|
||||||
|
provenance: skill.provenance,
|
||||||
|
overrides,
|
||||||
|
diagnostics: skill.diagnostics,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
entries.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
|
||||||
|
SkillCatalogResponse {
|
||||||
|
authority: "workspace-backend-skills-v0".to_string(),
|
||||||
|
entries,
|
||||||
|
diagnostics,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lint(workspace_root: &Path) -> SkillCatalogResponse {
|
||||||
|
catalog(workspace_root)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detail(workspace_root: &Path, name: &str) -> Result<SkillDetailResponse, SkillError> {
|
||||||
|
let skill = active_skill(workspace_root, name)?;
|
||||||
|
let overrides = if matches!(&skill.provenance.kind, SkillSourceKind::Workspace) {
|
||||||
|
builtin_skill_sources()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|source| parse_skill_source(source).ok())
|
||||||
|
.find(|builtin| builtin.name == skill.name)
|
||||||
|
.map(|builtin| vec![builtin.provenance])
|
||||||
|
.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
let resources = resource_refs(&skill);
|
||||||
|
Ok(SkillDetailResponse {
|
||||||
|
name: skill.name,
|
||||||
|
description: skill.description,
|
||||||
|
provenance: skill.provenance,
|
||||||
|
overrides,
|
||||||
|
diagnostics: skill.diagnostics,
|
||||||
|
body: skill.content,
|
||||||
|
allowed_tools: skill.allowed_tools,
|
||||||
|
allowed_tools_status:
|
||||||
|
"experimental_ignored_by_workspace_backend; does not grant or deny tool authority"
|
||||||
|
.to_string(),
|
||||||
|
resources,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn activation(
|
||||||
|
workspace_root: &Path,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<SkillActivationResponse, SkillError> {
|
||||||
|
let detail = detail(workspace_root, name)?;
|
||||||
|
Ok(SkillActivationResponse {
|
||||||
|
name: detail.name,
|
||||||
|
provenance: detail.provenance,
|
||||||
|
diagnostics: detail.diagnostics,
|
||||||
|
body: detail.body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_skill(workspace_root: &Path, name: &str) -> Result<ParsedSkill, SkillError> {
|
||||||
|
let mut parsed = BTreeMap::<String, ParsedSkill>::new();
|
||||||
|
for source in builtin_skill_sources() {
|
||||||
|
if let Ok(skill) = parse_skill_source(source) {
|
||||||
|
parsed.insert(skill.name.clone(), skill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for source in workspace_skill_sources(workspace_root)? {
|
||||||
|
if let Ok(skill) = parse_skill_source(source) {
|
||||||
|
parsed.insert(skill.name.clone(), skill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parsed
|
||||||
|
.remove(name)
|
||||||
|
.ok_or_else(|| SkillError::NotFound(name.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn builtin_skill_sources() -> Vec<SkillSource> {
|
||||||
|
vec![SkillSource {
|
||||||
|
source_kind: SkillSourceKind::Builtin,
|
||||||
|
parent_name: "agent-skills".to_string(),
|
||||||
|
content: BUILTIN_AGENT_SKILLS.to_string(),
|
||||||
|
resource_root: None,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_skill_sources(workspace_root: &Path) -> Result<Vec<SkillSource>, std::io::Error> {
|
||||||
|
let skills_dir = workspace_root.join(".yoi").join("skills");
|
||||||
|
if !skills_dir.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let mut sources = Vec::new();
|
||||||
|
for entry in fs::read_dir(&skills_dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let file_type = entry.file_type()?;
|
||||||
|
if !file_type.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let parent_name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
let skill_path = entry.path().join("SKILL.md");
|
||||||
|
if !skill_path.exists() {
|
||||||
|
sources.push(SkillSource {
|
||||||
|
source_kind: SkillSourceKind::Workspace,
|
||||||
|
parent_name,
|
||||||
|
content: String::new(),
|
||||||
|
resource_root: Some(entry.path()),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match fs::read_to_string(&skill_path) {
|
||||||
|
Ok(content) => sources.push(SkillSource {
|
||||||
|
source_kind: SkillSourceKind::Workspace,
|
||||||
|
parent_name,
|
||||||
|
content,
|
||||||
|
resource_root: Some(entry.path()),
|
||||||
|
}),
|
||||||
|
Err(error) => sources.push(SkillSource {
|
||||||
|
source_kind: SkillSourceKind::Workspace,
|
||||||
|
parent_name,
|
||||||
|
content: format!("__read_error__:{error}"),
|
||||||
|
resource_root: None,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_skill_source(source: SkillSource) -> Result<ParsedSkill, Vec<SkillDiagnostic>> {
|
||||||
|
let provenance = provenance(source.source_kind.clone(), &source.parent_name);
|
||||||
|
let mut diagnostics = Vec::new();
|
||||||
|
|
||||||
|
if !valid_skill_name(&source.parent_name) {
|
||||||
|
diagnostics.push(SkillDiagnostic::error(
|
||||||
|
"invalid_skill_directory_name",
|
||||||
|
"Skill directory name must be 1-64 chars of lowercase letters, numbers, or single hyphens with no leading/trailing hyphen",
|
||||||
|
Some(provenance.id.clone()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if source.content.is_empty() {
|
||||||
|
diagnostics.push(SkillDiagnostic::error(
|
||||||
|
"missing_skill_markdown",
|
||||||
|
"Skill directory must contain SKILL.md",
|
||||||
|
Some(provenance.id.clone()),
|
||||||
|
));
|
||||||
|
return Err(diagnostics);
|
||||||
|
}
|
||||||
|
if source.content.starts_with("__read_error__:") {
|
||||||
|
diagnostics.push(SkillDiagnostic::error(
|
||||||
|
"skill_markdown_read_failed",
|
||||||
|
source
|
||||||
|
.content
|
||||||
|
.trim_start_matches("__read_error__:")
|
||||||
|
.to_string(),
|
||||||
|
Some(provenance.id.clone()),
|
||||||
|
));
|
||||||
|
return Err(diagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some((frontmatter, _markdown)) = split_frontmatter(&source.content) else {
|
||||||
|
diagnostics.push(SkillDiagnostic::error(
|
||||||
|
"missing_frontmatter",
|
||||||
|
"SKILL.md must start with YAML frontmatter delimited by ---",
|
||||||
|
Some(provenance.id.clone()),
|
||||||
|
));
|
||||||
|
return Err(diagnostics);
|
||||||
|
};
|
||||||
|
let frontmatter_value = match serde_yaml::from_str::<serde_yaml::Value>(frontmatter) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
diagnostics.push(SkillDiagnostic::error(
|
||||||
|
"invalid_frontmatter_yaml",
|
||||||
|
format!("SKILL.md frontmatter is invalid YAML: {error}"),
|
||||||
|
Some(provenance.id.clone()),
|
||||||
|
));
|
||||||
|
return Err(diagnostics);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
diagnose_unsupported_frontmatter_keys(&frontmatter_value, &provenance, &mut diagnostics);
|
||||||
|
let frontmatter = match serde_yaml::from_value::<SkillFrontmatter>(frontmatter_value) {
|
||||||
|
Ok(frontmatter) => frontmatter,
|
||||||
|
Err(error) => {
|
||||||
|
diagnostics.push(SkillDiagnostic::error(
|
||||||
|
"invalid_frontmatter_yaml",
|
||||||
|
format!("SKILL.md frontmatter is invalid YAML: {error}"),
|
||||||
|
Some(provenance.id.clone()),
|
||||||
|
));
|
||||||
|
return Err(diagnostics);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(name) = frontmatter.name else {
|
||||||
|
diagnostics.push(SkillDiagnostic::error(
|
||||||
|
"missing_name",
|
||||||
|
"Skill frontmatter requires `name`",
|
||||||
|
Some(provenance.id.clone()),
|
||||||
|
));
|
||||||
|
return Err(diagnostics);
|
||||||
|
};
|
||||||
|
if name != source.parent_name {
|
||||||
|
diagnostics.push(SkillDiagnostic::error(
|
||||||
|
"name_parent_mismatch",
|
||||||
|
"Skill frontmatter `name` must match its parent directory name",
|
||||||
|
Some(provenance.id.clone()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !valid_skill_name(&name) {
|
||||||
|
diagnostics.push(SkillDiagnostic::error(
|
||||||
|
"invalid_name",
|
||||||
|
"Skill name must be 1-64 chars of lowercase letters, numbers, or single hyphens with no leading/trailing hyphen",
|
||||||
|
Some(provenance.id.clone()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(description) = frontmatter.description else {
|
||||||
|
diagnostics.push(SkillDiagnostic::error(
|
||||||
|
"missing_description",
|
||||||
|
"Skill frontmatter requires `description`",
|
||||||
|
Some(provenance.id.clone()),
|
||||||
|
));
|
||||||
|
return Err(diagnostics);
|
||||||
|
};
|
||||||
|
let description = description.trim().to_string();
|
||||||
|
if description.is_empty() || description.chars().count() > 1024 {
|
||||||
|
diagnostics.push(SkillDiagnostic::error(
|
||||||
|
"invalid_description",
|
||||||
|
"Skill description must be 1-1024 characters",
|
||||||
|
Some(provenance.id.clone()),
|
||||||
|
));
|
||||||
|
} else if description.chars().count() < 16 {
|
||||||
|
diagnostics.push(SkillDiagnostic::warning(
|
||||||
|
"description_too_generic",
|
||||||
|
"Skill description should state concrete when/what guidance",
|
||||||
|
Some(provenance.id.clone()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(license) = frontmatter.license.as_deref() {
|
||||||
|
validate_optional_string("license", license, &provenance, &mut diagnostics);
|
||||||
|
}
|
||||||
|
if let Some(compatibility) = frontmatter.compatibility.as_deref() {
|
||||||
|
validate_optional_string(
|
||||||
|
"compatibility",
|
||||||
|
compatibility,
|
||||||
|
&provenance,
|
||||||
|
&mut diagnostics,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(metadata) = frontmatter.metadata {
|
||||||
|
for (key, value) in metadata {
|
||||||
|
if !matches!(value, serde_yaml::Value::String(_)) {
|
||||||
|
diagnostics.push(SkillDiagnostic::error(
|
||||||
|
"invalid_metadata_value",
|
||||||
|
format!("metadata `{key}` must be a string value"),
|
||||||
|
Some(provenance.id.clone()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut allowed_tools = Vec::new();
|
||||||
|
if let Some(value) = frontmatter.allowed_tools {
|
||||||
|
allowed_tools = parse_allowed_tools(value, &provenance, &mut diagnostics);
|
||||||
|
diagnostics.push(SkillDiagnostic::warning(
|
||||||
|
"allowed_tools_ignored",
|
||||||
|
"allowed-tools is experimental metadata only; Workspace Skill activation does not grant or deny tools",
|
||||||
|
Some(provenance.id.clone()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if diagnostics
|
||||||
|
.iter()
|
||||||
|
.any(|d| d.severity == SkillDiagnosticSeverity::Error)
|
||||||
|
{
|
||||||
|
return Err(diagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ParsedSkill {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
content: source.content,
|
||||||
|
allowed_tools,
|
||||||
|
diagnostics,
|
||||||
|
provenance,
|
||||||
|
resource_root: source.resource_root,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provenance(kind: SkillSourceKind, parent_name: &str) -> SkillProvenance {
|
||||||
|
let prefix = match kind {
|
||||||
|
SkillSourceKind::Builtin => "builtin",
|
||||||
|
SkillSourceKind::Workspace => "workspace",
|
||||||
|
};
|
||||||
|
SkillProvenance {
|
||||||
|
kind,
|
||||||
|
id: format!("{prefix}:{parent_name}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
|
||||||
|
let rest = content.strip_prefix("---\n")?;
|
||||||
|
let (frontmatter, body) = rest.split_once("\n---")?;
|
||||||
|
let body = body.strip_prefix('\n').unwrap_or(body);
|
||||||
|
Some((frontmatter, body))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_skill_name(name: &str) -> bool {
|
||||||
|
let len = name.chars().count();
|
||||||
|
if !(1..=64).contains(&len)
|
||||||
|
|| name.starts_with('-')
|
||||||
|
|| name.ends_with('-')
|
||||||
|
|| name.contains("--")
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
name.chars()
|
||||||
|
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_optional_string(
|
||||||
|
field: &str,
|
||||||
|
value: &str,
|
||||||
|
provenance: &SkillProvenance,
|
||||||
|
diagnostics: &mut Vec<SkillDiagnostic>,
|
||||||
|
) {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
diagnostics.push(SkillDiagnostic::error(
|
||||||
|
format!("invalid_{field}"),
|
||||||
|
format!("optional `{field}` must be a non-empty string when present"),
|
||||||
|
Some(provenance.id.clone()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diagnose_unsupported_frontmatter_keys(
|
||||||
|
value: &serde_yaml::Value,
|
||||||
|
provenance: &SkillProvenance,
|
||||||
|
diagnostics: &mut Vec<SkillDiagnostic>,
|
||||||
|
) {
|
||||||
|
let Some(mapping) = value.as_mapping() else {
|
||||||
|
diagnostics.push(SkillDiagnostic::error(
|
||||||
|
"invalid_frontmatter_shape",
|
||||||
|
"SKILL.md frontmatter must be a YAML mapping",
|
||||||
|
Some(provenance.id.clone()),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
for key in mapping.keys() {
|
||||||
|
let Some(key) = key.as_str() else {
|
||||||
|
diagnostics.push(SkillDiagnostic::error(
|
||||||
|
"invalid_frontmatter_key",
|
||||||
|
"Skill frontmatter keys must be strings",
|
||||||
|
Some(provenance.id.clone()),
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !is_supported_frontmatter_key(key) {
|
||||||
|
let (code, message) = if is_workflow_projection_key(key) {
|
||||||
|
(
|
||||||
|
"unsupported_workflow_frontmatter_field",
|
||||||
|
format!(
|
||||||
|
"Skill frontmatter field `{key}` is a removed Workflow projection/invocation field and is not accepted as Skill semantics"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
"unsupported_frontmatter_field",
|
||||||
|
format!(
|
||||||
|
"Skill frontmatter field `{key}` is not supported; supported fields are name, description, license, compatibility, metadata, and allowed-tools"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
diagnostics.push(SkillDiagnostic::error(
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
Some(provenance.id.clone()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_supported_frontmatter_key(key: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
key,
|
||||||
|
"name" | "description" | "license" | "compatibility" | "metadata" | "allowed-tools"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_workflow_projection_key(key: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
key,
|
||||||
|
"model_invokation"
|
||||||
|
| "model_invocation"
|
||||||
|
| "user_invocable"
|
||||||
|
| "workflow"
|
||||||
|
| "workflow_record"
|
||||||
|
| "workflow_invoke"
|
||||||
|
| "invocation"
|
||||||
|
| "invocations"
|
||||||
|
| "graph"
|
||||||
|
| "nodes"
|
||||||
|
| "edges"
|
||||||
|
| "triggers"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_allowed_tools(
|
||||||
|
value: serde_yaml::Value,
|
||||||
|
provenance: &SkillProvenance,
|
||||||
|
diagnostics: &mut Vec<SkillDiagnostic>,
|
||||||
|
) -> Vec<String> {
|
||||||
|
match value {
|
||||||
|
serde_yaml::Value::String(text) => text
|
||||||
|
.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|item| !item.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.collect(),
|
||||||
|
serde_yaml::Value::Sequence(items) => items
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|item| match item {
|
||||||
|
serde_yaml::Value::String(text) if !text.trim().is_empty() => Some(text),
|
||||||
|
_ => {
|
||||||
|
diagnostics.push(SkillDiagnostic::warning(
|
||||||
|
"invalid_allowed_tools_entry_ignored",
|
||||||
|
"allowed-tools entries must be strings; invalid entries are ignored",
|
||||||
|
Some(provenance.id.clone()),
|
||||||
|
));
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
_ => {
|
||||||
|
diagnostics.push(SkillDiagnostic::warning(
|
||||||
|
"invalid_allowed_tools_ignored",
|
||||||
|
"allowed-tools must be a string or string list; value ignored",
|
||||||
|
Some(provenance.id.clone()),
|
||||||
|
));
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resource_refs(skill: &ParsedSkill) -> Vec<SkillResourceRef> {
|
||||||
|
let Some(root) = &skill.resource_root else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut refs = Vec::new();
|
||||||
|
for (dir, kind, supported, diagnostic) in [
|
||||||
|
(
|
||||||
|
"references",
|
||||||
|
"reference",
|
||||||
|
false,
|
||||||
|
Some(
|
||||||
|
"Skill references are listed only; resource read endpoints are not implemented yet",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"assets",
|
||||||
|
"asset",
|
||||||
|
false,
|
||||||
|
Some("Skill assets are listed only; resource read endpoints are not implemented yet"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"scripts",
|
||||||
|
"script",
|
||||||
|
false,
|
||||||
|
Some(
|
||||||
|
"Skill scripts are discovered but not executable; use normal typed tools and permissions",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let path = root.join(dir);
|
||||||
|
if !path.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(entries) = fs::read_dir(&path) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
if let Ok(file_type) = entry.file_type() {
|
||||||
|
if !(file_type.is_file() || file_type.is_dir()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let name = format!("{dir}/{}", entry.file_name().to_string_lossy());
|
||||||
|
refs.push(SkillResourceRef {
|
||||||
|
kind: kind.to_string(),
|
||||||
|
name,
|
||||||
|
supported,
|
||||||
|
diagnostic: diagnostic.map(ToOwned::to_owned),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refs.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
refs
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn write_skill(root: &Path, name: &str, content: &str) {
|
||||||
|
let dir = root.join(".yoi").join("skills").join(name);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
std::fs::write(dir.join("SKILL.md"), content).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_skill_is_cataloged_without_body_and_detail_contains_body() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
write_skill(
|
||||||
|
tmp.path(),
|
||||||
|
"debug-rust",
|
||||||
|
"---\nname: debug-rust\ndescription: Use when debugging Rust failures and deciding what tests to run.\nallowed-tools:\n - Bash\nmetadata:\n owner: dev\n---\n\n# Debug Rust\n\nRun focused checks.",
|
||||||
|
);
|
||||||
|
|
||||||
|
let catalog = catalog(tmp.path());
|
||||||
|
let entry = catalog
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.name == "debug-rust")
|
||||||
|
.expect("workspace skill listed");
|
||||||
|
assert_eq!(
|
||||||
|
entry.description,
|
||||||
|
"Use when debugging Rust failures and deciding what tests to run."
|
||||||
|
);
|
||||||
|
assert_eq!(entry.provenance.id, "workspace:debug-rust");
|
||||||
|
let catalog_json = serde_json::to_string(&catalog).unwrap();
|
||||||
|
assert!(!catalog_json.contains("# Debug Rust"));
|
||||||
|
|
||||||
|
let detail = detail(tmp.path(), "debug-rust").unwrap();
|
||||||
|
assert!(detail.body.contains("# Debug Rust"));
|
||||||
|
assert_eq!(detail.allowed_tools, vec!["Bash"]);
|
||||||
|
assert!(
|
||||||
|
detail
|
||||||
|
.allowed_tools_status
|
||||||
|
.contains("does not grant or deny")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
detail
|
||||||
|
.diagnostics
|
||||||
|
.iter()
|
||||||
|
.any(|d| d.code == "allowed_tools_ignored")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_name_and_parent_mismatch_are_lint_errors() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
write_skill(
|
||||||
|
tmp.path(),
|
||||||
|
"Bad--Name",
|
||||||
|
"---\nname: other\ndescription: Use when checking invalid examples.\n---\n\n# Invalid",
|
||||||
|
);
|
||||||
|
let diagnostics = catalog(tmp.path()).diagnostics;
|
||||||
|
assert!(
|
||||||
|
diagnostics
|
||||||
|
.iter()
|
||||||
|
.any(|d| d.code == "invalid_skill_directory_name")
|
||||||
|
);
|
||||||
|
assert!(diagnostics.iter().any(|d| d.code == "name_parent_mismatch"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workflow_projection_frontmatter_fields_are_rejected() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
write_skill(
|
||||||
|
tmp.path(),
|
||||||
|
"workflow-shaped",
|
||||||
|
"---\nname: workflow-shaped\ndescription: Use when proving workflow projection fields are rejected as Skills.\nmodel_invokation: old-typo\nuser_invocable: true\ngraph: {}\ninvocation:\n run: now\n---\n\n# Workflow Shaped",
|
||||||
|
);
|
||||||
|
|
||||||
|
let catalog = catalog(tmp.path());
|
||||||
|
assert!(
|
||||||
|
catalog
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.all(|entry| entry.name != "workflow-shaped")
|
||||||
|
);
|
||||||
|
let codes = catalog
|
||||||
|
.diagnostics
|
||||||
|
.iter()
|
||||||
|
.map(|diagnostic| diagnostic.code.as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert!(codes.contains(&"unsupported_workflow_frontmatter_field"));
|
||||||
|
for unsupported in ["model_invokation", "user_invocable", "graph", "invocation"] {
|
||||||
|
assert!(
|
||||||
|
catalog.diagnostics.iter().any(|diagnostic| {
|
||||||
|
diagnostic.code == "unsupported_workflow_frontmatter_field"
|
||||||
|
&& diagnostic.message.contains(unsupported)
|
||||||
|
}),
|
||||||
|
"missing unsupported-field diagnostic for {unsupported}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
detail(tmp.path(), "workflow-shaped"),
|
||||||
|
Err(SkillError::NotFound(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_frontmatter_fields_are_rejected() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
write_skill(
|
||||||
|
tmp.path(),
|
||||||
|
"unknown-field",
|
||||||
|
"---\nname: unknown-field\ndescription: Use when proving unsupported Skill fields are rejected.\ncustom-authority: no\n---\n\n# Unknown",
|
||||||
|
);
|
||||||
|
|
||||||
|
let catalog = catalog(tmp.path());
|
||||||
|
assert!(
|
||||||
|
catalog
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.all(|entry| entry.name != "unknown-field")
|
||||||
|
);
|
||||||
|
assert!(catalog.diagnostics.iter().any(|diagnostic| diagnostic.code
|
||||||
|
== "unsupported_frontmatter_field"
|
||||||
|
&& diagnostic.message.contains("custom-authority")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_skill_overrides_builtin() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
write_skill(
|
||||||
|
tmp.path(),
|
||||||
|
"agent-skills",
|
||||||
|
"---\nname: agent-skills\ndescription: Use when testing deterministic workspace override of builtin Skills.\n---\n\n# Workspace Override",
|
||||||
|
);
|
||||||
|
let catalog = catalog(tmp.path());
|
||||||
|
let entry = catalog
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.name == "agent-skills")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(entry.provenance.id, "workspace:agent-skills");
|
||||||
|
assert_eq!(entry.overrides[0].id, "builtin:agent-skills");
|
||||||
|
let detail = detail(tmp.path(), "agent-skills").unwrap();
|
||||||
|
assert!(detail.body.contains("Workspace Override"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scripts_are_reported_not_executable_without_raw_paths() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
write_skill(
|
||||||
|
tmp.path(),
|
||||||
|
"scripted-help",
|
||||||
|
"---\nname: scripted-help\ndescription: Use when checking Skill resource diagnostics safely.\n---\n\n# Scripted",
|
||||||
|
);
|
||||||
|
let script_dir = tmp.path().join(".yoi/skills/scripted-help/scripts");
|
||||||
|
std::fs::create_dir_all(&script_dir).unwrap();
|
||||||
|
std::fs::write(script_dir.join("run.sh"), "echo no").unwrap();
|
||||||
|
let detail = detail(tmp.path(), "scripted-help").unwrap();
|
||||||
|
let script = detail
|
||||||
|
.resources
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.name == "scripts/run.sh")
|
||||||
|
.unwrap();
|
||||||
|
assert!(!script.supported);
|
||||||
|
assert!(
|
||||||
|
!serde_json::to_string(&detail)
|
||||||
|
.unwrap()
|
||||||
|
.contains(tmp.path().to_str().unwrap())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -217,7 +217,6 @@ fn collect_record_paths(layout: &WorkspaceLayout) -> Result<Vec<PathBuf>, LintCl
|
||||||
|
|
||||||
collect_md_files(&layout.decisions_dir(), &mut paths)?;
|
collect_md_files(&layout.decisions_dir(), &mut paths)?;
|
||||||
collect_md_files(&layout.requests_dir(), &mut paths)?;
|
collect_md_files(&layout.requests_dir(), &mut paths)?;
|
||||||
collect_md_files(&layout.knowledge_dir(), &mut paths)?;
|
|
||||||
|
|
||||||
Ok(paths)
|
Ok(paths)
|
||||||
}
|
}
|
||||||
|
|
@ -413,7 +412,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lints_only_workspace_memory_and_knowledge_records() {
|
fn lints_only_workspace_memory_records() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let root = dir.path();
|
let root = dir.path();
|
||||||
write(&root.join(".yoi/memory/summary.md"), valid_summary());
|
write(&root.join(".yoi/memory/summary.md"), valid_summary());
|
||||||
|
|
@ -425,7 +424,6 @@ mod tests {
|
||||||
&root.join(".yoi/memory/_logs/ignored.md"),
|
&root.join(".yoi/memory/_logs/ignored.md"),
|
||||||
"not frontmatter",
|
"not frontmatter",
|
||||||
);
|
);
|
||||||
write(&root.join(".yoi/workflow/ignored.md"), "not frontmatter");
|
|
||||||
|
|
||||||
let report = lint_workspace(root).unwrap();
|
let report = lint_workspace(root).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
|
||||||
|
|
@ -1196,9 +1196,8 @@ mod tests {
|
||||||
assert!(config.contains("# language = \"Japanese\""));
|
assert!(config.contains("# language = \"Japanese\""));
|
||||||
for role in TicketRole::ALL {
|
for role in TicketRole::ALL {
|
||||||
assert!(config.contains(&format!(
|
assert!(config.contains(&format!(
|
||||||
"[ticket.roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"",
|
"[ticket.roles.{role}]\nprofile = \"{}\"",
|
||||||
role.default_profile(),
|
role.default_profile()
|
||||||
role.default_workflow()
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
assert!(!config.contains("[ticket.roles.investigator]"));
|
assert!(!config.contains("[ticket.roles.investigator]"));
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ It is not a dumping ground for external research, old plans, API inventories, or
|
||||||
5. [`design/tool-permissions-scope.md`](design/tool-permissions-scope.md) — tool policy and filesystem scope.
|
5. [`design/tool-permissions-scope.md`](design/tool-permissions-scope.md) — tool policy and filesystem scope.
|
||||||
6. [`design/plugin-packages.md`](design/plugin-packages.md) — plugin package distribution, discovery, and enablement boundaries.
|
6. [`design/plugin-packages.md`](design/plugin-packages.md) — plugin package distribution, discovery, and enablement boundaries.
|
||||||
7. [`development/plugin-development.md`](development/plugin-development.md) — how to build, package, enable, and inspect Yoi Plugins.
|
7. [`development/plugin-development.md`](development/plugin-development.md) — how to build, package, enable, and inspect Yoi Plugins.
|
||||||
8. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory, Knowledge, and audit records.
|
8. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory and audit records.
|
||||||
9. [`design/workspace-kanban-orchestrator-runtime.md`](design/workspace-kanban-orchestrator-runtime.md) — how Kanban operations become durable orchestration events and backend-internal routing decisions.
|
9. [`design/workspace-kanban-orchestrator-runtime.md`](design/workspace-kanban-orchestrator-runtime.md) — how Kanban operations become durable orchestration events and backend-internal routing decisions.
|
||||||
10. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed.
|
10. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed.
|
||||||
11. [`development/validation.md`](development/validation.md) — how to check changes.
|
11. [`development/validation.md`](development/validation.md) — how to check changes.
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,15 @@
|
||||||
# Memory and Knowledge
|
# Generated memory records
|
||||||
|
|
||||||
Yoi memory is generated context, not project authority.
|
Yoi keeps generated memory under `.yoi/memory/` for durable, low-volume context:
|
||||||
|
|
||||||
The authoritative record for work is still code, git history, work item files, tickets, session logs, and explicit user instruction. Memory helps the agent retrieve durable preferences and prior rationale, but it must not replace the records that made those facts true.
|
- `summary.md` is optional resident context.
|
||||||
|
- `decisions/*.md` capture durable decisions and rationale.
|
||||||
|
- `requests/*.md` capture durable user preferences or standing requests.
|
||||||
|
|
||||||
## Record types
|
Memory records are not workspace record authority for exact implementation state. Use tickets, objectives, repository files, git history, and session logs for exact current facts.
|
||||||
|
|
||||||
- `summary.md` is resident background context for normal Workers.
|
## Historical Knowledge records
|
||||||
- `decisions/` stores durable decisions that are useful across turns.
|
|
||||||
- `requests/` stores durable user requests and preferences.
|
|
||||||
- `.yoi/knowledge/` stores curated Knowledge records when available.
|
|
||||||
- `_logs/` stores append-only audit observations.
|
|
||||||
- `_staging/` is generated candidate state before consolidation.
|
|
||||||
|
|
||||||
Generated `.yoi/memory` is personal/generated state in this repository. Curated workflow/Knowledge assets may be tracked separately when intended.
|
Older workspaces may contain `.yoi/knowledge/`. Knowledge is no longer an active supported feature or workspace record authority. Current memory tooling ignores it; archive or inspect those files manually if needed.
|
||||||
|
|
||||||
## What memory should not do
|
Future Agent Skills are intentionally separate and are not implemented by this design note.
|
||||||
|
|
||||||
Memory should not duplicate authoritative project records. Do not copy ticket threads, TODO lists, implementation reports, or full docs into memory merely to make them resident.
|
|
||||||
|
|
||||||
The useful memory is the small part that changes future behavior: a policy, a rationale, a user preference, or a durable conclusion that would otherwise be hard to find.
|
|
||||||
|
|
||||||
## Lookup policy
|
|
||||||
|
|
||||||
Agents should use memory and Knowledge when the request depends on prior decisions, historical rationale, project workflow, or durable preferences.
|
|
||||||
|
|
||||||
Agents should not query memory every turn. Local repository files, current user instructions, command output, and tickets are more authoritative for exact current state.
|
|
||||||
|
|
||||||
## Audit and mutation
|
|
||||||
|
|
||||||
Memory extraction/consolidation writes append-only observations under `_logs`. No-op and idle notices belong there rather than in user-facing UI.
|
|
||||||
|
|
||||||
Memory mutation should be explicit work or part of the configured memory maintenance path. Casual edits during unrelated tasks make memory harder to trust.
|
|
||||||
|
|
||||||
## Language
|
|
||||||
|
|
||||||
Memory follows configured memory language policy. Conversation prose follows the user's language unless configured otherwise.
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ When these roots differ, implementation worktrees must be created under `impleme
|
||||||
- Ticket role launch prompts now emit a `Workspace routing context` section for Orchestrator launches, and for any launch that explicitly provides original/target roots.
|
- Ticket role launch prompts now emit a `Workspace routing context` section for Orchestrator launches, and for any launch that explicitly provides original/target roots.
|
||||||
- `TicketRoleLaunchPlan` carries `original_workspace_root`, `implementation_worktree_root`, and `target_workspace_root` as explicit plan fields for callers/tests.
|
- `TicketRoleLaunchPlan` carries `original_workspace_root`, `implementation_worktree_root`, and `target_workspace_root` as explicit plan fields for callers/tests.
|
||||||
- Orchestrator role prompt resources now say to create implementation worktrees under the original workspace root and to run merge-completion against the recorded target workspace.
|
- Orchestrator role prompt resources now say to create implementation worktrees under the original workspace root and to run merge-completion against the recorded target workspace.
|
||||||
- `worktree-workflow` now describes `<original-workspace-root>/.worktree/<task-name>` and `git -C <original-workspace-root> ...` instead of assuming the Orchestrator cwd is the repository root.
|
|
||||||
|
|
||||||
## Follow-up boundaries
|
## Follow-up boundaries
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ That rule shapes the crate split. The runtime can restart, attach, compact, or d
|
||||||
- `client` contains reusable one-shot socket/runtime-command mechanics so lower crates do not depend on the product CLI.
|
- `client` contains reusable one-shot socket/runtime-command mechanics so lower crates do not depend on the product CLI.
|
||||||
- `manifest` resolves Profiles, Manifests, model/provider references, scopes, prompts, and tool permission policy into a runtime contract.
|
- `manifest` resolves Profiles, Manifests, model/provider references, scopes, prompts, and tool permission policy into a runtime contract.
|
||||||
- `tools` implements built-in tools with bounded output and policy-aware execution.
|
- `tools` implements built-in tools with bounded output and policy-aware execution.
|
||||||
- `memory` owns generated memory, Knowledge records, linting, staging, and audit observations.
|
- `memory` owns generated memory summary/decision/request records, linting, staging, and audit observations.
|
||||||
- `workspace-server` is the local Workspace control-plane seam. It can project Tickets, Workers, lifecycle, usage, and orchestration events, but browser/API operations must stay on opaque backend identities instead of raw local paths, sockets, Worker names, or session files.
|
- `workspace-server` is the local Workspace control-plane seam. It can project Tickets, Workers, lifecycle, usage, and orchestration events, but browser/API operations must stay on opaque backend identities instead of raw local paths, sockets, Worker names, or session files.
|
||||||
- `tui` is a UI over Worker authority; it should not invent durable state.
|
- `tui` is a UI over Worker authority; it should not invent durable state.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
# Public builtin workflows and Yoi dogfood workflows
|
|
||||||
|
|
||||||
Yoi should split workflows into public builtin procedures and project-local dogfood procedures. The builtin set should teach the general Ticket/role lifecycle; this repository's Git worktree, branch, cargo, nix, merge, cleanup, and report conventions should stay project-local unless they are explicitly selected as an optional extension.
|
|
||||||
|
|
||||||
This document records the design/audit decision for Ticket `00001KTRKZ14C`. It does not implement the builtin workflow loader.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
- Add a typed builtin workflow source, conceptually `WorkflowSource::Builtin`, with clear provenance in any list/show/launch surface.
|
|
||||||
- Store public builtin workflow resources under `resources/workflows/<slug>.md`, not under `resources/prompts`.
|
|
||||||
- Workflows are user-visible procedural artifacts selected by slug and source. They need source/provenance/listing semantics distinct from prompt fragments.
|
|
||||||
- They are still embedded runtime resources, but prompt-fragment override rules should not silently become workflow authority.
|
|
||||||
- Source priority for workflow lookup should be:
|
|
||||||
1. explicit path/file selector, if the CLI/config surface allows one;
|
|
||||||
2. workspace `.yoi/workflow/<slug>.md`;
|
|
||||||
3. user workflow directory, if/when one is added;
|
|
||||||
4. builtin `resources/workflows/<slug>.md`;
|
|
||||||
5. external skill/plugin workflow sources, only when explicitly enabled and named.
|
|
||||||
- Workspace workflows override builtin workflows by slug. Overrides must show provenance so a role launch can say whether it used workspace, user, builtin, or plugin workflow text.
|
|
||||||
- Keep role Profile policy separate from workflow prose. Profiles select role behavior/tool policy; workflows provide procedural flow; launch prompts provide concrete Ticket/action context.
|
|
||||||
- Public resident core should remain small: intake requirements sync and orchestrator routing. Multi-agent implementation/review is builtin-available but not necessarily resident in every profile. Git worktree mechanics are optional/project-local by default.
|
|
||||||
|
|
||||||
## Audit of current workspace workflows
|
|
||||||
|
|
||||||
| Current slug | Public builtin candidate | Dogfood/project-local material | Decision |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `ticket-intake-workflow` | Yes. The core requirement-sync flow, existing-Ticket refinement, readiness, duplicate avoidance, and Ticket materialization are product-generic. | Mentions current local storage and Yoi-specific Ticket command examples. | Move cleaned generic version to builtin core. Keep storage-command specifics either bounded and product-generic or in project-local notes. |
|
|
||||||
| `ticket-orchestrator-routing` | Yes. The routing classifications, queued acceptance contract, relation/orchestration-plan checks, and planning-return rules are product-generic. | Contains Yoi dogfood examples, worktree/multi-agent handoff specifics, and merge-completion guidance tied to this repo's operating policy. | Move generic routing gate to builtin core. Split dogfood merge/worktree details into workspace override or separate dogfood workflow. |
|
|
||||||
| `ticket-preflight-workflow` | Compatibility candidate only. It now describes planning/requirements sync and legacy canonical id compatibility rather than a standalone lane. | Mostly project-history wording and deprecated-preflight vocabulary. | Do not make resident core. Keep as builtin compatibility alias only if existing configs still reference it; otherwise replace references with intake/planning sync. |
|
|
||||||
| `multi-agent-workflow` | Partly. The sibling coder/reviewer loop, intent packet, branch-local review, and merge-ready dossier are useful product concepts. | Heavy Yoi dogfood specifics: Git worktrees, commits, cargo/nix validation, branch cleanup, docs/report conventions, and parent/child policy. | Split into builtin `multi-agent-workflow` for role loop + dossier, and workspace dogfood extension for Git worktree/cargo/nix mechanics. |
|
|
||||||
| `worktree-workflow` | Not resident public core. | Almost entirely this repository's Git worktree mechanics and `.yoi` path exclusions. | Keep workspace-local dogfood workflow. Optionally create a non-resident builtin `git-worktree-isolation` later, disabled unless explicitly selected. |
|
|
||||||
|
|
||||||
## Slug and workspace Ticket settings migration decision
|
|
||||||
|
|
||||||
Use explicit dogfood slugs for workflows whose semantics differ from the public builtin workflow. Same-slug workspace overrides are allowed for local wording/policy tweaks of the same public contract, but they should not be used to hide Yoi repository Git/worktree/cargo/nix/merge semantics behind a public slug. This avoids accidental shadowing when a user expects `multi-agent-workflow` to mean the generic builtin role loop.
|
|
||||||
|
|
||||||
Planned selector mapping for this repository after builtin workflow loading exists:
|
|
||||||
|
|
||||||
| Role/config surface | Workflow selector | Source intent | Rationale |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `[ticket.roles.intake].workflow` | `ticket-intake-workflow` | builtin core by default; workspace same-slug override only if this repo needs a true local refinement of the same intake contract | Intake is mostly product-generic; dogfood Git/worktree policy does not belong here. |
|
|
||||||
| `[ticket.roles.orchestrator].workflow` | `ticket-orchestrator-routing` | builtin core by default; dogfood merge/worktree instructions come from explicit launch context or a separate dogfood workflow, not a hidden same-slug override | Routing semantics should stay public/generic; repo-specific implementation mechanics should be opt-in and visible. |
|
|
||||||
| `[ticket.roles.coder].workflow` | `yoi-dogfood-multi-agent-workflow` | workspace-local dogfood workflow | Coder work in this repository is tied to the repo worktree/branch/validation contract and must not shadow generic builtin `multi-agent-workflow`. |
|
|
||||||
| `[ticket.roles.reviewer].workflow` | `yoi-dogfood-multi-agent-workflow` | workspace-local dogfood workflow | Reviewer evidence in this repository includes branch/worktree/cargo/nix/Ticket-doctor conventions; use an explicit dogfood slug. |
|
|
||||||
| compatibility references | `ticket-preflight-workflow` | builtin compatibility alias or workspace compatibility file, non-resident | Do not keep it as a role default. It should point to planning/requirements sync language only. |
|
|
||||||
| dogfood worktree helper | `yoi-dogfood-worktree-workflow` | workspace-local helper, not a role default by itself | Referenced from `yoi-dogfood-multi-agent-workflow`; keeps Git worktree mechanics out of generic builtin workflows. |
|
|
||||||
|
|
||||||
`multi-agent-workflow` should be reserved for the generic builtin sibling coder/reviewer loop. This repository should not keep a same-slug workspace override with dogfood semantics once the builtin exists. If a temporary same-slug override is needed during migration, it must be short-lived and the Ticket/config comment must say it shadows the builtin intentionally.
|
|
||||||
|
|
||||||
## Builtin workflow set
|
|
||||||
|
|
||||||
### Resident builtin core
|
|
||||||
|
|
||||||
These should be advertised by default when Ticket support is enabled:
|
|
||||||
|
|
||||||
- `ticket-intake-workflow`
|
|
||||||
- generic Ticket requirements sync/materialization;
|
|
||||||
- existing Ticket refinement with explicit user agreement;
|
|
||||||
- readiness and duplicate handling;
|
|
||||||
- no Yoi repository cargo/nix/worktree policy.
|
|
||||||
- `ticket-orchestrator-routing`
|
|
||||||
- explicit routing gate;
|
|
||||||
- queued acceptance before implementation side effects;
|
|
||||||
- relation/orchestration-plan/context checks;
|
|
||||||
- implementation-ready IntentPacket shape;
|
|
||||||
- no repository-specific merge authority or Git cleanup policy.
|
|
||||||
|
|
||||||
### Builtin available but optional
|
|
||||||
|
|
||||||
These can be embedded and selectable, but should not be injected as resident core unless the active Profile/config opts in:
|
|
||||||
|
|
||||||
- `multi-agent-workflow`
|
|
||||||
- generic sibling coder/reviewer loop;
|
|
||||||
- intent packet, reviewer evidence, blocker/fix-loop, merge-ready dossier;
|
|
||||||
- no mandatory Git worktree, cargo, nix, or `.yoi` cleanup rules.
|
|
||||||
- `ticket-preflight-workflow`
|
|
||||||
- transitional alias for old configurations that still name preflight;
|
|
||||||
- should route to planning/requirements sync language and avoid reintroducing preflight as a lifecycle state.
|
|
||||||
|
|
||||||
### Workspace-local dogfood workflows
|
|
||||||
|
|
||||||
These remain in this repository under `.yoi/workflow/` as overrides or dogfood-only slugs:
|
|
||||||
|
|
||||||
- `worktree-workflow`
|
|
||||||
- mechanical Yoi project worktree setup;
|
|
||||||
- tracked `.yoi` project records visible;
|
|
||||||
- `.yoi/memory`, local/runtime/log/lock/secret-like paths excluded;
|
|
||||||
- branch/worktree cleanup policy.
|
|
||||||
- A dogfood extension for multi-agent/merge-completion, if the generic builtin `multi-agent-workflow` is cleaned:
|
|
||||||
- concrete `cargo fmt`, `cargo test`, `target/debug/yoi ticket doctor`, validation policy;
|
|
||||||
- merge authority boundaries for this repository;
|
|
||||||
- docs/report conventions.
|
|
||||||
|
|
||||||
## Stale vocabulary cleanup
|
|
||||||
|
|
||||||
Builtin workflows and active workspace workflows should remove or replace the following old wording:
|
|
||||||
|
|
||||||
- `Action required` / `Attention required` as storage or state concepts. Use explicit Ticket state, typed relation metadata, orchestration-plan waiting notes, or Ticket thread comments instead.
|
|
||||||
- `preflight` as a lifecycle lane/state. Use planning/requirements sync or compatibility alias language.
|
|
||||||
- `open` / `pending` / `closed bucket` authority. Current authority is `state` plus optional `resolution.md` for closed Tickets.
|
|
||||||
- Long-lived umbrella/progress-container Ticket terminology. Use concrete implementation Tickets and optional Objective context.
|
|
||||||
|
|
||||||
## Follow-up implementation boundaries
|
|
||||||
|
|
||||||
1. **Builtin workflow resource loader**
|
|
||||||
- Add embedded `resources/workflows/*.md` resources.
|
|
||||||
- Add `WorkflowSource::Builtin` / equivalent typed provenance.
|
|
||||||
- Update workflow discovery/list/show/launch so workspace overrides builtin by slug and provenance is visible.
|
|
||||||
- Include tests for source priority and override behavior.
|
|
||||||
|
|
||||||
2. **Public workflow text cleanup**
|
|
||||||
- Create cleaned builtin versions of `ticket-intake-workflow` and `ticket-orchestrator-routing`.
|
|
||||||
- Split generic `multi-agent-workflow` from Yoi dogfood Git/worktree/cargo/nix mechanics.
|
|
||||||
- Keep `ticket-preflight-workflow` as compatibility-only or remove references if no longer needed.
|
|
||||||
|
|
||||||
3. **Dogfood workflow retention**
|
|
||||||
- Keep or rename workspace-local dogfood workflows for this repository's implementation mechanics.
|
|
||||||
- Ensure `.yoi/workspace.toml` `[ticket.roles.*].workflow` selectors intentionally point to builtin generic workflows or workspace dogfood overrides, with no accidental slug shadowing.
|
|
||||||
|
|
||||||
4. **Vocabulary migration**
|
|
||||||
- Sweep active workflow text, prompts, docs, and Ticket UI wording for `Action required`, `Attention required`, and old preflight lane language.
|
|
||||||
- Replace with typed state/relation/orchestration-plan terminology.
|
|
||||||
|
|
||||||
5. **Panel/role launch provenance display**
|
|
||||||
- When a role launches with a workflow, include the workflow source/provenance in diagnostic/launch context so builtin vs workspace override is debuggable.
|
|
||||||
|
|
||||||
## Non-goals for this Ticket
|
|
||||||
|
|
||||||
- Implementing the builtin workflow loader.
|
|
||||||
- Moving current workflow files into runtime resources in this branch.
|
|
||||||
- Changing Ticket lifecycle states.
|
|
||||||
- Changing role Profile policy.
|
|
||||||
- Changing scheduler/queue behavior.
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Tickets and development workflow
|
# Tickets and development workflow
|
||||||
|
|
||||||
Yoi project work is tracked through Tickets. For normal use, interact with Tickets through `yoi panel`, Ticket tools, the `yoi ticket ...` CLI, and Ticket workflows. Git history plus Ticket files remain the authoritative state-transition record behind those interfaces.
|
Yoi project work is tracked through Tickets. For normal use, interact with Tickets through `yoi panel`, Ticket tools, the `yoi ticket ...` CLI, and typed role surfaces. Git history plus Ticket files remain the authoritative state-transition record behind those interfaces.
|
||||||
|
|
||||||
The current local backend stores each Ticket in the flat `.yoi/tickets/<ticket-id>/` layout. The directory name is the canonical opaque Ticket id: a fixed-width Crockford base32 Unix epoch millisecond timestamp. Slugs and frontmatter `id`/`slug` fields are not current-state authority. That storage detail matters for maintainers and backend compatibility, but it is not the primary user-facing workflow.
|
The current local backend stores each Ticket in the flat `.yoi/tickets/<ticket-id>/` layout. The directory name is the canonical opaque Ticket id: a fixed-width Crockford base32 Unix epoch millisecond timestamp. Slugs and frontmatter `id`/`slug` fields are not current-state authority. That storage detail matters for maintainers and backend compatibility, but it is not the primary user-facing workflow.
|
||||||
|
|
||||||
|
|
@ -25,7 +25,7 @@ Use the highest-level interface that matches the work:
|
||||||
- Use `yoi panel` for the Ticket/Intake/Orchestrator workspace Dashboard and role-launch actions.
|
- Use `yoi panel` for the Ticket/Intake/Orchestrator workspace Dashboard and role-launch actions.
|
||||||
- Use `yoi objective ...` for lightweight medium-term Objective records and their non-blocking canonical Ticket links.
|
- Use `yoi objective ...` for lightweight medium-term Objective records and their non-blocking canonical Ticket links.
|
||||||
- Inside Workers, use typed Ticket tools to create, inspect, comment, review, and close Tickets.
|
- Inside Workers, use typed Ticket tools to create, inspect, comment, review, and close Tickets.
|
||||||
- For multi-step work, follow the Ticket Intake, Orchestrator Routing, planning/requirements-sync, and Multi-agent workflows.
|
- For multi-step work, follow the typed Ticket role surfaces and recorded Ticket lifecycle gates.
|
||||||
|
|
||||||
Maintainers can inspect the local `.yoi/tickets/` files directly when debugging storage, but normal user instructions should go through `yoi panel`, Ticket tools, or `yoi ticket ...`.
|
Maintainers can inspect the local `.yoi/tickets/` files directly when debugging storage, but normal user instructions should go through `yoi panel`, Ticket tools, or `yoi ticket ...`.
|
||||||
|
|
||||||
|
|
@ -55,7 +55,7 @@ Use them when a Worker needs to materialize or update project records:
|
||||||
- Reviewer records approve/request-changes review results.
|
- Reviewer records approve/request-changes review results.
|
||||||
- Maintainer closes a Ticket with a resolution when merge/validation/cleanup evidence is complete.
|
- Maintainer closes a Ticket with a resolution when merge/validation/cleanup evidence is complete.
|
||||||
|
|
||||||
Do not bypass workflow gates just because Ticket tools are available. Ticket mutation is a project-record operation and should remain auditable.
|
Do not bypass Ticket lifecycle gates just because Ticket tools are available. Ticket mutation is a project-record operation and should remain auditable.
|
||||||
|
|
||||||
## Objective records
|
## Objective records
|
||||||
|
|
||||||
|
|
@ -122,22 +122,18 @@ root = ".yoi/tickets"
|
||||||
[ticket.roles.intake]
|
[ticket.roles.intake]
|
||||||
profile = "project:intake"
|
profile = "project:intake"
|
||||||
launch_prompt = "$workspace/ticket/intake/launch"
|
launch_prompt = "$workspace/ticket/intake/launch"
|
||||||
workflow = "ticket-intake-workflow"
|
|
||||||
|
|
||||||
[ticket.roles.orchestrator]
|
[ticket.roles.orchestrator]
|
||||||
profile = "project:orchestrator"
|
profile = "project:orchestrator"
|
||||||
launch_prompt = "$workspace/ticket/orchestrator/launch"
|
launch_prompt = "$workspace/ticket/orchestrator/launch"
|
||||||
workflow = "ticket-orchestrator-routing"
|
|
||||||
|
|
||||||
[ticket.roles.coder]
|
[ticket.roles.coder]
|
||||||
profile = "project:coder"
|
profile = "project:coder"
|
||||||
launch_prompt = "$workspace/ticket/coder/launch"
|
launch_prompt = "$workspace/ticket/coder/launch"
|
||||||
workflow = "multi-agent-workflow"
|
|
||||||
|
|
||||||
[ticket.roles.reviewer]
|
[ticket.roles.reviewer]
|
||||||
profile = "project:reviewer"
|
profile = "project:reviewer"
|
||||||
launch_prompt = "$workspace/ticket/reviewer/launch"
|
launch_prompt = "$workspace/ticket/reviewer/launch"
|
||||||
workflow = "multi-agent-workflow"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Fixed roles are:
|
Fixed roles are:
|
||||||
|
|
@ -155,7 +151,7 @@ when a spike is useful, let the Orchestrator create an ordinary task-specific re
|
||||||
|
|
||||||
`launch_prompt` is a per-action first-run prompt reference for future prompt resolution. Current launcher behavior exposes the ref but does not treat it as system instruction.
|
`launch_prompt` is a per-action first-run prompt reference for future prompt resolution. Current launcher behavior exposes the ref but does not treat it as system instruction.
|
||||||
|
|
||||||
`workflow` is the workflow the launched role should follow. State and phase-specific prompt injection are future work; any dynamic prompt content must be committed as history before it affects model context.
|
Role launch prompts are plain history input. State and phase-specific prompt injection are future work; any dynamic prompt content must be committed as history before it affects model context.
|
||||||
|
|
||||||
`provider = "builtin:yoi_local"` selects Yoi's built-in local Ticket backend. `root = ".yoi/tickets"` is the canonical local storage root for this repository. Legacy `kind = "local"` is accepted only as a short transitional alias; new configs should use `provider`.
|
`provider = "builtin:yoi_local"` selects Yoi's built-in local Ticket backend. `root = ".yoi/tickets"` is the canonical local storage root for this repository. Legacy `kind = "local"` is accepted only as a short transitional alias; new configs should use `provider`.
|
||||||
|
|
||||||
|
|
@ -165,15 +161,10 @@ If `.yoi/workspace.toml` has no `[ticket]` table and no legacy fallback file exi
|
||||||
- backend root: `<workspace>/.yoi/tickets`
|
- backend root: `<workspace>/.yoi/tickets`
|
||||||
- all role profiles: `inherit`
|
- all role profiles: `inherit`
|
||||||
- no launch prompt refs
|
- no launch prompt refs
|
||||||
- workflows:
|
|
||||||
- intake: `ticket-intake-workflow`
|
|
||||||
- orchestrator: `ticket-orchestrator-routing`
|
|
||||||
- coder: `multi-agent-workflow`
|
|
||||||
- reviewer: `multi-agent-workflow`
|
|
||||||
|
|
||||||
Important: top-level Ticket role launches cannot execute `profile = "inherit"` because top-level launch has no parent Profile to inherit from. Configure concrete role profiles in `.yoi/workspace.toml` under `[ticket.roles.*]` before using `yoi panel` role-launch actions.
|
Important: top-level Ticket role launches cannot execute `profile = "inherit"` because top-level launch has no parent Profile to inherit from. Configure concrete role profiles in `.yoi/workspace.toml` under `[ticket.roles.*]` before using `yoi panel` role-launch actions.
|
||||||
|
|
||||||
## Workflow lifecycle
|
## Ticket lifecycle
|
||||||
|
|
||||||
Ticket-driven development normally moves through these gates:
|
Ticket-driven development normally moves through these gates:
|
||||||
|
|
||||||
|
|
@ -189,7 +180,7 @@ Each gate records its decision or evidence in the Ticket thread or artifacts.
|
||||||
|
|
||||||
### 1. Intake
|
### 1. Intake
|
||||||
|
|
||||||
Use `ticket-intake-workflow` when a user request is broad, ambiguous, or not yet a Ticket.
|
Use the Intake role launch prompt when a user request is broad, ambiguous, or not yet a Ticket.
|
||||||
|
|
||||||
Intake should:
|
Intake should:
|
||||||
|
|
||||||
|
|
@ -202,7 +193,7 @@ Intake should not schedule implementation, spawn coder/reviewer Workers, create
|
||||||
|
|
||||||
### 2. Orchestrator routing
|
### 2. Orchestrator routing
|
||||||
|
|
||||||
Use `ticket-orchestrator-routing` to classify the next action for an existing Ticket.
|
Use the Orchestrator role launch prompt to classify the next action for an existing Ticket.
|
||||||
|
|
||||||
Routing classifications include:
|
Routing classifications include:
|
||||||
|
|
||||||
|
|
@ -219,7 +210,7 @@ Routing decisions should be recorded with `TicketComment` using `plan` or `decis
|
||||||
|
|
||||||
### 3. Planning/requirements sync
|
### 3. Planning/requirements sync
|
||||||
|
|
||||||
Use `ticket-preflight-workflow` only as a legacy-compatible planning/requirements sync entry. Return `ready` or `queued` Tickets to `planning` only when the Orchestrator can name a concrete missing decision or information item after bounded project-context checks; risk flags and risky domains are context-lookup and reviewer-focus signals, not automatic stop gates.
|
Use planning/requirements sync only as a bounded Ticket refinement step. Return `ready` or `queued` Tickets to `planning` only when the Orchestrator can name a concrete missing decision or information item after bounded project-context checks; risk flags and risky domains are context-lookup and reviewer-focus signals, not automatic stop gates.
|
||||||
|
|
||||||
Planning sync should resolve or record:
|
Planning sync should resolve or record:
|
||||||
|
|
||||||
|
|
@ -233,7 +224,7 @@ Do not send Tickets with unresolved concrete missing decisions/information direc
|
||||||
|
|
||||||
### 4. Implementation assignment
|
### 4. Implementation assignment
|
||||||
|
|
||||||
Use `multi-agent-workflow` for implementation-ready Tickets.
|
Use the Coder and Reviewer role launch prompts for implementation-ready Tickets.
|
||||||
|
|
||||||
The Orchestrator should prepare an `IntentPacket` with:
|
The Orchestrator should prepare an `IntentPacket` with:
|
||||||
|
|
||||||
|
|
@ -292,19 +283,18 @@ The role-launch path is:
|
||||||
User triggers a Ticket action in yoi panel
|
User triggers a Ticket action in yoi panel
|
||||||
-> Dashboard builds a TicketRoleLaunchContext
|
-> Dashboard builds a TicketRoleLaunchContext
|
||||||
-> client Ticket role launcher reads .yoi/workspace.toml [ticket] settings
|
-> client Ticket role launcher reads .yoi/workspace.toml [ticket] settings
|
||||||
-> launcher selects the role Profile and workflow
|
-> launcher selects the role Profile
|
||||||
-> launcher spawns the role Worker
|
-> launcher spawns the role Worker
|
||||||
-> launcher sends Method::Run with WorkflowInvoke + Text segments
|
-> launcher sends Method::Run with Text segments
|
||||||
-> launcher waits for run-acceptance evidence
|
-> launcher waits for run-acceptance evidence
|
||||||
-> Dashboard reports success/failure
|
-> Dashboard reports success/failure
|
||||||
```
|
```
|
||||||
|
|
||||||
The launched Worker receives dynamic Ticket/action context as its first committed run input. The Dashboard does not inject hidden context, does not write Ticket files directly, and does not construct prompt/workflow segments by hand.
|
The launched Worker receives dynamic Ticket/action context as its first committed run input. The Dashboard does not inject hidden context, does not write Ticket files directly, and does not construct prompt segments by hand.
|
||||||
|
|
||||||
The first run input contains:
|
The first run input contains:
|
||||||
|
|
||||||
- the selected fixed role;
|
- the selected fixed role;
|
||||||
- the workflow canonical id from workspace `[ticket.roles.<role>]` settings;
|
|
||||||
- Ticket id when the action targets an existing Ticket;
|
- Ticket id when the action targets an existing Ticket;
|
||||||
- freeform user instruction/context from the action;
|
- freeform user instruction/context from the action;
|
||||||
- configured `launch_prompt` reference if present, as an unresolved reference for future prompt resolution.
|
- configured `launch_prompt` reference if present, as an unresolved reference for future prompt resolution.
|
||||||
|
|
@ -324,19 +314,15 @@ root = ".yoi/tickets"
|
||||||
|
|
||||||
[ticket.roles.intake]
|
[ticket.roles.intake]
|
||||||
profile = "project:intake"
|
profile = "project:intake"
|
||||||
workflow = "ticket-intake-workflow"
|
|
||||||
|
|
||||||
[ticket.roles.orchestrator]
|
[ticket.roles.orchestrator]
|
||||||
profile = "project:orchestrator"
|
profile = "project:orchestrator"
|
||||||
workflow = "ticket-orchestrator-routing"
|
|
||||||
|
|
||||||
[ticket.roles.coder]
|
[ticket.roles.coder]
|
||||||
profile = "project:coder"
|
profile = "project:coder"
|
||||||
workflow = "multi-agent-workflow"
|
|
||||||
|
|
||||||
[ticket.roles.reviewer]
|
[ticket.roles.reviewer]
|
||||||
profile = "project:reviewer"
|
profile = "project:reviewer"
|
||||||
workflow = "multi-agent-workflow"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If a role still uses `profile = "inherit"`, the Dashboard fails closed with a diagnostic explaining that a concrete profile is required.
|
If a role still uses `profile = "inherit"`, the Dashboard fails closed with a diagnostic explaining that a concrete profile is required.
|
||||||
|
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
# Workflows and orchestration
|
|
||||||
|
|
||||||
Yoi development uses workflows to make multi-step agent work repeatable without hiding authority in chat state.
|
|
||||||
|
|
||||||
Project-authored workflows live under `.yoi/workflow/`. Generated memory lives under `.yoi/memory/`; the two should not be mixed.
|
|
||||||
|
|
||||||
## Workflow role
|
|
||||||
|
|
||||||
A workflow should define how to coordinate work. It should not become a private implementation branch, an unreviewed design decision, or a replacement for Tickets.
|
|
||||||
|
|
||||||
Current workflow themes include:
|
|
||||||
|
|
||||||
- Intake clarification before materializing user requests as Tickets
|
|
||||||
- Orchestrator routing from Tickets to the next workflow/action
|
|
||||||
- planning/requirements synchronization when concrete missing decisions or information block routing
|
|
||||||
- worktree setup and cleanup
|
|
||||||
- sibling coder/reviewer Worker orchestration
|
|
||||||
- human-gated maintenance and merge readiness
|
|
||||||
|
|
||||||
## Child Workers
|
|
||||||
|
|
||||||
Spawned Workers are useful for scoped implementation, review, or exploration. They are not independent project authorities.
|
|
||||||
|
|
||||||
A parent/orchestrator must verify:
|
|
||||||
|
|
||||||
- child output via `ReadWorkerOutput`
|
|
||||||
- live/restorable state via Worker tools when relevant
|
|
||||||
- worktree state and diff
|
|
||||||
- validation command output
|
|
||||||
- Ticket requirements and acceptance criteria
|
|
||||||
|
|
||||||
Notifications are hints to inspect state. They are not proof of completion.
|
|
||||||
|
|
||||||
## Merge and close responsibility
|
|
||||||
|
|
||||||
Unless explicitly authorized otherwise, final merge, cleanup, design-boundary decisions, and ticket closure remain the orchestrator/human responsibility.
|
|
||||||
|
|
||||||
Child Workers may commit in delegated worktrees when the workflow allows it, but the merge-ready dossier should make the final decision auditable from repository records.
|
|
||||||
|
|
||||||
## Public and dogfood workflow split
|
|
||||||
|
|
||||||
Public builtin workflow candidates and this repository's dogfood-specific workflow mechanics are tracked in `docs/design/workflows-public-dogfood-split.md`.
|
|
||||||
|
|
@ -232,7 +232,7 @@ permission = "write"
|
||||||
# ===== [memory] =============================================================
|
# ===== [memory] =============================================================
|
||||||
# Memory subsystem の opt-in。
|
# Memory subsystem の opt-in。
|
||||||
# - セクションが *ある* … memory tools (MemoryRead/Write/Edit) を登録、
|
# - セクションが *ある* … memory tools (MemoryRead/Write/Edit) を登録、
|
||||||
# `<workspace>/memory/` と `<workspace>/knowledge/`
|
# `<workspace>/memory/` と `<workspace>/`
|
||||||
# の通常 write を Worker 自体に対して deny する。
|
# の通常 write を Worker 自体に対して deny する。
|
||||||
# - セクションが *無い* … 何も起きない (legacy 動作)。
|
# - セクションが *無い* … 何も起きない (legacy 動作)。
|
||||||
# `[memory]` だけ書いて中身を省略するのも有効 (全フィールド既定値で有効化)。
|
# `[memory]` だけ書いて中身を省略するのも有効 (全フィールド既定値で有効化)。
|
||||||
|
|
@ -243,7 +243,7 @@ permission = "write"
|
||||||
# workspace_root = "/abs/path/to/workspace"
|
# workspace_root = "/abs/path/to/workspace"
|
||||||
#
|
#
|
||||||
# # 任意。デフォルト: tool 側既定 = 20。
|
# # 任意。デフォルト: tool 側既定 = 20。
|
||||||
# # MemoryQuery / KnowledgeQuery が 1 回に返す最大件数。
|
# # MemoryQuery / MemoryQuery が 1 回に返す最大件数。
|
||||||
# query_result_limit = 20
|
# query_result_limit = 20
|
||||||
#
|
#
|
||||||
# # 任意。デフォルト: tool 側既定 = 3。
|
# # 任意。デフォルト: tool 側既定 = 3。
|
||||||
|
|
@ -281,8 +281,8 @@ permission = "write"
|
||||||
|
|
||||||
|
|
||||||
# ===== [skills] =============================================================
|
# ===== [skills] =============================================================
|
||||||
# 外部 Agent Skills (`SKILL.md`) を Workflow として読み込むディレクトリ群。
|
# 外部 Agent Skills (`SKILL.md`) の候補ディレクトリ群。
|
||||||
# セクション省略 = 何もロードしない (implicit な `$config_dir/skills/` 検索や
|
# セクション省略 = 何も使用しない (implicit な `$config_dir/skills/` 検索や
|
||||||
# builtin probe は存在しない)。
|
# builtin probe は存在しない)。
|
||||||
# [skills]
|
# [skills]
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -28,5 +28,4 @@
|
||||||
- [ticket](ticket.md) — # テスト妥当性レビュー: ticket - 評価: 概ね良い
|
- [ticket](ticket.md) — # テスト妥当性レビュー: ticket - 評価: 概ね良い
|
||||||
- [tools](tools.md) — # テスト妥当性レビュー: tools - 評価: 混在
|
- [tools](tools.md) — # テスト妥当性レビュー: tools - 評価: 混在
|
||||||
- [tui](tui.md) — # テスト妥当性レビュー: tui - 評価: 混在
|
- [tui](tui.md) — # テスト妥当性レビュー: tui - 評価: 混在
|
||||||
- [workflow](workflow.md) — # テスト妥当性レビュー: workflow
|
|
||||||
- [yoi](yoi.md) — # テスト妥当性レビュー: yoi - 評価: 混在
|
- [yoi](yoi.md) — # テスト妥当性レビュー: yoi - 評価: 混在
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
- `inherit` profile は top-level launches で拒否される;
|
- `inherit` profile は top-level launches で拒否される;
|
||||||
- 解決不能な profile selectors は spawn 前に失敗する;
|
- 解決不能な profile selectors は spawn 前に失敗する;
|
||||||
- concrete role config と scaffold config が launch plans を生成する;
|
- concrete role config と scaffold config が launch plans を生成する;
|
||||||
- 設定された workflow/profile/launch prompt refs が表面化される;
|
- 設定された profile/launch prompt refs が表面化される;
|
||||||
- spawn config が pod name、profile、role、workspace root、`cwd` 境界を正しく保持する;
|
- spawn config が pod name、profile、role、workspace root、`cwd` 境界を正しく保持する;
|
||||||
- prompt override precedence がテストされている;
|
- prompt override precedence がテストされている;
|
||||||
- role-specific launch prompts が intake、orchestrator、coder、reviewer 向けの重要な運用ガイダンスを含む。
|
- role-specific launch prompts が intake、orchestrator、coder、reviewer 向けの重要な運用ガイダンスを含む。
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,12 @@
|
||||||
|
|
||||||
## 現在のテストがよくカバーしていること
|
## 現在のテストがよくカバーしていること
|
||||||
- crate の責務に沿った主要な pure / filesystem-local invariants はかなり広く押さえられている。
|
- crate の責務に沿った主要な pure / filesystem-local invariants はかなり広く押さえられている。
|
||||||
- `.yoi/memory` / `.yoi/knowledge` の path classification、opaque subtree (`_staging`, `_usage`, `_logs`) の除外、invalid slug / nested path reject。
|
- `.yoi/memory` / `.yoi` の path classification、opaque subtree (`_staging`, `_usage`, `_logs`) の除外、invalid slug / nested path reject。
|
||||||
- workspace root resolution で `.yoi` project records だけを memory marker と見なさない挙動。
|
- workspace root resolution で `.yoi` project records だけを memory marker と見なさない挙動。
|
||||||
- linter の frontmatter 必須 field、`replaced_by` の存在確認・self reference、body size、Knowledge `model_invokation` description cap、same slug create reject、similar slug warning。
|
- linter の frontmatter 必須 field、`replaced_by` の存在確認・self reference、body size、`resident-injection` description cap、same slug create reject、similar slug warning。
|
||||||
- `MemoryRead` / `MemoryWrite` / `MemoryEdit` / `MemoryDelete` / `MemoryQuery` / `KnowledgeQuery` の基本成功・基本失敗・slug rule・workflow kind 非公開。
|
- `MemoryRead` / `MemoryWrite` / `MemoryEdit` / `MemoryDelete` / `MemoryQuery` / `MemoryQuery` の基本成功・基本失敗・slug rule・workflow kind 非公開。
|
||||||
- query の list/search、case-insensitive search、excerpt context、result limit、Knowledge kind filter、frontmatter search、query が usage event を増やさないこと。
|
- query の list/search、case-insensitive search、excerpt context、result limit、kind filter、frontmatter search、query が usage event を増やさないこと。
|
||||||
- resident summary / resident knowledge collection の missing/malformed/empty/並び順/`model_invokation` filter。
|
- resident summary / の missing/malformed/empty/並び順/`resident-injection` filter。
|
||||||
- extract staging, extract pointer fold, extract input rendering で tool result content や reasoning を落とすこと。
|
- extract staging, extract pointer fold, extract input rendering で tool result content や reasoning を落とすこと。
|
||||||
- consolidation staging list, invalid staging count, lock acquire/release/stale handling, tidy hints, consolidate prompt sections。
|
- consolidation staging list, invalid staging count, lock acquire/release/stale handling, tidy hints, consolidate prompt sections。
|
||||||
- usage event aggregation で explicit use と resident exposure を分けること。
|
- usage event aggregation で explicit use と resident exposure を分けること。
|
||||||
|
|
@ -37,13 +37,13 @@
|
||||||
- extract payload → staging → consolidation input → memory tools write/edit/delete → audit/usage までの一連の流れは、個別部品ごとにはあるが、run-level の不変条件としては検証されていない。
|
- extract payload → staging → consolidation input → memory tools write/edit/delete → audit/usage までの一連の流れは、個別部品ごとにはあるが、run-level の不変条件としては検証されていない。
|
||||||
- Pod / Worker 経由の real tool registry や permission scope との接続はこの crate 単体ではほぼ未検証。
|
- Pod / Worker 経由の real tool registry や permission scope との接続はこの crate 単体ではほぼ未検証。
|
||||||
- linter の網羅性に穴がある。
|
- linter の網羅性に穴がある。
|
||||||
- `InvalidStatus`、unknown/extra frontmatter fields、malformed date、`sources` / `last_sources` の shape、request / knowledge / summary の必須 field failure が体系的には確認されていない。
|
- `InvalidStatus`、unknown/extra frontmatter fields、malformed date、`sources` / `last_sources` の shape、request / summary の必須 field failure が体系的には確認されていない。
|
||||||
- `replaced_by` cycle detection の実シナリオは弱い。`references.rs` の test は unknown reference で 1 error になる smoke に近く、既存 A→B / B→A のような cycle report を明確には固定していない。
|
- `replaced_by` cycle detection の実シナリオは弱い。`references.rs` の test は unknown reference で 1 error になる smoke に近く、既存 A→B / B→A のような cycle report を明確には固定していない。
|
||||||
- `LowImportanceLargeRecord` と `SourcesOverflow` warning は tidy 側では一部見ているが、linter warning と tool output/audit への伝播としては薄い。
|
- `LowImportanceLargeRecord` と `SourcesOverflow` warning は tidy 側では一部見ているが、linter warning と tool output/audit への伝播としては薄い。
|
||||||
- tool の edge case が不足している。
|
- tool の edge case が不足している。
|
||||||
- `MemoryRead` の `offset` / `limit` / truncation summary、limit=0 の `.max(1)` 挙動、空ファイル・末尾改行なしの line numbering が未検証。
|
- `MemoryRead` の `offset` / `limit` / truncation summary、limit=0 の `.max(1)` 挙動、空ファイル・末尾改行なしの line numbering が未検証。
|
||||||
- `MemoryEdit` の `replace_all`, duplicate old_string reject, old_string empty, identical replacement, non-UTF-8 file, summary/knowledge edit path が未検証。
|
- `MemoryEdit` の `replace_all`, duplicate old_string reject, old_string empty, identical replacement, non-UTF-8 file, summary edit path が未検証。
|
||||||
- `MemoryWrite` の invalid slug、summary with slug、Knowledge/Request 作成、write success audit contents、warning summary/audit reason が未検証。
|
- `MemoryWrite` の invalid slug、summary with slug、Request 作成、write success audit contents、warning summary/audit reason が未検証。
|
||||||
- `MemoryDelete` は成功 path のみで、missing file、summary slug forbidden、invalid slug、workflow kind reject、audit failure record が未検証。
|
- `MemoryDelete` は成功 path のみで、missing file、summary slug forbidden、invalid slug、workflow kind reject、audit failure record が未検証。
|
||||||
- 誤解を招く / 弱い test がある。
|
- 誤解を招く / 弱い test がある。
|
||||||
- `write_aggregates_multiple_errors` は名前に反して、実際の assertion は `status` / `missing` の substring だけで、body too long など複数 error aggregation を確認していない。現実の linter は frontmatter parse failure で早期 return するため、この test は「複数 error が集約される」保証になっていない。
|
- `write_aggregates_multiple_errors` は名前に反して、実際の assertion は `status` / `missing` の substring だけで、body too long など複数 error aggregation を確認していない。現実の linter は frontmatter parse failure で早期 return するため、この test は「複数 error が集約される」保証になっていない。
|
||||||
|
|
@ -51,7 +51,7 @@
|
||||||
- filesystem failure / concurrency はほぼ未検証。
|
- filesystem failure / concurrency はほぼ未検証。
|
||||||
- write/edit/delete の permission error、directory/file collision、partial write、外部同時変更は現状未カバー。
|
- write/edit/delete の permission error、directory/file collision、partial write、外部同時変更は現状未カバー。
|
||||||
- consolidation lock は live pid / stale pid / cleanup は見ているが、corrupt lock overwrite や `release_only` は明示テストがない。
|
- consolidation lock は live pid / stale pid / cleanup は見ているが、corrupt lock overwrite や `release_only` は明示テストがない。
|
||||||
- query / resident の malformed handling はある程度あるが、KnowledgeQuery の kind filter 時に malformed frontmatter を skip する仕様、malformed でも query だけなら body hit できる仕様は直接の regression test があるとよい。
|
- query / resident の malformed handling はある程度あるが、MemoryQuery の kind filter 時に malformed frontmatter を skip する仕様、malformed でも query だけなら body hit できる仕様は直接の regression test があるとよい。
|
||||||
- schema strictness が仕様なら危険。
|
- schema strictness が仕様なら危険。
|
||||||
- `frontmatter::deserialize_strict` という名前だが、schema structs 側に `deny_unknown_fields` が見当たらず、unknown field reject の test もない。extra field を許す設計なら問題ないが、「strict」を期待するならテスト・実装とも不足。
|
- `frontmatter::deserialize_strict` という名前だが、schema structs 側に `deny_unknown_fields` が見当たらず、unknown field reject の test もない。extra field を許す設計なら問題ないが、「strict」を期待するならテスト・実装とも不足。
|
||||||
|
|
||||||
|
|
@ -63,8 +63,8 @@
|
||||||
- `MemoryRead` の `offset` / `limit` / truncation / limit=0 を追加。
|
- `MemoryRead` の `offset` / `limit` / truncation / limit=0 を追加。
|
||||||
- `MemoryDelete` の missing file・summary slug forbidden・invalid slug・workflow kind reject を追加。
|
- `MemoryDelete` の missing file・summary slug forbidden・invalid slug・workflow kind reject を追加。
|
||||||
- 中優先度:
|
- 中優先度:
|
||||||
- linter の invalid status、malformed timestamps、request/knowledge/summary の必須 field、Knowledge `last_sources` malformed、warning propagation を追加。
|
- linter の invalid status、malformed timestamps、request/summary の必須 field、`last_sources` malformed、warning propagation を追加。
|
||||||
- KnowledgeQuery の malformed frontmatter behavior: kind filter では skip、query-only では body match 可能、という仕様を固定。
|
- MemoryQuery の malformed frontmatter behavior: kind filter では skip、query-only では body match 可能、という仕様を固定。
|
||||||
- write/edit/delete/read の audit log JSON を success/failure それぞれで軽く確認する。
|
- write/edit/delete/read の audit log JSON を success/failure それぞれで軽く確認する。
|
||||||
- consolidation lock の corrupt lock overwrite と `release_only` の staging preservation を追加。
|
- consolidation lock の corrupt lock overwrite と `release_only` の staging preservation を追加。
|
||||||
- 低〜中優先度:
|
- 低〜中優先度:
|
||||||
|
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
# テスト妥当性レビュー: protocol
|
|
||||||
|
|
||||||
- 評価: 概ね良い
|
|
||||||
|
|
||||||
## 確認範囲
|
|
||||||
|
|
||||||
- 対象は `crates/protocol` と解釈した。
|
|
||||||
- 読んだ範囲:
|
|
||||||
- `crates/protocol/README.md`
|
|
||||||
- `crates/protocol/Cargo.toml`
|
|
||||||
- `crates/protocol/src/lib.rs`
|
|
||||||
- `crates/protocol/src/stream.rs`
|
|
||||||
- 実行した検証:
|
|
||||||
- `cargo test -p protocol` — pass。unit tests 38 件、doc tests 0 件。
|
|
||||||
|
|
||||||
## 現在のテストがよくカバーしていること
|
|
||||||
|
|
||||||
`protocol` crate の主責務である「Pod client/server 間の wire protocol 型」をかなり広く検証できている。特に以下は妥当。
|
|
||||||
|
|
||||||
- `Method` / `Event` の serde tag 形式を、`serde_json::Value` または固定 JSON で確認している。
|
|
||||||
- `Method::Run` と `Segment::{Text, Paste}` の roundtrip がある。
|
|
||||||
- 未知の `Segment` kind が `Segment::Unknown` に落ちる forward compatibility 要件を直接テストしている。
|
|
||||||
- `Notify`, `PodEvent`, completion, snapshot, status, compaction, tool result, LLM retry/continuation など、最近の運用上重要そうな wire shape が個別に押さえられている。
|
|
||||||
- `Snapshot` の legacy default (`status`, `context_window`, `context_tokens`) を確認しており、互換性テストとして価値がある。
|
|
||||||
- `PodEvent::should_notify_agent()` のような単なる serde ではない分類 invariant もテストされている。
|
|
||||||
|
|
||||||
この crate はロジックが薄く、ほぼ protocol schema の authority なので、現在のテストの中心が serde shape / roundtrip になっていること自体は適切。
|
|
||||||
|
|
||||||
## 不足 / 疑問のあるテスト
|
|
||||||
|
|
||||||
- `src/stream.rs` の `JsonLineReader` / `JsonLineWriter` にテストがない。JSONL reader/writer はこの crate の README 上の「JSONL message boundary」に近い責務なので、空行 skip、EOF、invalid JSON の `InvalidData`、writer の newline/flush 相当は最低限押さえたい。
|
|
||||||
- `Method` / `Event` の variant coverage は完全ではない。
|
|
||||||
- 未確認の例: `Method::Cancel`, `ListRewindTargets`, `RewindTo`, `Shutdown`; `Event::TextDone`, `ToolCallStart`, `ToolCallArgsDelta`, `ToolCallDone`, `Usage`, `Shutdown`, `MemoryWorker`, `RewindTargets`, `RewindApplied` など。
|
|
||||||
- protocol crate は variant 追加・rename の影響が大きいため、未テスト variant が増えると wire contract の退行を見逃しやすい。
|
|
||||||
- `Segment::flatten_to_text()` と `Method::run_text()` が未テスト。特に `flatten_to_text()` は sigil 復元・貼り付け content・unknown placeholder という明確な仕様を持つので、serde よりも壊れやすい実装ロジックとしてテスト価値が高い。
|
|
||||||
- `ScopeRule` / `Permission` の wire shape と `recursive` default は `ScopeSubDelegated` 経由で一部触れているが、`recursive` 省略時の default や `Permission` の lowercase serde は直接確認されていない。
|
|
||||||
- 一部テストは roundtrip のみで、期待 JSON 名や payload 値の確認が弱い。
|
|
||||||
- 例: `pod_discovery_methods_roundtrip()` は `RestorePod { name }` / `RegisterPeer { name }` の値を assert していない。
|
|
||||||
- roundtrip だけだと、同一型内で serialize/deserialize が揃って壊れた場合に検出できないことがある。
|
|
||||||
- 固定 JSON 文字列との完全一致テストが一部ある。wire format の固定には有効だが、serde の field order に依存するため、意図しない脆さもある。重要な canonical shape だけに絞り、他は `serde_json::Value` で意味を assert する現在の混合方針は概ね妥当。
|
|
||||||
|
|
||||||
## 追加提案
|
|
||||||
|
|
||||||
- `stream.rs` に async unit tests を追加する。
|
|
||||||
- writer が JSON + `\n` を出す。
|
|
||||||
- reader が空行を skip して次の JSON を読む。
|
|
||||||
- EOF で `Ok(None)`。
|
|
||||||
- invalid JSON が `io::ErrorKind::InvalidData` になる。
|
|
||||||
- writer → reader の小さな end-to-end roundtrip。
|
|
||||||
- `Segment::flatten_to_text()` の仕様テストを追加する。
|
|
||||||
- `Text`, `Paste`, `FileRef`, `KnowledgeRef`, `WorkflowInvoke`, `Unknown` を混ぜた順序保持。
|
|
||||||
- `@`, `#`, `/` の sigil 復元。
|
|
||||||
- 未カバーの control/event variant に、少なくとも代表的な pinned wire-shape テストを足す。
|
|
||||||
- `Cancel`, `Shutdown`, `RewindTo`, `ListRewindTargets`
|
|
||||||
- `ToolCall*`, `Usage`, `TextDone`, `RewindTargets`, `RewindApplied`, `MemoryWorker`
|
|
||||||
- roundtrip だけのテストは、重要 payload を assert する。
|
|
||||||
- `RestorePod.name`
|
|
||||||
- `RegisterPeer.name`
|
|
||||||
- `PodsListed` / `PodRestored` / `PeerRegistered` は現在 payload equality があるので良い。
|
|
||||||
- `ScopeRule` の default と permission rename を単体で確認する。
|
|
||||||
- `{"target": "...", "permission": "read"}` で `recursive == true`
|
|
||||||
- `Permission::Write` が `"write"` になる。
|
|
||||||
|
|
||||||
## 実行したコマンド
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd /home/hare/Projects/yoi && cargo test -p protocol
|
|
||||||
```
|
|
||||||
|
|
||||||
結果: pass。38 unit tests passed、0 failed。doc tests は 0 件。
|
|
||||||
|
|
||||||
|
|
@ -40,7 +40,7 @@
|
||||||
- running 中の submit queueing
|
- running 中の submit queueing
|
||||||
- rollback 時の入力復元
|
- rollback 時の入力復元
|
||||||
- input history persistence
|
- input history persistence
|
||||||
- file / knowledge completion
|
- file completion
|
||||||
- typed `Segment` の保持
|
- typed `Segment` の保持
|
||||||
- context usage 表示
|
- context usage 表示
|
||||||
- live system item / task snapshot の反映
|
- live system item / task snapshot の反映
|
||||||
|
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
# テスト妥当性レビュー: workflow
|
|
||||||
- 判定: 概ね良い
|
|
||||||
|
|
||||||
## 確認範囲
|
|
||||||
- Crate: `crates/workflow`
|
|
||||||
- 確認した責務:
|
|
||||||
- Workflow フロントマターの分割と schema defaults
|
|
||||||
- 人間編集向け Workflow linter
|
|
||||||
- builtin resources と `.yoi/workflow` からの Workflow discovery/loading
|
|
||||||
- `SKILL.md` parsing と Skill-to-Workflow projection
|
|
||||||
- Workflow registry の collision/shadowing behavior
|
|
||||||
- Workflow directory write-deny scope helper
|
|
||||||
- 主に読んだファイル:
|
|
||||||
- `crates/workflow/README.md`
|
|
||||||
- `crates/workflow/Cargo.toml`
|
|
||||||
- `crates/workflow/src/{lib.rs,schema.rs,linter.rs,workflow.rs,skill.rs,scope.rs,error.rs}`
|
|
||||||
- 狭い integration 参照: Workflow invocation resolution 周辺の `crates/pod/src/workflow/mod.rs` tests
|
|
||||||
|
|
||||||
## 現在のテストがよくカバーしていること
|
|
||||||
- この crate には、純粋な parsing/loading 責務に対する堅実な unit-test set がある: `cargo test -p workflow` は 34 unit tests を実行し、すべて成功している。
|
|
||||||
- Workflow loading は妥当にカバーされている:
|
|
||||||
- valid workspace Workflow loading と default flags
|
|
||||||
- `model_invokation` / `user_invocable` behavior
|
|
||||||
- workspace Workflow が slug で builtin Workflow を override すること
|
|
||||||
- 少なくとも 1 つの builtin Workflow の builtin provenance
|
|
||||||
- invalid filename が hard error になること
|
|
||||||
- required `description` の欠落が hard error になること
|
|
||||||
- legacy `.yoi/memory/workflow` が無視されること
|
|
||||||
- resident description cap enforcement
|
|
||||||
- Registry/Skill collision behavior がカバーされている:
|
|
||||||
- collision がない場合の insertion
|
|
||||||
- workspace Workflow が Skill を shadow すること
|
|
||||||
- first-fed Skill が later-fed Skill に勝つこと
|
|
||||||
- human-readable shadow message の smoke check
|
|
||||||
- `SKILL.md` parsing は主要な invariants をよくカバーしている:
|
|
||||||
- minimal valid skill
|
|
||||||
- directory/name mismatch
|
|
||||||
- invalid slug names
|
|
||||||
- empty description
|
|
||||||
- description が cap ちょうど、および cap 超過の場合
|
|
||||||
- missing frontmatter
|
|
||||||
- `allowed-tools` を含む optional spec fields が受け入れられること
|
|
||||||
- scan behavior: missing root、deterministic ordering、broken sibling を skip しつつ good sibling を保持すること
|
|
||||||
- Skill-to-Workflow defaults
|
|
||||||
- Linter tests は現在の重要な linter checks をカバーしている:
|
|
||||||
- required Knowledge が存在する valid file
|
|
||||||
- missing required Knowledge
|
|
||||||
- resident description cap
|
|
||||||
- Workflow body size limit
|
|
||||||
- テストは temporary directories を使い、private implementation details よりも observable API behavior を主に assert しており、この crate には適切である。
|
|
||||||
|
|
||||||
## ギャップ / 疑問のあるテスト
|
|
||||||
- Builtin Workflow coverage はやや弱い。`missing_directory_loads_builtin_registry` は unrelated slug が存在しないことだけを確認しており、`builtin_workflow_records_have_visible_provenance` は `multi-agent-workflow` だけを確認している。別のテストがたまたま触れない限り、1 つの builtin slug が削除または misconfigure される regression を見逃す可能性がある。
|
|
||||||
- Loader と linter の invariants がテスト上で完全には揃っていない。linter は `WORKFLOW_BODY_LIMIT` を enforce するが、`load_workflows` は現在 enforce していない。body size が runtime/load invariant の意図なら loader test がない。意図的に lint-only なら、その境界を明示したままにすべきである。
|
|
||||||
- `requires` validation は linter と後段の `pod` invocation resolution でのみテストされており、`load_workflows` ではテストされていない。これは意図的かもしれないが、crate tests だけでは責務分担が明確ではない。
|
|
||||||
- linter の Knowledge existence check は filename-stem ベースである。テストは malformed Knowledge files、invalid Knowledge filenames、または「一致する `.md` file が存在する」と「valid Knowledge record が存在する」の違いをカバーしていない。意図する invariant が「valid Knowledge record」なら、現在のテストは許容しすぎている。
|
|
||||||
- 複数の hard-error paths が十分にテストされていない:
|
|
||||||
- Workflow files の malformed YAML frontmatter
|
|
||||||
- Workflow files の missing frontmatter
|
|
||||||
- `SKILL.md` の missing `name` / missing `description`
|
|
||||||
- `.yoi/workflow` 配下の non-`.md` files と subdirectories
|
|
||||||
- Workflow description cap と body cap の exact boundary
|
|
||||||
- `skill::tests::invalid_slug_name_is_error` は必要以上に緩い: fixture は uppercase の directory/name が一致しているため、期待される error は `InvalidName` であるべき。`NameDirMismatch` も許容すると、validation ordering や fixture の regression を隠してしまう。
|
|
||||||
- `skill::tests::extra_frontmatter_fields_are_kept` は名前がやや不自然である: parsed `SkillRecord` はそれらの optional fields を保持しない。このテストが実際に assert しているのは「optional/spec-compatible fields are accepted and ignored」であり、それ自体は妥当だが、より直接的に表現すべきである。
|
|
||||||
- crate-level tests は、「workspace Workflows を load し、configured directories から Skills を load し、registry に merge し、shadowed Skills を report する」という full pipeline を exercise していない。隣接する一部 behavior は `pod` でカバーされているが、`workflow` crate 自体では pieces を独立にテストしているだけである。
|
|
||||||
|
|
||||||
## 追加するとよいもの
|
|
||||||
- builtin assertions を強化する:
|
|
||||||
- 期待されるすべての builtin slugs が存在することを assert する
|
|
||||||
- それらの `source`, `path`, `model_invokation`, `user_invocable`, および選択した `requires` values を assert する
|
|
||||||
- builtin resource changes が明確に fail するよう、小さな contract test として維持する
|
|
||||||
- Workflow loader の negative/edge tests を追加する:
|
|
||||||
- missing frontmatter
|
|
||||||
- malformed YAML
|
|
||||||
- `model_invokation: true` のとき description cap ちょうどが accepted になること
|
|
||||||
- non-`.md` files が ignored されること
|
|
||||||
- subdirectories が ignored されること
|
|
||||||
- workspace Workflow が `requires` values を preserve すること
|
|
||||||
- body limit が lint-only なのか load-time でもあるのかをテストで明確にする
|
|
||||||
- linter edge tests を追加する:
|
|
||||||
- body が `WORKFLOW_BODY_LIMIT` ちょうどなら accepted
|
|
||||||
- resident description が cap ちょうどなら accepted
|
|
||||||
- 複数の `requires` がすべての missing references を report すること
|
|
||||||
- invalid `requires` slug が malformed frontmatter または slug parse error になること
|
|
||||||
- valid Knowledge parsing が意図されている場合、malformed Knowledge record behavior
|
|
||||||
- Skill tests を引き締める:
|
|
||||||
- matching invalid directory/name に対して invalid slug test が `SkillParseError::InvalidName` を assert するようにする
|
|
||||||
- missing `name` と missing `description` を追加する
|
|
||||||
- optional-field test を、それらの fields が accepted/ignored されることを表す名前または内容にする
|
|
||||||
- parsed Skills からの registry assembly に対する crate-level integration-style unit test を 1 つ追加する:
|
|
||||||
- builtin/workspace registry を load する
|
|
||||||
- 2 つの Skill directories を parse する
|
|
||||||
- priority order で merge する
|
|
||||||
- accepted Skill、shadowed Skill、および結果の user-invocable/resident entries を assert する
|
|
||||||
|
|
||||||
## 実行したコマンド
|
|
||||||
- `cargo test -p workflow`
|
|
||||||
- 結果: passed
|
|
||||||
- 34 unit tests passed, 0 failed; doc-tests: 0 tests
|
|
||||||
- `cargo test -p workflow -- --list`
|
|
||||||
- 結果: passed
|
|
||||||
- 34 unit tests, 0 benchmarks を確認; doc-tests: 0 tests
|
|
||||||
- `git status --short`
|
|
||||||
- Read-only check; この review の変更外に既存の modified paths があることを示した:
|
|
||||||
- `.yoi/tickets/00001KTVPS6K3/item.md`
|
|
||||||
- `.yoi/tickets/00001KTVPS6K3/thread.md`
|
|
||||||
- `crates/tui/src/multi_pod.rs`
|
|
||||||
|
|
||||||
|
|
@ -38,7 +38,7 @@
|
||||||
|
|
||||||
- `memory_lint.rs` は主要な CLI 固有 invariants をカバーしている:
|
- `memory_lint.rs` は主要な CLI 固有 invariants をカバーしている:
|
||||||
- option parsing と usage error
|
- option parsing と usage error
|
||||||
- 意図した memory/Knowledge record path だけが lint されること
|
- 意図した memory record path だけが lint されること
|
||||||
- invalid record が lint failure になること
|
- invalid record が lint failure になること
|
||||||
- `--warnings-as-errors` が status を変えること
|
- `--warnings-as-errors` が status を変えること
|
||||||
- JSON output が parse 可能で、期待される counts を含むこと
|
- JSON output が parse 可能で、期待される counts を含むこと
|
||||||
|
|
@ -97,7 +97,7 @@
|
||||||
- `show` における path traversal / invalid id rejection
|
- `show` における path traversal / invalid id rejection
|
||||||
- paused/done/archived/all に対する list filtering
|
- paused/done/archived/all に対する list filtering
|
||||||
|
|
||||||
- `memory_lint.rs` には妥当な focused tests があるが、decisions と Knowledge records を明示的にはカバーしておらず、symlink/directory の特殊性、読めない file、summary fragments を超えた stdout human-output details もカバーしていない。
|
- `memory_lint.rs` には妥当な focused tests があるが、decisions を明示的にはカバーしておらず、symlink/directory の特殊性、読めない file、summary fragments を超えた stdout human-output details もカバーしていない。
|
||||||
|
|
||||||
- `session_cli.rs` は意図的に薄いが、ほとんどは最小 fixture を通じて delegated analytics output shape を検証している。より広い analytics correctness は `session-analytics` に属する。`yoi` crate には、unknown subcommands/options と duplicate path handling の test がまだない。
|
- `session_cli.rs` は意図的に薄いが、ほとんどは最小 fixture を通じて delegated analytics output shape を検証している。より広い analytics correctness は `session-analytics` に属する。`yoi` crate には、unknown subcommands/options と duplicate path handling の test がまだない。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec {
|
||||||
filter = sourceFilter;
|
filter = sourceFilter;
|
||||||
};
|
};
|
||||||
|
|
||||||
cargoHash = "sha256-OTZ0VTCz4mZiQj1Li0COMLLrcYEoSZBTdzJIPqlv70k=";
|
cargoHash = "sha256-Xlaq8/cyMGk3m4z6BNLtSgoRnWqVVnGaLyzgBlr70rk=";
|
||||||
|
|
||||||
depsExtraArgs = {
|
depsExtraArgs = {
|
||||||
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
---
|
|
||||||
kind: policy
|
|
||||||
description: Public workflow resources are procedural artifacts, not prompt fragments or dogfood policy
|
|
||||||
model_invokation: true
|
|
||||||
user_invocable: true
|
|
||||||
last_sources: []
|
|
||||||
---
|
|
||||||
Builtin workflow resources live under `resources/workflows` and should contain public, product-generic procedure. Project dogfood details such as repository-specific Git worktree, cargo, nix, merge, and cleanup policy belong in workspace workflows or explicit launch context. Workspace workflow records override builtin workflow resources by slug, and provenance should remain visible.
|
|
||||||
|
|
@ -5,20 +5,22 @@ When searching, use grep/glob primitives rather than shell pipelines.
|
||||||
|
|
||||||
You can run multiple tools simultaneously by calling them within a single response.
|
You can run multiple tools simultaneously by calling them within a single response.
|
||||||
It is recommended to run tools that handle asynchronous processing, such as queries and readings, in batches.
|
It is recommended to run tools that handle asynchronous processing, such as queries and readings, in batches.
|
||||||
|
|
||||||
|
### Agent Skills
|
||||||
|
|
||||||
|
When an Agent Skill is explicitly activated, follow its committed `SKILL.md` body as LLM-facing procedural guidance only. A Skill does not grant authority to mutate Tickets, repositories, networks, worktrees, queues, schedules, scripts, or other external state; use the normal typed tools, features, and permissions for those actions. Skill catalog metadata is lightweight, and full Skill bodies should enter context only through explicit activation/read.
|
||||||
{% if tool_capabilities.memory_any %}
|
{% if tool_capabilities.memory_any %}
|
||||||
|
|
||||||
### Memory and knowledge
|
### Memory
|
||||||
|
|
||||||
{% if tool_capabilities.memory_records and tool_capabilities.knowledge_query %}Use memory and knowledge proactively{% elif tool_capabilities.memory_records %}Use memory proactively{% else %}Use knowledge proactively{% endif %} when the request may depend on prior project decisions, historical rationale, durable user preferences, recently completed tickets, or established workflow/policy conventions.
|
Use memory proactively when the request may depend on prior project decisions, historical rationale, durable user preferences, recently completed tickets, or established workflow/policy conventions.
|
||||||
{% if tool_capabilities.memory_query and tool_capabilities.knowledge_query %}Prefer a small targeted `MemoryQuery` / `KnowledgeQuery` before relying on vague recollection.
|
{% if tool_capabilities.memory_query %}Prefer a small targeted `MemoryQuery` before relying on vague recollection.
|
||||||
{% elif tool_capabilities.memory_query %}Prefer a small targeted `MemoryQuery` before relying on vague recollection.
|
|
||||||
{% elif tool_capabilities.knowledge_query %}Prefer a small targeted `KnowledgeQuery` before relying on vague recollection.
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
Strong lookup triggers include: the user says "recently", "previously", "that decision", "the ticket", "why", "policy", or "workflow"; you are about to make a design recommendation; you are reviewing, merging, closing, or rescoping a work item; or you are about to assert project history from memory.
|
Strong lookup triggers include: the user says "recently", "previously", "that decision", "the ticket", "why", "policy", or "workflow"; you are about to make a design recommendation; you are reviewing, merging, closing, or rescoping a work item; or you are about to assert project history from memory.
|
||||||
{% if tool_capabilities.memory_read %}
|
{% if tool_capabilities.memory_read %}
|
||||||
Use `MemoryRead(kind=summary)` for the full memory summary, and `MemoryRead` on returned slugs when excerpts are insufficient.
|
Use `MemoryRead(kind=summary)` for the full memory summary, and `MemoryRead` on returned slugs when excerpts are insufficient.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if tool_capabilities.memory_records and tool_capabilities.knowledge_query %}Resident memory and knowledge are{% elif tool_capabilities.knowledge_query %}Resident knowledge is{% else %}Resident memory is{% endif %} helpful context but may be stale; current user instructions, repository files, tickets, git history, and session logs are authoritative for exact current state.
|
Resident memory is helpful context but may be stale; current user instructions, repository files, tickets, git history, and session logs are authoritative for exact current state.
|
||||||
Do not query memory every turn or mechanically. Skip memory lookup for purely local facts answered by current repository files, command output, or current user instructions.
|
Do not query memory every turn or mechanically. Skip memory lookup for purely local facts answered by current repository files, command output, or current user instructions.
|
||||||
{% if tool_capabilities.memory_mutation %}Normally prefer read/query tools; use available mutation tools ({% if tool_capabilities.memory_write %}`MemoryWrite`{% endif %}{% if tool_capabilities.memory_edit %}{% if tool_capabilities.memory_write %}, {% endif %}`MemoryEdit`{% endif %}{% if tool_capabilities.memory_delete %}{% if tool_capabilities.memory_write or tool_capabilities.memory_edit %}, {% endif %}`MemoryDelete`{% endif %}) only when explicitly asked or in a memory maintenance worker.
|
{% if tool_capabilities.memory_mutation %}Normally prefer read/query tools; use available mutation tools ({% if tool_capabilities.memory_write %}`MemoryWrite`{% endif %}{% if tool_capabilities.memory_edit %}{% if tool_capabilities.memory_write %}, {% endif %}`MemoryEdit`{% endif %}{% if tool_capabilities.memory_delete %}{% if tool_capabilities.memory_write or tool_capabilities.memory_edit %}, {% endif %}`MemoryDelete`{% endif %}) only when explicitly asked or in a memory maintenance worker.
|
||||||
{% endif %}{% endif %}
|
{% endif %}{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -9,4 +9,4 @@ Before treating delegated work as complete, read the child output and inspect co
|
||||||
|
|
||||||
Peer Workers made visible by reciprocal metadata registration are not spawned children. Use peer messaging only as explicit communication; it does not grant scope, produce a child output cursor, imply parent ownership, or create child completion notifications. Peer sends require a live peer and do not auto-restore stopped peers.
|
Peer Workers made visible by reciprocal metadata registration are not spawned children. Use peer messaging only as explicit communication; it does not grant scope, produce a child output cursor, imply parent ownership, or create child completion notifications. Peer sends require a live peer and do not auto-restore stopped peers.
|
||||||
|
|
||||||
This guidance is not scheduler or auto-maintain authorization. Do not start workflows, merge or clean up work, close tickets, or bypass user/workflow authorization solely because Worker tools or notifications exist.
|
This guidance is not scheduler or auto-maintain authorization. Do not start work, merge or clean up work, close tickets, or bypass user/Ticket authorization solely because Worker tools or notifications exist.
|
||||||
|
|
|
||||||
|
|
@ -48,23 +48,6 @@ The following is the current durable session/workspace summary. Treat it as back
|
||||||
{{ summary }}\
|
{{ summary }}\
|
||||||
"""
|
"""
|
||||||
|
|
||||||
resident_knowledge_section = """\
|
|
||||||
---
|
|
||||||
## Resident knowledge
|
|
||||||
|
|
||||||
The following knowledge records are advertised resident.{% if knowledge_query_available and memory_read_available %} Use the KnowledgeQuery / MemoryRead tools to fetch the full body when relevant.{% elif knowledge_query_available %} Use KnowledgeQuery to search related knowledge records when relevant.{% elif memory_read_available %} Use MemoryRead on a known knowledge slug when the full body is required.{% endif %}
|
|
||||||
|
|
||||||
{{ entries }}\
|
|
||||||
"""
|
|
||||||
|
|
||||||
resident_workflows_section = """\
|
|
||||||
---
|
|
||||||
## Resident workflows
|
|
||||||
|
|
||||||
The following workflows are advertised resident. When a user request matches one, follow its procedure as authoritative instead of improvising. User-invocable workflows can additionally be triggered by the user typing /<slug>; you cannot invoke any of them yourself.
|
|
||||||
|
|
||||||
{{ entries }}\
|
|
||||||
"""
|
|
||||||
|
|
||||||
worker_orchestration_guidance_section = "{% include \"$yoi/common/worker-orchestration\" %}"
|
worker_orchestration_guidance_section = "{% include \"$yoi/common/worker-orchestration\" %}"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ The conversation input is a bounded overview/index, not the full transcript. Tre
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
1. Read the provided overview/index, current TaskStore snapshot, and any Active Workflow Invocation State section.
|
1. Read the provided overview/index and current TaskStore snapshot.
|
||||||
2. If the overview does not contain enough detail, use `search_session_log` to find relevant compact-target history items, then `read_session_items` to inspect only the needed range.
|
2. If the overview does not contain enough detail, use `search_session_log` to find relevant compact-target history items, then `read_session_items` to inspect only the needed range.
|
||||||
3. Use `read_file` to inspect referenced files before deciding what the next session needs. Prefer skimming over blind inclusion.
|
3. Use `read_file` to inspect referenced files before deciding what the next session needs. Prefer skimming over blind inclusion.
|
||||||
4. For files whose current contents are load-bearing for the active work, call `mark_read_required` to inject them into the next session. These count against the auto-read token budget — spend it deliberately.
|
4. For files whose current contents are load-bearing for the active work, call `mark_read_required` to inject them into the next session. These count against the auto-read token budget — spend it deliberately.
|
||||||
|
|
@ -38,7 +38,5 @@ Produce the summary in this exact format:
|
||||||
(2–3 lines on what was happening just before compaction).
|
(2–3 lines on what was happening just before compaction).
|
||||||
|
|
||||||
## Constraints
|
## Constraints
|
||||||
|
|
||||||
- Preserve active workflow invocation state when present: active slug, invocation scope/source/time, status, open obligations/checkpoints, and snapshotted workflow guidance. Do not replace a snapshotted invocation with merely advertised/latest workflow resources.
|
|
||||||
- Keep code snippets and raw tool output OUT of the summary — that is what auto-read and references are for.
|
- Keep code snippets and raw tool output OUT of the summary — that is what auto-read and references are for.
|
||||||
- Follow the summary target stated in the run input; if asked to shrink, call `write_summary` again with a shorter version.
|
- Follow the summary target stated in the run input; if asked to shrink, call `write_summary` again with a shorter version.
|
||||||
|
|
|
||||||
|
|
@ -1,72 +1,42 @@
|
||||||
You are the consolidation worker for a Yoi memory subsystem.
|
# Memory consolidation worker
|
||||||
|
|
||||||
Your job is to take extract activity-log staging entries together with the workspace's current `memory/*` / `knowledge/*` records, then run two steps back-to-back in this single session:
|
Your job is to take extract activity-log staging entries together with the workspace's current `memory/*` records, then run two steps back-to-back in this single session:
|
||||||
|
|
||||||
1. **Integration step** — fold staging into memory and knowledge.
|
1. **Integration step** — fold staging into memory.
|
||||||
2. **Tidy step** — clean up the existing records that the integration step didn't already touch.
|
2. **Tidy step** — produce a compact tidy report for stale / redundant / protected memory records.
|
||||||
|
|
||||||
You have:
|
You may use:
|
||||||
- `MemoryRead`, `MemoryWrite`, `MemoryEdit`, `MemoryDelete` for memory and knowledge records.
|
|
||||||
- `MemoryQuery` for memory-side records (summary / decisions / requests).
|
|
||||||
- `KnowledgeQuery` for knowledge records — use it to find existing slugs before creating new ones.
|
|
||||||
|
|
||||||
Your initial user message contains the staging entries, the full memory records, the knowledge candidate report, and the tidy hints. Existing knowledge bodies are NOT in the prompt; pull them through `KnowledgeQuery` + `MemoryRead` when relevant.
|
- `MemoryQuery` to find existing memory records.
|
||||||
|
- `MemoryRead`, `MemoryWrite`, `MemoryEdit`, `MemoryDelete` for memory records.
|
||||||
|
|
||||||
# Memory language
|
Your initial user message contains the staging entries, the full memory records, and the tidy hints.
|
||||||
|
|
||||||
- `language`: `{{ language }}`.
|
## Language
|
||||||
- Write durable memory and knowledge prose in this language, including frontmatter descriptions and record bodies.
|
|
||||||
- Existing records in another language may be rewritten into this language when you touch them for integration or tidy work; do not rewrite untouched records only for language normalization.
|
|
||||||
- Preserve code identifiers, paths, command names, quoted user text, logs, and external proper nouns when translation would reduce fidelity.
|
|
||||||
|
|
||||||
# Common rules (both steps)
|
- `language`: `{{language}}`
|
||||||
|
- Write durable memory prose in this language, including frontmatter descriptions and record bodies.
|
||||||
|
- Preserve literal identifiers, paths, commands, branch names, issue IDs, tool names, model names, and quoted user/system text as-is.
|
||||||
|
- If the configured language is unclear, use English.
|
||||||
|
|
||||||
- **Do not invent provenance.** Decisions / Requests `sources` arrays MUST be copied from the staging `source` field for the originating activity log entries. Do not synthesise `session_id` or entry ranges. Do not fabricate `last_sources` for Knowledge.
|
## Integration step
|
||||||
- **Rewrite is allowed and often preferred over append.** When integrating new information, restructure existing records to raise information density. Preserve the existing claims, rationale, and `sources` while you compress.
|
|
||||||
- **Update over create.** If an existing slug fits, edit it. Only create a new slug when no existing record fits and you can articulate why.
|
|
||||||
- **`replaced` over delete.** When a Decision is superseded by a different one, mark the old one `status: replaced` with `replaced_by: <new-slug>`. Do not silently drop it.
|
|
||||||
- **Don't duplicate static docs.** Skip content that already lives in `AGENTS.md`, `docs/plan/*`, or other fixed project documents.
|
|
||||||
- **Respect authoritative project records.** Issue trackers, task boards, planning documents, changelogs, version-control history, and generated reports are authoritative for their exact contents. Do not mirror them verbatim, preserve raw status churn, or maintain a parallel ledger in memory. Durable project-management facts and abstractions are valid when they help future work: recurring workflow constraints, prioritisation rationale, long-lived ownership / process decisions, and stable lessons that cut across individual items. If a candidate write only makes sense as an exact status mirror or when paired with a transient identifier, drop it.
|
|
||||||
- **Empty output is fine.** If a staging entry doesn't justify a memory write, skip it.
|
|
||||||
- **Slug rules.** Slugs are kebab-case, short, recognisable, and must be unique within their kind. Same-slug create is a linter error — use Edit instead.
|
|
||||||
- **Linter errors come back as tool errors.** When the memory linter rejects a write, read the error, fix the issue (missing frontmatter field, oversized body, unknown reference, etc.), and try again. Do not work around the rule.
|
|
||||||
|
|
||||||
# Integration step
|
The generated memory principle is: do not mirror tickets, task boards, reports, changelogs, git history, or generated artifacts verbatim. Keep durable abstractions, policy, rationale, recurring constraints, and user preferences.
|
||||||
|
|
||||||
Walk every staging entry in the input. For each one:
|
- Summary (`summary.md`) is resident body context. Keep it concise and strategic.
|
||||||
|
- Decisions (`memory/decisions/*.md`) capture durable accepted direction/rationale.
|
||||||
|
- Requests (`memory/requests/*.md`) capture durable user preferences or standing instructions.
|
||||||
|
- Preserve and update sources for decisions/requests when staging entries support them.
|
||||||
|
- **Do not invent provenance.** Decisions / Requests `sources` arrays MUST be copied from the staging `source` field for the originating activity log entries. Do not synthesise `session_id` or entry ranges.
|
||||||
|
- Keep records useful as durable context. Delete or merge stale duplicates only when safe and supported by the supplied evidence.
|
||||||
|
|
||||||
- **Routing by staging field:**
|
## Tidy step
|
||||||
- `decisions` (staging) → `memory/decisions/<slug>.md`, but only when the entry is a real **design / policy / approach** judgement. "We did X in this session" is not a decision — it's a session log; drop it. The rationale must outlive the session.
|
|
||||||
- `requests` (staging) → `memory/requests/<slug>.md`. Copy `sources` verbatim.
|
|
||||||
- `attempts` (staging) → default is **drop**. Memory has no `attempts/` folder by design; do not invent one and do not stash attempts under `decisions/`. The only exception is when several attempts together form a durable trend worth a one-line summary in `memory/summary.md` (e.g. "X reliably fails on Y").
|
|
||||||
- `discussions` (staging) → if the discussion settled on a design / policy direction during the slice, fold the conclusion into a `decisions/` record. If it stayed unresolved but the question itself is durable, fold a one-line note into `summary.md`. Otherwise drop. Never create a `decisions/` record that just records "we discussed X".
|
|
||||||
- Update existing knowledge records when the staging activity refines them. Use `KnowledgeQuery` to find candidates before creating anything new.
|
|
||||||
- **Knowledge creation is gated.** Only create a new `knowledge/<slug>.md` when the originating source appears in the supplied "Knowledge candidate report". When the report is empty (the metrics pipeline is still being built), do not create new knowledge — fold the activity into decisions / requests / summary or update existing knowledge instead.
|
|
||||||
- Rewrite `memory/summary.md` only when needed. Aim for 1–5k tokens. Preserve the high-level shape (current focus, recent decisions, stable facts) while pruning stale items.
|
|
||||||
|
|
||||||
# Tidy step
|
Once the integration step is done, evaluate every existing memory record against four categories:
|
||||||
|
|
||||||
Once the integration step is done, evaluate every existing memory and knowledge record against four categories:
|
- `obsolete`: contradicted or no longer useful.
|
||||||
|
- `superseded`: replaced by a newer/more accurate record; include the replacement slug if obvious.
|
||||||
|
- `duplicate`: overlaps enough to merge/delete; include the canonical slug if obvious.
|
||||||
|
- `protected`: keep despite low recent use because it is foundational, policy-like, safety-critical, or currently relevant.
|
||||||
|
|
||||||
- `outdated`: was correct, no longer matches the current implementation / policy / operation.
|
Emit tidy recommendations in your final response. Do not delete protected records.
|
||||||
- `superseded`: another record is now the de-facto authoritative one; this one is mostly redundant.
|
|
||||||
- `unused`: not wrong, but rarely referenced — noise rather than signal.
|
|
||||||
- `noisy`: useful content but bad shape (overlap, sources accumulation, fractured slugs that should merge).
|
|
||||||
|
|
||||||
A single record may fall into more than one category. Choose one of `drop / merge / split / trim / rewrite`:
|
|
||||||
|
|
||||||
- Prefer `merge` and `trim` over `drop` for anything you'd flag as `unused` or `noisy` — git can reverse you, but a confidently-wrong drop hurts discovery.
|
|
||||||
- `drop` is allowed for `outdated` / `superseded` records you can justify in the diff.
|
|
||||||
- `replaced` markers (`status: replaced`) and chains pointed at by the tidy hints should be collapsed in this step.
|
|
||||||
|
|
||||||
**Protection threshold.** When the tidy hints include explicit-invoke metrics, records with `frequency >= 1.0 invokes/Mtoken` are off-limits to drop / large compression. The metrics pipeline is not always populated; when the input lacks frequency data, behave conservatively and skip drop on long-standing records.
|
|
||||||
|
|
||||||
# Closing the turn
|
|
||||||
|
|
||||||
When both steps are done, write a short final assistant message stating:
|
|
||||||
|
|
||||||
- which staging entries you folded in (by short summary, not by ID),
|
|
||||||
- which existing records you touched (slug + operation),
|
|
||||||
- anything you intentionally left alone and why.
|
|
||||||
|
|
||||||
Then end the turn. Do not ask questions — there is no human in the loop for this run.
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user