Merge branch 'workflow-impl' into develop

# Conflicts:
#	crates/pod/src/controller.rs
#	crates/pod/src/pod.rs
This commit is contained in:
Keisuke Hirata 2026-05-02 01:47:49 +09:00
commit 14862fbc37
19 changed files with 816 additions and 139 deletions

View File

@ -1,5 +1,4 @@
- [ ] Workflow / Skills
- [ ] Workflow 実装 → [tickets/workflow.md](tickets/workflow.md)
- [ ] 内部 Worker / 内部 Pod の Workflow 化 → [tickets/internal-worker-workflow.md](tickets/internal-worker-workflow.md)
- [ ] Agent Skills を Workflow として ingest → [tickets/agent-skills.md](tickets/agent-skills.md)
- [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md)

View File

@ -15,6 +15,7 @@ pub mod schema;
pub mod scope;
pub mod slug;
pub mod tool;
pub mod workflow;
pub mod workspace;
pub use error::{LintError, LintWarning, MemoryError};
@ -23,4 +24,8 @@ pub use linter::{LintReport, Linter};
pub use resident::{ResidentKnowledgeEntry, collect_resident_knowledge};
pub use scope::deny_write_rules;
pub use slug::Slug;
pub use workflow::{
ResidentWorkflowEntry, WORKFLOW_DESCRIPTION_HARD_CAP, WorkflowLoadError, WorkflowRecord,
WorkflowRegistry, load_workflows,
};
pub use workspace::WorkspaceLayout;

View File

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

View File

@ -25,6 +25,7 @@ use crate::schema::{
DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, SummaryFrontmatter,
WorkflowFrontmatter, split_frontmatter,
};
use crate::workflow::WORKFLOW_DESCRIPTION_HARD_CAP;
use crate::workspace::{ClassifiedPath, RecordKind, WorkspaceLayout};
pub use existing::{ExistingRecords, scan_existing};
@ -258,6 +259,18 @@ impl Linter {
};
size::check_body::<WorkflowFrontmatter>(parsed.body, &mut report);
// Mirror the loader's cap so human-edit paths fail fast instead
// of surfacing the same error only at Pod startup.
if parsed.frontmatter.model_invokation {
let actual = parsed.frontmatter.description.chars().count();
if actual > WORKFLOW_DESCRIPTION_HARD_CAP {
report.push_error(LintError::DescriptionTooLong {
actual,
limit: WORKFLOW_DESCRIPTION_HARD_CAP,
});
}
}
let existing = match existing::scan_existing(&self.layout) {
Ok(e) => e,
Err(e) => {
@ -323,10 +336,9 @@ mod tests {
fn workflow_write_rejected() {
let (dir, linter) = workspace();
let path = dir.path().join(".insomnia/memory/workflow/wf.md");
let content = format!(
"---\nupdated_at: {now}\ndescription: x\nauto_invoke: false\nuser_invocable: true\n---\nbody",
now = iso_now()
);
let content =
"---\ndescription: x\nmodel_invokation: false\nuser_invocable: true\n---\nbody"
.to_string();
let report = linter.lint(&path, &content, WriteMode::Create);
assert!(
report
@ -499,10 +511,7 @@ mod tests {
n = iso_now()
),
);
let wf = format!(
"---\nupdated_at: {n}\ndescription: do thing\nauto_invoke: false\nuser_invocable: true\nrequires: [foo]\n---\nstep 1\n",
n = iso_now()
);
let wf = "---\ndescription: do thing\nmodel_invokation: false\nuser_invocable: true\nrequires: [foo]\n---\nstep 1\n".to_string();
let report = linter.lint_workflow(&wf);
assert!(!report.has_errors(), "got errors: {:?}", report.errors);
}
@ -510,10 +519,7 @@ mod tests {
#[test]
fn workflow_lint_flags_unknown_requires() {
let (_dir, linter) = workspace();
let wf = format!(
"---\nupdated_at: {n}\ndescription: x\nauto_invoke: false\nuser_invocable: true\nrequires: [missing-knowledge]\n---\n",
n = iso_now()
);
let wf = "---\ndescription: x\nmodel_invokation: false\nuser_invocable: true\nrequires: [missing-knowledge]\n---\n".to_string();
let report = linter.lint_workflow(&wf);
assert!(report.errors.iter().any(|e| matches!(
e,
@ -525,13 +531,42 @@ mod tests {
)));
}
#[test]
fn workflow_lint_flags_long_description_when_model_invokation() {
let (_dir, linter) = workspace();
let desc = "x".repeat(crate::workflow::WORKFLOW_DESCRIPTION_HARD_CAP + 1);
let wf = format!(
"---\ndescription: {desc}\nmodel_invokation: true\nuser_invocable: true\n---\n"
);
let report = linter.lint_workflow(&wf);
assert!(
report
.errors
.iter()
.any(|e| matches!(e, LintError::DescriptionTooLong { .. })),
);
}
#[test]
fn workflow_lint_allows_long_description_when_not_model_invokation() {
let (_dir, linter) = workspace();
let desc = "x".repeat(crate::workflow::WORKFLOW_DESCRIPTION_HARD_CAP + 1);
let wf = format!(
"---\ndescription: {desc}\nmodel_invokation: false\nuser_invocable: true\n---\n"
);
let report = linter.lint_workflow(&wf);
assert!(
!report
.errors
.iter()
.any(|e| matches!(e, LintError::DescriptionTooLong { .. })),
);
}
#[test]
fn workflow_lint_collects_multiple_unknown_requires() {
let (_dir, linter) = workspace();
let wf = format!(
"---\nupdated_at: {n}\ndescription: x\nauto_invoke: false\nuser_invocable: true\nrequires: [a, b, c]\n---\n",
n = iso_now()
);
let wf = "---\ndescription: x\nmodel_invokation: false\nuser_invocable: true\nrequires: [a, b, c]\n---\n".to_string();
let report = linter.lint_workflow(&wf);
let unknown_count = report
.errors

View File

@ -13,25 +13,37 @@ use crate::slug::Slug;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WorkflowFrontmatter {
/// Workflows don't carry sources/created_at requirements in the
/// plan doc; only `updated_at` is required at the schema level.
pub updated_at: DateTime<Utc>,
/// Workflows do not require timestamps in the MVP. Human-authored files
/// may carry them; when absent the linter uses Unix epoch as a neutral
/// placeholder for the shared `Frontmatter` trait.
#[serde(default)]
pub updated_at: Option<DateTime<Utc>>,
#[serde(default)]
pub created_at: Option<DateTime<Utc>>,
pub description: String,
pub auto_invoke: bool,
#[serde(default)]
pub model_invokation: bool,
#[serde(default = "default_user_invocable")]
pub user_invocable: bool,
#[serde(default)]
pub requires: Vec<Slug>,
}
fn default_user_invocable() -> bool {
true
}
fn epoch() -> DateTime<Utc> {
DateTime::<Utc>::from_timestamp(0, 0).expect("Unix epoch timestamp is valid")
}
impl Frontmatter for WorkflowFrontmatter {
const BODY_LIMIT: usize = 8000;
fn created_at(&self) -> DateTime<Utc> {
self.created_at.unwrap_or(self.updated_at)
self.created_at.or(self.updated_at).unwrap_or_else(epoch)
}
fn updated_at(&self) -> DateTime<Utc> {
self.updated_at
self.updated_at.unwrap_or_else(epoch)
}
}

View File

@ -15,7 +15,7 @@ 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.
/// at Read for the generic tools, including `memory/workflow/`.
pub fn deny_write_rules(layout: &WorkspaceLayout) -> Vec<ScopeRule> {
vec![
deny_write(layout.memory_dir().as_path()),

View File

@ -0,0 +1,313 @@
//! 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
//! 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::error::LintError;
use crate::schema::{WorkflowFrontmatter, split_frontmatter};
use crate::slug::Slug;
use crate::workspace::WorkspaceLayout;
/// Hard cap on Workflow descriptions that are advertised resident.
/// Mirrors agent-skills and resident Knowledge descriptions.
pub const WORKFLOW_DESCRIPTION_HARD_CAP: usize = 1024;
#[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,
}
#[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()
}
}
#[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: LintError },
#[error("invalid workflow frontmatter in {}: {source}", .path.display())]
Frontmatter { path: PathBuf, source: LintError },
#[error(
"Workflow {} with model_invokation: true cannot have description longer than {limit} chars (got {actual})",
.path.display()
)]
DescriptionTooLong {
path: PathBuf,
actual: usize,
limit: usize,
},
}
pub fn load_workflows(layout: &WorkspaceLayout) -> Result<WorkflowRegistry, WorkflowLoadError> {
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::empty());
}
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();
let mut records = BTreeMap::new();
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,
})?;
if records.contains_key(&slug) {
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 (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,
});
}
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(),
};
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) -> LintError {
let msg = err.to_string();
if let Some(field) = parse_missing_field(&msg) {
return LintError::MissingField(field);
}
LintError::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(".insomnia/memory/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"));
std::fs::write(path, format!("---\n{frontmatter}\n---\n{body}")).unwrap();
}
#[test]
fn missing_directory_loads_empty_registry() {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
let got = load_workflows(&layout).unwrap();
assert!(got.is_empty());
}
#[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_eq!(got.resident_entries()[0].slug, "auto");
assert!(got.list_user_invocable("").is_empty());
}
#[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 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 { .. }));
}
}

View File

@ -263,11 +263,7 @@ impl PodController {
// query — keep a clone for the FS view we attach below,
// since the tools consume `fs` itself.
fs_for_view = fs.clone();
worker.register_tools(tools::builtin_tools(
fs,
tracker.clone(),
bash_output_dir,
));
worker.register_tools(tools::builtin_tools(fs, tracker.clone(), bash_output_dir));
// Memory subsystem opt-in. When `[memory]` is present in
// the manifest, register the memory-specific Read/Write/Edit
@ -321,6 +317,12 @@ impl PodController {
shared_state.update_history(pod.worker().history().to_vec());
shared_state.set_user_segments(pod.user_segments().to_vec());
shared_state.set_fs_view(crate::fs_view::PodFsView::new(fs_for_view));
shared_state.set_workflows(
pod.workflow_completions()
.into_iter()
.map(|slug| crate::shared_state::WorkflowCandidate { slug })
.collect(),
);
runtime_dir.write_manifest(&manifest_toml).await?;
runtime_dir.write_status(&shared_state).await?;
runtime_dir.write_history(&shared_state).await?;
@ -365,6 +367,14 @@ impl PodController {
});
continue;
}
let was_paused = status_before == PodStatus::Paused;
if let Err(e) = pod.validate_workflow_invocations(&input) {
let _ = event_tx.send(Event::Error {
code: ErrorCode::InvalidRequest,
message: e.to_string(),
});
continue;
}
// Broadcast the accepted user message so every
// subscriber (including the submitter) can
// render the turn header + user line from a
@ -374,7 +384,6 @@ impl PodController {
let _ = event_tx.send(Event::UserMessage {
segments: input.clone(),
});
let was_paused = status_before == PodStatus::Paused;
shared_state.set_status(PodStatus::Running);
let _ = runtime_dir.write_status(&shared_state).await;

View File

@ -647,6 +647,7 @@ permission = "write"
tool_names: Vec::new(),
agents_md: None,
resident_knowledge: None,
resident_workflows: None,
prompts: &catalog,
};
let rendered = tmpl.render(&ctx).unwrap();

View File

@ -102,11 +102,16 @@ async fn handle_connection(stream: tokio::net::UnixStream, handle: PodHandle) {
is_dir: c.is_dir,
})
.collect(),
// Knowledge / Workflow resolvers are not wired
// up yet — reply empty so the TUI sees a
// consistent shape regardless of kind.
protocol::CompletionKind::Knowledge
| protocol::CompletionKind::Workflow => Vec::new(),
protocol::CompletionKind::Knowledge => Vec::new(),
protocol::CompletionKind::Workflow => handle
.shared_state
.list_workflow_completions(&prefix)
.into_iter()
.map(|c| protocol::CompletionEntry {
value: c.slug,
is_dir: false,
})
.collect(),
};
if writer
.write(&Event::Completions { kind, entries })

View File

@ -7,6 +7,7 @@ pub mod prompt;
pub mod runtime;
pub mod shared_state;
pub mod spawn;
pub mod workflow;
mod factory;
mod interrupt_and_run;

View File

@ -30,6 +30,7 @@ use crate::prompt::loader::PromptLoader;
use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
use crate::runtime::dir;
use crate::runtime::pod_registry::{self, ScopeAllocationGuard, ScopeLockError};
use crate::workflow::WorkflowResolveError;
use async_trait::async_trait;
use llm_worker::interceptor::PreRequestAction;
use protocol::{AlertLevel, AlertSource, Event, Segment};
@ -130,6 +131,12 @@ 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.
workflow_registry: memory::WorkflowRegistry,
/// Memory workspace layout used by the workflow resolver to load required
/// Knowledge records by exact slug.
memory_layout: Option<memory::WorkspaceLayout>,
/// When true (default), the system-prompt assembler walks
/// `<workspace>/knowledge/*` and appends a `## Resident knowledge`
/// section listing records with `model_invokation: true`.
@ -206,6 +213,8 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
scope_allocation: None,
callback_socket: None,
prompts,
workflow_registry: memory::WorkflowRegistry::empty(),
memory_layout: None,
inject_resident_knowledge: true,
extract_in_flight: Arc::new(AtomicBool::new(false)),
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
@ -606,23 +615,31 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
// Owned `Vec` lives for the duration of `render` below; the
// context borrows a slice into it.
let resident: Vec<memory::ResidentKnowledgeEntry> = if self.inject_resident_knowledge {
self.manifest
.memory
self.memory_layout
.as_ref()
.map(|mem| {
let layout = memory::WorkspaceLayout::resolve(mem, &self.pwd);
memory::collect_resident_knowledge(&layout)
})
.map(memory::collect_resident_knowledge)
.unwrap_or_default()
} else {
Vec::new()
};
let resident_slice: Option<&[memory::ResidentKnowledgeEntry]> =
if self.inject_resident_knowledge && self.manifest.memory.is_some() {
if self.inject_resident_knowledge && self.memory_layout.is_some() {
Some(&resident)
} else {
None
};
let resident_workflows: Vec<memory::ResidentWorkflowEntry> =
if self.inject_resident_knowledge && self.memory_layout.is_some() {
self.workflow_registry.resident_entries()
} else {
Vec::new()
};
let resident_workflow_slice: Option<&[memory::ResidentWorkflowEntry]> =
if self.inject_resident_knowledge && self.memory_layout.is_some() {
Some(&resident_workflows)
} else {
None
};
let scope_snapshot = self.scope.snapshot();
let ctx = SystemPromptContext {
now: chrono::Utc::now(),
@ -631,6 +648,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
tool_names,
agents_md: agents_md_read.body,
resident_knowledge: resident_slice,
resident_workflows: resident_workflow_slice,
prompts: &self.prompts,
};
let rendered = template
@ -678,11 +696,12 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
.await?;
self.user_segments.push(input.clone());
// Resolve `@<path>` refs to system messages stashed for the
// PodInterceptor to attach right after the user message. Failures
// surface as user-facing Alerts and the placeholder remains in
// the flattened text so the LLM sees the unresolved intent.
let attachments = self.resolve_file_refs(&input);
// Resolve `@<path>` refs and `/<slug>` workflow invocations to
// system messages stashed for the PodInterceptor to attach right
// after the user message. File failures are non-fatal alerts; explicit
// workflow invocation failures abort before the Worker sees the turn.
let mut attachments = self.resolve_file_refs(&input);
attachments.extend(self.resolve_workflow_invocations(&input)?);
if !attachments.is_empty() {
*self
.pending_attachments
@ -732,6 +751,63 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
out
}
fn resolve_workflow_invocations(
&self,
segments: &[Segment],
) -> Result<Vec<Item>, WorkflowResolveError> {
let Some(layout) = self.memory_layout.as_ref() else {
if let Some(slug) = segments.iter().find_map(|seg| match seg {
Segment::WorkflowInvoke { slug } => Some(slug.clone()),
_ => None,
}) {
return Err(WorkflowResolveError::NotFound { slug });
}
return Ok(Vec::new());
};
let mut out = Vec::new();
for seg in segments {
let Segment::WorkflowInvoke { slug } = seg else {
continue;
};
let items = crate::workflow::resolve_workflow_invocation(
&self.workflow_registry,
layout,
slug,
)?;
out.extend(items);
}
Ok(out)
}
/// Validate explicit workflow invocations without reading dependency
/// bodies. Used by the controller before broadcasting `UserMessage` so
/// user-invocation errors are returned immediately and never reach the
/// Worker or client history.
pub fn validate_workflow_invocations(
&self,
segments: &[Segment],
) -> Result<(), WorkflowResolveError> {
for seg in segments {
let Segment::WorkflowInvoke { slug } = seg else {
continue;
};
let parsed =
memory::Slug::parse(slug.clone()).map_err(WorkflowResolveError::InvalidSlug)?;
let record = self
.workflow_registry
.get(&parsed)
.ok_or_else(|| WorkflowResolveError::NotFound { slug: slug.clone() })?;
if !record.user_invocable {
return Err(WorkflowResolveError::NotUserInvocable { slug: slug.clone() });
}
}
Ok(())
}
pub fn workflow_completions(&self) -> Vec<String> {
self.workflow_registry.list_user_invocable("")
}
/// Flatten a typed segment list into the single string the Worker
/// receives as the user message, and emit user-facing alerts for
/// segments that fall through to placeholder (knowledge / workflow
@ -753,16 +829,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
),
);
}
Segment::WorkflowInvoke { slug } => {
self.alert(
AlertLevel::Warn,
AlertSource::Pod,
format!(
"workflow /{slug} cannot be resolved \
(resolver not yet implemented); passed to LLM as placeholder"
),
);
}
Segment::WorkflowInvoke { .. } => {}
Segment::Unknown => {
self.alert(
AlertLevel::Warn,
@ -1701,7 +1768,10 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
worker.register_tool(memory::tool::write_tool(layout.clone()));
worker.register_tool(memory::tool::edit_tool(layout.clone()));
worker.register_tool(memory::tool::memory_query_tool(layout.clone(), query_cfg));
worker.register_tool(memory::tool::knowledge_query_tool(layout.clone(), query_cfg));
worker.register_tool(memory::tool::knowledge_query_tool(
layout.clone(),
query_cfg,
));
let tidy = consolidate::collect_tidy_hints(&layout);
let candidates = consolidate::KnowledgeCandidateReport::empty();
@ -1858,6 +1928,8 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
scope_allocation: Some(scope_allocation),
callback_socket: None,
prompts: common.prompts,
workflow_registry: common.workflow_registry,
memory_layout: common.memory_layout,
inject_resident_knowledge: true,
extract_in_flight: Arc::new(AtomicBool::new(false)),
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
@ -1916,6 +1988,8 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
scope_allocation: Some(scope_allocation),
callback_socket: Some(callback_socket),
prompts: common.prompts,
workflow_registry: common.workflow_registry,
memory_layout: common.memory_layout,
inject_resident_knowledge: true,
extract_in_flight: Arc::new(AtomicBool::new(false)),
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
@ -2026,6 +2100,8 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
scope_allocation: Some(scope_allocation),
callback_socket: None,
prompts: common.prompts,
workflow_registry: common.workflow_registry,
memory_layout: common.memory_layout,
inject_resident_knowledge: true,
extract_in_flight: Arc::new(AtomicBool::new(false)),
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
@ -2233,6 +2309,12 @@ pub enum PodError {
#[error("memory Phase 2 lock acquisition failed: {0}")]
ConsolidationLock(#[source] memory::consolidate::LockError),
#[error("workflow load failed: {0}")]
WorkflowLoad(#[source] memory::WorkflowLoadError),
#[error("workflow invocation failed: {0}")]
WorkflowResolve(#[from] WorkflowResolveError),
#[error("session {session_id} has no entries to restore")]
SessionEmpty { session_id: SessionId },
}
@ -2246,6 +2328,8 @@ struct PodCommon {
scope: Scope,
client: Box<dyn LlmClient>,
prompts: Arc<PromptCatalog>,
workflow_registry: memory::WorkflowRegistry,
memory_layout: Option<memory::WorkspaceLayout>,
system_prompt_template: Option<SystemPromptTemplate>,
}
@ -2272,6 +2356,14 @@ fn prepare_pod_common(
let client = provider::build_client(&manifest.model)?;
let prompts = PromptCatalog::load(loader, manifest.pod.prompt_pack.as_deref())?;
let memory_layout = manifest
.memory
.as_ref()
.map(|mem| memory::WorkspaceLayout::resolve(mem, &pwd));
let workflow_registry = match memory_layout.as_ref() {
Some(layout) => memory::load_workflows(layout).map_err(PodError::WorkflowLoad)?,
None => memory::WorkflowRegistry::empty(),
};
let system_prompt_template = if parse_template {
Some(
@ -2287,6 +2379,8 @@ fn prepare_pod_common(
scope,
client,
prompts,
workflow_registry,
memory_layout,
system_prompt_template,
})
}

View File

@ -79,6 +79,10 @@ pub enum PodPrompt {
/// AGENTS.md section when memory is enabled and at least one
/// `knowledge/*` record advertises `model_invokation: true`.
ResidentKnowledgeSection,
/// Trailing `## Resident workflows` section, appended after resident
/// knowledge when memory is enabled and at least one workflow advertises
/// `model_invokation: true`.
ResidentWorkflowsSection,
}
impl PodPrompt {
@ -91,6 +95,7 @@ impl PodPrompt {
Self::WorkingBoundariesSection => "working_boundaries_section",
Self::AgentsMdSection => "agents_md_section",
Self::ResidentKnowledgeSection => "resident_knowledge_section",
Self::ResidentWorkflowsSection => "resident_workflows_section",
}
}
@ -105,6 +110,7 @@ impl PodPrompt {
PodPrompt::WorkingBoundariesSection,
PodPrompt::AgentsMdSection,
PodPrompt::ResidentKnowledgeSection,
PodPrompt::ResidentWorkflowsSection,
];
pub const KEYS: &'static [&'static str] = &[
@ -115,6 +121,7 @@ impl PodPrompt {
"working_boundaries_section",
"agents_md_section",
"resident_knowledge_section",
"resident_workflows_section",
];
}
@ -330,6 +337,15 @@ impl PromptCatalog {
single("entries", entries),
)
}
/// Render `PodPrompt::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(
PodPrompt::ResidentWorkflowsSection,
single("entries", entries),
)
}
}
fn single(key: &'static str, value: &str) -> Value {

View File

@ -18,7 +18,7 @@ use std::sync::Arc;
use chrono::{DateTime, SecondsFormat, Utc};
use manifest::Scope;
use memory::ResidentKnowledgeEntry;
use memory::{ResidentKnowledgeEntry, ResidentWorkflowEntry};
use minijinja::value::Value;
use minijinja::{Environment, ErrorKind, UndefinedBehavior};
use thiserror::Error;
@ -122,6 +122,7 @@ impl SystemPromptTemplate {
ctx.scope,
ctx.agents_md.as_deref(),
ctx.resident_knowledge,
ctx.resident_workflows,
)
}
}
@ -153,6 +154,10 @@ 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/*`
/// 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]>,
/// Catalog used to render the fixed trailing section headers.
/// Passed by reference so callers do not give up ownership across
/// the short-lived render borrow.
@ -201,6 +206,7 @@ pub fn append_trailing_section(
scope: &Scope,
agents_md: Option<&str>,
resident_knowledge: Option<&[ResidentKnowledgeEntry]>,
resident_workflows: Option<&[ResidentWorkflowEntry]>,
) -> Result<String, SystemPromptError> {
let mut out = String::with_capacity(body.len() + 256);
out.push_str(body);
@ -227,6 +233,15 @@ pub fn append_trailing_section(
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');
}
}
// Canonicalise the tail so the emitted prompt has a single form
// regardless of how individual templates chose to end.
while out.ends_with('\n') || out.ends_with(' ') {
@ -238,15 +253,31 @@ pub fn append_trailing_section(
/// `- <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, e) in entries.iter().enumerate() {
for (i, (slug, description)) in entries.enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str("- ");
out.push_str(&e.slug);
out.push_str(slug);
out.push_str(": ");
for ch in e.description.chars() {
for ch in description.chars() {
if ch == '\n' || ch == '\r' {
out.push(' ');
} else {
@ -300,6 +331,7 @@ mod tests {
tool_names: tools,
agents_md,
resident_knowledge: None,
resident_workflows: None,
prompts: test_prompts(),
}
}
@ -316,6 +348,7 @@ mod tests {
tool_names: Vec::new(),
agents_md: None,
resident_knowledge: Some(resident),
resident_workflows: None,
prompts: test_prompts(),
}
}

View File

@ -7,6 +7,11 @@ use session_store::SessionId;
use crate::fs_view::PodFsView;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkflowCandidate {
pub slug: String,
}
/// Shared state between PodController and runtime directory.
///
/// Controller updates this in-memory; RuntimeDir writes it to disk.
@ -31,6 +36,7 @@ pub struct PodSharedState {
/// (only relevant for unit tests that build a `PodSharedState`
/// directly without spinning up a controller).
fs_view: OnceLock<PodFsView>,
workflows: OnceLock<Vec<WorkflowCandidate>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@ -57,6 +63,7 @@ impl PodSharedState {
history: RwLock::new(Vec::new()),
user_segments: RwLock::new(Vec::new()),
fs_view: OnceLock::new(),
workflows: OnceLock::new(),
}
}
@ -72,6 +79,23 @@ impl PodSharedState {
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 user_segments(&self) -> Vec<Vec<Segment>> {
self.user_segments
.read()

View File

@ -0,0 +1,197 @@
//! Pod-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 Worker.
use std::fmt;
use llm_worker::Item;
use memory::schema::split_frontmatter;
use memory::{Slug, WorkflowRegistry, WorkspaceLayout};
#[derive(Debug)]
pub enum WorkflowResolveError {
InvalidSlug(memory::LintError),
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 {}
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(WorkflowResolveError::InvalidSlug)?;
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 path = layout.knowledge_path(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| {
WorkflowResolveError::KnowledgeFrontmatter {
workflow: slug.to_string(),
slug: req.to_string(),
source,
}
})?;
out.push(Item::system_message(format!(
"[Workflow /{} requires Knowledge #{}]\n{}",
slug,
req,
body.trim_end()
)));
}
out.push(Item::system_message(format!(
"[Workflow /{}]\n{}",
slug,
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(".insomnia/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(".insomnia/memory/workflow/run-it.md"),
"---\ndescription: run\nrequires: [policy]\n---\nworkflow body\n",
);
let registry = memory::load_workflows(&layout).unwrap();
(dir, layout, registry)
}
#[test]
fn resolves_requires_before_workflow_body() {
let (_dir, layout, registry) = setup();
let items = resolve_workflow_invocation(&registry, &layout, "run-it").unwrap();
assert_eq!(items.len(), 2);
let first = format!("{:?}", items[0]);
let second = format!("{:?}", items[1]);
assert!(first.contains("Knowledge #policy"));
assert!(first.contains("policy body"));
assert!(second.contains("[Workflow /run-it]"));
assert!(second.contains("workflow body"));
}
#[test]
fn user_invocable_false_errors() {
let (dir, layout, _registry) = setup();
write(
&dir.path().join(".insomnia/memory/workflow/hidden.md"),
"---\ndescription: hidden\nuser_invocable: false\n---\nbody\n",
);
let registry = memory::load_workflows(&layout).unwrap();
let err = resolve_workflow_invocation(&registry, &layout, "hidden").unwrap_err();
assert!(matches!(err, WorkflowResolveError::NotUserInvocable { .. }));
}
#[test]
fn missing_required_knowledge_errors() {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
write(
&dir.path().join(".insomnia/memory/workflow/bad.md"),
"---\ndescription: bad\nrequires: [ghost]\n---\nbody\n",
);
let registry = memory::load_workflows(&layout).unwrap();
let err = resolve_workflow_invocation(&registry, &layout, "bad").unwrap_err();
assert!(matches!(
err,
WorkflowResolveError::KnowledgeNotFound { .. }
));
}
}

View File

@ -412,6 +412,7 @@ pub enum ErrorCode {
NotPaused,
ProviderError,
ToolError,
InvalidRequest,
Internal,
}

View File

@ -43,3 +43,12 @@ The following knowledge records are advertised resident. Use the KnowledgeQuery
{{ 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 }}\
"""

View File

@ -1,76 +0,0 @@
# Workflow 実装
## 背景
`docs/plan/workflow.md` で決まった「制約付きの強制的な作業フロー」を `/<slug>` で呼び出せるようにする。Knowledge (`#<slug>`) を依存として inject できる経路を持つことで、procedural な能力を再利用可能な単位に固定する。
memory 機構(`docs/plan/memory.md`)からは独立してスタートできる: Workflow は人間が書く / consolidation の offer 経由でしか作られず、自動書き込み禁止のため Phase 2 の前提に依存しない。Knowledge resolver は `requires` の inject 経路として相互依存する。
agent-skills (agentskills.io 形式) は本チケットの ingest 経路を再利用して Workflow として読み込む側になる(`tickets/agent-skills.md` 参照)。
## 決定事項の参照
詳細は `docs/plan/workflow.md` 参照。要点のみ:
- 呼び出し: `/<slug>`、フラットな名前空間、kebab-case
- 配置: `<workspace_root>/.insomnia/memory/workflow/<slug>.md`(ファイル名 = slug、frontmatter に `name` を持たない)
- frontmatter: `description` / `model_invokation` (default OFF) / `user_invocable` (default ON) / `requires: [knowledge-slug, ...]`
- 実行: `requires` の Knowledge 本文を context に inject してから Workflow 本文を実行
- 自動書き込み禁止consolidation の write tool schema に `workflow` カテゴリを含めないことで構造的に担保。Linter で人間にも見える形で再保証)
## 方針
### MVP スコープ
1. **Workflow loader / 検証**
- `<workspace_root>/.insomnia/memory/workflow/*.md` を走査
- frontmatter を仕様通り検証。必須欠落・型不一致・slug とファイル名の不一致は hard errorPod 起動失敗)
- 未知フィールドは `tracing::warn!` して無視(既存 manifest と同方針)
- 重複 slug は最初に見つかったものを採用 + warn後述の skill ingest が乗ると衝突解決ルールが追加で必要になる)
2. **`/<slug>` 呼び出し経路**
- `Segment::WorkflowInvoke { slug }` を Pod 側で resolve
- 解決失敗slug 未登録 / `user_invocable: false`)は `ToolError` 相当でユーザーに返し、Worker には届かない
- `requires` の Knowledge 本文を Knowledge 検索ツールの slug 完全一致経路で取得し、Workflow 本文の前に context へ inject
- Workflow 本文は Markdown のままサブミット内容として扱うDSL 化はしない)
3. **`model_invokation` 注入**
- `model_invokation: true` な Workflow の `description` を通常 Pod の system prompt に常駐注入する。Phase 2 prompt には入れない
- 予算は Knowledge の常駐注入(`memory.md` §retrieval 経路と合算管理。description 上限は agentskills 準拠の 1024 chars に揃える
4. **Linter ルール**
- `memory/workflow/*.md` への write/edit は memory 専用 Tool 経由でのみ許可(汎用 Write/Edit は Scope deny
- consolidation の write tool schema からは `workflow` カテゴリを除外(自動書き込み禁止の構造的担保)
- Workflow 自体の Linter は frontmatter 検証 + slug/ファイル名一致のみ。意味検証は将来検討
### 範囲外
- DSL 化や step 粒度の制約Markdown 本文をそのまま実行)
- 中断・再開・トランザクション管理
- 品質検証フローexternal author empirical-prompt-tuning 相当、`docs/plan/workflow.md` §将来検討)
- LLM による Workflow 自律生成offer までで留める方針は本チケットでは扱わず、consolidation 側の責務)
- Knowledge 検索ツール本体の実装memory チケット側)。本チケットは slug 完全一致経路の利用者に留まる
## 完了条件
- `<workspace_root>/.insomnia/memory/workflow/*.md` をロードし、frontmatter 違反は Pod 起動エラーになる
- `/<slug>` を含む submit が `Segment::WorkflowInvoke` として送られ、Pod 側で `requires` Knowledge を inject した上で本文が実行される
- `model_invokation: true` の Workflow description が通常 Pod の system prompt に列挙される
- `user_invocable: false` の Workflow は `/<slug>` 補完候補から除外され、明示呼び出しもエラーになる
- 単体テストで frontmatter 検証の正常 / 異常系、`requires` 解決、フラグ別の挙動が verify される
## 実装順序
1. `manifest` または既存 memory クレートに `Workflow` 構造体と `WorkflowDirectoryLoader` を置く。frontmatter パースと検証のみでテスト完結
2. Pod に Workflow registry を持たせ、`model_invokation` description の system prompt 注入を組む
3. `Segment::WorkflowInvoke` の resolver を Pod 側に実装。Knowledge 検索ツールの slug 完全一致経路で `requires` を inject
4. 汎用 Write/Edit に対する `memory/workflow/` deny を Scope に追加、Linter 仕上げ
各ステップ終了時点でビルド通過・既存テスト合格を維持する。
## 参照
- 設計: `docs/plan/workflow.md`
- Knowledge / `#<slug>` の retrieval: `docs/plan/memory.md` §retrieval 経路
- Submit segment: `tickets/submit-tui-completion.md``Atom::WorkflowInvoke`)、`tickets/session-log-segments.md`
- 後続: `tickets/agent-skills.md`(外部 SKILL を Workflow として ingest する経路)