From 2e789778aff99bdde7059d489ab4a0df5f41690c Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 6 Jun 2026 05:20:18 -0500 Subject: [PATCH 1/6] feat(skills): add Coven task manager --- crates/coven-cli/src/cockpit_sources.rs | 34 +- skills/README.md | 1 + skills/coven-task-manager/SKILL.md | 78 +++ .../coven-task-blocked-escalation.toml | 19 + .../coven-task-freshness-daily.toml | 22 + .../coven-task-weekly-cleanup.toml | 19 + skills/coven-task-manager/metadata.json | 13 + skills/coven-task-manager/task-manager.mjs | 452 ++++++++++++++++++ .../coven-task-manager/task-manager.test.mjs | 189 ++++++++ 9 files changed, 824 insertions(+), 3 deletions(-) create mode 100644 skills/coven-task-manager/SKILL.md create mode 100644 skills/coven-task-manager/automation-templates/coven-task-blocked-escalation.toml create mode 100644 skills/coven-task-manager/automation-templates/coven-task-freshness-daily.toml create mode 100644 skills/coven-task-manager/automation-templates/coven-task-weekly-cleanup.toml create mode 100644 skills/coven-task-manager/metadata.json create mode 100755 skills/coven-task-manager/task-manager.mjs create mode 100644 skills/coven-task-manager/task-manager.test.mjs diff --git a/crates/coven-cli/src/cockpit_sources.rs b/crates/coven-cli/src/cockpit_sources.rs index ea8e71a..07ce753 100644 --- a/crates/coven-cli/src/cockpit_sources.rs +++ b/crates/coven-cli/src/cockpit_sources.rs @@ -259,10 +259,14 @@ pub fn scan_skills(coven_home: &Path) -> Result> { let mut out = Vec::new(); for entry in entries { let entry = entry?; - if !entry.file_type()?.is_dir() { - continue; - } let dir = entry.path(); + match fs::metadata(&dir) { + Ok(meta) if meta.is_dir() => {} + Ok(_) => continue, + Err(err) => { + return Err(err).with_context(|| format!("failed to inspect {}", dir.display())); + } + } let metadata_path = dir.join("metadata.json"); let raw = match fs::read_to_string(&metadata_path) { Ok(raw) => raw, @@ -815,6 +819,30 @@ description = "..." Ok(()) } + #[cfg(unix)] + #[test] + fn scan_skills_follows_symlinked_skill_dirs() -> Result<()> { + let temp = tempfile::tempdir()?; + let canonical = temp.path().join("canonical").join("delta"); + fs::create_dir_all(&canonical)?; + fs::write( + canonical.join("metadata.json"), + r#"{"name":"Delta","description":"D","author":"coven","category":"operations"}"#, + )?; + + let skills_root = temp.path().join(SKILLS_DIR); + fs::create_dir_all(&skills_root)?; + std::os::unix::fs::symlink(&canonical, skills_root.join("delta"))?; + + let out = scan_skills(temp.path())?; + + assert_eq!(out.len(), 1); + assert_eq!(out[0].id, "delta"); + assert_eq!(out[0].name, "Delta"); + assert_eq!(out[0].owner, "coven"); + Ok(()) + } + #[test] fn scan_memory_returns_empty_when_dir_missing() -> Result<()> { let temp = tempfile::tempdir()?; diff --git a/skills/README.md b/skills/README.md index a3bfbec..32d3238 100644 --- a/skills/README.md +++ b/skills/README.md @@ -13,6 +13,7 @@ Canonical Coven-wide skills live here. Each skill is a directory with a `SKILL.m | Skill | Purpose | |-------|---------| | `coven-board-entry` | Create a new entry on the Coven task board programmatically | +| `coven-task-manager` | Keep Coven task boards fresh with scheduled stale/blocked/review task hygiene | | `opencoven-design` | OpenCoven design system and visual language reference | ## Adding a new Coven skill diff --git a/skills/coven-task-manager/SKILL.md b/skills/coven-task-manager/SKILL.md new file mode 100644 index 0000000..c4c4b7c --- /dev/null +++ b/skills/coven-task-manager/SKILL.md @@ -0,0 +1,78 @@ +--- +name: coven-task-manager +description: Keep Coven task boards fresh by auditing stale, blocked, active, review, and completed work; use for scheduled task hygiene, task triage, and dynamic task-management runs. +--- + +# Coven Task Manager + +Use this skill when asked to manage, refresh, audit, triage, or summarize Coven tasks, or when a scheduled automation asks for task-board freshness. + +## Sources + +Start with the Cave task board: + +```bash +node /Users/buns/Documents/GitHub/OpenCoven/coven/skills/coven-task-manager/task-manager.mjs report +``` + +The helper reads `~/.coven/cave-board.json` and writes `~/.coven/task-manager/freshness-report.md` by default. + +If the canonical repo path is different, run the helper from this skill directory: + +```bash +node ./task-manager.mjs report --coven-home ~/.coven +``` + +## Workflow + +1. Load the task board and build a freshness report. +2. Inspect the report sections in this order: `Stale Running`, `Needs Human`, `Ready For Review`, then `Next Actions`. +3. Read `Thread Coordination` before touching individual cards. Treat it as the concurrency control surface for simultaneous sessions. +4. For every active/review/blocked thread, build a small ledger: card id/title, familiar, session id, repo/branch/worktree if known, last evidence checked, current state, and one next action. +5. Resolve collisions before dispatching new work: + - If multiple cards share a session, resume that session once and update all linked cards from the same evidence. + - If one familiar has multiple active lanes, choose the primary lane and mark the rest as waiting, review, or blocked with a reason. + - If multiple lanes touch the same repo or branch, verify branch/worktree ownership before allowing parallel writes. + - Prefer resuming a viable linked session over starting a new thread; start a fresh thread only when no current session can be resumed. +6. For each task that needs action, gather concrete evidence before changing state: linked sessions, git branches, PRs, CI, user messages, or task notes. +7. Keep task state fresh: + - Move stale running work only when evidence shows it is blocked, ready for review, done, or abandoned. + - Keep blocked tasks explicit: include the blocker, owner, and smallest next unblock action. + - Move review tasks only after the actual review/CI state is checked. + - Mark work done only when merge, delivery, or acceptance evidence exists. +8. Update the freshness report after meaningful changes. + +## Guardrails + +- Do not delete, archive, or bulk-close tasks unless the user explicitly asks. +- Do not invent blockers or progress. +- Do not mark a task done from memory alone; verify current state first. +- Preserve user-written task notes. Append concise evidence instead of replacing useful context. +- If evidence is missing, leave the task in place and write the missing check as the next action. +- Do not spawn parallel work for the same repo/branch/session just because several cards look stale; reconcile the existing thread ledger first. + +## Default Automations + +Install the default Codex automation set with: + +```bash +node ./task-manager.mjs install-default-automations --status PAUSED +``` + +The templates are: + +- `coven-task-freshness-daily` — daily sweep of stale, blocked, review, and active work. +- `coven-task-blocked-escalation` — weekday blocked-task escalation. +- `coven-task-weekly-cleanup` — weekly summary and cleanup recommendations. + +Use `--status ACTIVE` only when the user wants the automations enabled immediately. + +## Local Market Install + +To make the skill visible to the local Cave Skills market, symlink it into Coven home: + +```bash +node ./task-manager.mjs install-local --status PAUSED +``` + +The Cave market reads `~/.coven/skills/*/metadata.json` through the Coven daemon. diff --git a/skills/coven-task-manager/automation-templates/coven-task-blocked-escalation.toml b/skills/coven-task-manager/automation-templates/coven-task-blocked-escalation.toml new file mode 100644 index 0000000..b19f8da --- /dev/null +++ b/skills/coven-task-manager/automation-templates/coven-task-blocked-escalation.toml @@ -0,0 +1,19 @@ +version = 1 +id = "coven-task-blocked-escalation" +kind = "cron" +name = "Coven Blocked Task Escalation" +status = "PAUSED" +rrule = "RRULE:FREQ=WEEKLY;BYHOUR=10;BYMINUTE=0;BYDAY=MO,TU,WE,TH,FR" +model = "gpt-5.5" +reasoning_effort = "high" +execution_environment = "worktree" +cwds = [] +tags = ["coven", "tasks", "blocked"] +prompt = '''Use the `coven-task-manager` skill to review blocked Coven tasks. + +Goals: +- Find blocked cards, cards marked needsHuman, and stale running cards. +- Use Thread Coordination to avoid escalating the same live session or repo collision multiple times. +- Separate real blockers from stale bookkeeping. +- Prepare a short escalation list with owners, evidence, and the smallest next unblock action. +- Do not close, delete, or mark tasks done without concrete evidence.''' diff --git a/skills/coven-task-manager/automation-templates/coven-task-freshness-daily.toml b/skills/coven-task-manager/automation-templates/coven-task-freshness-daily.toml new file mode 100644 index 0000000..b2201fd --- /dev/null +++ b/skills/coven-task-manager/automation-templates/coven-task-freshness-daily.toml @@ -0,0 +1,22 @@ +version = 1 +id = "coven-task-freshness-daily" +kind = "cron" +name = "Coven Task Freshness Daily" +status = "PAUSED" +rrule = "RRULE:FREQ=WEEKLY;BYHOUR=8;BYMINUTE=30;BYDAY=SU,MO,TU,WE,TH,FR,SA" +model = "gpt-5.5" +reasoning_effort = "high" +execution_environment = "worktree" +cwds = [] +tags = ["coven", "tasks", "freshness"] +prompt = '''Use the `coven-task-manager` skill to run the daily Coven task freshness sweep. + +Goals: +- Load the current Cave task board from the local Coven home. +- Identify stale running tasks, blocked tasks that need human attention, review-ready work, and stale backlog/inbox items. +- Read the Thread Coordination section first and reconcile duplicated sessions, overloaded familiars, and repo/branch collisions before spawning or resuming work. +- Update the task freshness report. +- Only edit task state when there is concrete evidence from the board, linked sessions, git, CI, or explicit user instructions. + +Deliverable: +- A concise task freshness summary with next actions and any task cards that need human attention.''' diff --git a/skills/coven-task-manager/automation-templates/coven-task-weekly-cleanup.toml b/skills/coven-task-manager/automation-templates/coven-task-weekly-cleanup.toml new file mode 100644 index 0000000..f393a14 --- /dev/null +++ b/skills/coven-task-manager/automation-templates/coven-task-weekly-cleanup.toml @@ -0,0 +1,19 @@ +version = 1 +id = "coven-task-weekly-cleanup" +kind = "cron" +name = "Coven Weekly Task Cleanup" +status = "PAUSED" +rrule = "RRULE:FREQ=WEEKLY;BYHOUR=17;BYMINUTE=0;BYDAY=FR" +model = "gpt-5.5" +reasoning_effort = "high" +execution_environment = "worktree" +cwds = [] +tags = ["coven", "tasks", "cleanup"] +prompt = '''Use the `coven-task-manager` skill for the weekly Coven task-board cleanup. + +Goals: +- Summarize done, review, blocked, active, inbox, and backlog cards. +- Summarize simultaneous threads by session, familiar, repo, and branch, then recommend which lanes to resume, park, or merge. +- Detect duplicate or stale cards. +- Suggest archive/delete candidates, but do not perform destructive cleanup unless explicitly approved. +- Update the task freshness report with changes since the last run.''' diff --git a/skills/coven-task-manager/metadata.json b/skills/coven-task-manager/metadata.json new file mode 100644 index 0000000..afb4467 --- /dev/null +++ b/skills/coven-task-manager/metadata.json @@ -0,0 +1,13 @@ +{ + "name": "coven-task-manager", + "description": "Comprehensive Coven task-board freshness: audits stale running work, blocked tasks, review queues, active work, and completed-task hygiene.", + "version": "0.1.0", + "author": "OpenCoven Team", + "category": "operations", + "tags": ["tasks", "automation", "freshness", "triage", "cave"], + "defaultAutomations": [ + "coven-task-freshness-daily", + "coven-task-blocked-escalation", + "coven-task-weekly-cleanup" + ] +} diff --git a/skills/coven-task-manager/task-manager.mjs b/skills/coven-task-manager/task-manager.mjs new file mode 100755 index 0000000..ac1a8da --- /dev/null +++ b/skills/coven-task-manager/task-manager.mjs @@ -0,0 +1,452 @@ +#!/usr/bin/env node + +import { lstat, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const DEFAULT_COVEN_HOME = join(homedir(), ".coven"); +const DEFAULT_CODEX_HOME = join(homedir(), ".codex"); + +const DEFAULT_AUTOMATIONS = [ + { + id: "coven-task-freshness-daily", + name: "Coven Task Freshness Daily", + rrule: "RRULE:FREQ=WEEKLY;BYHOUR=8;BYMINUTE=30;BYDAY=SU,MO,TU,WE,TH,FR,SA", + prompt: `Use the \`coven-task-manager\` skill to run the daily Coven task freshness sweep. + +Goals: +- Load the current Cave task board from the local Coven home. +- Identify stale running tasks, blocked tasks that need human attention, review-ready work, and stale backlog/inbox items. +- Build the Thread Coordination map before resuming or spawning work. +- Update the task freshness report. +- Only edit task state when there is concrete evidence from the board, linked sessions, git, CI, or explicit user instructions. + +Deliverable: +- A concise task freshness summary with next actions and any task cards that need human attention.`, + }, + { + id: "coven-task-blocked-escalation", + name: "Coven Blocked Task Escalation", + rrule: "RRULE:FREQ=WEEKLY;BYHOUR=10;BYMINUTE=0;BYDAY=MO,TU,WE,TH,FR", + prompt: `Use the \`coven-task-manager\` skill to review blocked Coven tasks. + +Goals: +- Find blocked cards, cards marked needsHuman, and stale running cards. +- Check the Thread Coordination map for duplicated sessions, overloaded familiars, or repo/branch conflicts. +- Separate real blockers from stale bookkeeping. +- Prepare a short escalation list with owners, evidence, and the smallest next unblock action. +- Do not close, delete, or mark tasks done without concrete evidence.`, + }, + { + id: "coven-task-weekly-cleanup", + name: "Coven Weekly Task Cleanup", + rrule: "RRULE:FREQ=WEEKLY;BYHOUR=17;BYMINUTE=0;BYDAY=FR", + prompt: `Use the \`coven-task-manager\` skill for the weekly Coven task-board cleanup. + +Goals: +- Summarize done, review, blocked, active, inbox, and backlog cards. +- Summarize simultaneous active/review/blocked threads by session, familiar, repo, and branch. +- Detect duplicate or stale cards. +- Suggest archive/delete candidates, but do not perform destructive cleanup unless explicitly approved. +- Update the task freshness report with changes since the last run.`, + }, +]; + +function expandHome(input) { + if (!input || input === "~") return homedir(); + if (input.startsWith("~/")) return join(homedir(), input.slice(2)); + return input; +} + +function ageHours(iso, now) { + if (!iso) return 0; + const time = new Date(iso).getTime(); + if (!Number.isFinite(time)) return 0; + return Math.max(0, (now.getTime() - time) / 36e5); +} + +function priorityRank(card) { + return { urgent: 0, high: 1, medium: 2, low: 3 }[card.priority] ?? 4; +} + +function compareCards(a, b) { + return priorityRank(a) - priorityRank(b) || (a.updatedAt ?? "").localeCompare(b.updatedAt ?? ""); +} + +function isOpenCoordinationCard(card) { + if (card.status === "done" || card.lifecycle === "completed") return false; + return ["running", "blocked", "review"].includes(card.status) + || ["running", "dispatched", "review", "failed"].includes(card.lifecycle); +} + +function titleList(cards, now) { + return cards.slice(0, 4).map((card) => cardLine(card, now)).join("\n"); +} + +function addGroup(groups, key, title, cards, action) { + if (cards.length < 2) return; + groups.push({ key, title, cards: [...cards].sort(compareCards), action }); +} + +function groupBy(items, keyFn) { + const grouped = new Map(); + for (const item of items) { + const key = keyFn(item); + const bucket = grouped.get(key); + if (bucket) bucket.push(item); + else grouped.set(key, [item]); + } + return grouped; +} + +function extractBranch(card) { + const haystack = `${card.title ?? ""}\n${card.notes ?? ""}`; + const match = haystack.match(/\b(?:branch|head)\s*[:=]\s*([A-Za-z0-9._/-]+)/i) + ?? haystack.match(/\b(?:on|from)\s+([A-Za-z0-9._/-]+)\s+branch\b/i); + return match?.[1]?.replace(/[),.;]+$/, "") ?? null; +} + +function extractRepo(card) { + const labels = Array.isArray(card.labels) ? card.labels.map((label) => String(label).toLowerCase()) : []; + const projectLabel = labels.find((label) => + ["cave", "coven", "openclaw", "openmeow", "opentrust", "covencave"].includes(label), + ); + if (projectLabel) return projectLabel === "covencave" ? "cave" : projectLabel; + + const haystack = `${card.title ?? ""}\n${card.notes ?? ""}`; + const gh = haystack.match(/github\.com\/(?:OpenCoven|BunsDev)\/([A-Za-z0-9._-]+)/i); + if (gh) return gh[1].toLowerCase(); + const path = haystack.match(/\/OpenCoven\/([A-Za-z0-9._-]+)/i); + return path?.[1]?.toLowerCase() ?? null; +} + +export function analyzeThreadCoordination(cards) { + const open = cards.filter(isOpenCoordinationCard); + const groups = []; + + const bySession = groupBy(open.filter((card) => card.sessionId), (card) => card.sessionId); + for (const [sessionId, group] of bySession) { + addGroup( + groups, + `session:${sessionId}`, + `Shared session ${sessionId}`, + group, + "Resume the existing session once, then update or park every linked card from that evidence.", + ); + } + + const byFamiliar = groupBy(open.filter((card) => card.familiarId), (card) => card.familiarId); + for (const [familiarId, group] of byFamiliar) { + addGroup( + groups, + `familiar:${familiarId}`, + `Concurrent lanes for @${familiarId}`, + group, + "Pick one primary lane for the next action; leave the others with explicit wait/review/blocker notes.", + ); + } + + const withRepos = open + .map((card) => [extractRepo(card), card]) + .filter(([repo]) => repo); + const byRepo = groupBy(withRepos, ([repo]) => repo); + for (const [repo, pairs] of byRepo) { + addGroup( + groups, + `repo:${repo}`, + `Repo collision: ${repo}`, + pairs.map(([, card]) => card), + "Verify branch/worktree ownership before allowing simultaneous writes in this repo.", + ); + } + + const withBranches = open + .map((card) => [extractBranch(card), card]) + .filter(([branch]) => branch); + const byBranch = groupBy(withBranches, ([branch]) => branch); + for (const [branch, pairs] of byBranch) { + addGroup( + groups, + `branch:${branch}`, + `Branch collision: ${branch}`, + pairs.map(([, card]) => card), + "Do not run these in parallel; choose the freshest thread and reconcile the rest into it.", + ); + } + + groups.sort((a, b) => b.cards.length - a.cards.length || a.title.localeCompare(b.title)); + return groups; +} + +export async function loadBoard({ covenHome = DEFAULT_COVEN_HOME } = {}) { + const boardPath = join(expandHome(covenHome), "cave-board.json"); + try { + const raw = await readFile(boardPath, "utf8"); + const parsed = JSON.parse(raw); + return Array.isArray(parsed.cards) ? parsed.cards : []; + } catch (error) { + if (error?.code === "ENOENT") return []; + throw error; + } +} + +export function classifyTasks(cards, { now = new Date(), staleRunningHours = 4 } = {}) { + const staleRunning = []; + const blocked = []; + const review = []; + const active = []; + const done = []; + + for (const card of cards) { + if (card.status === "done" || card.lifecycle === "completed") { + done.push(card); + continue; + } + if (card.status === "blocked" || card.needsHuman || card.lifecycle === "failed") { + blocked.push(card); + continue; + } + if (card.status === "review" || card.lifecycle === "review") { + review.push(card); + continue; + } + if (card.status === "running" || card.lifecycle === "running" || card.lifecycle === "dispatched") { + const basis = card.runningSince ?? card.lifecycleAt ?? card.updatedAt; + if (ageHours(basis, now) >= staleRunningHours) staleRunning.push(card); + else active.push(card); + continue; + } + if (card.status === "inbox" || card.status === "backlog" || card.lifecycle === "queued") { + active.push(card); + } + } + + staleRunning.sort(compareCards); + blocked.sort(compareCards); + review.sort(compareCards); + active.sort(compareCards); + done.sort(compareCards); + + return { + staleRunning, + blocked, + review, + active, + done, + counts: { + total: cards.length, + staleRunning: staleRunning.length, + blocked: blocked.length, + review: review.length, + active: active.length, + done: done.length, + }, + }; +} + +function cardLine(card, now) { + const owner = card.familiarId ? ` @${card.familiarId}` : ""; + const priority = card.priority ? ` [${card.priority}]` : ""; + const age = card.updatedAt ? ` updated ${Math.round(ageHours(card.updatedAt, now))}h ago` : ""; + return `- ${card.title}${priority}${owner}${age} (${card.id})`; +} + +function section(title, cards, now, empty) { + const lines = [`## ${title}`]; + if (cards.length === 0) lines.push(empty); + else lines.push(...cards.map((card) => cardLine(card, now))); + return lines.join("\n"); +} + +function threadCoordinationSection(cards, now) { + const groups = analyzeThreadCoordination(cards); + const lines = ["## Thread Coordination"]; + if (groups.length === 0) { + lines.push("No obvious simultaneous-thread conflicts."); + return lines.join("\n"); + } + + for (const group of groups.slice(0, 8)) { + lines.push(`### ${group.title}`); + lines.push(group.action); + lines.push(titleList(group.cards, now)); + lines.push(""); + } + return lines.join("\n").trimEnd(); +} + +export function buildTaskFreshnessReport(cards, { now = new Date(), staleRunningHours = 4 } = {}) { + const classified = classifyTasks(cards, { now, staleRunningHours }); + const coordinationGroups = analyzeThreadCoordination(cards); + const date = now.toISOString().slice(0, 10); + const lines = [ + `# Coven Task Freshness - ${date}`, + "", + "## Summary", + `- Total: ${classified.counts.total}`, + `- Stale running: ${classified.counts.staleRunning}`, + `- Needs human: ${classified.counts.blocked}`, + `- Review: ${classified.counts.review}`, + `- Active: ${classified.counts.active}`, + `- Done: ${classified.counts.done}`, + `- Thread coordination groups: ${coordinationGroups.length}`, + "", + threadCoordinationSection(cards, now), + "", + section("Stale Running", classified.staleRunning, now, "None."), + "", + section("Needs Human", classified.blocked, now, "None."), + "", + section("Ready For Review", classified.review, now, "None."), + "", + section("Next Actions", [...classified.staleRunning, ...classified.blocked, ...classified.review].slice(0, 10), now, "No immediate task-management action needed."), + "", + ]; + return `${lines.join("\n")}\n`; +} + +export async function writeTaskFreshnessReport({ + covenHome = DEFAULT_COVEN_HOME, + out, + now = new Date(), + staleRunningHours = 4, +} = {}) { + const cards = await loadBoard({ covenHome }); + const report = buildTaskFreshnessReport(cards, { now, staleRunningHours }); + const outPath = resolve( + expandHome(out ?? join(covenHome, "task-manager", "freshness-report.md")), + ); + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, report, "utf8"); + return { path: outPath, report }; +} + +function tomlString(value) { + return JSON.stringify(value); +} + +function tomlMultiline(value) { + return `'''${value.replace(/'''/g, "'''\"'''\"'''")}'''`; +} + +function automationToml(template, { skillPath, status }) { + return [ + "version = 1", + `id = ${tomlString(template.id)}`, + 'kind = "cron"', + `name = ${tomlString(template.name)}`, + `prompt = ${tomlMultiline(template.prompt)}`, + `status = ${tomlString(status)}`, + `rrule = ${tomlString(template.rrule)}`, + 'model = "gpt-5.5"', + 'reasoning_effort = "high"', + 'execution_environment = "worktree"', + "cwds = []", + 'tags = ["coven", "tasks", "freshness"]', + `skill_path = ${tomlString(skillPath)}`, + "", + ].join("\n"); +} + +export async function installDefaultAutomations({ + codexHome = DEFAULT_CODEX_HOME, + skillPath = HERE, + status = "PAUSED", +} = {}) { + const root = join(expandHome(codexHome), "automations"); + const installed = []; + for (const template of DEFAULT_AUTOMATIONS) { + const dir = join(root, template.id); + await mkdir(dir, { recursive: true }); + const toml = automationToml(template, { skillPath, status }); + await writeFile(join(dir, "automation.toml"), toml, "utf8"); + installed.push({ id: template.id, path: join(dir, "automation.toml") }); + } + return installed; +} + +export async function installSkillSymlink({ + covenHome = DEFAULT_COVEN_HOME, + skillPath = HERE, + replace = false, +} = {}) { + const target = join(expandHome(covenHome), "skills", "coven-task-manager"); + await mkdir(dirname(target), { recursive: true }); + if (existsSync(target)) { + if (!replace) return { path: target, changed: false }; + const current = await lstat(target); + if (!current.isSymbolicLink()) { + throw new Error(`refusing to replace non-symlink skill install at ${target}`); + } + await rm(target, { recursive: true, force: true }); + } + await symlink(resolve(skillPath), target, "dir"); + return { path: target, changed: true }; +} + +function parseArgs(argv) { + const args = { _: [] }; + for (let i = 0; i < argv.length; i += 1) { + const item = argv[i]; + if (!item.startsWith("--")) { + args._.push(item); + continue; + } + const key = item.slice(2); + const next = argv[i + 1]; + args[key] = next && !next.startsWith("--") ? argv[++i] : "true"; + } + return args; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const command = args._[0]; + if (command === "report") { + const result = await writeTaskFreshnessReport({ + covenHome: args["coven-home"] ?? DEFAULT_COVEN_HOME, + out: args.out, + staleRunningHours: Number(args["stale-running-hours"] ?? 4), + }); + process.stdout.write(`${result.report}\nWrote ${result.path}\n`); + return; + } + if (command === "install-default-automations") { + const installed = await installDefaultAutomations({ + codexHome: args["codex-home"] ?? DEFAULT_CODEX_HOME, + skillPath: args["skill-path"] ?? HERE, + status: args.status ?? "PAUSED", + }); + process.stdout.write(JSON.stringify({ installed }, null, 2) + "\n"); + return; + } + if (command === "install-local") { + const skill = await installSkillSymlink({ + covenHome: args["coven-home"] ?? DEFAULT_COVEN_HOME, + skillPath: args["skill-path"] ?? HERE, + replace: args.replace === "true", + }); + const automations = await installDefaultAutomations({ + codexHome: args["codex-home"] ?? DEFAULT_CODEX_HOME, + skillPath: skill.path, + status: args.status ?? "PAUSED", + }); + process.stdout.write(JSON.stringify({ skill, automations }, null, 2) + "\n"); + return; + } + + process.stderr.write( + "Usage: task-manager.mjs report|install-default-automations|install-local [--status PAUSED|ACTIVE]\n", + ); + process.exit(1); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((error) => { + process.stderr.write(`${error?.stack ?? error}\n`); + process.exit(1); + }); +} diff --git a/skills/coven-task-manager/task-manager.test.mjs b/skills/coven-task-manager/task-manager.test.mjs new file mode 100644 index 0000000..029721e --- /dev/null +++ b/skills/coven-task-manager/task-manager.test.mjs @@ -0,0 +1,189 @@ +import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + analyzeThreadCoordination, + buildTaskFreshnessReport, + classifyTasks, + installDefaultAutomations, + installSkillSymlink, + loadBoard, +} from "./task-manager.mjs"; + +test("classifyTasks separates stale running, blocked, review, active, and done cards", () => { + const now = new Date("2026-06-06T12:00:00.000Z"); + const cards = [ + { + id: "stale-1", + title: "Long-running implementation", + status: "running", + priority: "urgent", + familiarId: "cody", + updatedAt: "2026-06-06T00:00:00.000Z", + runningSince: "2026-06-06T00:00:00.000Z", + }, + { + id: "blocked-1", + title: "Needs API decision", + status: "blocked", + priority: "high", + familiarId: "sage", + updatedAt: "2026-06-06T10:00:00.000Z", + needsHuman: true, + }, + { + id: "review-1", + title: "Check PR fallout", + status: "review", + priority: "medium", + updatedAt: "2026-06-06T11:00:00.000Z", + }, + { + id: "active-1", + title: "Fresh run", + status: "running", + priority: "medium", + updatedAt: "2026-06-06T11:30:00.000Z", + runningSince: "2026-06-06T11:30:00.000Z", + }, + { + id: "done-1", + title: "Merged fix", + status: "done", + priority: "low", + updatedAt: "2026-06-06T09:00:00.000Z", + }, + ]; + + const result = classifyTasks(cards, { now, staleRunningHours: 4 }); + + assert.deepEqual(result.counts, { + total: 5, + staleRunning: 1, + blocked: 1, + review: 1, + active: 1, + done: 1, + }); + assert.equal(result.staleRunning[0].id, "stale-1"); + assert.equal(result.blocked[0].id, "blocked-1"); + assert.equal(result.review[0].id, "review-1"); +}); + +test("analyzeThreadCoordination groups simultaneous sessions and repo lanes", () => { + const groups = analyzeThreadCoordination([ + { + id: "one", + title: "Cave editor", + status: "running", + familiarId: "cody", + sessionId: "sess-1", + labels: ["cave"], + notes: "branch: cody/editor", + }, + { + id: "two", + title: "Cave API", + status: "review", + familiarId: "cody", + sessionId: "sess-1", + labels: ["cave"], + notes: "branch: cody/editor", + }, + { + id: "three", + title: "Finished", + status: "done", + familiarId: "cody", + sessionId: "sess-1", + labels: ["cave"], + }, + ]); + + assert.ok(groups.some((group) => group.key === "session:sess-1")); + assert.ok(groups.some((group) => group.key === "familiar:cody")); + assert.ok(groups.some((group) => group.key === "repo:cave")); + assert.ok(groups.some((group) => group.key === "branch:cody/editor")); + assert.equal(groups.find((group) => group.key === "session:sess-1").cards.length, 2); +}); + +test("buildTaskFreshnessReport includes prioritized sections and concrete cards", () => { + const report = buildTaskFreshnessReport( + [ + { + id: "blocked-1", + title: "Needs API decision", + status: "blocked", + priority: "urgent", + familiarId: "sage", + updatedAt: "2026-06-06T10:00:00.000Z", + needsHuman: true, + }, + ], + { now: new Date("2026-06-06T12:00:00.000Z") }, + ); + + assert.match(report, /^# Coven Task Freshness - 2026-06-06/m); + assert.match(report, /## Needs Human/); + assert.match(report, /## Thread Coordination/); + assert.match(report, /Needs API decision/); + assert.match(report, /sage/); + assert.match(report, /## Next Actions/); +}); + +test("loadBoard reads Cave task cards from a Coven home", async () => { + const root = await mkdtemp(join(tmpdir(), "coven-task-manager-")); + await writeFile( + join(root, "cave-board.json"), + JSON.stringify({ version: 1, cards: [{ id: "one", title: "Card", status: "inbox" }] }), + "utf8", + ); + + const cards = await loadBoard({ covenHome: root }); + + assert.equal(cards.length, 1); + assert.equal(cards[0].id, "one"); +}); + +test("installDefaultAutomations writes paused Codex automation TOMLs by default", async () => { + const root = await mkdtemp(join(tmpdir(), "coven-task-manager-")); + const codexHome = join(root, ".codex"); + const skillPath = join(root, "skill"); + await mkdir(skillPath, { recursive: true }); + + const installed = await installDefaultAutomations({ + codexHome, + skillPath, + status: "PAUSED", + }); + + assert.deepEqual( + installed.map((item) => item.id), + [ + "coven-task-freshness-daily", + "coven-task-blocked-escalation", + "coven-task-weekly-cleanup", + ], + ); + + const first = join(codexHome, "automations", "coven-task-freshness-daily", "automation.toml"); + const contents = await readFile(first, "utf8"); + assert.match(contents, /status = "PAUSED"/); + assert.match(contents, /Use the `coven-task-manager` skill/); + assert.match(contents, /rrule = "RRULE:FREQ=WEEKLY;BYHOUR=8;BYMINUTE=30;BYDAY=SU,MO,TU,WE,TH,FR,SA"/); + await stat(first); +}); + +test("installSkillSymlink refuses to replace a real skill directory", async () => { + const root = await mkdtemp(join(tmpdir(), "coven-task-manager-")); + const existing = join(root, ".coven", "skills", "coven-task-manager"); + await mkdir(existing, { recursive: true }); + + await assert.rejects( + () => installSkillSymlink({ covenHome: join(root, ".coven"), replace: true }), + /refusing to replace non-symlink/, + ); +}); From 2b72012b3bf0cc830507e0fe8e0c889062b48256 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sat, 6 Jun 2026 05:28:16 -0500 Subject: [PATCH 2/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- skills/coven-task-manager/SKILL.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/skills/coven-task-manager/SKILL.md b/skills/coven-task-manager/SKILL.md index c4c4b7c..7ab7876 100644 --- a/skills/coven-task-manager/SKILL.md +++ b/skills/coven-task-manager/SKILL.md @@ -11,9 +11,7 @@ Use this skill when asked to manage, refresh, audit, triage, or summarize Coven Start with the Cave task board: -```bash -node /Users/buns/Documents/GitHub/OpenCoven/coven/skills/coven-task-manager/task-manager.mjs report -``` + node ./task-manager.mjs report The helper reads `~/.coven/cave-board.json` and writes `~/.coven/task-manager/freshness-report.md` by default. From 8a073a8872f3a9a97730709b8b92dfc6e38a2283 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sat, 6 Jun 2026 05:28:21 -0500 Subject: [PATCH 3/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- crates/coven-cli/src/cockpit_sources.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/coven-cli/src/cockpit_sources.rs b/crates/coven-cli/src/cockpit_sources.rs index 07ce753..ea6d7a6 100644 --- a/crates/coven-cli/src/cockpit_sources.rs +++ b/crates/coven-cli/src/cockpit_sources.rs @@ -263,10 +263,13 @@ pub fn scan_skills(coven_home: &Path) -> Result> { match fs::metadata(&dir) { Ok(meta) if meta.is_dir() => {} Ok(_) => continue, + Err(err) if err.kind() == io::ErrorKind::NotFound => continue, Err(err) => { - return Err(err).with_context(|| format!("failed to inspect {}", dir.display())); + return Err(err) + .with_context(|| format!("failed to inspect {}", dir.display())); } } + } let metadata_path = dir.join("metadata.json"); let raw = match fs::read_to_string(&metadata_path) { Ok(raw) => raw, From c450924ecdfa275be91ec467b324d835c8d401f4 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sat, 6 Jun 2026 05:28:28 -0500 Subject: [PATCH 4/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- skills/coven-task-manager/task-manager.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/skills/coven-task-manager/task-manager.mjs b/skills/coven-task-manager/task-manager.mjs index ac1a8da..60234ac 100755 --- a/skills/coven-task-manager/task-manager.mjs +++ b/skills/coven-task-manager/task-manager.mjs @@ -406,10 +406,12 @@ async function main() { const args = parseArgs(process.argv.slice(2)); const command = args._[0]; if (command === "report") { + const parsedStaleRunningHours = Number(args["stale-running-hours"] ?? 4); + const staleRunningHours = Number.isFinite(parsedStaleRunningHours) ? parsedStaleRunningHours : 4; const result = await writeTaskFreshnessReport({ covenHome: args["coven-home"] ?? DEFAULT_COVEN_HOME, out: args.out, - staleRunningHours: Number(args["stale-running-hours"] ?? 4), + staleRunningHours, }); process.stdout.write(`${result.report}\nWrote ${result.path}\n`); return; From 6a796cb2b2d00bc4ea92984296a91d2cb331fbce Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 6 Jun 2026 05:36:59 -0500 Subject: [PATCH 5/6] fix(skills): address task manager review feedback --- crates/coven-cli/src/cockpit_sources.rs | 21 ++++++++++++++++--- skills/coven-task-manager/SKILL.md | 6 ++++-- skills/coven-task-manager/task-manager.mjs | 9 +++++--- .../coven-task-manager/task-manager.test.mjs | 9 ++++++++ 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/crates/coven-cli/src/cockpit_sources.rs b/crates/coven-cli/src/cockpit_sources.rs index ea6d7a6..929b06a 100644 --- a/crates/coven-cli/src/cockpit_sources.rs +++ b/crates/coven-cli/src/cockpit_sources.rs @@ -265,11 +265,9 @@ pub fn scan_skills(coven_home: &Path) -> Result> { Ok(_) => continue, Err(err) if err.kind() == io::ErrorKind::NotFound => continue, Err(err) => { - return Err(err) - .with_context(|| format!("failed to inspect {}", dir.display())); + return Err(err).with_context(|| format!("failed to inspect {}", dir.display())); } } - } let metadata_path = dir.join("metadata.json"); let raw = match fs::read_to_string(&metadata_path) { Ok(raw) => raw, @@ -846,6 +844,23 @@ description = "..." Ok(()) } + #[cfg(unix)] + #[test] + fn scan_skills_skips_dangling_symlinks() -> Result<()> { + let temp = tempfile::tempdir()?; + let skills_root = temp.path().join(SKILLS_DIR); + fs::create_dir_all(&skills_root)?; + std::os::unix::fs::symlink( + temp.path().join("missing-skill"), + skills_root.join("missing-skill"), + )?; + + let out = scan_skills(temp.path())?; + + assert!(out.is_empty()); + Ok(()) + } + #[test] fn scan_memory_returns_empty_when_dir_missing() -> Result<()> { let temp = tempfile::tempdir()?; diff --git a/skills/coven-task-manager/SKILL.md b/skills/coven-task-manager/SKILL.md index 7ab7876..8b1e3aa 100644 --- a/skills/coven-task-manager/SKILL.md +++ b/skills/coven-task-manager/SKILL.md @@ -9,9 +9,11 @@ Use this skill when asked to manage, refresh, audit, triage, or summarize Coven ## Sources -Start with the Cave task board: +Start with the Cave task board from the skill directory: - node ./task-manager.mjs report +```bash +node ./task-manager.mjs report +``` The helper reads `~/.coven/cave-board.json` and writes `~/.coven/task-manager/freshness-report.md` by default. diff --git a/skills/coven-task-manager/task-manager.mjs b/skills/coven-task-manager/task-manager.mjs index 60234ac..6a0ace8 100755 --- a/skills/coven-task-manager/task-manager.mjs +++ b/skills/coven-task-manager/task-manager.mjs @@ -402,16 +402,19 @@ function parseArgs(argv) { return args; } +export function parseStaleRunningHours(value, fallback = 4) { + const parsed = Number(value ?? fallback); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + async function main() { const args = parseArgs(process.argv.slice(2)); const command = args._[0]; if (command === "report") { - const parsedStaleRunningHours = Number(args["stale-running-hours"] ?? 4); - const staleRunningHours = Number.isFinite(parsedStaleRunningHours) ? parsedStaleRunningHours : 4; const result = await writeTaskFreshnessReport({ covenHome: args["coven-home"] ?? DEFAULT_COVEN_HOME, out: args.out, - staleRunningHours, + staleRunningHours: parseStaleRunningHours(args["stale-running-hours"]), }); process.stdout.write(`${result.report}\nWrote ${result.path}\n`); return; diff --git a/skills/coven-task-manager/task-manager.test.mjs b/skills/coven-task-manager/task-manager.test.mjs index 029721e..1596179 100644 --- a/skills/coven-task-manager/task-manager.test.mjs +++ b/skills/coven-task-manager/task-manager.test.mjs @@ -11,6 +11,7 @@ import { installDefaultAutomations, installSkillSymlink, loadBoard, + parseStaleRunningHours, } from "./task-manager.mjs"; test("classifyTasks separates stale running, blocked, review, active, and done cards", () => { @@ -148,6 +149,14 @@ test("loadBoard reads Cave task cards from a Coven home", async () => { assert.equal(cards[0].id, "one"); }); +test("parseStaleRunningHours falls back for invalid CLI values", () => { + assert.equal(parseStaleRunningHours(undefined), 4); + assert.equal(parseStaleRunningHours("true"), 4); + assert.equal(parseStaleRunningHours("nope"), 4); + assert.equal(parseStaleRunningHours("0"), 4); + assert.equal(parseStaleRunningHours("6"), 6); +}); + test("installDefaultAutomations writes paused Codex automation TOMLs by default", async () => { const root = await mkdtemp(join(tmpdir(), "coven-task-manager-")); const codexHome = join(root, ".codex"); From 3962fd0543405e72fe92a1b3e703465a1af22f85 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 6 Jun 2026 06:48:24 -0500 Subject: [PATCH 6/6] fix(skills): omit pinned model from task automations --- .../coven-task-blocked-escalation.toml | 1 - .../coven-task-freshness-daily.toml | 1 - .../coven-task-weekly-cleanup.toml | 1 - skills/coven-task-manager/task-manager.mjs | 16 +++++++++++----- skills/coven-task-manager/task-manager.test.mjs | 9 +++++++++ 5 files changed, 20 insertions(+), 8 deletions(-) diff --git a/skills/coven-task-manager/automation-templates/coven-task-blocked-escalation.toml b/skills/coven-task-manager/automation-templates/coven-task-blocked-escalation.toml index b19f8da..52222bb 100644 --- a/skills/coven-task-manager/automation-templates/coven-task-blocked-escalation.toml +++ b/skills/coven-task-manager/automation-templates/coven-task-blocked-escalation.toml @@ -4,7 +4,6 @@ kind = "cron" name = "Coven Blocked Task Escalation" status = "PAUSED" rrule = "RRULE:FREQ=WEEKLY;BYHOUR=10;BYMINUTE=0;BYDAY=MO,TU,WE,TH,FR" -model = "gpt-5.5" reasoning_effort = "high" execution_environment = "worktree" cwds = [] diff --git a/skills/coven-task-manager/automation-templates/coven-task-freshness-daily.toml b/skills/coven-task-manager/automation-templates/coven-task-freshness-daily.toml index b2201fd..7145666 100644 --- a/skills/coven-task-manager/automation-templates/coven-task-freshness-daily.toml +++ b/skills/coven-task-manager/automation-templates/coven-task-freshness-daily.toml @@ -4,7 +4,6 @@ kind = "cron" name = "Coven Task Freshness Daily" status = "PAUSED" rrule = "RRULE:FREQ=WEEKLY;BYHOUR=8;BYMINUTE=30;BYDAY=SU,MO,TU,WE,TH,FR,SA" -model = "gpt-5.5" reasoning_effort = "high" execution_environment = "worktree" cwds = [] diff --git a/skills/coven-task-manager/automation-templates/coven-task-weekly-cleanup.toml b/skills/coven-task-manager/automation-templates/coven-task-weekly-cleanup.toml index f393a14..923993f 100644 --- a/skills/coven-task-manager/automation-templates/coven-task-weekly-cleanup.toml +++ b/skills/coven-task-manager/automation-templates/coven-task-weekly-cleanup.toml @@ -4,7 +4,6 @@ kind = "cron" name = "Coven Weekly Task Cleanup" status = "PAUSED" rrule = "RRULE:FREQ=WEEKLY;BYHOUR=17;BYMINUTE=0;BYDAY=FR" -model = "gpt-5.5" reasoning_effort = "high" execution_environment = "worktree" cwds = [] diff --git a/skills/coven-task-manager/task-manager.mjs b/skills/coven-task-manager/task-manager.mjs index 6a0ace8..8281f1c 100755 --- a/skills/coven-task-manager/task-manager.mjs +++ b/skills/coven-task-manager/task-manager.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { lstat, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { lstat, mkdir, readFile, symlink, unlink, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; @@ -333,15 +333,15 @@ function tomlMultiline(value) { } function automationToml(template, { skillPath, status }) { + const normalizedStatus = normalizeAutomationStatus(status); return [ "version = 1", `id = ${tomlString(template.id)}`, 'kind = "cron"', `name = ${tomlString(template.name)}`, `prompt = ${tomlMultiline(template.prompt)}`, - `status = ${tomlString(status)}`, + `status = ${tomlString(normalizedStatus)}`, `rrule = ${tomlString(template.rrule)}`, - 'model = "gpt-5.5"', 'reasoning_effort = "high"', 'execution_environment = "worktree"', "cwds = []", @@ -357,11 +357,12 @@ export async function installDefaultAutomations({ status = "PAUSED", } = {}) { const root = join(expandHome(codexHome), "automations"); + const normalizedStatus = normalizeAutomationStatus(status); const installed = []; for (const template of DEFAULT_AUTOMATIONS) { const dir = join(root, template.id); await mkdir(dir, { recursive: true }); - const toml = automationToml(template, { skillPath, status }); + const toml = automationToml(template, { skillPath, status: normalizedStatus }); await writeFile(join(dir, "automation.toml"), toml, "utf8"); installed.push({ id: template.id, path: join(dir, "automation.toml") }); } @@ -381,7 +382,7 @@ export async function installSkillSymlink({ if (!current.isSymbolicLink()) { throw new Error(`refusing to replace non-symlink skill install at ${target}`); } - await rm(target, { recursive: true, force: true }); + await unlink(target); } await symlink(resolve(skillPath), target, "dir"); return { path: target, changed: true }; @@ -407,6 +408,11 @@ export function parseStaleRunningHours(value, fallback = 4) { return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } +export function normalizeAutomationStatus(value, fallback = "PAUSED") { + const status = String(value ?? fallback).trim().toUpperCase(); + return status === "PAUSED" || status === "ACTIVE" ? status : fallback; +} + async function main() { const args = parseArgs(process.argv.slice(2)); const command = args._[0]; diff --git a/skills/coven-task-manager/task-manager.test.mjs b/skills/coven-task-manager/task-manager.test.mjs index 1596179..323d008 100644 --- a/skills/coven-task-manager/task-manager.test.mjs +++ b/skills/coven-task-manager/task-manager.test.mjs @@ -11,6 +11,7 @@ import { installDefaultAutomations, installSkillSymlink, loadBoard, + normalizeAutomationStatus, parseStaleRunningHours, } from "./task-manager.mjs"; @@ -157,6 +158,13 @@ test("parseStaleRunningHours falls back for invalid CLI values", () => { assert.equal(parseStaleRunningHours("6"), 6); }); +test("normalizeAutomationStatus accepts only supported automation states", () => { + assert.equal(normalizeAutomationStatus(undefined), "PAUSED"); + assert.equal(normalizeAutomationStatus("active"), "ACTIVE"); + assert.equal(normalizeAutomationStatus("PAUSED"), "PAUSED"); + assert.equal(normalizeAutomationStatus("delete-everything"), "PAUSED"); +}); + test("installDefaultAutomations writes paused Codex automation TOMLs by default", async () => { const root = await mkdtemp(join(tmpdir(), "coven-task-manager-")); const codexHome = join(root, ".codex"); @@ -183,6 +191,7 @@ test("installDefaultAutomations writes paused Codex automation TOMLs by default" assert.match(contents, /status = "PAUSED"/); assert.match(contents, /Use the `coven-task-manager` skill/); assert.match(contents, /rrule = "RRULE:FREQ=WEEKLY;BYHOUR=8;BYMINUTE=30;BYDAY=SU,MO,TU,WE,TH,FR,SA"/); + assert.doesNotMatch(contents, /^model = /m); await stat(first); });