Ver código fonte

fix(v0.3.1): workflow engine — critical bug fixes

- Replace command.execute.before with custom tools (workflow_list/run/create/reload)
- Fix cron: date wrapping validation, year rollover, input validation
- Fix glob-to-regex: escape metacharacters, handle ? wildcard
- Fix YAML: block scalars (|), double-quote escaping, comment handling, dedup
- Add /workflow command markdown entry point
- Plugin uses proper tool() helper from @kirincode-ai/plugin
KirinCode 1 mês atrás
pai
commit
1ed759abcd

+ 16 - 0
.kirincode/command/workflow.md

@@ -0,0 +1,16 @@
+---
+description: Manage workflow definitions — list, create, run, or reload automated tasks
+agent: build
+---
+
+Use the workflow tools to manage KirinCode automated workflows.
+
+- To see all workflows, call `workflow_list`
+- To run a workflow immediately, call `workflow_run` with the workflow name
+- To create a new workflow, call `workflow_create` with name, trigger (cron or "file_change"), and prompt
+- To reload from disk, call `workflow_reload`
+
+If the user asks for `/workflow list`, call `workflow_list`.
+If the user asks for `/workflow run <name>`, call `workflow_run` with that name.
+If the user asks for `/workflow create`, gather name, trigger, and prompt, then call `workflow_create`.
+If the user asks for `/workflow reload`, call `workflow_reload`.

+ 1 - 1
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@kirincode-ai/app",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "description": "",
   "type": "module",
   "exports": {

+ 1 - 1
packages/cli/package.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@kirincode-ai/cli",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "type": "module",
   "license": "MIT",
   "bin": {

+ 1 - 1
packages/codemode/package.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@kirincode-ai/codemode",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "description": "Effect-native confined code execution over schema-described tools",
   "private": true,
   "type": "module",

+ 1 - 1
packages/console/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@kirincode-ai/console-app",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "type": "module",
   "license": "MIT",
   "scripts": {

+ 1 - 1
packages/console/core/package.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@kirincode-ai/console-core",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "private": true,
   "type": "module",
   "license": "MIT",

+ 1 - 1
packages/console/function/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@kirincode-ai/console-function",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "$schema": "https://json.schemastore.org/package.json",
   "private": true,
   "type": "module",

+ 1 - 1
packages/console/mail/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@kirincode-ai/console-mail",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "dependencies": {
     "@jsx-email/all": "2.2.3",
     "@jsx-email/cli": "1.4.3",

+ 1 - 1
packages/console/support/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@kirincode-ai/console-support",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "type": "module",
   "license": "MIT",
   "scripts": {

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "name": "@kirincode-ai/core",
   "type": "module",
   "license": "MIT",

+ 1 - 1
packages/desktop/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@kirincode-ai/desktop",
   "private": true,
-  "version": "0.3.0",
+  "version": "0.3.1",
   "type": "module",
   "license": "MIT",
   "homepage": "https://kirincode.ai",

+ 1 - 1
packages/effect-drizzle-sqlite/package.json

@@ -1,6 +1,6 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "name": "@kirincode-ai/effect-drizzle-sqlite",
   "type": "module",
   "license": "MIT",

+ 1 - 1
packages/effect-sqlite-node/package.json

@@ -1,6 +1,6 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "name": "@kirincode-ai/effect-sqlite-node",
   "type": "module",
   "license": "MIT",

+ 1 - 1
packages/enterprise/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@kirincode-ai/enterprise",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "private": true,
   "type": "module",
   "license": "MIT",

+ 1 - 1
packages/function/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@kirincode-ai/function",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "$schema": "https://json.schemastore.org/package.json",
   "private": true,
   "type": "module",

+ 1 - 1
packages/http-recorder/package.json

@@ -1,6 +1,6 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "name": "@kirincode-ai/http-recorder",
   "description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
   "type": "module",

+ 1 - 1
packages/kirincode-workflow/package.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@kirincode-ai/kirincode-workflow",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "type": "module",
   "description": "KirinCode workflow engine — scheduled tasks, event triggers, background agents",
   "license": "MIT",

+ 78 - 71
packages/kirincode-workflow/src/index.ts

@@ -1,5 +1,5 @@
 import type { Plugin } from "@kirincode-ai/plugin"
-import type { Part } from "@kirincode-ai/sdk"
+import { tool } from "@kirincode-ai/plugin"
 import { CronScheduler } from "./scheduler"
 import { FileWatcher } from "./watcher"
 import { WorkflowStore } from "./store"
@@ -35,15 +35,20 @@ export const KirinCodeWorkflowPlugin: Plugin = async ({ client, directory, workt
       run.sessionId = sessionId
       store.saveRun(run)
 
-      await client.session.prompt({
-        path: { id: sessionId },
-        body: {
-          parts: [{ type: "text", text: def.prompt }],
-          model: def.model && def.agent
-            ? { providerID: def.model.split("/")[0], modelID: def.model.split("/").slice(1).join("/") }
-            : undefined,
-        },
-      })
+      const promptBody: Record<string, unknown> = {
+        parts: [{ type: "text", text: def.prompt }],
+      }
+      if (def.model) {
+        const idx = def.model.indexOf("/")
+        if (idx > 0) {
+          promptBody.model = {
+            providerID: def.model.slice(0, idx),
+            modelID: def.model.slice(idx + 1),
+          }
+        }
+      }
+
+      await client.session.prompt({ path: { id: sessionId }, body: promptBody })
 
       run.status = "completed"
       run.endedAt = Date.now()
@@ -54,7 +59,6 @@ export const KirinCodeWorkflowPlugin: Plugin = async ({ client, directory, workt
     } finally {
       RUNNING_TASKS.delete(run.id)
       store.saveRun(run)
-
       const state = store.loadState(def.name)
       state.lastRun = run.startedAt
       state.runCount++
@@ -62,27 +66,20 @@ export const KirinCodeWorkflowPlugin: Plugin = async ({ client, directory, workt
     }
   }
 
-  // Load and register all workflow definitions
   function reloadWorkflows(): void {
     scheduler.stopAll()
     watcher.stopAll()
-
     const defs = store.loadDefs()
     for (const def of defs) {
       if (def.enabled === false) continue
-
       if (def.schedule) {
-        const cron = scheduler.parse(def.schedule)
-        const intervalMs = 30000 // check every 30s
-        scheduler.start(`wf_${def.name}`, intervalMs, () => {
-          const next = cron.next()
-          const now = Date.now()
-          if (Math.abs(next - now) < intervalMs) {
-            executeWorkflow(def)
-          }
-        })
+        try {
+          const cron = scheduler.parse(def.schedule)
+          scheduler.start(`wf_${def.name}`, 30000, () => {
+            if (Math.abs(cron.next() - Date.now()) < 30000) executeWorkflow(def)
+          })
+        } catch { /* invalid cron, skip */ }
       }
-
       if (def.trigger === "file_change") {
         watcher.watch(def, worktree, () => executeWorkflow(def))
       }
@@ -92,11 +89,11 @@ export const KirinCodeWorkflowPlugin: Plugin = async ({ client, directory, workt
   reloadWorkflows()
 
   return {
-    "command.execute.before": async (input, output) => {
-      const args = input.arguments.trim()
-
-      if (input.command === "workflow") {
-        if (!args || args === "list") {
+    tool: {
+      workflow_list: tool({
+        description: "List all defined workflows and their status. Shows workflow name, schedule/trigger, run count, and recent execution history. Use before workflow_run or workflow_create.",
+        args: {},
+        async execute() {
           const defs = store.loadDefs()
           const runs = store.listRuns(10)
           const lines = ["## Workflows", ""]
@@ -107,57 +104,67 @@ export const KirinCodeWorkflowPlugin: Plugin = async ({ client, directory, workt
             const trig = d.trigger ? ` trigger: ${d.trigger}` : ""
             lines.push(`- ${icon} **${d.name}** — ${d.description || d.prompt.slice(0, 60)}${sch}${trig} (runs: ${state.runCount})`)
           }
-          if (defs.length === 0) lines.push("*No workflows defined. Create .kirincode/workflows/*.yml files.*")
+          if (defs.length === 0) return "No workflows defined. Create .kirincode/workflows/*.yml files."
           lines.push("", "## Recent Runs", "")
           for (const r of runs) {
             const icon = r.status === "completed" ? "✅" : r.status === "failed" ? "❌" : "🔄"
-            lines.push(`- ${icon} ${r.workflow} — ${new Date(r.startedAt).toLocaleTimeString()}`)
+            const err = r.error ? ` — ${r.error}` : ""
+            lines.push(`- ${icon} ${r.workflow} — ${new Date(r.startedAt).toLocaleTimeString()}${err}`)
           }
-          output.parts = [{ type: "text", text: lines.join("\n") }]
-          return
-        }
-
-        if (args === "reload") {
-          reloadWorkflows()
-          output.parts = [{ type: "text", text: `Workflows reloaded. ${store.loadDefs().length} active.` }]
-          return
-        }
+          return lines.join("\n")
+        },
+      }),
 
-        if (args.startsWith("run ")) {
-          const name = args.slice(4).trim()
-          const def = store.loadDefs().find((d) => d.name === name)
-          if (!def) {
-            output.parts = [{ type: "text", text: `Workflow "${name}" not found.` }]
-            return
-          }
+      workflow_run: tool({
+        description: "Execute a workflow immediately by name. Use workflow_list first to see available workflows.",
+        args: {
+          name: tool.schema.string().describe("Name of the workflow to run"),
+        },
+        async execute(args) {
+          const def = store.loadDefs().find((d) => d.name === args.name)
+          if (!def) return `Workflow "${args.name}" not found. Use workflow_list to see available workflows.`
           executeWorkflow(def)
-          output.parts = [{ type: "text", text: `Started workflow: ${name}` }]
-          return
-        }
-
-        if (args.startsWith("create ")) {
-          const parts = args.slice(7).split(/\s+/)
-          if (parts.length < 2) {
-            output.parts = [{ type: "text", text: "Usage: /workflow create <name> <cron|file_change> [prompt...]" }]
-            return
-          }
-          const name = parts[0]
-          const trigger = parts[1]
-          const prompt = parts.slice(2).join(" ") || "TODO: add your prompt here"
-          
-          const def: WorkflowDef = { name, prompt }
-          if (trigger.match(/^[\d\*,\-\/ ]{9,}$/)) def.schedule = trigger
-          else if (trigger === "file_change") def.trigger = "file_change"
-          else {
-            output.parts = [{ type: "text", text: "Invalid trigger. Use a cron expression or 'file_change'." }]
-            return
+          return `Started workflow: ${args.name}`
+        },
+      }),
+
+      workflow_create: tool({
+        description: "Create a new workflow definition. Workflows can be scheduled (cron format) or triggered by file changes.",
+        args: {
+          name: tool.schema.string().describe("Unique workflow name"),
+          trigger: tool.schema.string().describe("Cron expression (e.g. '0 9 * * *') or 'file_change'"),
+          prompt: tool.schema.string().describe("The prompt to send to the AI agent when triggered"),
+          description: tool.schema.string().optional().describe("Optional description of what this workflow does"),
+          debounce: tool.schema.number().optional().describe("For file_change: debounce in seconds (default 60)"),
+          model: tool.schema.string().optional().describe("Optional model (e.g. 'anthropic/claude-haiku-4-5')"),
+        },
+        async execute(args) {
+          const def: WorkflowDef = { name: args.name, prompt: args.prompt }
+          if (args.description) def.description = args.description
+          if (args.model) def.model = args.model
+          if (args.debounce) def.debounce = args.debounce
+          if (args.trigger === "file_change") {
+            def.trigger = "file_change"
+          } else if (/^[\d\*,\-\/ ]{9,}$/.test(args.trigger)) {
+            try { scheduler.parse(args.trigger); def.schedule = args.trigger }
+            catch { return `Invalid cron: ${args.trigger}. Use format "min hour day month weekday" (e.g. "0 9 * * *")` }
+          } else {
+            return `Invalid trigger: ${args.trigger}. Use a cron expression or "file_change".`
           }
           store.saveDef(def)
           reloadWorkflows()
-          output.parts = [{ type: "text", text: `Workflow '${name}' created at .kirincode/workflows/${name}.yml` }]
-          return
-        }
-      }
+          return `Workflow '${args.name}' created at .kirincode/workflows/${args.name}.yml`
+        },
+      }),
+
+      workflow_reload: tool({
+        description: "Reload all workflow definitions from disk. Use after manually editing .yml files.",
+        args: {},
+        async execute() {
+          reloadWorkflows()
+          return `Workflows reloaded. ${store.loadDefs().length} active.`
+        },
+      }),
     },
 
     dispose: async () => {

+ 51 - 33
packages/kirincode-workflow/src/scheduler.ts

@@ -3,78 +3,96 @@ export class CronScheduler {
 
   parse(cron: string): { next: () => number } {
     const parts = cron.trim().split(/\s+/)
-    if (parts.length !== 5) throw new Error(`Invalid cron: ${cron}`)
+    if (parts.length !== 5) throw new Error(`Invalid cron: "${cron}" — need 5 fields`)
 
     const [min, hour, day, month, weekday] = parts
-    const mins = this.parseField(min, 0, 59)
-    const hours = this.parseField(hour, 0, 23)
-    const days = this.parseField(day, 1, 31)
-    const months = this.parseField(month, 1, 12)
-    const weekdays = this.parseField(weekday, 0, 6)
+    const mins = this.parseField(min, 0, 59, "minute")
+    const hours = this.parseField(hour, 0, 23, "hour")
+    const days = this.parseField(day, 1, 31, "day")
+    const months = this.parseField(month, 1, 12, "month")
+    const weekdays = this.parseField(weekday, 0, 6, "weekday")
 
     return {
       next: () => {
         const now = new Date()
-        const candidates: number[] = []
-        for (let m = 0; m < 60; m++) {
-          for (let h = 0; h < 24; h++) {
-            for (let d = 1; d <= 31; d++) {
-              for (let mo = 1; mo <= 12; mo++) {
-                if (mins.has(m) && hours.has(h) && days.has(d) && months.has(mo)) {
-                  const candidate = new Date(now.getFullYear(), mo - 1, d, h, m, 0, 0)
+        // Search up to 2 years ahead
+        for (let yearOffset = 0; yearOffset < 2; yearOffset++) {
+          const year = now.getFullYear() + yearOffset
+          for (const mo of months) {
+            for (const d of days) {
+              // Skip invalid dates (e.g. Feb 31)
+              const maxDay = new Date(year, mo, 0).getDate()
+              if (d > maxDay) continue
+
+              for (const h of hours) {
+                for (const m of mins) {
+                  const candidate = new Date(year, mo - 1, d, h, m, 0, 0)
+                  // Verify no month wrap
+                  if (candidate.getMonth() !== mo - 1) continue
                   if (candidate > now && weekdays.has(candidate.getDay())) {
-                    candidates.push(candidate.getTime())
+                    return candidate.getTime()
                   }
                 }
               }
             }
           }
         }
-        candidates.sort((a, b) => a - b)
-        return candidates[0] || (now.getTime() + 60000)
+        // Fallback: 1 minute from now
+        return now.getTime() + 60000
       }
     }
   }
 
-  private parseField(field: string, min: number, max: number): Set<number> {
+  private parseField(field: string, min: number, max: number, _label: string): Set<number> {
     const result = new Set<number>()
     if (field === "*") {
       for (let i = min; i <= max; i++) result.add(i)
       return result
     }
     for (const part of field.split(",")) {
-      if (part.includes("/")) {
-        const [range, step] = part.split("/")
-        const s = parseInt(step)
-        const [rMin, rMax] = range === "*" ? [min, max] : range.split("-").map(Number)
-        for (let i = rMin!; i <= (rMax ?? rMin!); i += s) {
+      const trimmed = part.trim()
+      if (!trimmed) continue
+
+      if (trimmed.includes("/")) {
+        const [range, stepStr] = trimmed.split("/")
+        const step = parseInt(stepStr)
+        if (isNaN(step) || step < 1) throw new Error(`Invalid step: ${stepStr} in field "${field}"`)
+
+        let rMin = min, rMax = max
+        if (range !== "*") {
+          const dashParts = range.split("-")
+          rMin = parseInt(dashParts[0])
+          rMax = dashParts.length > 1 ? parseInt(dashParts[1]) : rMin
+          if (isNaN(rMin) || isNaN(rMax)) throw new Error(`Invalid range: ${range} in field "${field}"`)
+        }
+        for (let i = rMin; i <= rMax; i += step) {
           if (i >= min && i <= max) result.add(i)
         }
-      } else if (part.includes("-")) {
-        const [lo, hi] = part.split("-").map(Number)
-        for (let i = lo!; i <= hi!; i++) {
+      } else if (trimmed.includes("-")) {
+        const [loStr, hiStr] = trimmed.split("-")
+        const lo = parseInt(loStr), hi = parseInt(hiStr)
+        if (isNaN(lo) || isNaN(hi)) throw new Error(`Invalid range: ${trimmed} in field "${field}"`)
+        for (let i = lo; i <= hi; i++) {
           if (i >= min && i <= max) result.add(i)
         }
       } else {
-        const v = parseInt(part)
-        if (v >= min && v <= max) result.add(v)
+        const v = parseInt(trimmed)
+        if (isNaN(v) || v < min || v > max) throw new Error(`Invalid value: ${trimmed} in field "${field}" (expected ${min}-${max})`)
+        result.add(v)
       }
     }
+    if (result.size === 0) throw new Error(`Empty field: "${field}"`)
     return result
   }
 
   start(name: string, intervalMs: number, fn: () => void): void {
     this.stop(name)
-    const t = setInterval(fn, intervalMs)
-    this.timers.set(name, t)
+    this.timers.set(name, setInterval(fn, intervalMs))
   }
 
   stop(name: string): void {
     const t = this.timers.get(name)
-    if (t) {
-      clearInterval(t)
-      this.timers.delete(name)
-    }
+    if (t) { clearInterval(t); this.timers.delete(name) }
   }
 
   stopAll(): void {

+ 92 - 37
packages/kirincode-workflow/src/store.ts

@@ -1,15 +1,13 @@
 import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from "node:fs"
-import { join, basename } from "node:path"
+import { join } from "node:path"
 import type { WorkflowDef, WorkflowState, TaskRun } from "./types"
 
 export class WorkflowStore {
   private statePath: string
   private runsPath: string
   private defsPath: string
-  private worktree: string
 
   constructor(worktree: string) {
-    this.worktree = worktree
     const base = join(worktree, ".kirincode", "workflows")
     this.defsPath = base
     this.statePath = join(base, ".state")
@@ -22,29 +20,41 @@ export class WorkflowStore {
   loadDefs(): WorkflowDef[] {
     if (!existsSync(this.defsPath)) return []
     const files = readdirSync(this.defsPath).filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"))
+    const seen = new Set<string>()
     const defs: WorkflowDef[] = []
     for (const file of files) {
       try {
         const content = readFileSync(join(this.defsPath, file), "utf-8")
         const def = this.parseYaml(content)
-        def.name = def.name || basename(file, file.includes(".yml") ? ".yml" : ".yaml")
+        def.name = def.name || file.replace(/\.(yml|yaml)$/, "")
+        if (seen.has(def.name)) continue
+        seen.add(def.name)
         defs.push(def)
       } catch {
-        // skip invalid
+        // skip unparseable
       }
     }
     return defs
   }
 
   saveDef(def: WorkflowDef): void {
-    const yaml = this.toYaml(def)
-    writeFileSync(join(this.defsPath, `${def.name}.yml`), yaml, "utf-8")
+    // Delete existing .yaml if we're writing .yml (avoid duplicates)
+    const yamlPath = join(this.defsPath, `${def.name}.yaml`)
+    if (existsSync(yamlPath)) {
+      try { require("node:fs").unlinkSync(yamlPath) } catch { /* ignore */ }
+    }
+    const yml = this.toYaml(def)
+    writeFileSync(join(this.defsPath, `${def.name}.yml`), yml, "utf-8")
   }
 
   loadState(name: string): WorkflowState {
     const p = join(this.statePath, `${name}.json`)
     if (!existsSync(p)) return { name, runCount: 0, enabled: true }
-    return JSON.parse(readFileSync(p, "utf-8"))
+    try {
+      return JSON.parse(readFileSync(p, "utf-8"))
+    } catch {
+      return { name, runCount: 0, enabled: true }
+    }
   }
 
   saveState(state: WorkflowState): void {
@@ -59,34 +69,71 @@ export class WorkflowStore {
     if (!existsSync(this.runsPath)) return []
     return readdirSync(this.runsPath)
       .filter((f) => f.endsWith(".json"))
-      .map((f) => {
-        try { return JSON.parse(readFileSync(join(this.runsPath, f), "utf-8")) }
-        catch { return null }
-      })
-      .filter(Boolean)
-      .sort((a, b) => b!.startedAt - a!.startedAt)
-      .slice(0, limit) as TaskRun[]
+      .reduce((acc: TaskRun[], f) => {
+        try {
+          const run = JSON.parse(readFileSync(join(this.runsPath, f), "utf-8")) as TaskRun
+          acc.push(run)
+        } catch { /* skip corrupt */ }
+        return acc
+      }, [])
+      .sort((a, b) => b.startedAt - a.startedAt)
+      .slice(0, limit)
   }
 
   private parseYaml(s: string): WorkflowDef {
-    const def: any = {}
+    const def: Record<string, any> = {}
     const lines = s.split("\n")
-    let key = ""
-    for (const line of lines) {
-      const m = line.match(/^(\w[\w_-]*):\s*(.*)$/)
-      if (m) {
-        key = m[1]
-        const val = m[2].trim()
-        if (val.startsWith("[") && val.endsWith("]")) {
-          def[key] = val.slice(1, -1).split(",").map((x) => x.trim().replace(/['"]/g, ""))
-        } else if (val === "true") def[key] = true
-        else if (val === "false") def[key] = false
-        else if (/^\d+$/.test(val)) def[key] = parseInt(val)
-        else def[key] = val
-      } else if (line.startsWith("  ") && key) {
-        if (typeof def[key] === "string") def[key] += "\n" + line.trim()
+    let i = 0
+
+    while (i < lines.length) {
+      const line = lines[i]
+      // Skip comments and empty lines
+      if (/^\s*#/.test(line) || /^\s*$/.test(line)) { i++; continue }
+
+      const keyMatch = line.match(/^([\w_-]+):\s*(.*)$/)
+      if (!keyMatch) { i++; continue }
+
+      const key = keyMatch[1]
+      let value = keyMatch[2]
+
+      // Multiline block scalar (|)
+      if (value === "|" || value === "|+") {
+        const blockLines: string[] = []
+        i++
+        while (i < lines.length && /^  /.test(lines[i])) {
+          blockLines.push(lines[i].slice(2))
+          i++
+        }
+        def[key] = blockLines.join("\n")
+        continue
+      }
+
+      // Quoted string
+      if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
+        def[key] = value.slice(1, -1)
+        i++
+        continue
+      }
+
+      // Array notation
+      if (value.startsWith("[") && value.endsWith("]")) {
+        def[key] = value.slice(1, -1).split(",").map((x) => x.trim().replace(/^['"]|['"]$/g, ""))
+        i++
+        continue
       }
+
+      // Booleans
+      if (value === "true") { def[key] = true; i++; continue }
+      if (value === "false") { def[key] = false; i++; continue }
+
+      // Numbers
+      if (/^-?\d+$/.test(value)) { def[key] = parseInt(value); i++; continue }
+
+      // Plain string
+      def[key] = value
+      i++
     }
+
     return def as WorkflowDef
   }
 
@@ -94,14 +141,22 @@ export class WorkflowStore {
     const lines: string[] = []
     for (const [k, v] of Object.entries(def)) {
       if (v === undefined) continue
-      if (Array.isArray(v)) lines.push(`${k}: [${v.map((x) => `"${x}"`).join(", ")}]`)
-      else if (typeof v === "boolean") lines.push(`${k}: ${v}`)
-      else if (typeof v === "number") lines.push(`${k}: ${v}`)
-      else if (typeof v === "string" && v.includes("\n")) {
-        lines.push(`${k}: |`)
-        for (const l of v.split("\n")) lines.push(`  ${l}`)
+      if (Array.isArray(v)) {
+        const items = v.map((x) => `"${String(x).replace(/"/g, '\\"')}"`).join(", ")
+        lines.push(`${k}: [${items}]`)
+      } else if (typeof v === "boolean") {
+        lines.push(`${k}: ${v}`)
+      } else if (typeof v === "number") {
+        lines.push(`${k}: ${v}`)
+      } else if (typeof v === "string") {
+        if (v.includes("\n")) {
+          lines.push(`${k}: |`)
+          for (const l of v.split("\n")) lines.push(`  ${l}`)
+        } else {
+          const escaped = v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
+          lines.push(`${k}: "${escaped}"`)
+        }
       }
-      else lines.push(`${k}: "${v}"`)
     }
     return lines.join("\n") + "\n"
   }

+ 9 - 11
packages/kirincode-workflow/src/watcher.ts

@@ -1,5 +1,4 @@
 import { watch } from "node:fs"
-import { join } from "node:path"
 import type { WorkflowDef } from "./types"
 
 export class FileWatcher {
@@ -12,8 +11,13 @@ export class FileWatcher {
     this.unwatch(key)
 
     const patterns = def.paths.map((p) => {
-      const cleaned = p.replace(/\*\*/g, "__GLOBSTAR__").replace(/\*/g, "[^/]*").replace(/__GLOBSTAR__/g, ".*")
-      return new RegExp(cleaned)
+      let escaped = p
+        .replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape regex metachars
+        .replace(/\*\*/g, "\x00")               // globstar placeholder
+        .replace(/\*/g, "[^/]*")                 // * → any non-slash
+        .replace(/\x00/g, ".*")                  // globstar placeholder → .*
+        .replace(/\?/g, "[^/]")                  // ? → single char
+      return new RegExp("^" + escaped + "$")
     })
 
     const debounceMs = (def.debounce ?? 60) * 1000
@@ -32,15 +36,9 @@ export class FileWatcher {
 
   unwatch(key: string): void {
     const w = this.watchers.get(key)
-    if (w) {
-      w.close()
-      this.watchers.delete(key)
-    }
+    if (w) { w.close(); this.watchers.delete(key) }
     const t = this.timers.get(key)
-    if (t) {
-      clearTimeout(t)
-      this.timers.delete(key)
-    }
+    if (t) { clearTimeout(t); this.timers.delete(key) }
   }
 
   stopAll(): void {

+ 1 - 1
packages/kirincode/package.json

@@ -1,6 +1,6 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "name": "kirincode",
   "type": "module",
   "license": "MIT",

+ 1 - 1
packages/llm/package.json

@@ -1,6 +1,6 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "name": "@kirincode-ai/llm",
   "type": "module",
   "license": "MIT",

+ 1 - 1
packages/plugin/package.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@kirincode-ai/plugin",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "type": "module",
   "license": "MIT",
   "scripts": {

+ 1 - 1
packages/sdk/js/package.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@kirincode-ai/sdk",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "type": "module",
   "license": "MIT",
   "scripts": {

+ 1 - 1
packages/server/package.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@kirincode-ai/server",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "private": true,
   "type": "module",
   "license": "MIT",

+ 1 - 1
packages/session-ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@kirincode-ai/session-ui",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "private": true,
   "type": "module",
   "license": "MIT",

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@kirincode-ai/slack",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "type": "module",
   "license": "MIT",
   "scripts": {

+ 1 - 1
packages/stats/app/package.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@kirincode-ai/stats-app",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "private": true,
   "type": "module",
   "license": "MIT",

+ 1 - 1
packages/stats/core/package.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@kirincode-ai/stats-core",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "private": true,
   "type": "module",
   "license": "MIT",

+ 1 - 1
packages/stats/server/package.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@kirincode-ai/stats-server",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "private": true,
   "type": "module",
   "license": "MIT",

+ 1 - 1
packages/tui/package.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@kirincode-ai/tui",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "private": true,
   "type": "module",
   "license": "MIT",

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@kirincode-ai/ui",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "type": "module",
   "license": "MIT",
   "repository": {

+ 1 - 1
packages/web/package.json

@@ -2,7 +2,7 @@
   "name": "@kirincode-ai/web",
   "type": "module",
   "license": "MIT",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "scripts": {
     "dev": "astro dev",
     "dev:remote": "VITE_API_URL=https://api.kirincode.ai astro dev",