feat: Workflowの読み取り位置変更の実装

This commit is contained in:
2026-05-08 00:15:50 +09:00
parent 22d87f8ade
commit 902b8d759b
18 changed files with 330 additions and 63 deletions
+1 -1
View File
@@ -70,7 +70,7 @@ pub enum LintError {
BodyTooLong { actual: usize, limit: usize },
#[error(
"write to `memory/workflow/` is forbidden via the memory tool — Workflows are human-edited"
"write to a Workflow path is forbidden via the memory tool — Workflows are human-edited"
)]
WorkflowWriteForbidden,
+4 -3
View File
@@ -1,6 +1,7 @@
//! Walks `<workspace>/memory/{decisions,requests}/`, `memory/workflow/`,
//! and `<workspace>/knowledge/` to collect the slug set the linter
//! needs for reference-integrity and same-slug-duplication checks.
//! Walks `<workspace>/memory/{decisions,requests}/`,
//! `<workspace>/workflow/`, and `<workspace>/knowledge/` to collect
//! the slug set the linter needs for reference-integrity and
//! same-slug-duplication checks.
//!
//! No caching: each lint call walks fresh. Tree size is expected to
//! stay small (hundreds of files, not thousands).
+1 -1
View File
@@ -335,7 +335,7 @@ mod tests {
#[test]
fn workflow_write_rejected() {
let (dir, linter) = workspace();
let path = dir.path().join(".insomnia/memory/workflow/wf.md");
let path = dir.path().join(".insomnia/workflow/wf.md");
let content =
"---\ndescription: x\nmodel_invokation: false\nuser_invocable: true\n---\nbody"
.to_string();
+2 -1
View File
@@ -3,7 +3,8 @@
//! NOTE: Workflows are written by humans, not by the memory tool. The
//! linter only validates frontmatter when invoked directly (e.g. by a
//! future CLI / pre-commit hook). The memory write/edit tool rejects
//! `memory/workflow/` paths outright via [`LintError::WorkflowWriteForbidden`].
//! `.insomnia/workflow/` paths outright via
//! [`LintError::WorkflowWriteForbidden`].
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
+10 -5
View File
@@ -13,13 +13,17 @@ use manifest::{Permission, ScopeRule};
use crate::workspace::WorkspaceLayout;
/// Build deny rules that strip Write permission from `<workspace>/memory/`
/// and `<workspace>/knowledge/`. Recursive — every descendant is capped
/// at Read for the generic tools, including `memory/workflow/`.
/// Build deny rules that strip Write permission from `<workspace>/memory/`,
/// `<workspace>/knowledge/`, and `<workspace>/workflow/`. Recursive —
/// every descendant is capped at Read for the generic tools.
///
/// Workflow files are human-edited on the host side; the generic CRUD
/// tools must not touch them.
pub fn deny_write_rules(layout: &WorkspaceLayout) -> Vec<ScopeRule> {
vec![
deny_write(layout.memory_dir().as_path()),
deny_write(layout.knowledge_dir().as_path()),
deny_write(layout.workflow_dir().as_path()),
]
}
@@ -37,13 +41,14 @@ mod tests {
use std::path::PathBuf;
#[test]
fn deny_targets_memory_and_knowledge() {
fn deny_targets_memory_knowledge_and_workflow() {
let layout = WorkspaceLayout::new(PathBuf::from("/ws"));
let rules = deny_write_rules(&layout);
assert_eq!(rules.len(), 2);
assert_eq!(rules.len(), 3);
assert_eq!(rules[0].target, PathBuf::from("/ws/.insomnia/memory"));
assert_eq!(rules[0].permission, Permission::Write);
assert!(rules[0].recursive);
assert_eq!(rules[1].target, PathBuf::from("/ws/.insomnia/knowledge"));
assert_eq!(rules[2].target, PathBuf::from("/ws/.insomnia/workflow"));
}
}
+1 -1
View File
@@ -9,7 +9,7 @@
//!
//! 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 (`memory/workflow/<slug>.md`) keep
//! skills loadable. Internal Workflows (`.insomnia/workflow/<slug>.md`) keep
//! their hard-error semantics.
use std::io;
+2 -2
View File
@@ -7,8 +7,8 @@
//! enumerate what records exist without knowing what's inside them.
//!
//! - `MemoryQuery` walks `.insomnia/memory/{summary.md,decisions/,
//! requests/}`. `.insomnia/memory/workflow/` and
//! `.insomnia/memory/_staging/` are excluded by construction.
//! requests/}`. `.insomnia/workflow/` and `.insomnia/memory/_staging/`
//! are excluded by construction.
//! - `KnowledgeQuery` walks `.insomnia/knowledge/*.md` and supports a
//! `kind` filter against the Knowledge frontmatter's `kind` field.
//!
+26 -10
View File
@@ -1,8 +1,8 @@
//! Workflow loader and registry.
//!
//! Workflows live under `<workspace>/.insomnia/memory/workflow/<slug>.md`.
//! They are human-authored Markdown documents with YAML frontmatter. The loader
//! is intentionally strict about malformed records because Pod startup should
//! Workflows live under `<workspace>/.insomnia/workflow/<slug>.md`. They are
//! human-authored Markdown documents with YAML frontmatter. The loader is
//! intentionally strict about malformed records because Pod startup should
//! fail rather than silently ignoring a broken procedural instruction.
use std::collections::BTreeMap;
@@ -26,8 +26,8 @@ pub const WORKFLOW_DESCRIPTION_HARD_CAP: usize = 1024;
/// win over external skills.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkflowSource {
/// `<workspace>/.insomnia/memory/workflow/<slug>.md`. Authored
/// in-tree by the project.
/// `<workspace>/.insomnia/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
@@ -309,15 +309,13 @@ mod tests {
fn setup() -> (TempDir, WorkspaceLayout) {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".insomnia/memory/workflow")).unwrap();
std::fs::create_dir_all(dir.path().join(".insomnia/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(".insomnia/memory/workflow")
.join(format!("{slug}.md"));
let path = root.join(".insomnia/workflow").join(format!("{slug}.md"));
std::fs::write(path, format!("---\n{frontmatter}\n---\n{body}")).unwrap();
}
@@ -373,6 +371,24 @@ mod tests {
assert!(matches!(err, WorkflowLoadError::Frontmatter { .. }));
}
#[test]
fn workflow_under_memory_is_ignored() {
// The legacy `.insomnia/memory/workflow/` location is no longer
// a Workflow source. Files placed there must be ignored (the
// loader is rooted at `.insomnia/workflow/` only).
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
let legacy = dir.path().join(".insomnia/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.is_empty());
}
fn skill_record(slug: &str, path: &Path) -> WorkflowRecord {
WorkflowRecord {
slug: Slug::parse(slug).unwrap(),
@@ -471,7 +487,7 @@ mod tests {
let s = ShadowedSkill {
slug: Slug::parse("x").unwrap(),
kept_source: WorkflowSource::WorkspaceWorkflow,
kept_path: std::path::PathBuf::from("/ws/.insomnia/memory/workflow/x.md"),
kept_path: std::path::PathBuf::from("/ws/.insomnia/workflow/x.md"),
shadowed_source: WorkflowSource::Skill {
dir: std::path::PathBuf::from("/skills"),
},
+27 -12
View File
@@ -3,15 +3,18 @@
//! `WorkspaceLayout` carries the workspace root (typically the Pod's
//! pwd). All insomnia-managed content lives under the conventional
//! `<root>/.insomnia/` subdirectory — the same place that holds
//! `manifest.toml` and `prompts/`. The memory subsystem nests its
//! trees inside it:
//! `manifest.toml` and `prompts/`. The trees inside it:
//!
//! - `<root>/.insomnia/workflow/<slug>.md`
//! - `<root>/.insomnia/knowledge/<slug>.md`
//! - `<root>/.insomnia/memory/summary.md`
//! - `<root>/.insomnia/memory/decisions/<slug>.md`
//! - `<root>/.insomnia/memory/requests/<slug>.md`
//! - `<root>/.insomnia/memory/workflow/<slug>.md`
//! - `<root>/.insomnia/memory/_staging/<id>.json`
//! - `<root>/.insomnia/knowledge/<slug>.md`
//!
//! `memory/` is reserved for session-derived / generated state;
//! Workflows are human-managed and live one level up under
//! `.insomnia/workflow/`.
//!
//! Configuring `[memory]` with an empty body is therefore sufficient
//! for any workspace that already uses the `.insomnia/` convention; no
@@ -25,10 +28,10 @@ use crate::slug::Slug;
const INSOMNIA_DIR: &str = ".insomnia";
const MEMORY_DIR: &str = "memory";
const KNOWLEDGE_DIR: &str = "knowledge";
const WORKFLOW_DIR: &str = "workflow";
const SUMMARY_FILE: &str = "summary.md";
const DECISIONS_DIR: &str = "decisions";
const REQUESTS_DIR: &str = "requests";
const WORKFLOW_DIR: &str = "workflow";
const STAGING_DIR: &str = "_staging";
/// What kind of record a path under the memory tree represents.
@@ -114,8 +117,9 @@ impl WorkspaceLayout {
self.memory_dir().join(REQUESTS_DIR)
}
/// Workflow directory: `<root>/.insomnia/workflow/`.
pub fn workflow_dir(&self) -> PathBuf {
self.memory_dir().join(WORKFLOW_DIR)
self.insomnia_dir().join(WORKFLOW_DIR)
}
pub fn staging_dir(&self) -> PathBuf {
@@ -139,9 +143,9 @@ impl WorkspaceLayout {
}
/// Classify a path under the memory tree. Returns `None` if the
/// path is not under `.insomnia/memory/` or `.insomnia/knowledge/`
/// of this workspace, or if it lives in `_staging/` (which is
/// opaque to the linter).
/// path is not under `.insomnia/memory/`, `.insomnia/knowledge/`,
/// or `.insomnia/workflow/` of this workspace, or if it lives in
/// `_staging/` (which is opaque to the linter).
///
/// On a conventional path that's *almost* a record but malformed
/// (e.g. `.insomnia/memory/decisions/Foo.md` with an invalid slug),
@@ -150,10 +154,14 @@ impl WorkspaceLayout {
pub fn classify(&self, path: &Path) -> Result<Option<ClassifiedPath>, LintError> {
let memory = self.memory_dir();
let knowledge = self.knowledge_dir();
let workflow = self.workflow_dir();
if let Ok(rel) = path.strip_prefix(&knowledge) {
return Ok(Some(classify_kinded_md(rel, RecordKind::Knowledge, path)?));
}
if let Ok(rel) = path.strip_prefix(&workflow) {
return Ok(Some(classify_kinded_md(rel, RecordKind::Workflow, path)?));
}
let rel = match path.strip_prefix(&memory) {
Ok(r) => r,
Err(_) => return Ok(None),
@@ -183,8 +191,6 @@ impl WorkspaceLayout {
RecordKind::Decision
} else if first == REQUESTS_DIR {
RecordKind::Request
} else if first == WORKFLOW_DIR {
RecordKind::Workflow
} else {
return Err(LintError::InvalidPath(path.to_path_buf()));
};
@@ -264,10 +270,19 @@ mod tests {
#[test]
fn classifies_workflow() {
let cp = layout()
.classify(&PathBuf::from("/ws/.insomnia/memory/workflow/wf.md"))
.classify(&PathBuf::from("/ws/.insomnia/workflow/wf.md"))
.unwrap()
.unwrap();
assert_eq!(cp.kind, RecordKind::Workflow);
assert_eq!(cp.slug.unwrap().as_str(), "wf");
}
#[test]
fn workflow_under_memory_is_invalid_path() {
let err = layout()
.classify(&PathBuf::from("/ws/.insomnia/memory/workflow/wf.md"))
.unwrap_err();
assert!(matches!(err, LintError::InvalidPath(_)));
}
#[test]
+2 -2
View File
@@ -148,8 +148,8 @@ pub struct Pod<C: LlmClient, St: Store> {
/// [`Self::from_manifest`], or defaults to the builtin pack when a
/// Pod is constructed through lower-level paths that have no loader.
prompts: Arc<PromptCatalog>,
/// Registry loaded from `<workspace>/.insomnia/memory/workflow/*.md`
/// when memory is enabled. Missing memory config keeps this empty.
/// Registry loaded from `<workspace>/.insomnia/workflow/*.md` when
/// memory is enabled. Missing memory config keeps this empty.
workflow_registry: memory::WorkflowRegistry,
/// Memory workspace layout used by the workflow resolver to load required
/// Knowledge records by exact slug.
+1 -1
View File
@@ -154,7 +154,7 @@ pub struct SystemPromptContext<'a> {
/// section entirely (memory disabled, or a Phase 2 worker that opts
/// out); `Some(&[])` also yields no section.
pub resident_knowledge: Option<&'a [ResidentKnowledgeEntry]>,
/// Resident workflow descriptions from `<workspace>/memory/workflow/*`
/// Resident workflow descriptions from `<workspace>/.insomnia/workflow/*`
/// whose frontmatter has `model_invokation: true`. `None` disables the
/// section; Phase 2 workers opt out together with resident Knowledge.
pub resident_workflows: Option<&'a [ResidentWorkflowEntry]>,
+3 -3
View File
@@ -147,7 +147,7 @@ mod tests {
"---\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(".insomnia/memory/workflow/run-it.md"),
&dir.path().join(".insomnia/workflow/run-it.md"),
"---\ndescription: run\nrequires: [policy]\n---\nworkflow body\n",
);
let registry = memory::load_workflows(&layout).unwrap();
@@ -171,7 +171,7 @@ mod tests {
fn user_invocable_false_errors() {
let (dir, layout, _registry) = setup();
write(
&dir.path().join(".insomnia/memory/workflow/hidden.md"),
&dir.path().join(".insomnia/workflow/hidden.md"),
"---\ndescription: hidden\nuser_invocable: false\n---\nbody\n",
);
let registry = memory::load_workflows(&layout).unwrap();
@@ -184,7 +184,7 @@ mod tests {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
write(
&dir.path().join(".insomnia/memory/workflow/bad.md"),
&dir.path().join(".insomnia/workflow/bad.md"),
"---\ndescription: bad\nrequires: [ghost]\n---\nbody\n",
);
let registry = memory::load_workflows(&layout).unwrap();