merge: builtin workflow knowledge resources

This commit is contained in:
Keisuke Hirata 2026-06-11 17:50:54 +09:00
commit ef2099c10a
No known key found for this signature in database
6 changed files with 252 additions and 60 deletions

View File

@ -72,6 +72,49 @@ impl fmt::Display for WorkflowResolveError {
impl std::error::Error for WorkflowResolveError {} 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( pub fn resolve_workflow_invocation(
registry: &WorkflowRegistry, registry: &WorkflowRegistry,
layout: &WorkspaceLayout, layout: &WorkspaceLayout,
@ -92,21 +135,7 @@ pub fn resolve_workflow_invocation(
let mut out = Vec::new(); let mut out = Vec::new();
for req in &record.requires { for req in &record.requires {
let path = layout.knowledge_dir().join(format!("{req}.md")); let (raw, knowledge_source) = read_required_knowledge(&slug, layout, req)?;
let raw = std::fs::read_to_string(&path).map_err(|source| {
if source.kind() == std::io::ErrorKind::NotFound {
WorkflowResolveError::KnowledgeNotFound {
workflow: slug.to_string(),
slug: req.to_string(),
}
} else {
WorkflowResolveError::KnowledgeRead {
workflow: slug.to_string(),
slug: req.to_string(),
source,
}
}
})?;
let (_yaml, body) = split_frontmatter(&raw).map_err(|source| { let (_yaml, body) = split_frontmatter(&raw).map_err(|source| {
WorkflowResolveError::KnowledgeFrontmatter { WorkflowResolveError::KnowledgeFrontmatter {
workflow: slug.to_string(), workflow: slug.to_string(),
@ -115,15 +144,17 @@ pub fn resolve_workflow_invocation(
} }
})?; })?;
out.push(Item::system_message(format!( out.push(Item::system_message(format!(
"[Workflow /{} requires Knowledge #{}]\n{}", "[Workflow /{} requires Knowledge #{} from {}]\n{}",
slug, slug,
req, req,
knowledge_source,
body.trim_end() body.trim_end()
))); )));
} }
out.push(Item::system_message(format!( out.push(Item::system_message(format!(
"[Workflow /{}]\n{}", "[Workflow /{} from {}]\n{}",
slug, slug,
record.source.label(),
record.body.trim_end() record.body.trim_end()
))); )));
Ok(out) Ok(out)
@ -165,10 +196,41 @@ mod tests {
let second = format!("{:?}", items[1]); let second = format!("{:?}", items[1]);
assert!(first.contains("Knowledge #policy")); assert!(first.contains("Knowledge #policy"));
assert!(first.contains("policy body")); assert!(first.contains("policy body"));
assert!(second.contains("[Workflow /run-it]")); assert!(second.contains("[Workflow /run-it from workspace workflow]"));
assert!(second.contains("workflow body")); assert!(second.contains("workflow body"));
} }
#[test]
fn builtin_workflow_uses_builtin_required_knowledge_when_workspace_missing() {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
let registry = workflow_crate::load_workflows(&layout).unwrap();
let items =
resolve_workflow_invocation(&registry, &layout, "ticket-intake-workflow").unwrap();
let first = format!("{:?}", items[0]);
let second = format!("{:?}", items[1]);
assert!(first.contains("Knowledge #workflow-resource-boundary from builtin"));
assert!(first.contains("Builtin workflow resources live under"));
assert!(second.contains("[Workflow /ticket-intake-workflow from builtin workflow]"));
}
#[test]
fn workspace_knowledge_overrides_builtin_required_knowledge() {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
write(
&dir.path()
.join(".yoi/knowledge/workflow-resource-boundary.md"),
"---\ncreated_at: 2026-01-01T00:00:00Z\nupdated_at: 2026-01-01T00:00:00Z\nkind: policy\ndescription: p\nmodel_invokation: false\nuser_invocable: true\nlast_sources: []\n---\nworkspace override knowledge\n",
);
let registry = workflow_crate::load_workflows(&layout).unwrap();
let items =
resolve_workflow_invocation(&registry, &layout, "ticket-intake-workflow").unwrap();
let first = format!("{:?}", items[0]);
assert!(first.contains("Knowledge #workflow-resource-boundary from workspace"));
assert!(first.contains("workspace override knowledge"));
}
#[test] #[test]
fn user_invocable_false_errors() { fn user_invocable_false_errors() {
let (dir, layout, _registry) = setup(); let (dir, layout, _registry) = setup();

View File

@ -27,19 +27,23 @@ pub const WORKFLOW_DESCRIPTION_HARD_CAP: usize = 1024;
/// win over external skills. /// win over external skills.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkflowSource { pub enum WorkflowSource {
Builtin,
/// `<workspace>/.yoi/workflow/<slug>.md`. Authored in-tree by /// `<workspace>/.yoi/workflow/<slug>.md`. Authored in-tree by
/// the project. /// the project.
WorkspaceWorkflow, WorkspaceWorkflow,
/// SKILL.md ingested from a `[skills] directories` entry in the /// SKILL.md ingested from a `[skills] directories` entry in the
/// manifest. `dir` is the skills root that contained /// manifest. `dir` is the skills root that contained
/// `<slug>/SKILL.md`. /// `<slug>/SKILL.md`.
Skill { dir: PathBuf }, Skill {
dir: PathBuf,
},
} }
impl WorkflowSource { impl WorkflowSource {
/// Human-readable label used in shadow-notification messages. /// Human-readable label used in shadow-notification messages.
pub fn label(&self) -> &'static str { pub fn label(&self) -> &'static str {
match self { match self {
Self::Builtin => "builtin workflow",
Self::WorkspaceWorkflow => "workspace workflow", Self::WorkspaceWorkflow => "workspace workflow",
Self::Skill { .. } => "skill", Self::Skill { .. } => "skill",
} }
@ -188,12 +192,93 @@ pub enum WorkflowLoadError {
}, },
} }
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> { pub fn load_workflows(layout: &WorkspaceLayout) -> Result<WorkflowRegistry, WorkflowLoadError> {
let mut records = builtin_workflow_records()?;
let dir = layout.workflow_dir(); let dir = layout.workflow_dir();
let entries = match std::fs::read_dir(&dir) { let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries, Ok(entries) => entries,
Err(err) if err.kind() == io::ErrorKind::NotFound => { Err(err) if err.kind() == io::ErrorKind::NotFound => {
return Ok(WorkflowRegistry::empty()); return Ok(WorkflowRegistry { records });
} }
Err(source) => return Err(WorkflowLoadError::ReadDir { dir, source }), Err(source) => return Err(WorkflowLoadError::ReadDir { dir, source }),
}; };
@ -211,7 +296,6 @@ pub fn load_workflows(layout: &WorkspaceLayout) -> Result<WorkflowRegistry, Work
} }
paths.sort(); paths.sort();
let mut records = BTreeMap::new();
for path in paths { for path in paths {
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue; continue;
@ -221,45 +305,18 @@ pub fn load_workflows(layout: &WorkspaceLayout) -> Result<WorkflowRegistry, Work
path: path.clone(), path: path.clone(),
source: source.into(), source: source.into(),
})?; })?;
if records.contains_key(&slug) { if let Some(existing) = records.get(&slug) {
warn!(slug = %slug, path = %path.display(), "duplicate workflow slug encountered; keeping first record"); if !matches!(existing.source, WorkflowSource::Builtin) {
continue; 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 { let raw = std::fs::read_to_string(&path).map_err(|source| WorkflowLoadError::ReadFile {
path: path.clone(), path: path.clone(),
source, source,
})?; })?;
let (yaml, body) = let record =
split_frontmatter(&raw).map_err(|source| WorkflowLoadError::Frontmatter { parse_workflow_record(slug.clone(), path, WorkflowSource::WorkspaceWorkflow, &raw)?;
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,
});
}
let record = WorkflowRecord {
slug: slug.clone(),
description: frontmatter.description,
model_invokation: frontmatter.model_invokation,
user_invocable: frontmatter.user_invocable,
requires: frontmatter.requires,
body: body.to_string(),
path: path.clone(),
source: WorkflowSource::WorkspaceWorkflow,
};
records.insert(slug.clone(), record); records.insert(slug.clone(), record);
} }
@ -327,11 +384,11 @@ mod tests {
} }
#[test] #[test]
fn missing_directory_loads_empty_registry() { fn missing_directory_loads_builtin_registry() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf()); let layout = WorkspaceLayout::new(dir.path().to_path_buf());
let got = load_workflows(&layout).unwrap(); let got = load_workflows(&layout).unwrap();
assert!(got.is_empty()); assert!(got.get(&Slug::parse("ghost").unwrap()).is_none());
} }
#[test] #[test]
@ -358,8 +415,40 @@ mod tests {
"Body", "Body",
); );
let got = load_workflows(&layout).unwrap(); let got = load_workflows(&layout).unwrap();
assert_eq!(got.resident_entries()[0].slug, "auto"); assert!(
assert!(got.list_user_invocable("").is_empty()); 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] #[test]
@ -393,7 +482,7 @@ mod tests {
) )
.unwrap(); .unwrap();
let got = load_workflows(&layout).unwrap(); let got = load_workflows(&layout).unwrap();
assert!(got.is_empty()); assert!(got.get(&Slug::parse("ghost").unwrap()).is_none());
} }
fn skill_record(slug: &str, path: &Path) -> WorkflowRecord { fn skill_record(slug: &str, path: &Path) -> WorkflowRecord {

View File

@ -0,0 +1,8 @@
---
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.

View File

@ -0,0 +1,11 @@
---
description: Public sibling coder/reviewer workflow
model_invokation: false
user_invocable: true
requires: [workflow-resource-boundary]
---
# Multi-agent Workflow
Use sibling implementation and review roles for a bounded Ticket. The Orchestrator owns intent, acceptance boundaries, blocker decisions, final merge-completion authority, and cleanup. The coder implements within delegated scope; the reviewer checks the recorded Ticket intent and acceptance criteria rather than unrecorded preferences.
Produce a merge-ready dossier with Ticket id, branch/worktree, commits, implementation summary, reviewer verdict, validation evidence, residual risks, dirty state, and any remaining human decision needs.

View File

@ -0,0 +1,11 @@
---
description: Public Ticket intake requirements-sync workflow
model_invokation: true
user_invocable: true
requires: [workflow-resource-boundary]
---
# Ticket Intake Workflow
Clarify a user request until it can be represented as a concrete Ticket. Preserve user intent, write bounded requirements and acceptance criteria, and avoid creating duplicate or umbrella Tickets when a more concrete Ticket is appropriate.
Do not perform implementation side effects. If an existing Ticket is refined, make scope/readiness changes explicit and keep broad changes as drafts until user agreement is clear.

View File

@ -0,0 +1,11 @@
---
description: Public Ticket orchestrator routing workflow
model_invokation: true
user_invocable: true
requires: [workflow-resource-boundary]
---
# Ticket Orchestrator Routing Workflow
Read the Ticket, relation metadata, orchestration-plan records, and relevant workspace state before deciding the next action. Treat `queued -> inprogress` as the implementation acceptance marker and record it before worktree creation, role Pod spawn, or other implementation side effects.
Classify the Ticket as planning return, blocked, spike, implementation-ready, review-needed, close-ready, or noop. If implementation-ready, record an IntentPacket with binding decisions, implementation latitude, acceptance criteria, escalation conditions, validation, and reviewer focus.