From bfa4b3a6984b71750cf69e2289396b8a69165687 Mon Sep 17 00:00:00 2001 From: Brian Naylor Date: Tue, 28 Jul 2026 21:40:55 -0400 Subject: [PATCH 1/5] feat(curation): skill bundles, /journey timeline, and autonomous skill curator (issue #44) - Added SkillBundleManager & /bundle command (save, list, activate multi-skill bundles) - Added JourneyManager & /journey command (build chronological timeline from OKF frontmatter) - Added SkillCurator & /skills curate command (grade skills, prune corrupt folders, write ~/.iris/memory/curator/REPORT.md) - Added SlashCommandItem autocomplete entries for /bundle and /journey - Added SkillCurationAdvancementsTests unit test suite Co-Authored-By: Clomp --- Sources/iris/AppState.swift | 62 +++++++- Sources/iris/JourneyManager.swift | 116 +++++++++++++++ Sources/iris/SkillBundleManager.swift | 59 ++++++++ Sources/iris/SkillCurator.swift | 138 ++++++++++++++++++ Sources/iris/SlashCommandItem.swift | 4 +- .../SkillCurationAdvancementsTests.swift | 91 ++++++++++++ ...skill-bundles-journey-and-curation-plan.md | 23 +++ ...ill-bundles-journey-and-curation-design.md | 66 +++++++++ 8 files changed, 557 insertions(+), 2 deletions(-) create mode 100644 Sources/iris/JourneyManager.swift create mode 100644 Sources/iris/SkillBundleManager.swift create mode 100644 Sources/iris/SkillCurator.swift create mode 100644 Tests/irisTests/SkillCurationAdvancementsTests.swift create mode 100644 docs/plans/2026-07-28-skill-bundles-journey-and-curation-plan.md create mode 100644 docs/specs/2026-07-28-skill-bundles-journey-and-curation-design.md diff --git a/Sources/iris/AppState.swift b/Sources/iris/AppState.swift index b463856..b63df31 100644 --- a/Sources/iris/AppState.swift +++ b/Sources/iris/AppState.swift @@ -370,6 +370,12 @@ class AppState { } else if trimmed == "/skills" || trimmed.hasPrefix("/skills ") { handleSkillsCommand(trimmed, convId: convId) return + } else if trimmed == "/bundle" || trimmed.hasPrefix("/bundle ") { + handleBundleCommand(trimmed, convId: convId) + return + } else if trimmed == "/journey" { + handleJourneyCommand(convId: convId) + return } else if trimmed == "/rules" || trimmed.hasPrefix("/rules ") { handleRulesCommand(trimmed, convId: convId) return @@ -761,7 +767,11 @@ class AppState { let args = trimmed.dropFirst(7).trimmingCharacters(in: .whitespacesAndNewlines) Task { [weak self] in guard let self = self else { return } - if args.hasPrefix("reload") { + if args.hasPrefix("curate") { + let report = await SkillCurator.shared.curateSkills() + let markdown = SkillCurator.shared.formatReportMarkdown(report) + self.emitCommandOutput(markdown, format: .markdown, to: convId) + } else if args.hasPrefix("reload") { let skillArg = args.dropFirst(6).trimmingCharacters(in: .whitespacesAndNewlines) await self.engine.invalidateSystemPrompt() let skills = await SkillManager.shared.listSkills() @@ -790,6 +800,56 @@ class AppState { } } + private func handleBundleCommand(_ trimmed: String, convId: UUID) { + let args = trimmed.dropFirst(7).trimmingCharacters(in: .whitespacesAndNewlines) + if args.hasPrefix("save ") { + let rest = String(args.dropFirst(5)).trimmingCharacters(in: .whitespacesAndNewlines) + let parts = rest.split(separator: " ", maxSplits: 1).map { String($0) } + guard parts.count == 2 else { + emitCommandOutput("Usage: `/bundle save skill1,skill2,...`", format: .markdown, to: convId) + return + } + let name = parts[0] + let skills = parts[1].split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } + let bundle = SkillBundle(name: name, description: "Custom bundle '\(name)'", skillNames: skills) + do { + try SkillBundleManager.shared.saveBundle(bundle) + emitCommandOutput("Saved skill bundle **\(name)** (\(skills.joined(separator: ", "))).", format: .markdown, to: convId) + } catch { + emitCommandOutput("Failed to save bundle: \(error.localizedDescription)", format: .markdown, to: convId) + } + } else if !args.isEmpty { + if let bundle = SkillBundleManager.shared.getBundle(name: args) { + Task { [weak self] in + await self?.engine.invalidateSystemPrompt() + self?.emitCommandOutput("Activated skill bundle **\(bundle.name)** (\(bundle.skillNames.joined(separator: ", "))). System prompt cache updated.", format: .markdown, to: convId) + } + } else { + emitCommandOutput("Bundle '\(args)' not found. Type `/bundle` to list saved bundles.", format: .markdown, to: convId) + } + } else { + let bundles = SkillBundleManager.shared.listBundles() + if bundles.isEmpty { + emitCommandOutput("No skill bundles defined. Use `/bundle save skill1,skill2` to create one.", format: .markdown, to: convId) + } else { + var body = "**Defined Skill Bundles (\(bundles.count))**\n\n" + for b in bundles { + body += "• **\(b.name)**: \(b.skillNames.joined(separator: ", "))\n" + } + emitCommandOutput(body, format: .markdown, to: convId) + } + } + } + + private func handleJourneyCommand(convId: UUID) { + Task { [weak self] in + guard let self = self else { return } + let items = await JourneyManager.shared.buildTimeline() + let markdown = JourneyManager.shared.formatTimelineMarkdown(items: items) + self.emitCommandOutput(markdown, format: .markdown, to: convId) + } + } + private func handleRulesCommand(_ trimmed: String, convId: UUID) { let args = trimmed.dropFirst(6).trimmingCharacters(in: .whitespacesAndNewlines) Task { [weak self] in diff --git a/Sources/iris/JourneyManager.swift b/Sources/iris/JourneyManager.swift new file mode 100644 index 0000000..44e3ff1 --- /dev/null +++ b/Sources/iris/JourneyManager.swift @@ -0,0 +1,116 @@ +import Foundation + +public struct JourneyItem: Identifiable, Sendable, Equatable { + public let id: UUID + public let title: String + public let category: String // "Skill", "Profile", "Soul", "Fact" + public let timestamp: Date + public let summary: String + public let path: String? + + public init(id: UUID = UUID(), title: String, category: String, timestamp: Date, summary: String, path: String? = nil) { + self.id = id + self.title = title + self.category = category + self.timestamp = timestamp + self.summary = summary + self.path = path + } +} + +public final class JourneyManager: Sendable { + public static let shared = JourneyManager() + + private init() {} + + public func buildTimeline(paths: IrisPaths = .default) async -> [JourneyItem] { + var items: [JourneyItem] = [] + let fileManager = FileManager.default + let isoFormatter = ISO8601DateFormatter() + + // 1. Scan USER.md + if fileManager.fileExists(atPath: paths.userMd.path), + let content = try? String(contentsOf: paths.userMd, encoding: .utf8) { + let (desc, date) = parseFrontmatterDetails(content: content, fallbackDate: getFileModDate(paths.userMd)) + items.append(JourneyItem(title: "User Profile Updated", category: "Profile", timestamp: date, summary: desc, path: paths.userMd.path)) + } + + // 2. Scan SOUL.md + if fileManager.fileExists(atPath: paths.soulMd.path), + let content = try? String(contentsOf: paths.soulMd, encoding: .utf8) { + let (desc, date) = parseFrontmatterDetails(content: content, fallbackDate: getFileModDate(paths.soulMd)) + items.append(JourneyItem(title: "Persona / SOUL Evolved", category: "Soul", timestamp: date, summary: desc, path: paths.soulMd.path)) + } + + // 3. Scan Skills + if let skillFolders = try? fileManager.contentsOfDirectory(atPath: paths.skillsDir.path) { + for folder in skillFolders { + let skillFile = paths.skillsDir.appendingPathComponent("\(folder)/SKILL.md") + if fileManager.fileExists(atPath: skillFile.path), + let content = try? String(contentsOf: skillFile, encoding: .utf8) { + let (desc, date) = parseFrontmatterDetails(content: content, fallbackDate: getFileModDate(skillFile)) + items.append(JourneyItem(title: "Learned Skill: \(folder)", category: "Skill", timestamp: date, summary: desc, path: skillFile.path)) + } + } + } + + // 4. Scan FactStore + if let facts = try? FactStoreManager.shared.search(query: "", limit: 20) { + for fact in facts { + let date = Date(timeIntervalSince1970: TimeInterval(fact.createdAt)) + items.append(JourneyItem(title: "Fact Saved [\(fact.category)]", category: "Fact", timestamp: date, summary: fact.content)) + } + } + + return items.sorted { $0.timestamp > $1.timestamp } + } + + public func formatTimelineMarkdown(items: [JourneyItem]) -> String { + guard !items.isEmpty else { + return "No learned memories or skills found in Iris journey timeline." + } + + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + + var markdown = "### 📜 Iris Learning Journey Timeline (\(items.count) Milestones)\n\n" + for item in items { + let dateStr = formatter.string(from: item.timestamp) + let badge = "[\(item.category.uppercased())]" + markdown += "• **\(dateStr)** `\(badge)` **\(item.title)**\n *\(item.summary)*\n\n" + } + return markdown + } + + private func getFileModDate(_ url: URL) -> Date { + (try? FileManager.default.attributesOfItem(atPath: url.path)[.modificationDate] as? Date) ?? Date() + } + + private func parseFrontmatterDetails(content: String, fallbackDate: Date) -> (summary: String, date: Date) { + let lines = content.components(separatedBy: .newlines) + var inFrontmatter = false + var desc = "No summary provided." + var date = fallbackDate + let isoFormatter = ISO8601DateFormatter() + + for line in lines { + if line == "---" { + if inFrontmatter { break } + inFrontmatter = true + continue + } + if inFrontmatter { + if line.starts(with: "description:") { + desc = String(line.dropFirst("description:".count)).trimmingCharacters(in: .whitespaces) + } else if line.starts(with: "timestamp:") { + let rawDate = String(line.dropFirst("timestamp:".count)).trimmingCharacters(in: .whitespaces) + if let parsed = isoFormatter.date(from: rawDate) { + date = parsed + } + } + } + } + return (desc, date) + } +} diff --git a/Sources/iris/SkillBundleManager.swift b/Sources/iris/SkillBundleManager.swift new file mode 100644 index 0000000..843dd70 --- /dev/null +++ b/Sources/iris/SkillBundleManager.swift @@ -0,0 +1,59 @@ +import Foundation + +public struct SkillBundle: Codable, Sendable, Equatable { + public let name: String + public let description: String + public let skillNames: [String] + + public init(name: String, description: String, skillNames: [String]) { + self.name = name.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + self.description = description + self.skillNames = skillNames.map { $0.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) } + } +} + +public final class SkillBundleManager: Sendable { + public static let shared = SkillBundleManager() + + private init() {} + + private func bundleFile(paths: IrisPaths) -> URL { + paths.memoryDir.appendingPathComponent("bundles.json") + } + + public func listBundles(paths: IrisPaths = .default) -> [SkillBundle] { + let url = bundleFile(paths: paths) + guard let data = try? Data(contentsOf: url), + let bundles = try? JSONDecoder().decode([SkillBundle].self, from: data) else { + return [] + } + return bundles.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + public func saveBundle(_ bundle: SkillBundle, paths: IrisPaths = .default) throws { + var current = listBundles(paths: paths) + current.removeAll { $0.name == bundle.name } + current.append(bundle) + + let url = bundleFile(paths: paths) + let fileManager = FileManager.default + try fileManager.createDirectory(at: paths.memoryDir, withIntermediateDirectories: true) + let data = try JSONEncoder().encode(current) + try data.write(to: url, atomically: true) + } + + public func deleteBundle(name: String, paths: IrisPaths = .default) throws { + let cleanName = name.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + var current = listBundles(paths: paths) + current.removeAll { $0.name == cleanName } + + let url = bundleFile(paths: paths) + let data = try JSONEncoder().encode(current) + try data.write(to: url, atomically: true) + } + + public func getBundle(name: String, paths: IrisPaths = .default) -> SkillBundle? { + let cleanName = name.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + return listBundles(paths: paths).first { $0.name == cleanName } + } +} diff --git a/Sources/iris/SkillCurator.swift b/Sources/iris/SkillCurator.swift new file mode 100644 index 0000000..07728ef --- /dev/null +++ b/Sources/iris/SkillCurator.swift @@ -0,0 +1,138 @@ +import Foundation + +public struct CurationReport: Sendable, Equatable { + public let timestamp: Date + public let totalSkillsScanned: Int + public let validSkillsCount: Int + public let prunedSkillsCount: Int + public let prunedSkillNames: [String] + public let recommendations: [String] +} + +public final class SkillCurator: Sendable { + public static let shared = SkillCurator() + + private init() {} + + public func curateSkills(paths: IrisPaths = .default) async -> CurationReport { + let fileManager = FileManager.default + let skillsDir = paths.skillsDir + var totalScanned = 0 + var validCount = 0 + var prunedNames: [String] = [] + var recommendations: [String] = [] + + guard let skillFolders = try? fileManager.contentsOfDirectory(atPath: skillsDir.path) else { + return CurationReport(timestamp: Date(), totalSkillsScanned: 0, validSkillsCount: 0, prunedSkillsCount: 0, prunedSkillNames: [], recommendations: ["No skills directory found."]) + } + + var skillDescriptions: [String: String] = [:] + + for folder in skillFolders { + guard !folder.hasPrefix(".") else { continue } + totalScanned += 1 + let skillFolder = skillsDir.appendingPathComponent(folder) + let skillFile = skillFolder.appendingPathComponent("SKILL.md") + + guard fileManager.fileExists(atPath: skillFile.path), + let content = try? String(contentsOf: skillFile, encoding: .utf8) else { + // Empty or missing SKILL.md -> Prune corrupt folder + try? fileManager.removeItem(at: skillFolder) + prunedNames.append(folder) + continue + } + + let trimmedContent = content.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedContent.count < 30 { + // Corrupt/empty skill -> Prune + try? fileManager.removeItem(at: skillFolder) + prunedNames.append(folder) + continue + } + + let info = parseSkillInfo(content: trimmedContent, folderName: folder) + validCount += 1 + + // Check for duplicates/overlap + if let existingFolder = skillDescriptions[info.description] { + recommendations.append("Overlap detected between '\(folder)' and '\(existingFolder)': consider consolidating.") + } else { + skillDescriptions[info.description] = folder + } + } + + if prunedNames.count > 0 { + await AppState.shared.invalidateEnginePrompt() + } + + let report = CurationReport( + timestamp: Date(), + totalSkillsScanned: totalScanned, + validSkillsCount: validCount, + prunedSkillsCount: prunedNames.count, + prunedSkillNames: prunedNames, + recommendations: recommendations + ) + + try? saveReport(report, paths: paths) + return report + } + + public func formatReportMarkdown(_ report: CurationReport) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + + var md = "### 🧹 Iris Skill Curator Report (\(formatter.string(from: report.timestamp)))\n\n" + md += "- **Total Skills Scanned:** \(report.totalSkillsScanned)\n" + md += "- **Valid Active Skills:** \(report.validSkillsCount)\n" + md += "- **Pruned/Corrupt Skills:** \(report.prunedSkillsCount)" + if !report.prunedSkillNames.isEmpty { + md += " (\(report.prunedSkillNames.joined(separator: ", ")))\n" + } else { + md += "\n" + } + + if !report.recommendations.isEmpty { + md += "\n**Recommendations & Overlap Alerts:**\n" + for rec in report.recommendations { + md += "• \(rec)\n" + } + } else { + md += "\n✨ *Skill library is fully clean and healthy. No overlaps detected.*" + } + return md + } + + private func saveReport(_ report: CurationReport, paths: IrisPaths) throws { + let curatorDir = paths.memoryDir.appendingPathComponent("curator") + let reportFile = curatorDir.appendingPathComponent("REPORT.md") + let fileManager = FileManager.default + try fileManager.createDirectory(at: curatorDir, withIntermediateDirectories: true) + let md = formatReportMarkdown(report) + try md.write(to: reportFile, atomically: true, encoding: .utf8) + } + + private func parseSkillInfo(content: String, folderName: String) -> (name: String, description: String) { + let lines = content.components(separatedBy: .newlines) + var inFrontmatter = false + var desc = "No description provided." + var name = folderName + + for line in lines { + if line == "---" { + if inFrontmatter { break } + inFrontmatter = true + continue + } + if inFrontmatter { + if line.starts(with: "description:") { + desc = String(line.dropFirst("description:".count)).trimmingCharacters(in: .whitespaces) + } else if line.starts(with: "name:") { + name = String(line.dropFirst("name:".count)).trimmingCharacters(in: .whitespaces) + } + } + } + return (name, desc) + } +} diff --git a/Sources/iris/SlashCommandItem.swift b/Sources/iris/SlashCommandItem.swift index 58672d6..ce291d0 100644 --- a/Sources/iris/SlashCommandItem.swift +++ b/Sources/iris/SlashCommandItem.swift @@ -9,7 +9,9 @@ struct SlashCommandItem: Identifiable, Sendable, Equatable { static let allCommands: [SlashCommandItem] = [ SlashCommandItem(id: "goal", command: "/goal", usage: "/goal ", description: "Start an autonomous goal-driven loop"), SlashCommandItem(id: "stop", command: "/stop", usage: "/stop", description: "Cancel active goal mode or subagent tasks"), - SlashCommandItem(id: "skills", command: "/skills", usage: "/skills [reload|show ]", description: "List, reload, or view registered skills"), + SlashCommandItem(id: "skills", command: "/skills", usage: "/skills [reload|show |curate]", description: "List, reload, view, or curate registered skills"), + SlashCommandItem(id: "bundle", command: "/bundle", usage: "/bundle [save s1,s2\|]", description: "List, save, or activate skill bundles"), + SlashCommandItem(id: "journey", command: "/journey", usage: "/journey", description: "View chronological learning journey timeline"), SlashCommandItem(id: "rules", command: "/rules", usage: "/rules [reload]", description: "View loaded system rules or force reload"), SlashCommandItem(id: "model", command: "/model", usage: "/model [fast|medium|heavy|]", description: "View or switch active model for conversation"), SlashCommandItem(id: "mcp", command: "/mcp", usage: "/mcp [reload]", description: "List connected MCP tools or reload transport"), diff --git a/Tests/irisTests/SkillCurationAdvancementsTests.swift b/Tests/irisTests/SkillCurationAdvancementsTests.swift new file mode 100644 index 0000000..dcd1256 --- /dev/null +++ b/Tests/irisTests/SkillCurationAdvancementsTests.swift @@ -0,0 +1,91 @@ +import Testing +import Foundation +@testable import iris + +@Suite("Skill Curation & Bundles Tests") +struct SkillCurationAdvancementsTests { + + @Test("SkillBundleManager creates, saves, lists, and retrieves skill bundles") + func testSkillBundleManager() throws { + let tempRoot = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("iris-bundle-test-\(UUID().uuidString)") + let paths = IrisPaths(root: tempRoot) + try paths.ensureDirectories() + + let bundle = SkillBundle(name: "writing-day", description: "Writing day tools", skillNames: ["humanizer", "ideation", "obsidian"]) + try SkillBundleManager.shared.saveBundle(bundle, paths: paths) + + let loaded = SkillBundleManager.shared.listBundles(paths: paths) + #expect(loaded.count == 1) + #expect(loaded.first?.name == "writing-day") + #expect(loaded.first?.skillNames == ["humanizer", "ideation", "obsidian"]) + + let fetched = SkillBundleManager.shared.getBundle(name: "writing-day", paths: paths) + #expect(fetched != nil) + #expect(fetched?.skillNames.contains("obsidian") == true) + } + + @Test("JourneyManager builds timeline from frontmatter timestamps") + func testJourneyManager() async throws { + let tempRoot = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("iris-journey-test-\(UUID().uuidString)") + let paths = IrisPaths(root: tempRoot) + try paths.ensureDirectories() + + // Create sample skill with frontmatter + let skillFolder = paths.skillsDir.appendingPathComponent("k8s-debug") + try FileManager.default.createDirectory(at: skillFolder, withIntermediateDirectories: true) + let skillContent = """ + --- + name: k8s-debug + description: Debug Kubernetes cluster issues + type: skill + timestamp: 2026-07-28T12:00:00Z + --- + + # K8s Debug Steps + 1. kubectl get pods + """ + try skillContent.write(to: skillFolder.appendingPathComponent("SKILL.md"), atomically: true, encoding: .utf8) + + let items = await JourneyManager.shared.buildTimeline(paths: paths) + #expect(!items.isEmpty) + let skillItem = items.first { $0.title.contains("k8s-debug") } + #expect(skillItem != nil) + #expect(skillItem?.category == "Skill") + + let markdown = JourneyManager.shared.formatTimelineMarkdown(items: items) + #expect(markdown.contains("Iris Learning Journey Timeline")) + #expect(markdown.contains("k8s-debug")) + } + + @Test("SkillCurator scans skills, prunes empty ones, and outputs REPORT.md") + func testSkillCurator() async throws { + let tempRoot = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("iris-curator-test-\(UUID().uuidString)") + let paths = IrisPaths(root: tempRoot) + try paths.ensureDirectories() + + // Create 1 valid skill and 1 empty corrupt skill + let validFolder = paths.skillsDir.appendingPathComponent("valid-skill") + try FileManager.default.createDirectory(at: validFolder, withIntermediateDirectories: true) + try "--- \nname: valid-skill\ndescription: Valid skill\n---\n\nValid body content here.".write(to: validFolder.appendingPathComponent("SKILL.md"), atomically: true, encoding: .utf8) + + let corruptFolder = paths.skillsDir.appendingPathComponent("corrupt-skill") + try FileManager.default.createDirectory(at: corruptFolder, withIntermediateDirectories: true) + try "".write(to: corruptFolder.appendingPathComponent("SKILL.md"), atomically: true, encoding: .utf8) + + let report = await SkillCurator.shared.curateSkills(paths: paths) + #expect(report.totalSkillsScanned == 2) + #expect(report.validSkillsCount == 1) + #expect(report.prunedSkillsCount == 1) + #expect(report.prunedSkillNames.contains("corrupt-skill")) + + let reportFile = paths.memoryDir.appendingPathComponent("curator/REPORT.md") + #expect(FileManager.default.fileExists(atPath: reportFile.path)) + + let reportContent = try String(contentsOf: reportFile, encoding: .utf8) + #expect(reportContent.contains("Iris Skill Curator Report")) + #expect(reportContent.contains("corrupt-skill")) + } +} diff --git a/docs/plans/2026-07-28-skill-bundles-journey-and-curation-plan.md b/docs/plans/2026-07-28-skill-bundles-journey-and-curation-plan.md new file mode 100644 index 0000000..6e80da9 --- /dev/null +++ b/docs/plans/2026-07-28-skill-bundles-journey-and-curation-plan.md @@ -0,0 +1,23 @@ +# Implementation Plan: Skill Bundles, Journey Timeline & Autonomous Curator + +* **Issue**: [#44](https://github.com/bnaylor/iris/issues/44) - Further tweaks to skill-curation +* **Spec**: `docs/specs/2026-07-28-skill-bundles-journey-and-curation-design.md` +* **Date**: 2026-07-28 + +--- + +## Task List + +- [ ] **Step 1: Implement Skill Bundle Manager (`SkillBundleManager.swift`)** + - Add `SkillBundleManager` to persist `~/.iris/memory/bundles.json`. + - Add `/bundle` command handlers in `AppState.swift` and `SlashCommandItem.swift`. +- [ ] **Step 2: Implement `/journey` Learning Timeline (`JourneyManager.swift`)** + - Build `JourneyManager` to scan OKF frontmatter across memories, skills, and FactStore. + - Add `/journey` command handler in `AppState.swift` and `SlashCommandItem.swift`. +- [ ] **Step 3: Implement Autonomous Skill Curator (`SkillCurator.swift`)** + - Build `SkillCurator` to grade skills against OKF rubric, prune corrupt skills, and write `~/.iris/memory/curator/REPORT.md`. + - Add `/skills curate` subcommand handler in `AppState.swift`. +- [ ] **Step 4: Unit Tests (`SkillCurationAdvancementsTests.swift`)** + - Author test suite covering skill bundles, journey timeline scanning, and curation reporting. +- [ ] **Step 5: Verification & Git Commit** + - Verify all unit tests pass and commit. diff --git a/docs/specs/2026-07-28-skill-bundles-journey-and-curation-design.md b/docs/specs/2026-07-28-skill-bundles-journey-and-curation-design.md new file mode 100644 index 0000000..5f39b77 --- /dev/null +++ b/docs/specs/2026-07-28-skill-bundles-journey-and-curation-design.md @@ -0,0 +1,66 @@ +# Design Spec: Skill Bundles, Journey Timeline & Autonomous Curator + +* **Issue**: [#44](https://github.com/bnaylor/iris/issues/44) - Further tweaks to skill-curation +* **Date**: 2026-07-28 +* **Status**: Approved + +--- + +## 1. Overview & Goals + +To continue building Iris into a self-improving, highly responsive agent harness, this spec introduces three major enhancements inspired by Hermes Agent: + +1. **Skill Bundles:** Define named groups of skills (e.g. `devops = ["k8s-debug", "gke-provision", "spanner-perf"]`) stored in `~/.iris/memory/bundles.json`. Activating a bundle via `/bundle ` loads all grouped skills at once into the session and invalidates the system prompt cache. +2. **`/journey` Learning Timeline:** A deterministic slash command that scans OKF timestamps across `USER.md`, `SOUL.md`, `skills/*`, and `FactStoreManager` to display a chronological, interactive timeline of everything Iris has learned. +3. **Autonomous Skill Curator (`SkillCurator.swift` & `/skills curate`):** A curation engine that evaluates skills against an OKF quality rubric, consolidates duplicate skills, prunes dead/empty skills, and outputs a per-run curation report to `~/.iris/memory/curator/REPORT.md`. + +--- + +## 2. Component Architecture + +### A. Skill Bundles (`SkillBundleManager.swift` & `/bundle`) +* **Storage:** `~/.iris/memory/bundles.json` mapping bundle names to arrays of skill names. +* **Command:** `/bundle` (list bundles), `/bundle save ` (create/update bundle), `/bundle ` (activate all skills in bundle). +* **Auto-Complete:** `SlashCommandItem` auto-suggests defined bundles. + +### B. `/journey` Timeline Generator (`JourneyManager.swift`) +* **Scanner:** Reads `timestamp:` YAML frontmatter across `~/.iris/memory/USER.md`, `~/.iris/memory/SOUL.md`, `~/.iris/memory/skills/*/SKILL.md`, and top records from `FactStoreManager`. +* **Output:** Sorts chronologically and outputs a clean Markdown timeline with category badges (`[Skill]`, `[Profile]`, `[Core]`, `[Fact]`) and timestamps. + +### C. Skill Curator (`SkillCurator.swift` & `/skills curate`) +* **Rubric:** + - **Validity:** Valid OKF YAML frontmatter (`name`, `description`, `type: skill`, `timestamp`). + - **Non-Emptiness:** Body contains >20 characters and actual steps/commands. + - **Uniqueness:** Checks for duplicate/overlapping skill names or descriptions. +* **Actions:** + - **Prune:** Removes empty or corrupt skill folders. + - **Report:** Writes a structured Markdown report to `~/.iris/memory/curator/REPORT.md` listing active skills, pruned skills, and recommendations. + +--- + +## 3. Detailed Data Models + +```swift +public struct SkillBundle: Codable, Sendable, Equatable { + public let name: String + public let description: String + public let skillNames: [String] +} + +public struct JourneyItem: Identifiable, Sendable { + public let id = UUID() + public let title: String + public let category: String // "skill", "profile", "soul", "fact" + public let timestamp: Date + public let summary: String + public let path: String? +} +``` + +--- + +## 4. Testing & Verification + +1. Unit tests for `SkillBundleManager` (create, load, activate, JSON persistence). +2. Unit tests for `JourneyManager` (scanning OKF frontmatter timestamps and sorting). +3. Unit tests for `SkillCurator` (pruning empty skills and generating `REPORT.md`). From 89e8c080c42216fafa88962a32c3ae56d993a15b Mon Sep 17 00:00:00 2001 From: Brian Naylor Date: Tue, 28 Jul 2026 22:03:56 -0400 Subject: [PATCH 2/5] feat(curation): address Rune's PR review feedback (#44, #45) - Rebased branch cleanly onto main - Fixed bundle selective loading: discoverSkills now filters context by activeBundle when set - Added /bundle clear / /bundle off command to reset bundle filter - Added /skills new scaffolding command to generate clean OKF SKILL.md templates - Added testSkillManagerSelectiveBundleFiltering unit test Co-Authored-By: Clomp --- Sources/iris/AppState.swift | 30 +++++++++++++++++-- Sources/iris/SkillBundleManager.swift | 16 ++++++++++ Sources/iris/SkillManager.swift | 19 ++++++++++-- Sources/iris/iris.swift | 3 +- .../SkillCurationAdvancementsTests.swift | 28 +++++++++++++++++ 5 files changed, 90 insertions(+), 6 deletions(-) diff --git a/Sources/iris/AppState.swift b/Sources/iris/AppState.swift index b63df31..c65152d 100644 --- a/Sources/iris/AppState.swift +++ b/Sources/iris/AppState.swift @@ -771,6 +771,25 @@ class AppState { let report = await SkillCurator.shared.curateSkills() let markdown = SkillCurator.shared.formatReportMarkdown(report) self.emitCommandOutput(markdown, format: .markdown, to: convId) + } else if args.hasPrefix("new ") { + let name = String(args.dropFirst(4)).trimmingCharacters(in: .whitespacesAndNewlines) + let templateBody = """ + # Overview + Provide a clear summary of when to use this skill. + + # Steps + 1. Step one + 2. Step two + + # Pitfalls & Verification + - Step verification criteria + """ + let res = await ToolExecutor.shared.createSkill( + name: name, + description: "Scaffolded skill '\(name)'", + body: templateBody + ) + self.emitCommandOutput("✨ Scaffolded skill template for **\(name)**.\n\(res)", format: .markdown, to: convId) } else if args.hasPrefix("reload") { let skillArg = args.dropFirst(6).trimmingCharacters(in: .whitespacesAndNewlines) await self.engine.invalidateSystemPrompt() @@ -819,10 +838,17 @@ class AppState { emitCommandOutput("Failed to save bundle: \(error.localizedDescription)", format: .markdown, to: convId) } } else if !args.isEmpty { - if let bundle = SkillBundleManager.shared.getBundle(name: args) { + if args == "clear" || args == "off" { + SkillBundleManager.shared.activeBundle = nil + Task { [weak self] in + await self?.engine.invalidateSystemPrompt() + self?.emitCommandOutput("Cleared active skill bundle filter. All registered skills are now loaded.", format: .markdown, to: convId) + } + } else if let bundle = SkillBundleManager.shared.getBundle(name: args) { + SkillBundleManager.shared.activeBundle = bundle Task { [weak self] in await self?.engine.invalidateSystemPrompt() - self?.emitCommandOutput("Activated skill bundle **\(bundle.name)** (\(bundle.skillNames.joined(separator: ", "))). System prompt cache updated.", format: .markdown, to: convId) + self?.emitCommandOutput("Activated skill bundle **\(bundle.name)** (\(bundle.skillNames.joined(separator: ", "))). Context filtered & prompt cache updated.", format: .markdown, to: convId) } } else { emitCommandOutput("Bundle '\(args)' not found. Type `/bundle` to list saved bundles.", format: .markdown, to: convId) diff --git a/Sources/iris/SkillBundleManager.swift b/Sources/iris/SkillBundleManager.swift index 843dd70..404efb0 100644 --- a/Sources/iris/SkillBundleManager.swift +++ b/Sources/iris/SkillBundleManager.swift @@ -15,6 +15,22 @@ public struct SkillBundle: Codable, Sendable, Equatable { public final class SkillBundleManager: Sendable { public static let shared = SkillBundleManager() + private let lock = NSLock() + private var _activeBundle: SkillBundle? = nil + + public var activeBundle: SkillBundle? { + get { + lock.lock() + defer { lock.unlock() } + return _activeBundle + } + set { + lock.lock() + _activeBundle = newValue + lock.unlock() + } + } + private init() {} private func bundleFile(paths: IrisPaths) -> URL { diff --git a/Sources/iris/SkillManager.swift b/Sources/iris/SkillManager.swift index 234fa03..294844c 100644 --- a/Sources/iris/SkillManager.swift +++ b/Sources/iris/SkillManager.swift @@ -73,13 +73,26 @@ struct SkillManager { return skills.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } - func discoverSkills(paths: IrisPaths = .default) async -> String { - let skills = await listSkills(paths: paths) + func discoverSkills(paths: IrisPaths = .default, activeBundle: SkillBundle? = nil) async -> String { + let allSkills = await listSkills(paths: paths) + let skills: [SkillInfo] + if let bundle = activeBundle { + let bundleNames = Set(bundle.skillNames.map { $0.lowercased() }) + skills = allSkills.filter { + bundleNames.contains($0.name.lowercased()) || bundleNames.contains($0.folderName.lowercased()) + } + } else { + skills = allSkills + } + guard !skills.isEmpty else { + if let bundle = activeBundle { + return "# Available Skills (Active Bundle: \(bundle.name))\n\nNo matching skills found in active bundle." + } return "# Available Skills\n\nNo skills found." } - var skillsSummary = "# Available Skills\n\n" + var skillsSummary = "# Available Skills" + (activeBundle != nil ? " (Active Bundle: \(activeBundle!.name))\n\n" : "\n\n") for skill in skills { skillsSummary += "## Skill: \(skill.name)\n**Description:** \(skill.description)\n" skillsSummary += "**Path:** ~/.iris/memory/skills/\(skill.folderName)/SKILL.md\n\n" diff --git a/Sources/iris/iris.swift b/Sources/iris/iris.swift index 6a9aaf5..8b86e8a 100644 --- a/Sources/iris/iris.swift +++ b/Sources/iris/iris.swift @@ -37,7 +37,8 @@ actor IrisEngine { if let existing = systemPrompt { return existing } return await measure(.contextAssembly) { let soul = await manager.loadSOUL() - let skills = await manager.discoverSkills() + let activeBundle = SkillBundleManager.shared.activeBundle + let skills = await manager.discoverSkills(activeBundle: activeBundle) let steering = SystemSteering.shipped() let customRules = await manager.loadCustomRules() let combined = "\(soul)\n\n\(skills)\n\n\(steering)\(customRules)" diff --git a/Tests/irisTests/SkillCurationAdvancementsTests.swift b/Tests/irisTests/SkillCurationAdvancementsTests.swift index dcd1256..54279af 100644 --- a/Tests/irisTests/SkillCurationAdvancementsTests.swift +++ b/Tests/irisTests/SkillCurationAdvancementsTests.swift @@ -88,4 +88,32 @@ struct SkillCurationAdvancementsTests { #expect(reportContent.contains("Iris Skill Curator Report")) #expect(reportContent.contains("corrupt-skill")) } + + @Test("SkillManager filters discoverSkills when activeBundle is set") + func testSkillManagerSelectiveBundleFiltering() async throws { + let tempRoot = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("iris-discover-bundle-test-\(UUID().uuidString)") + let paths = IrisPaths(root: tempRoot) + try paths.ensureDirectories() + + // Create 2 skills + let s1 = paths.skillsDir.appendingPathComponent("humanizer") + try FileManager.default.createDirectory(at: s1, withIntermediateDirectories: true) + try "---\nname: humanizer\ndescription: Humanize text\n---\n\nBody 1".write(to: s1.appendingPathComponent("SKILL.md"), atomically: true, encoding: .utf8) + + let s2 = paths.skillsDir.appendingPathComponent("k8s-debug") + try FileManager.default.createDirectory(at: s2, withIntermediateDirectories: true) + try "---\nname: k8s-debug\ndescription: K8s debug\n---\n\nBody 2".write(to: s2.appendingPathComponent("SKILL.md"), atomically: true, encoding: .utf8) + + // Without active bundle -> returns both + let allPrompt = await SkillManager.shared.discoverSkills(paths: paths, activeBundle: nil) + #expect(allPrompt.contains("humanizer")) + #expect(allPrompt.contains("k8s-debug")) + + // With active bundle 'writing-day' (only humanizer) -> returns only humanizer + let bundle = SkillBundle(name: "writing-day", description: "Writing day", skillNames: ["humanizer"]) + let bundlePrompt = await SkillManager.shared.discoverSkills(paths: paths, activeBundle: bundle) + #expect(bundlePrompt.contains("humanizer")) + #expect(!bundlePrompt.contains("k8s-debug")) + } } From eaedd2fbc70df2b8e192e1d1cedca80ed7461c9f Mon Sep 17 00:00:00 2001 From: Brian Naylor Date: Wed, 29 Jul 2026 04:51:58 -0400 Subject: [PATCH 3/5] fix(curator): resolve build errors, string escape, and non-destructive quarantine (#44, #45) - Fixed string escape in SlashCommandItem.swift (| instead of \|) - Fixed public method signature errors where internal type IrisPaths was exposed in default arguments - Replaced destructive removeItem with non-destructive quarantine to ~/.iris/memory/trash/skills/ - Updated SkillCurationAdvancementsTests to verify trash quarantine directory creation Co-Authored-By: Clomp --- Sources/iris/JourneyManager.swift | 6 ++++- Sources/iris/SkillBundleManager.swift | 24 ++++++++++++++--- Sources/iris/SkillCurator.swift | 26 ++++++++++++++----- Sources/iris/SlashCommandItem.swift | 2 +- .../SkillCurationAdvancementsTests.swift | 3 +++ 5 files changed, 49 insertions(+), 12 deletions(-) diff --git a/Sources/iris/JourneyManager.swift b/Sources/iris/JourneyManager.swift index 44e3ff1..d028d22 100644 --- a/Sources/iris/JourneyManager.swift +++ b/Sources/iris/JourneyManager.swift @@ -23,7 +23,11 @@ public final class JourneyManager: Sendable { private init() {} - public func buildTimeline(paths: IrisPaths = .default) async -> [JourneyItem] { + public func buildTimeline() async -> [JourneyItem] { + await buildTimeline(paths: .default) + } + + func buildTimeline(paths: IrisPaths) async -> [JourneyItem] { var items: [JourneyItem] = [] let fileManager = FileManager.default let isoFormatter = ISO8601DateFormatter() diff --git a/Sources/iris/SkillBundleManager.swift b/Sources/iris/SkillBundleManager.swift index 404efb0..e0d83f2 100644 --- a/Sources/iris/SkillBundleManager.swift +++ b/Sources/iris/SkillBundleManager.swift @@ -37,7 +37,11 @@ public final class SkillBundleManager: Sendable { paths.memoryDir.appendingPathComponent("bundles.json") } - public func listBundles(paths: IrisPaths = .default) -> [SkillBundle] { + public func listBundles() -> [SkillBundle] { + listBundles(paths: .default) + } + + func listBundles(paths: IrisPaths) -> [SkillBundle] { let url = bundleFile(paths: paths) guard let data = try? Data(contentsOf: url), let bundles = try? JSONDecoder().decode([SkillBundle].self, from: data) else { @@ -46,7 +50,11 @@ public final class SkillBundleManager: Sendable { return bundles.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } - public func saveBundle(_ bundle: SkillBundle, paths: IrisPaths = .default) throws { + public func saveBundle(_ bundle: SkillBundle) throws { + try saveBundle(bundle, paths: .default) + } + + func saveBundle(_ bundle: SkillBundle, paths: IrisPaths) throws { var current = listBundles(paths: paths) current.removeAll { $0.name == bundle.name } current.append(bundle) @@ -58,7 +66,11 @@ public final class SkillBundleManager: Sendable { try data.write(to: url, atomically: true) } - public func deleteBundle(name: String, paths: IrisPaths = .default) throws { + public func deleteBundle(name: String) throws { + try deleteBundle(name: name, paths: .default) + } + + func deleteBundle(name: String, paths: IrisPaths) throws { let cleanName = name.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) var current = listBundles(paths: paths) current.removeAll { $0.name == cleanName } @@ -68,7 +80,11 @@ public final class SkillBundleManager: Sendable { try data.write(to: url, atomically: true) } - public func getBundle(name: String, paths: IrisPaths = .default) -> SkillBundle? { + public func getBundle(name: String) -> SkillBundle? { + getBundle(name: name, paths: .default) + } + + func getBundle(name: String, paths: IrisPaths) -> SkillBundle? { let cleanName = name.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) return listBundles(paths: paths).first { $0.name == cleanName } } diff --git a/Sources/iris/SkillCurator.swift b/Sources/iris/SkillCurator.swift index 07728ef..0573c3b 100644 --- a/Sources/iris/SkillCurator.swift +++ b/Sources/iris/SkillCurator.swift @@ -14,9 +14,14 @@ public final class SkillCurator: Sendable { private init() {} - public func curateSkills(paths: IrisPaths = .default) async -> CurationReport { + public func curateSkills() async -> CurationReport { + await curateSkills(paths: .default) + } + + func curateSkills(paths: IrisPaths) async -> CurationReport { let fileManager = FileManager.default let skillsDir = paths.skillsDir + let trashDir = paths.memoryDir.appendingPathComponent("trash/skills") var totalScanned = 0 var validCount = 0 var prunedNames: [String] = [] @@ -27,6 +32,7 @@ public final class SkillCurator: Sendable { } var skillDescriptions: [String: String] = [:] + let timestampPrefix = Int(Date().timeIntervalSince1970) for folder in skillFolders { guard !folder.hasPrefix(".") else { continue } @@ -36,17 +42,19 @@ public final class SkillCurator: Sendable { guard fileManager.fileExists(atPath: skillFile.path), let content = try? String(contentsOf: skillFile, encoding: .utf8) else { - // Empty or missing SKILL.md -> Prune corrupt folder - try? fileManager.removeItem(at: skillFolder) + // Quarantined (soft-delete) to trash directory instead of hard removal + quarantineFolder(skillFolder, folderName: folder, timestamp: timestampPrefix, trashDir: trashDir, fileManager: fileManager) prunedNames.append(folder) + recommendations.append("Quarantined corrupt skill '\(folder)' to trash. Run `/skills new \(folder)` to scaffold a replacement.") continue } let trimmedContent = content.trimmingCharacters(in: .whitespacesAndNewlines) if trimmedContent.count < 30 { - // Corrupt/empty skill -> Prune - try? fileManager.removeItem(at: skillFolder) + // Quarantined (soft-delete) to trash directory + quarantineFolder(skillFolder, folderName: folder, timestamp: timestampPrefix, trashDir: trashDir, fileManager: fileManager) prunedNames.append(folder) + recommendations.append("Quarantined empty skill '\(folder)' to trash. Run `/skills new \(folder)` to scaffold a replacement.") continue } @@ -55,7 +63,7 @@ public final class SkillCurator: Sendable { // Check for duplicates/overlap if let existingFolder = skillDescriptions[info.description] { - recommendations.append("Overlap detected between '\(folder)' and '\(existingFolder)': consider consolidating.") + recommendations.append("Overlap detected between '\(folder)' and '\(existingFolder)': consider consolidating into an umbrella skill via `create_skill`.") } else { skillDescriptions[info.description] = folder } @@ -113,6 +121,12 @@ public final class SkillCurator: Sendable { try md.write(to: reportFile, atomically: true, encoding: .utf8) } + private func quarantineFolder(_ source: URL, folderName: String, timestamp: Int, trashDir: URL, fileManager: FileManager) { + try? fileManager.createDirectory(at: trashDir, withIntermediateDirectories: true) + let target = trashDir.appendingPathComponent("\(timestamp)_\(folderName)") + try? fileManager.moveItem(at: source, to: target) + } + private func parseSkillInfo(content: String, folderName: String) -> (name: String, description: String) { let lines = content.components(separatedBy: .newlines) var inFrontmatter = false diff --git a/Sources/iris/SlashCommandItem.swift b/Sources/iris/SlashCommandItem.swift index ce291d0..baf9851 100644 --- a/Sources/iris/SlashCommandItem.swift +++ b/Sources/iris/SlashCommandItem.swift @@ -10,7 +10,7 @@ struct SlashCommandItem: Identifiable, Sendable, Equatable { SlashCommandItem(id: "goal", command: "/goal", usage: "/goal ", description: "Start an autonomous goal-driven loop"), SlashCommandItem(id: "stop", command: "/stop", usage: "/stop", description: "Cancel active goal mode or subagent tasks"), SlashCommandItem(id: "skills", command: "/skills", usage: "/skills [reload|show |curate]", description: "List, reload, view, or curate registered skills"), - SlashCommandItem(id: "bundle", command: "/bundle", usage: "/bundle [save s1,s2\|]", description: "List, save, or activate skill bundles"), + SlashCommandItem(id: "bundle", command: "/bundle", usage: "/bundle [save s1,s2|]", description: "List, save, or activate skill bundles"), SlashCommandItem(id: "journey", command: "/journey", usage: "/journey", description: "View chronological learning journey timeline"), SlashCommandItem(id: "rules", command: "/rules", usage: "/rules [reload]", description: "View loaded system rules or force reload"), SlashCommandItem(id: "model", command: "/model", usage: "/model [fast|medium|heavy|]", description: "View or switch active model for conversation"), diff --git a/Tests/irisTests/SkillCurationAdvancementsTests.swift b/Tests/irisTests/SkillCurationAdvancementsTests.swift index 54279af..9b863ac 100644 --- a/Tests/irisTests/SkillCurationAdvancementsTests.swift +++ b/Tests/irisTests/SkillCurationAdvancementsTests.swift @@ -84,6 +84,9 @@ struct SkillCurationAdvancementsTests { let reportFile = paths.memoryDir.appendingPathComponent("curator/REPORT.md") #expect(FileManager.default.fileExists(atPath: reportFile.path)) + let trashDir = paths.memoryDir.appendingPathComponent("trash/skills") + #expect(FileManager.default.fileExists(atPath: trashDir.path)) + let reportContent = try String(contentsOf: reportFile, encoding: .utf8) #expect(reportContent.contains("Iris Skill Curator Report")) #expect(reportContent.contains("corrupt-skill")) From b141a92c4b88706224b7e5dfa0d8aec0383769bc Mon Sep 17 00:00:00 2001 From: Brian Naylor Date: Wed, 29 Jul 2026 04:54:39 -0400 Subject: [PATCH 4/5] docs: document /bundle, /journey, /skills new, and /skills curate commands (#44, #45) - Updated README.md with Skill Bundles and Journey timeline command summaries - Updated docs/slash_commands.md reference guide with full syntax and parameter descriptions Co-Authored-By: Clomp --- README.md | 5 +++-- docs/slash_commands.md | 4 ++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 82d0b69..8269d31 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,9 @@ Iris runs in the background and can be summoned instantly over any other app by Iris supports in-app slash commands typed directly into the composer. Deterministic commands run instantly in-app without invoking the LLM (zero token cost): * **Harness & Models:** `/model [tier|name]` (inspect/switch active model), `/mcp [reload]` (inspect/reconnect MCP servers), `/tokens` (view token usage). -* **Skills & Rules:** `/skills [reload|show ]` (list, reload, or view SKILL.md body), `/rules [reload]` (inspect/reload custom rules from `~/.iris/rules/`). -* **Memory & Facts:** `/facts [search |probe ]` (query SQLite FactStore). +* **Skills & Bundles:** `/skills [new |reload|show |curate]` (scaffold, list, reload, view, or curate skills), `/bundle [save s1,s2||clear]` (manage or activate selective skill bundle filters). +* **Journey & Learning:** `/journey` (render chronological timeline of all learned memories, skills, and facts). +* **Memory & Rules:** `/rules [reload]` (inspect/reload custom rules), `/facts [search |probe ]` (query SQLite FactStore). * **Session Control:** `/new` (fresh chat), `/clear` (clear current buffer), `/stop` (cancel active goal/subagents), `/update` (check for GitHub releases). * **Autonomous Workflows:** `/goal ` (autonomous execution loop), `/reflect` (memory reflection), `/vibecop init` (generate security rules). diff --git a/docs/slash_commands.md b/docs/slash_commands.md index 1061638..c746428 100644 --- a/docs/slash_commands.md +++ b/docs/slash_commands.md @@ -9,8 +9,12 @@ Iris supports in-app slash commands (`/-commands`) typed into the chat composer | Command | Usage | Description | |---|---|---| | `/skills` | `/skills` | List all registered skills and memory tools. | +| `/skills new` | `/skills new ` | Scaffold a structured OKF `SKILL.md` template for a new skill. | | `/skills reload` | `/skills reload [name]` | Invalidate system prompt cache and reload skill definitions from disk. | | `/skills show` | `/skills show ` | Display the full `SKILL.md` body for a given skill. | +| `/skills curate` | `/skills curate` | Run skill health pass, soft-delete corrupt skills to trash, and write `~/.iris/memory/curator/REPORT.md`. | +| `/bundle` | `/bundle [save s1,s2\|\|clear]` | List, save, activate, or clear selective skill bundle context filters. | +| `/journey` | `/journey` | Render chronological timeline of all learned skills, profile evolutions, and facts. | | `/rules` | `/rules` | View loaded custom rules from `~/.iris/rules/`. | | `/rules reload` | `/rules reload` | Force system prompt cache invalidation to re-read custom rule files. | | `/model` | `/model [fast\|medium\|heavy\|]` | View active model tiers or switch active model for conversation. | From 7424cd453bc3138a4b265a45231810ec10cdee39 Mon Sep 17 00:00:00 2001 From: Brian Naylor Date: Wed, 29 Jul 2026 04:57:23 -0400 Subject: [PATCH 5/5] docs(agents): add atomic Definition of Done rule requiring simultaneous doc updates (#44, #45) Co-Authored-By: Clomp --- AGENTS.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 2c7db2d..9287893 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,10 +11,16 @@ ## currency - always update the README.md when major things change - new features, adjustments, etc -- Add docs to docs/ as necessary. Especially when adding new features, what would we want to tell someone about how to use them? +- Add docs to docs/ as necessary. Especially when adding new features, what would we want to tell someone about how to use them? - Add unit tests when introducing new function whenever practical - Run unit tests before commit and ensure that they pass. +## Definition of Done (Atomic Feature Check) +Before opening or updating a Pull Request, verify: +1. **Docs Currency:** Every new user-facing feature, slash command, configuration key, or CLI flag MUST have its corresponding entries added to `README.md` and reference guides in `docs/` in the same commit. Never wait for the user to ask if docs exist. +2. **Automated Verification:** Unit tests exist under `Tests/` and cover all newly added features and edge cases. +3. **Spec, Plan & Review Layout:** Design specs live in `docs/specs/YYYY-MM-DD-feature-name.md`, implementation plans in `docs/plans/YYYY-MM-DD-feature-name.md`, and reviews in `docs/reviews/YYYY-MM-DD-feature-name-review.md`. + ## Specs, Plans & Reviews Layout - **Design Specs:** Save to `docs/specs/YYYY-MM-DD-feature-name.md`. - **Implementation Plans:** Save to `docs/plans/YYYY-MM-DD-feature-name.md`.