Skip to content
This repository was archived by the owner on Apr 11, 2026. It is now read-only.

Commit 4f4bb38

Browse files
authored
Merge pull request #17 from z23cc/fn-93-add-engineering-discipline-skills
feat: add engineering discipline skills inspired by agent-skills
2 parents 3201c97 + 273bc6b commit 4f4bb38

24 files changed

Lines changed: 2250 additions & 4 deletions

File tree

docs/adr-guide.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# ADR Integration Guide
2+
3+
Architecture Decision Records capture the *why* behind significant technical decisions. This guide covers when to write them, where to store them, and how they integrate with flow-code.
4+
5+
## When to Create an ADR
6+
7+
Write an ADR when making a decision that would be expensive to reverse:
8+
9+
- **Technology choices** — frameworks, libraries, major dependencies
10+
- **Architectural patterns** — data model design, API style, auth strategy
11+
- **Infrastructure decisions** — hosting, build tools, deployment approach
12+
- **Pattern changes** — moving from one approach to another (e.g., REST to GraphQL)
13+
14+
Do NOT write ADRs for routine implementation choices, obvious decisions, or throwaway prototypes.
15+
16+
## Where to Store ADRs
17+
18+
Use `docs/decisions/` with sequential numbering:
19+
20+
```
21+
docs/decisions/
22+
ADR-001-use-libsql-for-storage.md
23+
ADR-002-wave-checkpoint-execution-model.md
24+
ADR-003-teams-file-locking-protocol.md
25+
```
26+
27+
Use the template at `references/adr-template.md` as your starting point.
28+
29+
## Referencing ADRs in Task Specs
30+
31+
When a task implements or depends on an architectural decision, reference the ADR in the task spec:
32+
33+
```bash
34+
flowctl task create --title "Implement file locking" \
35+
--spec "Implements ADR-003. See docs/decisions/ADR-003-teams-file-locking-protocol.md"
36+
```
37+
38+
In inline code, link to the ADR near the relevant implementation:
39+
40+
```
41+
// Auth strategy per ADR-002. See docs/decisions/ADR-002-auth-strategy.md
42+
```
43+
44+
## Integration with /flow-code:plan
45+
46+
During planning, ADRs surface naturally at two points:
47+
48+
1. **Plan creation** — When `/flow-code:plan` encounters an architectural decision, create the ADR as a task in the epic. The ADR task should complete before implementation tasks that depend on it.
49+
50+
2. **Plan review**`/flow-code:plan-review` should verify that significant architectural decisions have corresponding ADRs. Missing ADRs are a review finding.
51+
52+
### Example: ADR as a Plan Task
53+
54+
```
55+
Epic: fn-50-migrate-to-graphql
56+
Task 1: Write ADR-005 documenting REST-to-GraphQL migration rationale
57+
Task 2: Implement GraphQL schema (depends on Task 1)
58+
Task 3: Migrate endpoints (depends on Task 2)
59+
```
60+
61+
## ADR Lifecycle
62+
63+
```
64+
PROPOSED -> ACCEPTED -> SUPERSEDED by ADR-XXX
65+
-> DEPRECATED
66+
```
67+
68+
Never delete old ADRs. When a decision changes, write a new ADR that supersedes the old one. The historical record is the whole point.

flowctl/crates/flowctl-cli/src/commands/db_shim.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ impl Connection {
3030
fn inner(&self) -> libsql::Connection {
3131
self.conn.clone()
3232
}
33+
34+
/// Public accessor for modules that need the raw libsql connection
35+
/// (e.g. skill commands that call async repos directly).
36+
pub fn inner_conn(&self) -> libsql::Connection {
37+
self.conn.clone()
38+
}
3339
}
3440

3541
fn block_on<F: std::future::Future>(fut: F) -> F::Output {

flowctl/crates/flowctl-cli/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,5 @@ pub mod rp;
1818
pub mod stack;
1919
pub mod stats;
2020
pub mod task;
21+
pub mod skill;
2122
pub mod workflow;
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
//! Skill commands: register, match.
2+
//!
3+
//! `skill register` scans `skills/*/SKILL.md` files, extracts YAML
4+
//! frontmatter (name + description), and upserts each into the DB with
5+
//! a BGE-small embedding for semantic matching.
6+
//!
7+
//! `skill match` performs semantic vector search against registered
8+
//! skills and returns ranked results.
9+
10+
use clap::Subcommand;
11+
use serde::Deserialize;
12+
use serde_json::json;
13+
14+
use crate::output::{error_exit, json_output, pretty_output};
15+
16+
use super::db_shim;
17+
18+
// ── CLI definition ─────────────────────────────────────────────────
19+
20+
#[derive(Subcommand, Debug)]
21+
pub enum SkillCmd {
22+
/// Scan skills/*/SKILL.md and register into DB with embeddings.
23+
Register {
24+
/// Directory to scan (default: DROID_PLUGIN_ROOT or CLAUDE_PLUGIN_ROOT).
25+
#[arg(long)]
26+
dir: Option<String>,
27+
},
28+
/// Semantic search against registered skills.
29+
Match {
30+
/// Search query text.
31+
query: String,
32+
/// Maximum results to return.
33+
#[arg(long, default_value = "5")]
34+
limit: usize,
35+
/// Minimum cosine similarity threshold.
36+
#[arg(long, default_value = "0.70")]
37+
threshold: f64,
38+
},
39+
}
40+
41+
// ── Frontmatter struct ─────────────────────────────────────────────
42+
43+
#[derive(Deserialize)]
44+
struct SkillFrontmatter {
45+
name: String,
46+
description: String,
47+
}
48+
49+
// ── Dispatch ───────────────────────────────────────────────────────
50+
51+
pub fn dispatch(cmd: &SkillCmd, json: bool) {
52+
match cmd {
53+
SkillCmd::Register { dir } => cmd_skill_register(json, dir.as_deref()),
54+
SkillCmd::Match {
55+
query,
56+
limit,
57+
threshold,
58+
} => cmd_skill_match(json, query, *limit, *threshold),
59+
}
60+
}
61+
62+
// ── Register ───────────────────────────────────────────────────────
63+
64+
fn cmd_skill_register(json: bool, dir: Option<&str>) {
65+
// Resolve plugin root directory.
66+
let root = match dir {
67+
Some(d) => std::path::PathBuf::from(d),
68+
None => {
69+
if let Ok(d) = std::env::var("DROID_PLUGIN_ROOT") {
70+
std::path::PathBuf::from(d)
71+
} else if let Ok(d) = std::env::var("CLAUDE_PLUGIN_ROOT") {
72+
std::path::PathBuf::from(d)
73+
} else {
74+
error_exit("No --dir given and DROID_PLUGIN_ROOT / CLAUDE_PLUGIN_ROOT not set");
75+
}
76+
}
77+
};
78+
79+
let skills_dir = root.join("skills");
80+
if !skills_dir.is_dir() {
81+
error_exit(&format!("Skills directory not found: {}", skills_dir.display()));
82+
}
83+
84+
// Walk skills/*/SKILL.md
85+
let mut entries: Vec<(String, String, String)> = Vec::new(); // (name, description, path)
86+
let read_dir = std::fs::read_dir(&skills_dir).unwrap_or_else(|e| {
87+
error_exit(&format!("Cannot read {}: {e}", skills_dir.display()));
88+
});
89+
90+
for entry in read_dir.flatten() {
91+
if !entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
92+
continue;
93+
}
94+
let skill_md = entry.path().join("SKILL.md");
95+
if !skill_md.is_file() {
96+
continue;
97+
}
98+
let content = match std::fs::read_to_string(&skill_md) {
99+
Ok(c) => c,
100+
Err(e) => {
101+
eprintln!("warn: cannot read {}: {e}", skill_md.display());
102+
continue;
103+
}
104+
};
105+
let fm: SkillFrontmatter =
106+
match flowctl_core::frontmatter::parse_frontmatter(&content) {
107+
Ok(f) => f,
108+
Err(e) => {
109+
eprintln!(
110+
"warn: cannot parse frontmatter in {}: {e}",
111+
skill_md.display()
112+
);
113+
continue;
114+
}
115+
};
116+
entries.push((
117+
fm.name,
118+
fm.description,
119+
skill_md.to_string_lossy().to_string(),
120+
));
121+
}
122+
123+
// Upsert each skill into DB.
124+
let conn = db_shim::require_db().unwrap_or_else(|e| {
125+
error_exit(&format!("Cannot open DB: {e}"));
126+
});
127+
128+
let rt = tokio::runtime::Builder::new_current_thread()
129+
.enable_all()
130+
.build()
131+
.expect("failed to create tokio runtime");
132+
133+
let repo = flowctl_db::skill::SkillRepo::new(conn.inner_conn());
134+
135+
for (name, desc, path) in &entries {
136+
rt.block_on(async {
137+
repo.upsert(name, desc, Some(path.as_str()))
138+
.await
139+
.unwrap_or_else(|e| {
140+
eprintln!("warn: failed to upsert skill '{}': {e}", name);
141+
});
142+
});
143+
}
144+
145+
let skills_json: Vec<serde_json::Value> = entries
146+
.iter()
147+
.map(|(n, d, _)| json!({"name": n, "description": d}))
148+
.collect();
149+
150+
if json {
151+
json_output(json!({
152+
"registered": entries.len(),
153+
"skills": skills_json,
154+
}));
155+
} else {
156+
pretty_output("skill_register", &format!("Registered {} skills", entries.len()));
157+
for (name, desc, _) in &entries {
158+
pretty_output("skill_register", &format!(" {} — {}", name, desc));
159+
}
160+
}
161+
}
162+
163+
// ── Match ──────────────────────────────────────────────────────────
164+
165+
fn cmd_skill_match(json: bool, query: &str, limit: usize, threshold: f64) {
166+
let conn = db_shim::require_db().unwrap_or_else(|e| {
167+
error_exit(&format!("Cannot open DB: {e}"));
168+
});
169+
170+
let rt = tokio::runtime::Builder::new_current_thread()
171+
.enable_all()
172+
.build()
173+
.expect("failed to create tokio runtime");
174+
175+
let repo = flowctl_db::skill::SkillRepo::new(conn.inner_conn());
176+
let matches = rt.block_on(async {
177+
repo.match_skills(query, limit, threshold)
178+
.await
179+
.unwrap_or_else(|e| {
180+
error_exit(&format!("match_skills failed: {e}"));
181+
})
182+
});
183+
184+
if json {
185+
let out: Vec<serde_json::Value> = matches
186+
.iter()
187+
.map(|m| {
188+
json!({
189+
"name": m.name,
190+
"description": m.description,
191+
"score": (m.score * 100.0).round() / 100.0,
192+
})
193+
})
194+
.collect();
195+
json_output(json!(out));
196+
} else {
197+
if matches.is_empty() {
198+
pretty_output("skill_match", "No matching skills found.");
199+
return;
200+
}
201+
pretty_output(
202+
"skill_match",
203+
&format!(" {:<6} {:<28} {}", "Score", "Name", "Description"),
204+
);
205+
for m in &matches {
206+
pretty_output(
207+
"skill_match",
208+
&format!(" {:<6.2} {:<28} {}", m.score, m.name, m.description),
209+
);
210+
}
211+
}
212+
}

flowctl/crates/flowctl-cli/src/main.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ use commands::{
2323
query,
2424
ralph::RalphCmd,
2525
rp::RpCmd,
26+
skill::SkillCmd,
2627
stack::{InvariantsCmd, StackCmd},
2728
stats::StatsCmd,
2829
task::TaskCmd,
@@ -216,6 +217,11 @@ enum Commands {
216217
#[command(subcommand)]
217218
cmd: RalphCmd,
218219
},
220+
/// Skill registry commands (register, match).
221+
Skill {
222+
#[command(subcommand)]
223+
cmd: SkillCmd,
224+
},
219225
/// RepoPrompt helpers.
220226
Rp {
221227
#[command(subcommand)]
@@ -484,6 +490,7 @@ fn main() {
484490
Commands::Stack { cmd } => commands::stack::dispatch(&cmd, json),
485491
Commands::Invariants { cmd } => commands::stack::dispatch_invariants(&cmd, json),
486492
Commands::Ralph { cmd } => commands::ralph::dispatch(&cmd, json),
493+
Commands::Skill { cmd } => commands::skill::dispatch(&cmd, json),
487494
Commands::Rp { cmd } => commands::rp::dispatch(&cmd, json),
488495
Commands::Codex { cmd } => commands::codex::dispatch(&cmd, json),
489496
Commands::Hook { cmd } => commands::hook::dispatch(&cmd),

flowctl/crates/flowctl-db/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,14 @@ pub mod memory;
2424
pub mod metrics;
2525
pub mod pool;
2626
pub mod repo;
27+
pub mod skill;
2728

2829
pub use error::DbError;
2930
pub use indexer::{reindex, ReindexResult};
3031
pub use events::{EventLog, TaskTokenSummary, TokenRecord, TokenUsageRow};
3132
pub use memory::{MemoryEntry, MemoryFilter, MemoryRepo};
3233
pub use metrics::StatsQuery;
34+
pub use skill::{SkillEntry, SkillMatch, SkillRepo};
3335
pub use pool::{cleanup, open_async, open_memory_async, resolve_db_path, resolve_libsql_path, resolve_state_dir};
3436
pub use repo::{
3537
DepRepo, EpicRepo, EventRepo, EventRow, EvidenceRepo, FileLockRepo, FileOwnershipRepo,

flowctl/crates/flowctl-db/src/memory.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ static EMBEDDER: OnceCell<Result<Mutex<TextEmbedding>, String>> = OnceCell::cons
8888
/// model (~130MB) via fastembed; subsequent calls return the cached
8989
/// instance. Initialization runs on a blocking thread because fastembed
9090
/// performs synchronous file I/O.
91-
async fn ensure_embedder() -> Result<(), DbError> {
91+
pub(crate) async fn ensure_embedder() -> Result<(), DbError> {
9292
let res = EMBEDDER
9393
.get_or_init(|| async {
9494
match tokio::task::spawn_blocking(|| {
@@ -109,7 +109,7 @@ async fn ensure_embedder() -> Result<(), DbError> {
109109
}
110110

111111
/// Embed a single passage into a 384-dim vector.
112-
async fn embed_one(text: &str) -> Result<Vec<f32>, DbError> {
112+
pub(crate) async fn embed_one(text: &str) -> Result<Vec<f32>, DbError> {
113113
ensure_embedder().await?;
114114
let text = text.to_string();
115115
let result = tokio::task::spawn_blocking(move || {
@@ -132,7 +132,7 @@ async fn embed_one(text: &str) -> Result<Vec<f32>, DbError> {
132132
}
133133

134134
/// Convert a `Vec<f32>` into a libSQL `vector32()` literal string.
135-
fn vec_to_literal(v: &[f32]) -> String {
135+
pub(crate) fn vec_to_literal(v: &[f32]) -> String {
136136
let parts: Vec<String> = v.iter().map(std::string::ToString::to_string).collect();
137137
format!("[{}]", parts.join(","))
138138
}

0 commit comments

Comments
 (0)