feat(lib-core): biblioteca atomica @pulse-libs/core v1.0.0-beta.1
Esta commit conteudo a estrutura atomica completa:
- types: Result<T,E>, AsyncState<T>, Paginated<T>, SortConfig<T>
- utils: date, str, num, cn, debounce, throttle, storage, arr, obj
- validators: Zod schemas — email, password, uuid, url, phone, CPF/CNPJ, sanitizedStr, safeParse
- hooks: useToggle, useAsync, useDebounce, useLocalStorage, useMedia, useInterval, useOnClickOutside, useClipboard, useFetch
- components: Button, Input, Alert, Card, Spinner (atomic design pattern)
- build: tsup v8 ESM+CJS + DTS + sourcemaps — 0 erros
- tests: 57 testes 100% usuarios
- docker: multi-stage Dockerfile (node 20-alpine)
- config: vitest, tsup, tsconfig strict, .npmignore
Filosofia atomica:/utils ← /types ← /validators ← /hooks ← /components
Build: npm run build | Test: npm test | Publish: npm publish
🤖 Generated with Pulse (openclaw + nova-self-improver)
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"version": 1,
|
||||
"registry": "https://clawhub.ai",
|
||||
"slug": "taskflow",
|
||||
"installedVersion": "1.1.1",
|
||||
"installedAt": 1779235737501,
|
||||
"fingerprint": "c59f89eeb1ba41f2d984cdcf28fef14c99d764d4078d756ba33525dd9533cb1a"
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
# TaskFlow
|
||||
|
||||
Structured project and task management for OpenClaw agents — markdown-first, SQLite-backed, zero dependencies.
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Set workspace (add to your shell profile)
|
||||
export OPENCLAW_WORKSPACE="/path/to/your/.openclaw/workspace"
|
||||
|
||||
# 2. Link the CLI
|
||||
ln -sf "$(pwd)/scripts/taskflow-cli.mjs" /opt/homebrew/bin/taskflow # macOS
|
||||
# ln -sf "$(pwd)/scripts/taskflow-cli.mjs" /usr/local/bin/taskflow # Linux
|
||||
|
||||
# 3. Run setup
|
||||
taskflow setup
|
||||
```
|
||||
|
||||
The setup wizard creates your workspace structure, walks you through your first project, initializes the SQLite database, syncs your markdown files, and optionally installs a macOS LaunchAgent for automatic 60-second sync.
|
||||
|
||||
**Non-interactive (scripted installs):**
|
||||
|
||||
```bash
|
||||
taskflow setup --name "My Project" --desc "What it does"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Markdown-first** — `PROJECTS.md` and `tasks/<slug>-tasks.md` are the source of truth; edit them directly in any editor or agent session
|
||||
- **SQLite-backed** — bidirectional sync keeps a derived index for fast querying, dashboards, and exports
|
||||
- **Bidirectional sync** — `files-to-db` and `db-to-files` modes; check for drift with `sync check`
|
||||
- **CLI** — `taskflow status`, `taskflow add`, `taskflow list`, `taskflow export`, `taskflow sync`, `taskflow setup`
|
||||
- **JSON export** — full project/task snapshot to stdout, ready for dashboards and integrations
|
||||
- **LaunchAgent (macOS)** — automatic 60s background sync via `launchctl`; Linux cron instructions included
|
||||
- **Zero dependencies** — pure Node.js, uses the built-in `node:sqlite` module (no npm install)
|
||||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||

|
||||
|
||||
```
|
||||
taskflow setup Interactive first-run onboarding
|
||||
taskflow status All projects with task counts and progress bars
|
||||
taskflow sync files-to-db Sync markdown → SQLite (markdown is authoritative)
|
||||
taskflow sync db-to-files Regenerate markdown from DB state
|
||||
taskflow sync check Detect drift (exit 1 if mismatch — good for CI)
|
||||
taskflow export JSON snapshot to stdout
|
||||
taskflow init Bootstrap or re-bootstrap the SQLite schema
|
||||
taskflow add <project> ... Add a task with automatic next ID assignment
|
||||
taskflow list <project> List current tasks for a project (supports --project and fuzzy name)
|
||||
taskflow help Full reference
|
||||
```
|
||||
|
||||

|
||||
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Node.js 22.5+** (uses `node:sqlite` / `DatabaseSync`)
|
||||
- macOS or Linux
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
<workspace>/
|
||||
├── PROJECTS.md # Project registry (one ## block per project)
|
||||
├── tasks/<slug>-tasks.md # Task list per project (five fixed sections)
|
||||
├── plans/<slug>-plan.md # Optional: architecture / design docs
|
||||
└── memory/
|
||||
└── taskflow.sqlite # Derived index — never edit directly
|
||||
```
|
||||
|
||||
Tasks live in five fixed sections: `## In Progress`, `## Pending Validation`, `## Backlog`, `## Blocked`, `## Done`. A task line looks like:
|
||||
|
||||
```
|
||||
- [ ] (task:myproject-007) [P1] [codex] Implement search endpoint
|
||||
```
|
||||
|
||||
The sync engine reads section headers (not checkboxes) to determine task status. Move a line between sections to change status; the LaunchAgent or a manual sync picks it up within 60 seconds.
|
||||
|
||||
---
|
||||
|
||||
## Agent Integration
|
||||
|
||||
TaskFlow is designed to be used by OpenClaw agents as a skill. The agent reads `PROJECTS.md` to discover projects, reads `tasks/<slug>-tasks.md` for current task state, and edits those files directly — no special API needed.
|
||||
|
||||
See [SKILL.md](SKILL.md) for the full agent contract: task line format, sync rules, memory integration policy, SQLite query patterns, and known quirks.
|
||||
|
||||
---
|
||||
|
||||
## Apple Notes Sync (macOS)
|
||||
|
||||
`taskflow notes` pushes a live project status summary to Apple Notes -- share it with your team or just keep it on your phone.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Use at your own risk. We are not liable for any issues you encounter. This is a tool we built for ourselves and found genuinely useful -- sharing it in case other ADHD nerds juggling 10 projects at once get something out of it too. PRs and feedback welcome.
|
||||
|
||||
---
|
||||
|
||||
## Credits
|
||||
|
||||
Built by [Aux](https://github.com/auxclawdbot) (an [OpenClaw](https://openclaw.ai) agent) and [@sm0ls](https://github.com/sm0ls).
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,695 @@
|
||||
---
|
||||
name: taskflow
|
||||
description: Structured project/task management for OpenClaw agents — markdown-first authoring, SQLite-backed querying, bidirectional sync, CLI, Apple Notes integration.
|
||||
metadata:
|
||||
{
|
||||
"openclaw":
|
||||
{
|
||||
"emoji": "📋",
|
||||
"os": ["darwin", "linux"],
|
||||
"requires": { "bins": ["node"], "env": ["OPENCLAW_WORKSPACE"] },
|
||||
},
|
||||
}
|
||||
---
|
||||
|
||||
# TaskFlow — Agent Skill Reference
|
||||
|
||||
TaskFlow gives any OpenClaw agent a **structured project/task/plan system** with markdown-first authoring, SQLite-backed querying, and bidirectional sync.
|
||||
|
||||
**Principle:** Markdown is canonical. Edit `tasks/*.md` directly. The SQLite DB is a derived index, not the source of truth.
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
### OPENCLAW_WORKSPACE Trust Boundary
|
||||
|
||||
`OPENCLAW_WORKSPACE` is a **high-trust value**. All TaskFlow scripts resolve file paths from it, and the CLI and sync daemon use it to locate the SQLite database, markdown task files, and log directory.
|
||||
|
||||
**Rules for safe use:**
|
||||
|
||||
1. **Set it only from trusted, controlled sources.** The value must come from:
|
||||
- Your own shell profile (`.zshrc`, `.bashrc`, `/etc/environment`)
|
||||
- The systemd user unit `Environment=` directive in a template you control
|
||||
- The macOS LaunchAgent `EnvironmentVariables` dictionary you installed
|
||||
|
||||
**Never** accept `OPENCLAW_WORKSPACE` from:
|
||||
- User-supplied CLI arguments or HTTP request parameters
|
||||
- Untrusted config files read at runtime
|
||||
- Any external input that has not been explicitly validated
|
||||
|
||||
2. **Validate the path exists before use.** Any script that reads `OPENCLAW_WORKSPACE` should confirm the directory exists before proceeding:
|
||||
|
||||
```js
|
||||
import { existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const workspace = process.env.OPENCLAW_WORKSPACE
|
||||
if (!workspace) {
|
||||
console.error('OPENCLAW_WORKSPACE is not set. Aborting.')
|
||||
process.exit(1)
|
||||
}
|
||||
if (!existsSync(workspace)) {
|
||||
console.error(`OPENCLAW_WORKSPACE path does not exist: ${workspace}`)
|
||||
process.exit(1)
|
||||
}
|
||||
// Resolve to absolute path to neutralize any relative-path tricks
|
||||
const safeWorkspace = path.resolve(workspace)
|
||||
```
|
||||
|
||||
3. **Do not construct paths from untrusted input.** Even with a valid `OPENCLAW_WORKSPACE`, never concatenate unvalidated user input onto it (e.g. `path.join(workspace, userSlug, '../../../etc/passwd')`). Use `path.resolve()` and check that the resolved path starts with the workspace root:
|
||||
|
||||
```js
|
||||
function safeJoin(base, ...parts) {
|
||||
const resolved = path.resolve(base, ...parts)
|
||||
if (!resolved.startsWith(path.resolve(base) + path.sep)) {
|
||||
throw new Error(`Path traversal attempt detected: ${resolved}`)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
```
|
||||
|
||||
4. **Treat `OPENCLAW_WORKSPACE` as a local system path only.** It must point to a directory on the local filesystem. Remote paths (NFS mounts, network shares) may work but are outside the tested configuration and could introduce TOCTOU (time-of-check/time-of-use) race conditions.
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Set environment variable
|
||||
|
||||
Add to your shell profile (`.zshrc`, `.bashrc`, etc.):
|
||||
|
||||
```bash
|
||||
export OPENCLAW_WORKSPACE="/path/to/your/.openclaw/workspace"
|
||||
```
|
||||
|
||||
All TaskFlow scripts and the CLI resolve paths from this variable. Without it, they fall back to `process.cwd()`, which is almost never what you want.
|
||||
|
||||
> **See also:** [OPENCLAW_WORKSPACE Trust Boundary](#openclaw_workspace-trust-boundary) above for security requirements.
|
||||
|
||||
### 2. Link the CLI
|
||||
|
||||
```bash
|
||||
ln -sf {baseDir}/scripts/taskflow-cli.mjs /opt/homebrew/bin/taskflow # macOS (Apple Silicon)
|
||||
# or: ln -sf {baseDir}/scripts/taskflow-cli.mjs /usr/local/bin/taskflow
|
||||
```
|
||||
|
||||
### 3. Run the setup wizard
|
||||
|
||||
```bash
|
||||
taskflow setup
|
||||
```
|
||||
|
||||
The wizard handles the rest: creates workspace directories, walks you through adding your first project(s), initializes the database, syncs, and optionally installs the macOS LaunchAgent for periodic sync.
|
||||
|
||||
**Alternative — manual setup:**
|
||||
|
||||
<details>
|
||||
<summary>Manual steps (if you prefer explicit control)</summary>
|
||||
|
||||
```bash
|
||||
# Create workspace dirs
|
||||
mkdir -p "$OPENCLAW_WORKSPACE/tasks" "$OPENCLAW_WORKSPACE/plans" "$OPENCLAW_WORKSPACE/memory" "$OPENCLAW_WORKSPACE/logs"
|
||||
|
||||
# Bootstrap the DB schema
|
||||
taskflow init
|
||||
|
||||
# Create PROJECTS.md and tasks/<slug>-tasks.md manually (see templates/)
|
||||
|
||||
# Sync markdown → DB
|
||||
taskflow sync files-to-db
|
||||
|
||||
# Verify
|
||||
taskflow status
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## First Run
|
||||
|
||||
### For agents (OpenClaw / AI)
|
||||
|
||||
When a user asks you to set up TaskFlow or you detect it has not been initialized:
|
||||
|
||||
1. **Detect state.** Check for `$OPENCLAW_WORKSPACE/PROJECTS.md` and `$OPENCLAW_WORKSPACE/memory/taskflow.sqlite`.
|
||||
2. **If clean slate:** Ask the user for their first project name and description, then run:
|
||||
```bash
|
||||
taskflow setup --name "Project Name" --desc "One-liner description"
|
||||
```
|
||||
Follow up by running `taskflow status` to confirm.
|
||||
3. **If PROJECTS.md exists but no DB:** Run `taskflow setup` (it detects the state automatically and offers to init + sync).
|
||||
4. **If both exist:** Run `taskflow status` — already set up.
|
||||
5. After setup, update `AGENTS.md` with the new project slug so future sessions discover it via `cat PROJECTS.md`.
|
||||
|
||||
### For humans (CLI)
|
||||
|
||||
```bash
|
||||
taskflow setup
|
||||
```
|
||||
|
||||
The interactive wizard will:
|
||||
- Detect your existing workspace state
|
||||
- Walk you through naming your first project(s)
|
||||
- Create `PROJECTS.md` and `tasks/<slug>-tasks.md` from templates
|
||||
- Initialize the SQLite database and sync
|
||||
- Offer to install the periodic-sync daemon (LaunchAgent on macOS, systemd timer on Linux) for automatic 60s sync
|
||||
|
||||
**Non-interactive (scripted installs):**
|
||||
|
||||
```bash
|
||||
taskflow setup --name "My Project" --desc "What it does"
|
||||
```
|
||||
|
||||
Passing `--name` skips all interactive prompts (daemon install is also skipped in non-interactive mode).
|
||||
|
||||
---
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
<workspace>/
|
||||
├── PROJECTS.md # Project registry (one ## block per project)
|
||||
├── tasks/<slug>-tasks.md # Task list per project
|
||||
├── plans/<slug>-plan.md # Optional: architecture/design doc per project
|
||||
└── taskflow/
|
||||
├── SKILL.md # This file
|
||||
├── scripts/
|
||||
│ ├── taskflow-cli.mjs # CLI entry point (symlink target)
|
||||
│ ├── task-sync.mjs # Bidirectional markdown ↔ SQLite sync
|
||||
│ ├── init-db.mjs # Bootstrap SQLite schema (idempotent)
|
||||
│ ├── export-projects-overview.mjs # JSON export of project/task state
|
||||
│ └── apple-notes-export.mjs # Optional: project state → Apple Notes (macOS only)
|
||||
├── templates/ # Starter files for new projects
|
||||
├── schema/
|
||||
│ └── taskflow.sql # Full DDL
|
||||
└── system/
|
||||
├── com.taskflow.sync.plist.xml # Periodic sync (macOS LaunchAgent)
|
||||
├── taskflow-sync.service # Periodic sync (Linux systemd user unit)
|
||||
└── taskflow-sync.timer # Systemd timer (60s interval)
|
||||
<workspace>/
|
||||
└── taskflow.config.json # Apple Notes config (auto-created on first notes run)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Creating a Project
|
||||
|
||||
Follow this full checklist when creating a new project:
|
||||
|
||||
### 1. Add a block to `PROJECTS.md`
|
||||
|
||||
```markdown
|
||||
## <slug>
|
||||
- Name: <Human-Readable Name>
|
||||
- Status: active
|
||||
- Description: One-sentence description of the project.
|
||||
```
|
||||
|
||||
- `slug` is lowercase, hyphenated (e.g., `my-project`). It becomes the canonical project ID everywhere.
|
||||
- Valid status values: `active`, `paused`, `done`.
|
||||
|
||||
### 2. Create the task file
|
||||
|
||||
Copy `taskflow/templates/tasks-template.md` → `tasks/<slug>-tasks.md` and update the project name in the heading.
|
||||
|
||||
The file **must** contain these five section headers in this order:
|
||||
|
||||
```markdown
|
||||
# <Project Name> — Tasks
|
||||
|
||||
## In Progress
|
||||
## Pending Validation
|
||||
## Backlog
|
||||
## Blocked
|
||||
## Done
|
||||
```
|
||||
|
||||
### 3. Optionally create a plan file
|
||||
|
||||
Copy `taskflow/templates/plan-template.md` → `plans/<slug>-plan.md` for architecture docs, design decisions, and phased roadmaps. Plan files are **not** synced to SQLite — they are reference-only for the agent.
|
||||
|
||||
### 4. DB row (auto-created on first sync)
|
||||
|
||||
You do **not** need to manually insert into the `projects` table. The sync engine auto-creates the project row from `PROJECTS.md` on the next `files-to-db` run. If you want to be explicit via Node.js, use a parameterized statement:
|
||||
|
||||
```js
|
||||
// Safe: parameterized insert — no string interpolation in the SQL
|
||||
db.prepare(`INSERT INTO projects (id, name, description, status)
|
||||
VALUES (:id, :name, :description, 'active')`)
|
||||
.run({ id: slug, name: projectName, description: projectDesc })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task Line Format
|
||||
|
||||
Every task line follows this exact format:
|
||||
|
||||
```
|
||||
- [x| ] (task:<id>) [<priority>] [<owner>] <title>
|
||||
```
|
||||
|
||||
| Field | Details |
|
||||
|---|---|
|
||||
| `[ ]` / `[x]` | Open / completed. Sync drives status from section header, not this checkbox. |
|
||||
| `(task:<id>)` | Task ID. Format: `<slug>-NNN` (zero-padded 3-digit). Sequential per project. |
|
||||
| `[<priority>]` | **Required. Must come before owner tag.** See priority table below. |
|
||||
| `[<owner>]` | Optional. Agent/model tag (e.g., `codex`, `sonnet`, `claude`). |
|
||||
| `<title>` | Human-readable task title. |
|
||||
|
||||
### ⚠️ Tag Order Rule
|
||||
|
||||
**Priority tag MUST come before owner tag.** The sync parser is positional — it reads the first `[Px]` bracket as priority, and the next `[tag]` as owner. Swapping them will misparse the task.
|
||||
|
||||
### ⚠️ Title Sanitization Rules
|
||||
|
||||
Task titles must be **plain text only**. Before writing any user-supplied string as a task title, apply the following rules:
|
||||
|
||||
1. **Reject lines that look like section headers.** A title may not start with one or more `#` characters followed by a space (e.g. `# My heading`, `## Done`). These would corrupt the sync parser's section detection.
|
||||
|
||||
2. **Reject the exact section header strings** even without leading whitespace:
|
||||
- `In Progress`, `Pending Validation`, `Backlog`, `Blocked`, `Done`
|
||||
- Comparison must be case-insensitive.
|
||||
|
||||
3. **Escape or strip markdown special characters** that have structural meaning in the task file:
|
||||
|
||||
| Character | Risk | Safe action |
|
||||
|-----------|------|-------------|
|
||||
| `#` | Looks like a header | Strip or reject |
|
||||
| `- ` (dash + space at line start) | Looks like a list item / task | Strip leading `- ` |
|
||||
| `[ ]` / `[x]` | Looks like a checkbox | Escape brackets: `\[` `\]` |
|
||||
| `]` / `[` alone | Can corrupt `(task:id)` parse | Escape: `\[` `\]` |
|
||||
| Newlines (`\n`, `\r`) | Creates multi-line titles | Strip / reject |
|
||||
|
||||
4. **Maximum length.** Titles should be ≤ 200 characters. Truncate or reject longer strings.
|
||||
|
||||
**Example sanitization (Node.js):**
|
||||
|
||||
```js
|
||||
// Safe: sanitize a user-supplied task title before writing to markdown
|
||||
function sanitizeTitle(raw) {
|
||||
if (typeof raw !== 'string') throw new TypeError('title must be a string')
|
||||
|
||||
// Strip newlines
|
||||
let title = raw.replace(/[\r\n]+/g, ' ').trim()
|
||||
|
||||
// Reject lines that look like section headers (# Heading or bare header words)
|
||||
if (/^#{1,6}\s/.test(title)) {
|
||||
throw new Error('Title may not start with a markdown heading (#)')
|
||||
}
|
||||
const BANNED_HEADERS = /^(in progress|pending validation|backlog|blocked|done)$/i
|
||||
if (BANNED_HEADERS.test(title)) {
|
||||
throw new Error('Title may not be a reserved section header name')
|
||||
}
|
||||
|
||||
// Escape structural markdown characters
|
||||
title = title
|
||||
.replace(/\[/g, '\\[')
|
||||
.replace(/\]/g, '\\]')
|
||||
|
||||
// Enforce length limit
|
||||
if (title.length > 200) {
|
||||
throw new Error('Title exceeds 200 character limit')
|
||||
}
|
||||
|
||||
return title
|
||||
}
|
||||
```
|
||||
|
||||
These rules apply whenever a task title comes from **any external or user-supplied source** (CLI args, API payloads, file imports). Titles hard-coded by agents in their own sessions are low-risk but should still avoid structural characters.
|
||||
|
||||
✅ Correct: `- [ ] (task:myproject-007) [P1] [codex] Implement search`
|
||||
❌ Wrong: `- [ ] (task:myproject-007) [codex] [P1] Implement search`
|
||||
|
||||
### Priority Levels (Configurable)
|
||||
|
||||
| Tag | Default Meaning |
|
||||
|---|---|
|
||||
| `P0` | Critical — must do now, blocks everything |
|
||||
| `P1` | High — important, do soon |
|
||||
| `P2` | Normal — standard priority (default) |
|
||||
| `P3` | Low — nice to have |
|
||||
| `P9` | Someday — no urgency, parking lot |
|
||||
|
||||
Priorities are configurable per-installation but the tags themselves (`P0`–`P3`, `P9`) are what the sync engine validates.
|
||||
|
||||
### Optional Note Lines
|
||||
|
||||
A note can follow a task line as an indented `- note:` line:
|
||||
|
||||
```markdown
|
||||
- [ ] (task:myproject-003) [P1] [codex] Implement auth flow
|
||||
- note: blocked on API key from vendor
|
||||
```
|
||||
|
||||
> **Known limitation (v1):** Notes are one-way. Removing or editing a note in markdown does not propagate to the DB. This is tracked for a post-MVP fix.
|
||||
|
||||
### Example Task File Section
|
||||
|
||||
```markdown
|
||||
## In Progress
|
||||
- [ ] (task:myproject-001) [P1] [codex] Wire up OAuth login
|
||||
- note: PR open, needs review
|
||||
|
||||
## Backlog
|
||||
- [ ] (task:myproject-002) [P2] Add rate limiting middleware
|
||||
- [ ] (task:myproject-003) [P3] Write integration tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Task
|
||||
|
||||
1. **Determine the next ID.** Scan the task file for the highest existing `<slug>-NNN` and increment by 1. Or query SQLite using a **parameterized statement** (never interpolate the slug into SQL strings):
|
||||
|
||||
```js
|
||||
// Node.js — safe, parameterized
|
||||
const db = new DatabaseSync(dbPath)
|
||||
const row = db
|
||||
.prepare(`SELECT MAX(CAST(SUBSTR(id, LENGTH(:slug) + 2) AS INTEGER)) AS max_seq
|
||||
FROM tasks_v2
|
||||
WHERE project_id = :slug`)
|
||||
.get({ slug: projectSlug })
|
||||
const nextSeq = (row.max_seq ?? 0) + 1
|
||||
const nextId = `${projectSlug}-${String(nextSeq).padStart(3, '0')}`
|
||||
```
|
||||
|
||||
> ⚠️ **Never construct SQL by string interpolation.** Use `db.prepare()` with named or positional parameters (`?` or `:name`) for all values that come from external input. This applies even for read-only queries.
|
||||
|
||||
2. **Append the task line** to the correct section (`## Backlog` for new work, `## In Progress` if starting immediately).
|
||||
|
||||
3. **Format the line** using the exact format above. No trailing spaces. Priority tag before owner tag.
|
||||
|
||||
---
|
||||
|
||||
## Updating Task Status
|
||||
|
||||
**Move the task line** from its current section to the target section in the markdown file.
|
||||
|
||||
| Target State | Move to Section |
|
||||
|---|---|
|
||||
| Started / picked up | `## In Progress` |
|
||||
| Needs human review | `## Pending Validation` |
|
||||
| Not started yet | `## Backlog` |
|
||||
| Waiting on dependency | `## Blocked` |
|
||||
| Finished | `## Done` |
|
||||
|
||||
Also flip the checkbox: `[ ]` for active states, `[x]` for `Done` (and optionally `Pending Validation`).
|
||||
|
||||
The periodic sync (60s) will pick up the change and update SQLite automatically. To force an immediate sync:
|
||||
|
||||
```bash
|
||||
node taskflow/scripts/task-sync.mjs files-to-db
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Querying Tasks
|
||||
|
||||
### Simple: Read the markdown file directly
|
||||
|
||||
```bash
|
||||
cat tasks/<slug>-tasks.md
|
||||
```
|
||||
|
||||
For a quick in-session view, just read the relevant section.
|
||||
|
||||
### Advanced: Query SQLite
|
||||
|
||||
> ⚠️ **SQL Safety Rule:** Any query that incorporates a variable value (project slug, task ID, status string, etc.) **must** use parameterized statements — not string interpolation. The `sqlite3` CLI examples below use only **static, hardcoded literal values** and are shown as diagnostic/inspection tools only. For programmatic use, always use the Node.js `db.prepare()` API with bound parameters.
|
||||
|
||||
#### sqlite3 CLI (static queries — for manual inspection only)
|
||||
|
||||
```bash
|
||||
# All in-progress tasks across all projects (by priority)
|
||||
# Safe: 'in_progress' is a static literal, not a variable
|
||||
sqlite3 "$OPENCLAW_WORKSPACE/memory/taskflow.sqlite" \
|
||||
"SELECT id, project_id, priority, title
|
||||
FROM tasks_v2
|
||||
WHERE status = 'in_progress'
|
||||
ORDER BY priority, project_id;"
|
||||
|
||||
# Task count by status per project (no variables — safe for CLI)
|
||||
sqlite3 "$OPENCLAW_WORKSPACE/memory/taskflow.sqlite" \
|
||||
"SELECT project_id, status, COUNT(*) AS count
|
||||
FROM tasks_v2
|
||||
GROUP BY project_id, status
|
||||
ORDER BY project_id, status;"
|
||||
```
|
||||
|
||||
> Do **not** embed shell variables directly in the SQL string (e.g. `WHERE project_id = '$SLUG'`). That pattern is SQL injection waiting to happen. Use the Node.js API with parameters instead.
|
||||
|
||||
#### Node.js API — parameterized queries (required for programmatic use)
|
||||
|
||||
```js
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import path from 'node:path'
|
||||
|
||||
const dbPath = path.join(process.env.OPENCLAW_WORKSPACE, 'memory', 'taskflow.sqlite')
|
||||
const db = new DatabaseSync(dbPath)
|
||||
db.exec('PRAGMA foreign_keys = ON')
|
||||
|
||||
// ── Backlog for a specific project ─────────────────────────────
|
||||
// :slug is a named parameter — never interpolated into the SQL string
|
||||
const backlog = db
|
||||
.prepare(`SELECT id, priority, title
|
||||
FROM tasks_v2
|
||||
WHERE project_id = :slug AND status = 'backlog'
|
||||
ORDER BY priority`)
|
||||
.all({ slug: 'my-project' }) // value bound at runtime, never in SQL string
|
||||
|
||||
// ── Audit trail for a specific task ────────────────────────────
|
||||
const transitions = db
|
||||
.prepare(`SELECT from_status, to_status, actor, at
|
||||
FROM task_transitions_v2
|
||||
WHERE task_id = ?
|
||||
ORDER BY at`)
|
||||
.all('my-project-007') // positional parameter — also safe
|
||||
|
||||
// ── Write: update task status ───────────────────────────────────
|
||||
// NEVER: db.exec(`UPDATE tasks_v2 SET status='${newStatus}' WHERE id='${id}'`)
|
||||
// ALWAYS:
|
||||
db.prepare(`UPDATE tasks_v2 SET status = :status, updated_at = datetime('now')
|
||||
WHERE id = :id`)
|
||||
.run({ status: 'done', id: 'my-project-007' })
|
||||
```
|
||||
|
||||
### CLI Quick Reference
|
||||
|
||||
```bash
|
||||
# Terminal summary: all projects + task counts by status
|
||||
taskflow status
|
||||
|
||||
# Add a task in markdown with automatic next ID
|
||||
taskflow add taskflow "Implement quick add command" --priority P1 --owner codex
|
||||
|
||||
# List current tasks for a project (excludes done by default)
|
||||
taskflow list taskflow
|
||||
taskflow list --project "TaskFlow" --all
|
||||
taskflow list task --status backlog,pending_validation --json
|
||||
|
||||
# JSON export of full project/task state (for dashboards, integrations)
|
||||
node taskflow/scripts/export-projects-overview.mjs
|
||||
|
||||
# Detect drift between markdown and DB (exit 1 if mismatch)
|
||||
node taskflow/scripts/task-sync.mjs check
|
||||
|
||||
# Sync markdown → DB (normal direction; run after editing task files)
|
||||
node taskflow/scripts/task-sync.mjs files-to-db
|
||||
|
||||
# Sync DB → markdown (run after programmatic DB updates)
|
||||
node taskflow/scripts/task-sync.mjs db-to-files
|
||||
```
|
||||
|
||||
### Apple Notes Export (Optional — macOS Only)
|
||||
|
||||
TaskFlow can maintain a live Apple Note with your current project status. The note is rendered as rich HTML and written via AppleScript.
|
||||
|
||||
```bash
|
||||
# Push current status to Apple Notes (creates note on first run)
|
||||
taskflow notes
|
||||
```
|
||||
|
||||
On first run (or during `taskflow setup`), a new note is created in the configured folder and its Core Data ID is saved to:
|
||||
|
||||
```
|
||||
$OPENCLAW_WORKSPACE/taskflow.config.json
|
||||
```
|
||||
|
||||
Config schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"appleNotesId": "x-coredata://...",
|
||||
"appleNotesFolder": "Notes",
|
||||
"appleNotesTitle": "TaskFlow - Project Status"
|
||||
}
|
||||
```
|
||||
|
||||
**Important — never delete the shared note.** The note is always edited in-place. Deleting and recreating it generates a new Core Data ID and breaks any existing share links. If the note is accidentally deleted, `taskflow notes` will create a new one and update the config automatically.
|
||||
|
||||
For hourly auto-refresh, add a cron entry:
|
||||
|
||||
```bash
|
||||
# Run: crontab -e
|
||||
0 * * * * OPENCLAW_WORKSPACE=/path/to/workspace /path/to/node /path/to/taskflow/scripts/apple-notes-export.mjs
|
||||
```
|
||||
|
||||
Or install a dedicated LaunchAgent (macOS) targeting `apple-notes-export.mjs` with an hourly `StartInterval` of `3600`.
|
||||
|
||||
This feature is entirely optional and macOS-specific. On other platforms, `taskflow notes` exits gracefully with a message.
|
||||
|
||||
---
|
||||
|
||||
## Memory Integration Rules
|
||||
|
||||
These rules keep daily memory logs clean and prevent duplication.
|
||||
|
||||
### ✅ Do
|
||||
|
||||
- Reference task IDs in daily memory logs when you complete or advance work:
|
||||
```
|
||||
Completed `myproject-007` (OAuth login). Moved `myproject-008` to In Progress.
|
||||
```
|
||||
- Keep memory entries narrative — what happened, what you decided, what's next.
|
||||
|
||||
### ❌ Do Not
|
||||
|
||||
- **Never duplicate the backlog in daily memory files.** `tasks/<slug>-tasks.md` is the single source of truth for all pending work. Memory files should not list what's left to do.
|
||||
- Do not track task state changes in memory (e.g., "Task 007 is now in progress"). Only note meaningful progress events or decisions.
|
||||
- Do not create new tasks in memory files. Add them to the task file directly.
|
||||
|
||||
### Pattern: Loading Project Context
|
||||
|
||||
At the start of a session involving a project:
|
||||
|
||||
1. `cat PROJECTS.md` — identify the project slug and status
|
||||
2. `cat tasks/<slug>-tasks.md` — load current task state
|
||||
3. `cat plans/<slug>-plan.md` — load architecture context (if it exists)
|
||||
4. Begin work. Record task ID references in memory at session end.
|
||||
|
||||
---
|
||||
|
||||
## Periodic Sync Daemon
|
||||
|
||||
The sync daemon runs `task-sync.mjs files-to-db` every **60 seconds** in the background. This means markdown edits are automatically reflected in SQLite within a minute.
|
||||
|
||||
- Logs: `logs/taskflow-sync.stdout.log` and `logs/taskflow-sync.stderr.log` (relative to workspace)
|
||||
- Lock: Advisory TTL lock in `sync_state` table prevents concurrent syncs
|
||||
- Conflict resolution: Last-write-wins per sync direction
|
||||
|
||||
### Quickest install (auto-detects OS)
|
||||
|
||||
```bash
|
||||
taskflow install-daemon
|
||||
```
|
||||
|
||||
This detects your platform and installs the appropriate unit. On macOS it installs and loads the LaunchAgent; on Linux it writes systemd user units and enables the timer.
|
||||
|
||||
### macOS — LaunchAgent (manual steps)
|
||||
|
||||
Templates: `taskflow/system/com.taskflow.sync.plist.xml`
|
||||
|
||||
1. Copy `taskflow/system/com.taskflow.sync.plist.xml` → `~/Library/LaunchAgents/com.taskflow.sync.plist`
|
||||
2. Replace `{{workspace}}` with the absolute path to your workspace (no trailing slash)
|
||||
3. Replace `{{node}}` with the path to your `node` binary (`which node`)
|
||||
4. Load: `launchctl load ~/Library/LaunchAgents/com.taskflow.sync.plist`
|
||||
5. Verify: `launchctl list | grep taskflow`
|
||||
|
||||
Uninstall:
|
||||
```bash
|
||||
launchctl unload ~/Library/LaunchAgents/com.taskflow.sync.plist
|
||||
rm ~/Library/LaunchAgents/com.taskflow.sync.plist
|
||||
```
|
||||
|
||||
### Linux — systemd user timer (manual steps)
|
||||
|
||||
Templates: `taskflow/system/taskflow-sync.service` and `taskflow/system/taskflow-sync.timer`
|
||||
|
||||
```bash
|
||||
# Create the user unit directory
|
||||
mkdir -p ~/.config/systemd/user
|
||||
|
||||
# Copy templates, replacing placeholders
|
||||
sed -e "s|{{workspace}}|$OPENCLAW_WORKSPACE|g" \
|
||||
-e "s|{{node}}|$(which node)|g" \
|
||||
taskflow/system/taskflow-sync.service > ~/.config/systemd/user/taskflow-sync.service
|
||||
|
||||
sed -e "s|{{workspace}}|$OPENCLAW_WORKSPACE|g" \
|
||||
-e "s|{{node}}|$(which node)|g" \
|
||||
taskflow/system/taskflow-sync.timer > ~/.config/systemd/user/taskflow-sync.timer
|
||||
|
||||
# Enable and start
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now taskflow-sync.timer
|
||||
```
|
||||
|
||||
Verify:
|
||||
```bash
|
||||
systemctl --user status taskflow-sync.timer
|
||||
journalctl --user -u taskflow-sync.service
|
||||
```
|
||||
|
||||
Uninstall:
|
||||
```bash
|
||||
systemctl --user disable --now taskflow-sync.timer
|
||||
rm ~/.config/systemd/user/taskflow-sync.{service,timer}
|
||||
systemctl --user daemon-reload
|
||||
```
|
||||
|
||||
> **Note:** systemd user units require a login session. To run them without an interactive session (e.g. on a server), enable lingering: `loginctl enable-linger $USER`
|
||||
|
||||
---
|
||||
|
||||
## Section Header → DB Status Map
|
||||
|
||||
| Markdown Header | DB `status` value |
|
||||
|---|---|
|
||||
| `## In Progress` | `in_progress` |
|
||||
| `## Pending Validation` | `pending_validation` |
|
||||
| `## Backlog` | `backlog` |
|
||||
| `## Blocked` | `blocked` |
|
||||
| `## Done` | `done` |
|
||||
|
||||
**Section headers are fixed.** Do not rename them. The sync parser maps these exact strings.
|
||||
|
||||
---
|
||||
|
||||
## Known Quirks
|
||||
|
||||
Things that work but might trip you up:
|
||||
|
||||
- **`MAX(id)` is lexicographic.** Task IDs are text, so `SELECT MAX(id)` works only because IDs are zero-padded (`-001`, `-002`). If you create `-1` instead of `-001`, sequencing breaks. Always zero-pad to 3 digits.
|
||||
- **Checkbox state is decorative.** Status comes from which `##` section a task lives under, not whether it's `[x]` or `[ ]`. The sync engine ignores the checkbox on read. On write-back, `done` tasks get `[x]`, everything else gets `[ ]`.
|
||||
- **Notes survive deletion.** If you remove a `- note:` line from markdown, the old note stays in the DB (COALESCE preserves it). This is intentional for v1 -- notes are one-way display. To truly clear a note, update the DB directly.
|
||||
- **Lock TTL is 60 seconds.** If a sync crashes without releasing the lock, the next run will be blocked for up to 60s. The SIGTERM/SIGINT handlers try to clean up, but a `kill -9` won't. The lock auto-expires.
|
||||
- **Auto-project creation derives names from slugs.** If sync encounters a task file with no matching `projects` row, it creates one with a name like "My Project" from slug "my-project". The name might not be what you want -- fix it in PROJECTS.md and re-sync.
|
||||
- **Tag order is strict.** `[P1] [codex]` works. `[codex] [P1]` silently assigns `codex` as... nothing useful. Priority tag must come first.
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations (v1)
|
||||
|
||||
- Notes are one-way (markdown → DB). Removing a note in markdown does not clear it in DB.
|
||||
- `db-to-files` rewrites all project task files, even unchanged ones.
|
||||
- One task file per project (1:1 mapping). Multiple files per project is post-MVP.
|
||||
- Periodic sync daemon: macOS (LaunchAgent) and Linux (systemd user timer) are supported. Run `taskflow install-daemon` to install.
|
||||
- Node.js 22.5+ required (`node:sqlite`). No Python fallback in v1.
|
||||
|
||||
---
|
||||
|
||||
## Quick Cheat Sheet
|
||||
|
||||
```
|
||||
New project: PROJECTS.md block + tasks/<slug>-tasks.md + optional plans/<slug>-plan.md
|
||||
New task: taskflow add <project> "title" (or append manually to section)
|
||||
Update status: Move line to correct ## section, flip checkbox if needed
|
||||
Query simple: cat tasks/<slug>-tasks.md
|
||||
Query complex: Use db.prepare('SELECT ... WHERE id = ?').all(id) — never interpolate variables into SQL
|
||||
CLI status: taskflow status
|
||||
CLI add: taskflow add dashboard "Fix cron panel" --priority P1 --owner codex
|
||||
Force sync: node taskflow/scripts/task-sync.mjs files-to-db
|
||||
Memory rule: Reference IDs in logs; never copy backlog into memory
|
||||
```
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn70t1yqncqxf8ajcssx37gc2x81ajqt",
|
||||
"slug": "taskflow",
|
||||
"version": "1.1.1",
|
||||
"publishedAt": 1771801350988
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
# Building a Dashboard on TaskFlow (taskflow-016)
|
||||
|
||||
TaskFlow is **UI-agnostic**. It ships data; you pick the surface.
|
||||
|
||||
The export script (`taskflow export`) writes a single, self-contained JSON document
|
||||
to stdout. That's the only contract. Everything else is up to you.
|
||||
|
||||
---
|
||||
|
||||
## The Core Loop
|
||||
|
||||
```
|
||||
TaskFlow DB → export JSON → your UI
|
||||
```
|
||||
|
||||
Run `taskflow export > /path/to/projects.json` on any schedule and your dashboard
|
||||
gets fresh data without any server, polling API, or WebSocket.
|
||||
|
||||
---
|
||||
|
||||
## How We Built Ours
|
||||
|
||||
We run a **Vite + React** single-page app that reads a static JSON file.
|
||||
The JSON is refreshed every few minutes by a macOS **LaunchAgent watchdog** that
|
||||
calls `taskflow export` and writes the result to the app's public directory.
|
||||
|
||||
No backend. No database exposed over HTTP. No caching layer.
|
||||
The browser just `fetch()`es a local file — or, in prod, an S3/CDN path.
|
||||
|
||||
The LaunchAgent entry looks like this (conceptually):
|
||||
|
||||
```
|
||||
ProgramArguments:
|
||||
/usr/bin/env node
|
||||
/path/to/taskflow/bin/taskflow export
|
||||
> /path/to/vite-app/public/data/projects.json
|
||||
StartInterval: 300 # every 5 minutes
|
||||
```
|
||||
|
||||
On the React side, a `useProjects` hook fetches `data/projects.json` at mount
|
||||
(and optionally on an interval if you want live-ish updates in dev). The rest is
|
||||
just components: progress rings, kanban columns, transition timelines.
|
||||
|
||||
---
|
||||
|
||||
## What the JSON Gives You
|
||||
|
||||
- **Every project** with its name, status, and description
|
||||
- **Task counts** in every bucket (`backlog`, `in_progress`, `pending_validation`,
|
||||
`blocked`, `done`) — ready to drive a status bar, ring, or number badge
|
||||
- **Progress percentage** pre-computed per project
|
||||
- **Last 20 transitions** across all projects — ready for an activity feed
|
||||
|
||||
See `export-schema.json` in this directory for the full JSON Schema.
|
||||
|
||||
---
|
||||
|
||||
## Why This Works Well
|
||||
|
||||
- **Static export = zero attack surface.** No live DB connection in the UI.
|
||||
- **Works offline.** The UI renders whatever is in the file, no network required.
|
||||
- **Framework-neutral.** Svelte, Vue, plain HTML + Chart.js — the JSON doesn't care.
|
||||
- **Cheap to refresh.** A 5-minute cron is fine. Sub-second latency is never needed
|
||||
for a task dashboard.
|
||||
- **Composable.** Pipe the JSON into `jq`, into a Notion integration, into a Slack
|
||||
bot — the export is just text.
|
||||
|
||||
---
|
||||
|
||||
## Other Ideas
|
||||
|
||||
| Surface | Approach |
|
||||
|---|---|
|
||||
| Terminal widget | `taskflow status` (built in) |
|
||||
| Apple Notes | `scripts/apple-notes-export.sh` (macOS) |
|
||||
| Raycast | Script Command reading the export JSON |
|
||||
| Obsidian | Dataview plugin querying the same SQLite directly |
|
||||
| Linear-style board | React + export JSON, one column per status |
|
||||
| CI badge | `taskflow sync check` exits 1 on drift — wire it to a status check |
|
||||
|
||||
---
|
||||
|
||||
The data is already there. Build whatever feels right.
|
||||
|
||||
## Validation Workflow Coupling (Projects Panel)
|
||||
|
||||
In our dashboard, validation actions are wired directly into TaskFlow task state.
|
||||
`decision-store.ts` updates `taskflow.sqlite` (`tasks_v2`) when reviewers act in the
|
||||
Projects panel:
|
||||
|
||||
- Confirm: `pending_validation` -> `done`
|
||||
- Reject: `pending_validation` -> `in_progress` (with reviewer feedback note)
|
||||
|
||||
This is an integration pattern for dashboards, not a required part of TaskFlow core.
|
||||
TaskFlow remains UI-agnostic and does not assume any specific frontend store.
|
||||
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://clawhub.dev/schemas/taskflow/export-projects-overview/v1",
|
||||
"title": "TaskFlow Export — Projects Overview",
|
||||
"description": "Output schema for scripts/export-projects-overview.mjs (taskflow-009). Emitted to stdout as a single JSON object.",
|
||||
"type": "object",
|
||||
"required": ["exported_at", "projects", "recent_transitions"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
|
||||
"exported_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "ISO 8601 timestamp when the export was generated (UTC)."
|
||||
},
|
||||
|
||||
"projects": {
|
||||
"type": "array",
|
||||
"description": "All projects in the TaskFlow DB, ordered by slug.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["id", "name", "description", "status", "task_counts", "progress_pct"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Project slug — primary key in the `projects` table.",
|
||||
"examples": ["dashboard", "trading", "smart-home"]
|
||||
},
|
||||
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Human-readable project name.",
|
||||
"examples": ["Dashboard", "Trading System"]
|
||||
},
|
||||
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "One-liner description (may be empty string)."
|
||||
},
|
||||
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["active", "paused", "done"],
|
||||
"description": "Project lifecycle status."
|
||||
},
|
||||
|
||||
"task_counts": {
|
||||
"type": "object",
|
||||
"description": "Count of tasks in each status bucket for this project.",
|
||||
"required": ["in_progress", "pending_validation", "backlog", "blocked", "done"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"in_progress": { "type": "integer", "minimum": 0 },
|
||||
"pending_validation": { "type": "integer", "minimum": 0 },
|
||||
"backlog": { "type": "integer", "minimum": 0 },
|
||||
"blocked": { "type": "integer", "minimum": 0 },
|
||||
"done": { "type": "integer", "minimum": 0 }
|
||||
}
|
||||
},
|
||||
|
||||
"progress_pct": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 100,
|
||||
"description": "Completion percentage: done / total * 100, rounded to 2 decimal places. 0 when there are no tasks."
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"recent_transitions": {
|
||||
"type": "array",
|
||||
"description": "Last 20 task status transitions across all projects, ordered oldest-first within the window. Sourced from task_transitions_v2.",
|
||||
"maxItems": 20,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["task_id", "to_status", "at"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
|
||||
"task_id": {
|
||||
"type": "string",
|
||||
"description": "Task identifier in <slug>-NNN format.",
|
||||
"examples": ["dashboard-025", "trading-003"]
|
||||
},
|
||||
|
||||
"from_status": {
|
||||
"type": ["string", "null"],
|
||||
"enum": ["backlog", "in_progress", "pending_validation", "blocked", "done", null],
|
||||
"description": "Previous status. Null for task creation events."
|
||||
},
|
||||
|
||||
"to_status": {
|
||||
"type": "string",
|
||||
"enum": ["backlog", "in_progress", "pending_validation", "blocked", "done"],
|
||||
"description": "Status the task transitioned into."
|
||||
},
|
||||
|
||||
"reason": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Optional free-text reason recorded at transition time."
|
||||
},
|
||||
|
||||
"at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "ISO 8601 timestamp of the transition (UTC)."
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@taskflow/core",
|
||||
"version": "0.1.0",
|
||||
"description": "TaskFlow — ClawHub skill for structured project/task management with markdown-first authoring and SQLite-backed querying.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.5"
|
||||
},
|
||||
"scripts": {
|
||||
"init-db": "node scripts/init-db.mjs",
|
||||
"sync": "node scripts/task-sync.mjs files-to-db",
|
||||
"export": "node scripts/export-projects-overview.mjs",
|
||||
"status": "node bin/taskflow status"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
-- TaskFlow SQLite Schema
|
||||
-- All statements use IF NOT EXISTS so this file is safe to re-run (idempotent).
|
||||
-- Generated: 2026-02-20
|
||||
-- Node requirement: >=22.5 (node:sqlite / DatabaseSync)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- PRAGMA tweaks (run once at connection time, not schema objects)
|
||||
-- ---------------------------------------------------------------------------
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- projects
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY, -- slug, e.g. "dashboard"
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'paused', 'done')),
|
||||
source_file TEXT NOT NULL DEFAULT 'PROJECTS.md',
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- tasks_v2
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS tasks_v2 (
|
||||
id TEXT PRIMARY KEY, -- <slug>-NNN, e.g. "dashboard-001"
|
||||
project_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL
|
||||
CHECK (status IN (
|
||||
'backlog',
|
||||
'in_progress',
|
||||
'pending_validation',
|
||||
'done',
|
||||
'blocked'
|
||||
)),
|
||||
priority TEXT NOT NULL DEFAULT 'P2'
|
||||
CHECK (priority IN ('P0', 'P1', 'P2', 'P3', 'P9')),
|
||||
owner_model TEXT, -- agent/model tag (optional)
|
||||
notes TEXT,
|
||||
source_file TEXT NOT NULL,
|
||||
legacy_key TEXT, -- for migrations from older systems
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id)
|
||||
ON UPDATE CASCADE ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_v2_project_status
|
||||
ON tasks_v2 (project_id, status, priority);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- task_transitions_v2 (audit log — every status change is recorded here)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS task_transitions_v2 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL,
|
||||
from_status TEXT, -- NULL on first insertion
|
||||
to_status TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
actor TEXT, -- 'sync', 'agent', 'human', etc.
|
||||
actor_model TEXT, -- which model made the change
|
||||
at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
FOREIGN KEY (task_id) REFERENCES tasks_v2(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_transitions_task_id
|
||||
ON task_transitions_v2 (task_id, at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_transitions_at
|
||||
ON task_transitions_v2 (at DESC);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- sync_state (singleton row — id must be 1)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS sync_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
files_hash TEXT, -- hash of all task file contents
|
||||
db_hash TEXT, -- hash of serialized DB task state
|
||||
last_sync_at TEXT,
|
||||
lock_owner TEXT, -- process/session that holds the lock
|
||||
lock_until TEXT, -- TTL-based lock expiry (ISO8601, 60s TTL)
|
||||
last_result TEXT -- 'ok' | 'error: ...'
|
||||
);
|
||||
|
||||
-- Ensure the singleton row exists (safe to run multiple times).
|
||||
INSERT OR IGNORE INTO sync_state (id) VALUES (1);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- legacy_key_map (migration bridge — maps old task keys to new tasks_v2 ids)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS legacy_key_map (
|
||||
legacy_key TEXT PRIMARY KEY,
|
||||
new_id TEXT NOT NULL,
|
||||
FOREIGN KEY (new_id) REFERENCES tasks_v2(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_legacy_key_map_new_id
|
||||
ON legacy_key_map (new_id);
|
||||
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* apple-notes-export.mjs (taskflow-012)
|
||||
*
|
||||
* Generates an HTML project-status summary from TaskFlow markdown files and
|
||||
* writes it to an Apple Note via osascript.
|
||||
*
|
||||
* Config: $OPENCLAW_WORKSPACE/taskflow.config.json
|
||||
* {
|
||||
* "appleNotesId": "x-coredata://...", // persisted after first run
|
||||
* "appleNotesFolder": "Notes", // Notes folder name
|
||||
* "appleNotesTitle": "TaskFlow - Project Status"
|
||||
* }
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/apple-notes-export.mjs
|
||||
* taskflow notes
|
||||
*
|
||||
* macOS only — exits gracefully on other platforms.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, readdirSync } from 'node:fs'
|
||||
import { execSync } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
|
||||
// ── Platform guard ─────────────────────────────────────────────────────────
|
||||
if (process.platform !== 'darwin') {
|
||||
console.log('[apple-notes-export] Skipping: Apple Notes sync is macOS only.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// ── Paths ──────────────────────────────────────────────────────────────────
|
||||
const workspace = process.env.OPENCLAW_WORKSPACE || process.cwd()
|
||||
const configPath = path.join(workspace, 'taskflow.config.json')
|
||||
const tasksDir = path.join(workspace, 'tasks')
|
||||
const projectsFile = path.join(workspace, 'PROJECTS.md')
|
||||
|
||||
// ── Config helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read taskflow.config.json (returns {} if missing or unparseable).
|
||||
*/
|
||||
function readConfig() {
|
||||
if (!existsSync(configPath)) return {}
|
||||
try {
|
||||
return JSON.parse(readFileSync(configPath, 'utf8'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge `updates` into taskflow.config.json (non-destructive patch).
|
||||
*/
|
||||
function writeConfig(updates) {
|
||||
const current = readConfig()
|
||||
const merged = { ...current, ...updates }
|
||||
writeFileSync(configPath, JSON.stringify(merged, null, 2) + '\n', 'utf8')
|
||||
}
|
||||
|
||||
// ── Parse PROJECTS.md ──────────────────────────────────────────────────────
|
||||
|
||||
function parseProjects() {
|
||||
if (!existsSync(projectsFile)) return {}
|
||||
const lines = readFileSync(projectsFile, 'utf8').split('\n')
|
||||
const projects = {}
|
||||
let currentSlug = null
|
||||
|
||||
for (const line of lines) {
|
||||
const h2 = line.match(/^## (.+)/)
|
||||
if (h2) {
|
||||
currentSlug = h2[1].trim()
|
||||
projects[currentSlug] = { name: currentSlug, desc: '' }
|
||||
continue
|
||||
}
|
||||
if (!currentSlug) continue
|
||||
const nameMatch = line.match(/^- Name: (.+)/)
|
||||
if (nameMatch) { projects[currentSlug].name = nameMatch[1].trim(); continue }
|
||||
const descMatch = line.match(/^- Description: (.+)/)
|
||||
if (descMatch) { projects[currentSlug].desc = descMatch[1].trim() }
|
||||
}
|
||||
return projects
|
||||
}
|
||||
|
||||
// ── Parse task files ───────────────────────────────────────────────────────
|
||||
|
||||
function parseTasks(projects) {
|
||||
const result = { in_progress: [], pending: [], backlog: [], done: [], blocked: [] }
|
||||
|
||||
if (!existsSync(tasksDir)) return result
|
||||
|
||||
const files = readdirSync(tasksDir)
|
||||
.filter(f => f.endsWith('-tasks.md'))
|
||||
.sort()
|
||||
|
||||
for (const file of files) {
|
||||
const slug = file.replace('-tasks.md', '')
|
||||
const projName = projects[slug]?.name ?? slug
|
||||
const lines = readFileSync(path.join(tasksDir, file), 'utf8').split('\n')
|
||||
|
||||
let section = null
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.startsWith('## ')) {
|
||||
const h = trimmed.slice(3).toLowerCase()
|
||||
if (h.includes('in progress')) section = 'in_progress'
|
||||
else if (h.includes('pending')) section = 'pending'
|
||||
else if (h.includes('backlog')) section = 'backlog'
|
||||
else if (h.includes('done')) section = 'done'
|
||||
else if (h.includes('blocked')) section = 'blocked'
|
||||
else section = null
|
||||
continue
|
||||
}
|
||||
if (!trimmed.startsWith('- [') || section === null) continue
|
||||
|
||||
// Strip checkbox, task ID, priority/owner tags
|
||||
let text = trimmed
|
||||
.replace(/^- \[.\]\s*/, '')
|
||||
.replace(/\(task:\S+\)\s*/g, '')
|
||||
.replace(/\[P\d\]\s*/g, '')
|
||||
.replace(/\[\S+\]\s*/g, '')
|
||||
.trim()
|
||||
if (!text) continue
|
||||
|
||||
result[section].push(`${projName}: ${text}`)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ── HTML generation ────────────────────────────────────────────────────────
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function ul(items) {
|
||||
if (!items || items.length === 0) {
|
||||
return '<ul><li><span style="color:#666;">None</span></li></ul>'
|
||||
}
|
||||
return '<ul>' + items.map(i => `<li>${escapeHtml(i)}</li>`).join('') + '</ul>'
|
||||
}
|
||||
|
||||
function generateHtml(tasks, title) {
|
||||
const { in_progress, pending, backlog, done, blocked } = tasks
|
||||
const top = [...in_progress, ...pending].slice(0, 5)
|
||||
const next3 = in_progress.length > 0 ? in_progress.slice(0, 3) : backlog.slice(0, 3)
|
||||
|
||||
const now = new Date()
|
||||
const stamp = now.toLocaleString('en-US', {
|
||||
timeZone: 'America/Chicago',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: true,
|
||||
}) + ' CST'
|
||||
|
||||
return `<div style="font-family:-apple-system,Helvetica,Arial,sans-serif; line-height:1.35;">
|
||||
<h1>${escapeHtml(title)}</h1>
|
||||
<h2>🎯 Top Priorities</h2>${ul(top)}
|
||||
<h2>🚧 In Progress (${in_progress.length})</h2>${ul(in_progress)}
|
||||
<h2>⏳ Pending Validation (${pending.length})</h2>${ul(pending)}
|
||||
<h2>📥 Backlog (top 12)</h2>${ul(backlog.slice(0, 12))}
|
||||
<h2>✅ Recently Done</h2>${ul(done.slice(0, 10))}
|
||||
<h2>🧱 Blockers</h2>${ul(blocked)}
|
||||
<h2>▶️ Next 3 Actions</h2>${ul(next3)}
|
||||
<p style="color:#888; font-size:0.85em;"><b>Updated:</b> ${escapeHtml(stamp)} · Source: tasks/*.md</p>
|
||||
</div>`
|
||||
}
|
||||
|
||||
// ── AppleScript helpers ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Escape a string for embedding inside an AppleScript string literal.
|
||||
*/
|
||||
function asEscape(str) {
|
||||
// In AppleScript, backslash and double-quote need to be escaped for shell
|
||||
// We'll write HTML to a temp file and read it in AppleScript to avoid quoting issues.
|
||||
return str
|
||||
}
|
||||
|
||||
const TMP_HTML = '/tmp/taskflow-apple-notes.html'
|
||||
|
||||
/**
|
||||
* Check whether a note with the given Core Data ID exists.
|
||||
* Returns true/false.
|
||||
*/
|
||||
function noteExists(noteId) {
|
||||
const script = `
|
||||
tell application "Notes"
|
||||
try
|
||||
set n to note id "${noteId}"
|
||||
return (name of n) as text
|
||||
on error
|
||||
return "NOT_FOUND"
|
||||
end try
|
||||
end tell
|
||||
`
|
||||
try {
|
||||
const result = execSync(`/usr/bin/osascript -e '${script.replace(/'/g, "'\\''")}'`, {
|
||||
encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']
|
||||
}).trim()
|
||||
return result !== 'NOT_FOUND'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing note by Core Data ID.
|
||||
*/
|
||||
function updateNote(noteId, title, html) {
|
||||
writeFileSync(TMP_HTML, html, 'utf8')
|
||||
const script = `
|
||||
set noteBody to (do shell script "cat /tmp/taskflow-apple-notes.html")
|
||||
tell application "Notes"
|
||||
try
|
||||
set targetNote to note id "${noteId}"
|
||||
set name of targetNote to "${title.replace(/"/g, '\\"')}"
|
||||
set body of targetNote to noteBody
|
||||
return (id of targetNote) as text
|
||||
on error errMsg
|
||||
error errMsg
|
||||
end try
|
||||
end tell
|
||||
`
|
||||
const result = execSync(`/usr/bin/osascript << 'OSASCRIPT'\n${script}\nOSASCRIPT`, {
|
||||
encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']
|
||||
}).trim()
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new note in the specified folder.
|
||||
* Returns the new note's Core Data ID.
|
||||
*/
|
||||
function createNote(title, html, folder) {
|
||||
writeFileSync(TMP_HTML, html, 'utf8')
|
||||
const script = `
|
||||
set noteBody to (do shell script "cat /tmp/taskflow-apple-notes.html")
|
||||
tell application "Notes"
|
||||
launch
|
||||
delay 0.5
|
||||
set targetFolder to missing value
|
||||
try
|
||||
set targetFolder to folder "${folder.replace(/"/g, '\\"')}"
|
||||
on error
|
||||
-- folder not found, use default account
|
||||
end try
|
||||
if targetFolder is missing value then
|
||||
set newNote to make new note with properties {name:"${title.replace(/"/g, '\\"')}", body:noteBody}
|
||||
else
|
||||
set newNote to make new note at targetFolder with properties {name:"${title.replace(/"/g, '\\"')}", body:noteBody}
|
||||
end if
|
||||
return (id of newNote) as text
|
||||
end tell
|
||||
`
|
||||
const result = execSync(`/usr/bin/osascript << 'OSASCRIPT'\n${script}\nOSASCRIPT`, {
|
||||
encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']
|
||||
}).trim()
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const config = readConfig()
|
||||
const folder = config.appleNotesFolder ?? 'Notes'
|
||||
const title = config.appleNotesTitle ?? 'TaskFlow - Project Status'
|
||||
let noteId = config.appleNotesId ?? null
|
||||
|
||||
// Parse content
|
||||
const projects = parseProjects()
|
||||
const tasks = parseTasks(projects)
|
||||
const html = generateHtml(tasks, title)
|
||||
|
||||
const { in_progress, pending, backlog, done, blocked } = tasks
|
||||
console.log(`[apple-notes-export] ${in_progress.length} in-progress, ${backlog.length} backlog, ${done.length} done`)
|
||||
|
||||
// Retry loop (up to 3 attempts)
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
if (noteId && noteExists(noteId)) {
|
||||
// Update existing note
|
||||
console.log(`[apple-notes-export] Updating existing note…`)
|
||||
updateNote(noteId, title, html)
|
||||
console.log(`[apple-notes-export] ✓ Note updated (${noteId})`)
|
||||
break
|
||||
} else {
|
||||
// Create new note (either no ID configured, or note was deleted)
|
||||
if (noteId) {
|
||||
console.log(`[apple-notes-export] Previous note not found — creating new note…`)
|
||||
} else {
|
||||
console.log(`[apple-notes-export] No note configured — creating new note in "${folder}"…`)
|
||||
}
|
||||
const newId = createNote(title, html, folder)
|
||||
if (!newId) throw new Error('osascript returned empty note ID')
|
||||
|
||||
noteId = newId
|
||||
writeConfig({
|
||||
appleNotesId: noteId,
|
||||
appleNotesFolder: folder,
|
||||
appleNotesTitle: title,
|
||||
})
|
||||
console.log(`[apple-notes-export] ✓ Note created and ID saved to taskflow.config.json`)
|
||||
console.log(`[apple-notes-export] ID: ${noteId}`)
|
||||
break
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[apple-notes-export] Attempt ${attempt} failed: ${err.message}`)
|
||||
if (attempt < 3) {
|
||||
await new Promise(r => setTimeout(r, 2000))
|
||||
} else {
|
||||
console.error('[apple-notes-export] All retries exhausted. Note not updated.')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await main()
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* export-projects-overview.mjs (taskflow-009)
|
||||
*
|
||||
* Reads the TaskFlow SQLite DB and emits a clean JSON summary to stdout.
|
||||
*
|
||||
* Output shape:
|
||||
* {
|
||||
* exported_at: ISO string,
|
||||
* projects: [ { id, name, description, status, task_counts, progress_pct } ],
|
||||
* recent_transitions: [ { task_id, from_status, to_status, reason, at } ]
|
||||
* }
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/export-projects-overview.mjs
|
||||
* node scripts/export-projects-overview.mjs | jq .
|
||||
*/
|
||||
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import path from 'node:path'
|
||||
import { existsSync } from 'node:fs'
|
||||
|
||||
// ── DB path resolution ─────────────────────────────────────────────────────
|
||||
const workspace = process.env.OPENCLAW_WORKSPACE || process.cwd()
|
||||
const dbPath = path.join(workspace, 'memory', 'taskflow.sqlite')
|
||||
|
||||
if (!existsSync(dbPath)) {
|
||||
process.stderr.write(
|
||||
`[taskflow-export] DB not found at: ${dbPath}\n` +
|
||||
` Run 'taskflow init' to bootstrap the database.\n`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const db = new DatabaseSync(dbPath)
|
||||
db.exec('PRAGMA foreign_keys = ON')
|
||||
db.exec('PRAGMA journal_mode = WAL')
|
||||
|
||||
// ── Fetch projects ─────────────────────────────────────────────────────────
|
||||
const projects = db
|
||||
.prepare('SELECT id, name, description, status FROM projects ORDER BY id')
|
||||
.all()
|
||||
|
||||
// ── Task counts per project ────────────────────────────────────────────────
|
||||
const ALL_STATUSES = ['in_progress', 'pending_validation', 'backlog', 'blocked', 'done']
|
||||
|
||||
const countRows = db
|
||||
.prepare(`
|
||||
SELECT project_id, status, COUNT(*) AS cnt
|
||||
FROM tasks_v2
|
||||
GROUP BY project_id, status
|
||||
`)
|
||||
.all()
|
||||
|
||||
// Build lookup: { project_id: { status: count } }
|
||||
const countsByProject = {}
|
||||
for (const row of countRows) {
|
||||
if (!countsByProject[row.project_id]) countsByProject[row.project_id] = {}
|
||||
countsByProject[row.project_id][row.status] = row.cnt
|
||||
}
|
||||
|
||||
// ── Assemble project objects ───────────────────────────────────────────────
|
||||
const projectsOut = projects.map((p) => {
|
||||
const counts = countsByProject[p.id] || {}
|
||||
|
||||
const task_counts = {
|
||||
in_progress: counts.in_progress ?? 0,
|
||||
pending_validation: counts.pending_validation ?? 0,
|
||||
backlog: counts.backlog ?? 0,
|
||||
blocked: counts.blocked ?? 0,
|
||||
done: counts.done ?? 0,
|
||||
}
|
||||
|
||||
const total = Object.values(task_counts).reduce((s, n) => s + n, 0)
|
||||
const progress_pct = total > 0
|
||||
? Math.round((task_counts.done / total) * 10000) / 100 // 2 decimal places
|
||||
: 0
|
||||
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
status: p.status,
|
||||
task_counts,
|
||||
progress_pct,
|
||||
}
|
||||
})
|
||||
|
||||
// ── Recent transitions (last 20) ───────────────────────────────────────────
|
||||
const recentTransitions = db
|
||||
.prepare(`
|
||||
SELECT task_id, from_status, to_status, reason, at
|
||||
FROM task_transitions_v2
|
||||
ORDER BY id DESC
|
||||
LIMIT 20
|
||||
`)
|
||||
.all()
|
||||
|
||||
// Reverse so oldest-first within the window (more natural for consumers)
|
||||
recentTransitions.reverse()
|
||||
|
||||
// ── Output ─────────────────────────────────────────────────────────────────
|
||||
const output = {
|
||||
exported_at: new Date().toISOString(),
|
||||
projects: projectsOut,
|
||||
recent_transitions: recentTransitions,
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(output, null, 2) + '\n')
|
||||
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* init-db.mjs — TaskFlow SQLite bootstrap (idempotent)
|
||||
*
|
||||
* Reads schema/taskflow.sql and executes it against the TaskFlow SQLite DB.
|
||||
* Safe to run multiple times — all DDL uses IF NOT EXISTS.
|
||||
*
|
||||
* DB path resolution (in order):
|
||||
* 1. $OPENCLAW_WORKSPACE/memory/taskflow.sqlite
|
||||
* 2. process.cwd()/memory/taskflow.sqlite
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/init-db.mjs
|
||||
* npm run init-db
|
||||
*
|
||||
* Requires: Node >=22.5 (node:sqlite / DatabaseSync)
|
||||
*/
|
||||
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { readFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { resolve, dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolve paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const schemaPath = resolve(__dirname, '..', 'schema', 'taskflow.sql');
|
||||
|
||||
const workspaceRoot = process.env.OPENCLAW_WORKSPACE
|
||||
? process.env.OPENCLAW_WORKSPACE
|
||||
: process.cwd();
|
||||
|
||||
const memoryDir = join(workspaceRoot, 'memory');
|
||||
const dbPath = join(memoryDir, 'taskflow.sqlite');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function log(msg) {
|
||||
const ts = new Date().toISOString();
|
||||
console.log(`[${ts}] ${msg}`);
|
||||
}
|
||||
|
||||
function err(msg) {
|
||||
const ts = new Date().toISOString();
|
||||
console.error(`[${ts}] ERROR: ${msg}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a SQL file into individual statements, stripping comments and blanks.
|
||||
* Handles multi-line statements and PRAGMA directives.
|
||||
*/
|
||||
function splitStatements(sql) {
|
||||
// Remove -- line comments
|
||||
const stripped = sql.replace(/--[^\n]*/g, '');
|
||||
// Split on semicolons, filter blank/whitespace-only entries
|
||||
return stripped
|
||||
.split(';')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the statement type for human-readable logging.
|
||||
*/
|
||||
function describeStatement(stmt) {
|
||||
const upper = stmt.trimStart().toUpperCase();
|
||||
if (upper.startsWith('CREATE TABLE IF NOT EXISTS')) {
|
||||
const m = stmt.match(/CREATE TABLE IF NOT EXISTS\s+(\S+)/i);
|
||||
return m ? `CREATE TABLE IF NOT EXISTS ${m[1]}` : 'CREATE TABLE (IF NOT EXISTS)';
|
||||
}
|
||||
if (upper.startsWith('CREATE INDEX IF NOT EXISTS')) {
|
||||
const m = stmt.match(/CREATE INDEX IF NOT EXISTS\s+(\S+)/i);
|
||||
return m ? `CREATE INDEX IF NOT EXISTS ${m[1]}` : 'CREATE INDEX (IF NOT EXISTS)';
|
||||
}
|
||||
if (upper.startsWith('CREATE')) {
|
||||
return stmt.trimStart().split(/\s+/).slice(0, 4).join(' ');
|
||||
}
|
||||
if (upper.startsWith('INSERT OR IGNORE')) {
|
||||
const m = stmt.match(/INTO\s+(\S+)/i);
|
||||
return m ? `INSERT OR IGNORE INTO ${m[1]}` : 'INSERT OR IGNORE';
|
||||
}
|
||||
if (upper.startsWith('PRAGMA')) {
|
||||
return stmt.trimStart().split('\n')[0].trim();
|
||||
}
|
||||
return stmt.trimStart().split(/\s+/).slice(0, 3).join(' ');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
log('TaskFlow init-db starting');
|
||||
log(`Schema: ${schemaPath}`);
|
||||
log(`DB: ${dbPath}`);
|
||||
|
||||
// Ensure memory directory exists
|
||||
if (!existsSync(memoryDir)) {
|
||||
mkdirSync(memoryDir, { recursive: true });
|
||||
log(`Created directory: ${memoryDir}`);
|
||||
} else {
|
||||
log(`Directory exists: ${memoryDir}`);
|
||||
}
|
||||
|
||||
// Read schema
|
||||
let schemaSQL;
|
||||
try {
|
||||
schemaSQL = readFileSync(schemaPath, 'utf8');
|
||||
} catch (e) {
|
||||
err(`Cannot read schema file: ${schemaPath}\n${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Open (or create) DB
|
||||
let db;
|
||||
try {
|
||||
db = new DatabaseSync(dbPath);
|
||||
log(`Opened DB (created if new): ${dbPath}`);
|
||||
} catch (e) {
|
||||
err(`Cannot open SQLite DB at ${dbPath}\n${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Execute each statement
|
||||
const statements = splitStatements(schemaSQL);
|
||||
log(`Executing ${statements.length} SQL statement(s)...`);
|
||||
console.log('');
|
||||
|
||||
let ok = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const stmt of statements) {
|
||||
const label = describeStatement(stmt);
|
||||
try {
|
||||
db.exec(stmt);
|
||||
log(` ✓ ${label}`);
|
||||
ok++;
|
||||
} catch (e) {
|
||||
// PRAGMA errors on read-only pragmas are non-fatal; everything else is fatal.
|
||||
if (stmt.trim().toUpperCase().startsWith('PRAGMA')) {
|
||||
log(` ~ ${label} (skipped — ${e.message})`);
|
||||
skipped++;
|
||||
} else {
|
||||
err(`Failed: ${label}\n ${e.message}`);
|
||||
db.close?.();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify expected tables exist
|
||||
const EXPECTED_TABLES = [
|
||||
'projects',
|
||||
'tasks_v2',
|
||||
'task_transitions_v2',
|
||||
'sync_state',
|
||||
'legacy_key_map',
|
||||
];
|
||||
|
||||
console.log('');
|
||||
log('Verifying tables...');
|
||||
|
||||
const tableRows = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
.all();
|
||||
const existingTables = new Set(tableRows.map(r => r.name));
|
||||
|
||||
let allPresent = true;
|
||||
for (const t of EXPECTED_TABLES) {
|
||||
if (existingTables.has(t)) {
|
||||
log(` ✓ table: ${t}`);
|
||||
} else {
|
||||
log(` ✗ MISSING table: ${t}`);
|
||||
allPresent = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify singleton sync_state row
|
||||
const syncRow = db.prepare('SELECT id FROM sync_state WHERE id = 1').get();
|
||||
if (syncRow) {
|
||||
log(` ✓ sync_state singleton row present`);
|
||||
} else {
|
||||
log(` ✗ sync_state singleton row MISSING`);
|
||||
allPresent = false;
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('');
|
||||
if (allPresent) {
|
||||
log(`init-db complete — ${ok} statement(s) executed, ${skipped} skipped`);
|
||||
log(`TaskFlow DB is ready at: ${dbPath}`);
|
||||
} else {
|
||||
err('init-db completed with warnings — some expected tables are missing');
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* task-sync.mjs -- Sync task markdown files to/from SQLite
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/task-sync.mjs files-to-db # parse markdown, update DB
|
||||
* node scripts/task-sync.mjs check # detect drift, exit 1 if mismatch
|
||||
* node scripts/task-sync.mjs db-to-files # project DB state into markdown
|
||||
*
|
||||
* Environment:
|
||||
* OPENCLAW_WORKSPACE Path to the workspace root (defaults to process.cwd())
|
||||
*/
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
// === PATHS (taskflow-001: portable, no hardcoded paths) ===
|
||||
const workspace = process.env.OPENCLAW_WORKSPACE || process.cwd()
|
||||
const dbPath = path.join(workspace, 'memory', 'taskflow.sqlite')
|
||||
const tasksDir = path.join(workspace, 'tasks')
|
||||
|
||||
const mode = process.argv[2] || 'check'
|
||||
if (!['files-to-db', 'check', 'db-to-files'].includes(mode)) {
|
||||
console.error('Usage: node scripts/task-sync.mjs [files-to-db|check|db-to-files]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// === STARTUP CHECKS (taskflow-020: clear errors for missing paths) ===
|
||||
if (!existsSync(workspace)) {
|
||||
console.error(`ERROR: Workspace directory not found: ${workspace}`)
|
||||
console.error('Set OPENCLAW_WORKSPACE to your workspace root and try again.')
|
||||
process.exit(1)
|
||||
}
|
||||
if (!existsSync(tasksDir)) {
|
||||
console.error(`ERROR: Tasks directory not found: ${tasksDir}`)
|
||||
console.error(`Create it with: mkdir -p "${tasksDir}"`)
|
||||
process.exit(1)
|
||||
}
|
||||
if (!existsSync(dbPath)) {
|
||||
console.error(`ERROR: Database file not found: ${dbPath}`)
|
||||
console.error('Run init-db.mjs first to create the database schema.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const db = new DatabaseSync(dbPath)
|
||||
db.exec('PRAGMA foreign_keys = ON')
|
||||
|
||||
// === LOCK (taskflow-002: atomic single-UPDATE acquisition) ===
|
||||
let lockAcquired = false
|
||||
|
||||
function acquireLock() {
|
||||
const owner = `task-sync:${mode}`
|
||||
const until = new Date(Date.now() + 60_000).toISOString() // 60s TTL
|
||||
|
||||
// Ensure the singleton row exists
|
||||
db.exec(`INSERT OR IGNORE INTO sync_state (id, lock_owner, lock_until) VALUES (1, NULL, NULL)`)
|
||||
|
||||
// Single atomic UPDATE: succeeds only when no valid lock is held
|
||||
const result = db.prepare(`
|
||||
UPDATE sync_state
|
||||
SET lock_owner = ?, lock_until = ?
|
||||
WHERE id = 1
|
||||
AND (lock_owner IS NULL OR lock_until < datetime('now'))
|
||||
`).run(owner, until)
|
||||
|
||||
if (result.changes === 0) {
|
||||
const row = db.prepare('SELECT lock_owner, lock_until FROM sync_state WHERE id = 1').get()
|
||||
console.error(`Sync locked by ${row?.lock_owner} until ${row?.lock_until}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
lockAcquired = true
|
||||
}
|
||||
|
||||
function releaseLock(result) {
|
||||
const now = new Date().toISOString()
|
||||
db.prepare(
|
||||
'UPDATE sync_state SET lock_owner = NULL, lock_until = NULL, last_sync_at = ?, last_result = ? WHERE id = 1'
|
||||
).run(now, result)
|
||||
lockAcquired = false
|
||||
}
|
||||
|
||||
// === SIGNAL HANDLERS (taskflow-019: release lock on exit) ===
|
||||
function handleSignal(signal) {
|
||||
if (lockAcquired) {
|
||||
try { releaseLock(`interrupted: ${signal}`) } catch (_) {}
|
||||
}
|
||||
process.exit(signal === 'SIGTERM' ? 0 : 130)
|
||||
}
|
||||
process.on('SIGTERM', () => handleSignal('SIGTERM'))
|
||||
process.on('SIGINT', () => handleSignal('SIGINT'))
|
||||
|
||||
// === PARSE MARKDOWN ===
|
||||
const STATUS_MAP = {
|
||||
'in progress': 'in_progress',
|
||||
'pending validation': 'pending_validation',
|
||||
'backlog': 'backlog',
|
||||
'done': 'done',
|
||||
'blocked': 'blocked',
|
||||
}
|
||||
|
||||
const TASK_RE = /^- \[([ x])\] \(task:([a-z0-9-]+)\)\s*(?:\[([^\]]*)\])?\s*(?:\[([^\]]*)\])?\s*(.+)$/
|
||||
|
||||
function parseTaskFile(filePath) {
|
||||
const content = readFileSync(filePath, 'utf8')
|
||||
const lines = content.split(/\r?\n/)
|
||||
const tasks = []
|
||||
let currentStatus = null
|
||||
const slug = path.basename(filePath).replace(/-tasks\.md$/, '')
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
|
||||
// Section header
|
||||
const headerMatch = line.match(/^## (.+)$/)
|
||||
if (headerMatch) {
|
||||
const normalized = headerMatch[1].trim().toLowerCase()
|
||||
currentStatus = STATUS_MAP[normalized] || null
|
||||
continue
|
||||
}
|
||||
|
||||
if (!currentStatus) continue
|
||||
|
||||
const taskMatch = line.match(TASK_RE)
|
||||
if (taskMatch) {
|
||||
const [, , id, tag1, tag2, title] = taskMatch
|
||||
|
||||
// Parse priority and model from tags
|
||||
let priority = 'P2', model = null
|
||||
for (const tag of [tag1, tag2]) {
|
||||
if (!tag) continue
|
||||
if (/^P\d$/.test(tag)) priority = tag
|
||||
else model = tag
|
||||
}
|
||||
|
||||
// Check for note on next line
|
||||
let notes = null
|
||||
if (i + 1 < lines.length) {
|
||||
const noteMatch = lines[i + 1].match(/^\s+- note:\s*(.+)$/)
|
||||
if (noteMatch) notes = noteMatch[1].trim()
|
||||
}
|
||||
|
||||
tasks.push({
|
||||
id,
|
||||
project_id: slug,
|
||||
title: title.trim(),
|
||||
status: currentStatus,
|
||||
priority,
|
||||
owner_model: model,
|
||||
notes,
|
||||
source_file: `tasks/${slug}-tasks.md`,
|
||||
})
|
||||
}
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
function parseAllFiles() {
|
||||
const files = readdirSync(tasksDir).filter(f => f.endsWith('-tasks.md'))
|
||||
const allTasks = []
|
||||
for (const f of files) {
|
||||
allTasks.push(...parseTaskFile(path.join(tasksDir, f)))
|
||||
}
|
||||
return allTasks
|
||||
}
|
||||
|
||||
// === DB READ ===
|
||||
function getDbTasks() {
|
||||
return db.prepare(
|
||||
'SELECT id, project_id, title, status, priority, owner_model, notes, source_file FROM tasks_v2 ORDER BY id'
|
||||
).all()
|
||||
}
|
||||
|
||||
// === HASH (taskflow-012: include notes field) ===
|
||||
function hashTasks(tasks) {
|
||||
const sorted = [...tasks].sort((a, b) => a.id.localeCompare(b.id))
|
||||
const data = sorted
|
||||
.map(t => `${t.id}|${t.status}|${t.priority}|${t.owner_model || ''}|${t.title}|${t.notes || ''}`)
|
||||
.join('\n')
|
||||
return createHash('sha256').update(data).digest('hex').slice(0, 16)
|
||||
}
|
||||
|
||||
// === DIFF ===
|
||||
function diffTasks(fileTasks, dbTasks) {
|
||||
const fileMap = new Map(fileTasks.map(t => [t.id, t]))
|
||||
const dbMap = new Map(dbTasks.map(t => [t.id, t]))
|
||||
const diffs = []
|
||||
|
||||
for (const [id, ft] of fileMap) {
|
||||
const dt = dbMap.get(id)
|
||||
if (!dt) {
|
||||
diffs.push({ type: 'file_only', id, task: ft })
|
||||
} else {
|
||||
const changes = []
|
||||
if (ft.status !== dt.status) changes.push(`status: ${dt.status} → ${ft.status}`)
|
||||
if (ft.priority !== dt.priority) changes.push(`priority: ${dt.priority} → ${ft.priority}`)
|
||||
if ((ft.owner_model || null) !== (dt.owner_model || null)) changes.push(`model: ${dt.owner_model} → ${ft.owner_model}`)
|
||||
if (ft.title !== dt.title) changes.push('title changed')
|
||||
if (changes.length) diffs.push({ type: 'changed', id, changes, fileTask: ft, dbTask: dt })
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id] of dbMap) {
|
||||
if (!fileMap.has(id)) {
|
||||
diffs.push({ type: 'db_only', id, task: dbMap.get(id) })
|
||||
}
|
||||
}
|
||||
|
||||
return diffs
|
||||
}
|
||||
|
||||
// === WRITE TO DB ===
|
||||
function syncFilesToDb(fileTasks, dbTasks, diffs) {
|
||||
// Auto-create projects referenced in files but missing from the projects table.
|
||||
const knownProjects = new Set(db.prepare('SELECT id FROM projects').all().map(r => r.id))
|
||||
const upsertProject = db.prepare(`
|
||||
INSERT OR IGNORE INTO projects (id, name, description, status, source_file)
|
||||
VALUES (?, ?, '', 'active', ?)
|
||||
`)
|
||||
const missingProjects = new Set()
|
||||
|
||||
for (const t of fileTasks) {
|
||||
if (!knownProjects.has(t.project_id) && !missingProjects.has(t.project_id)) {
|
||||
missingProjects.add(t.project_id)
|
||||
const name = t.project_id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
upsertProject.run(t.project_id, name, `tasks/${t.project_id}-tasks.md`)
|
||||
console.log(` Auto-created missing project: ${t.project_id} ("${name}")`)
|
||||
}
|
||||
}
|
||||
|
||||
const upsert = db.prepare(`
|
||||
INSERT INTO tasks_v2 (id, project_id, title, status, priority, owner_model, notes, source_file, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
status = excluded.status,
|
||||
priority = excluded.priority,
|
||||
owner_model = excluded.owner_model,
|
||||
notes = COALESCE(excluded.notes, tasks_v2.notes),
|
||||
source_file = excluded.source_file,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||
`)
|
||||
const insertTransition = db.prepare(`
|
||||
INSERT INTO task_transitions_v2 (task_id, from_status, to_status, reason, actor)
|
||||
VALUES (?, ?, ?, ?, 'sync')
|
||||
`)
|
||||
|
||||
let updated = 0, added = 0
|
||||
|
||||
for (const diff of diffs) {
|
||||
if (diff.type === 'file_only') {
|
||||
const t = diff.task
|
||||
upsert.run(t.id, t.project_id, t.title, t.status, t.priority, t.owner_model, t.notes, t.source_file)
|
||||
insertTransition.run(t.id, null, t.status, 'added via file sync')
|
||||
added++
|
||||
} else if (diff.type === 'changed') {
|
||||
const t = diff.fileTask
|
||||
upsert.run(t.id, t.project_id, t.title, t.status, t.priority, t.owner_model, t.notes, t.source_file)
|
||||
if (diff.dbTask.status !== t.status) {
|
||||
insertTransition.run(t.id, diff.dbTask.status, t.status, `file sync: ${diff.changes.join(', ')}`)
|
||||
}
|
||||
updated++
|
||||
}
|
||||
// db_only tasks stay in DB (no silent drops)
|
||||
}
|
||||
|
||||
// Update hashes
|
||||
const allFileTasks = parseAllFiles()
|
||||
const allDbTasks = getDbTasks()
|
||||
db.prepare('UPDATE sync_state SET files_hash = ?, db_hash = ? WHERE id = 1').run(
|
||||
hashTasks(allFileTasks), hashTasks(allDbTasks)
|
||||
)
|
||||
|
||||
return { added, updated, dbOnly: diffs.filter(d => d.type === 'db_only').length }
|
||||
}
|
||||
|
||||
// === WRITE TO FILES (taskflow-003: blocked is first-class) ===
|
||||
const STATUS_ORDER = ['in_progress', 'pending_validation', 'blocked', 'backlog', 'done']
|
||||
const STATUS_HEADERS = {
|
||||
in_progress: 'In Progress',
|
||||
pending_validation: 'Pending Validation',
|
||||
blocked: 'Blocked',
|
||||
backlog: 'Backlog',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
function projectDbToFiles(dbTasks) {
|
||||
const byProject = new Map()
|
||||
for (const t of dbTasks) {
|
||||
if (!byProject.has(t.project_id)) byProject.set(t.project_id, [])
|
||||
byProject.get(t.project_id).push(t)
|
||||
}
|
||||
|
||||
// Preserve existing file headers (everything before the first ## section)
|
||||
const files = readdirSync(tasksDir).filter(f => f.endsWith('-tasks.md'))
|
||||
const headers = new Map()
|
||||
for (const f of files) {
|
||||
const content = readFileSync(path.join(tasksDir, f), 'utf8')
|
||||
const slug = f.replace(/-tasks\.md$/, '')
|
||||
const lines = content.split(/\r?\n/)
|
||||
const headerLines = []
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('## ')) break
|
||||
headerLines.push(line)
|
||||
}
|
||||
headers.set(slug, headerLines.join('\n').trimEnd())
|
||||
}
|
||||
|
||||
// All project slugs from DB
|
||||
const slugs = db.prepare('SELECT id FROM projects ORDER BY id').all().map(r => r.id)
|
||||
let filesWritten = 0
|
||||
|
||||
for (const slug of slugs) {
|
||||
const tasks = byProject.get(slug) || []
|
||||
const header = headers.get(slug) || `# Tasks: ${slug}`
|
||||
|
||||
let md = header + '\n\n'
|
||||
for (const section of STATUS_ORDER) {
|
||||
md += `## ${STATUS_HEADERS[section]}\n`
|
||||
const sectionTasks = tasks
|
||||
.filter(t => t.status === section)
|
||||
.sort((a, b) => a.priority.localeCompare(b.priority) || a.id.localeCompare(b.id))
|
||||
|
||||
if (sectionTasks.length === 0) {
|
||||
md += '\n'
|
||||
} else {
|
||||
for (const t of sectionTasks) {
|
||||
const checkbox = t.status === 'done' ? '[x]' : '[ ]'
|
||||
const modelTag = t.owner_model ? ` [${t.owner_model}]` : ''
|
||||
md += `- ${checkbox} (task:${t.id}) [${t.priority}]${modelTag} ${t.title}\n`
|
||||
if (t.notes) md += ` - note: ${t.notes}\n`
|
||||
}
|
||||
md += '\n'
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(path.join(tasksDir, `${slug}-tasks.md`), md)
|
||||
filesWritten++
|
||||
}
|
||||
|
||||
return { filesWritten }
|
||||
}
|
||||
|
||||
// === MAIN ===
|
||||
try {
|
||||
if (mode !== 'check') acquireLock()
|
||||
|
||||
const fileTasks = parseAllFiles()
|
||||
const dbTasks = getDbTasks()
|
||||
const diffs = diffTasks(fileTasks, dbTasks)
|
||||
|
||||
if (mode === 'check') {
|
||||
const fileHash = hashTasks(fileTasks)
|
||||
const dbHash = hashTasks(dbTasks)
|
||||
if (diffs.length === 0) {
|
||||
console.log(`OK: ${fileTasks.length} tasks in sync (hash: ${fileHash})`)
|
||||
process.exit(0)
|
||||
} else {
|
||||
console.log(`DRIFT DETECTED: ${diffs.length} differences`)
|
||||
console.log(` File hash: ${fileHash}`)
|
||||
console.log(` DB hash: ${dbHash}`)
|
||||
for (const d of diffs) {
|
||||
if (d.type === 'file_only') console.log(` + ${d.id} (in files, not DB)`)
|
||||
else if (d.type === 'db_only') console.log(` - ${d.id} (in DB, not files)`)
|
||||
else console.log(` ~ ${d.id}: ${d.changes.join(', ')}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
} else if (mode === 'files-to-db') {
|
||||
const result = syncFilesToDb(fileTasks, dbTasks, diffs)
|
||||
console.log(`Synced files → DB: ${result.added} added, ${result.updated} updated, ${result.dbOnly} DB-only preserved`)
|
||||
releaseLock('ok')
|
||||
} else if (mode === 'db-to-files') {
|
||||
const freshDbTasks = getDbTasks()
|
||||
const result = projectDbToFiles(freshDbTasks)
|
||||
console.log(`Projected DB → files: ${result.filesWritten} files written`)
|
||||
releaseLock('ok')
|
||||
}
|
||||
} catch (err) {
|
||||
if (mode !== 'check') {
|
||||
try { releaseLock(`failed: ${err.message}`) } catch (_) {}
|
||||
}
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<!--
|
||||
TaskFlow Periodic Sync — macOS LaunchAgent
|
||||
==========================================
|
||||
Label: com.taskflow.sync
|
||||
Action: Runs task-sync.mjs files-to-db every 60 seconds.
|
||||
Markdown files are canonical; DB is kept in sync automatically.
|
||||
|
||||
INSTALLATION:
|
||||
1. Copy this file to ~/Library/LaunchAgents/com.taskflow.sync.plist
|
||||
2. Replace ALL occurrences of {{workspace}} with the absolute path
|
||||
to your workspace directory (no trailing slash).
|
||||
Example: /Users/you/.openclaw/workspace
|
||||
2b. Replace {{node}} with the path to your node binary.
|
||||
Find it with: which node
|
||||
Example: /opt/homebrew/bin/node
|
||||
3. Load the agent:
|
||||
launchctl load ~/Library/LaunchAgents/com.taskflow.sync.plist
|
||||
4. Verify it is running:
|
||||
launchctl list | grep taskflow
|
||||
|
||||
LOGS:
|
||||
stdout → {{workspace}}/logs/taskflow-sync.stdout.log
|
||||
stderr → {{workspace}}/logs/taskflow-sync.stderr.log
|
||||
Create the logs/ directory if it does not exist:
|
||||
mkdir -p {{workspace}}/logs
|
||||
|
||||
UNINSTALL:
|
||||
launchctl unload ~/Library/LaunchAgents/com.taskflow.sync.plist
|
||||
rm ~/Library/LaunchAgents/com.taskflow.sync.plist
|
||||
-->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
|
||||
<!-- Unique label for this agent -->
|
||||
<key>Label</key>
|
||||
<string>com.taskflow.sync</string>
|
||||
|
||||
<!-- Command to run -->
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>{{node}}</string>
|
||||
<string>{{workspace}}/taskflow/scripts/task-sync.mjs</string>
|
||||
<string>files-to-db</string>
|
||||
</array>
|
||||
|
||||
<!-- Run every 60 seconds -->
|
||||
<key>StartInterval</key>
|
||||
<integer>60</integer>
|
||||
|
||||
<!-- Working directory (workspace root, where PROJECTS.md and tasks/ live) -->
|
||||
<key>WorkingDirectory</key>
|
||||
<string>{{workspace}}</string>
|
||||
|
||||
<!-- Environment variables passed to the script -->
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>OPENCLAW_WORKSPACE</key>
|
||||
<string>{{workspace}}</string>
|
||||
</dict>
|
||||
|
||||
<!-- Standard output log -->
|
||||
<key>StandardOutPath</key>
|
||||
<string>{{workspace}}/logs/taskflow-sync.stdout.log</string>
|
||||
|
||||
<!-- Standard error log -->
|
||||
<key>StandardErrorPath</key>
|
||||
<string>{{workspace}}/logs/taskflow-sync.stderr.log</string>
|
||||
|
||||
<!-- Start on load (do not wait for first interval) -->
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,30 @@
|
||||
# Projects
|
||||
|
||||
<!-- ============================================================
|
||||
PROJECTS.md — Project Registry
|
||||
============================================================
|
||||
|
||||
FORMAT: One ## block per project. The slug (## heading text)
|
||||
is the canonical project ID used in task IDs, file names,
|
||||
and SQLite foreign keys. Keep it lowercase and hyphenated.
|
||||
|
||||
FIELDS:
|
||||
Name Human-readable display name (any capitalization).
|
||||
Status One of: active | paused | done
|
||||
Description One-sentence summary of the project's purpose.
|
||||
|
||||
FILE CONVENTIONS:
|
||||
Task file: tasks/<slug>-tasks.md (required)
|
||||
Plan file: plans/<slug>-plan.md (optional)
|
||||
|
||||
NOTE: Do not add task counts or progress bars here. Those are
|
||||
computed by the sync/export scripts and belong in dashboards,
|
||||
not in this registry file.
|
||||
============================================================ -->
|
||||
|
||||
## example-project
|
||||
- Name: Example Project
|
||||
- Status: active
|
||||
- Description: A starter project to demonstrate the TaskFlow format — replace with your own.
|
||||
|
||||
<!-- Add additional ## blocks below. Each block is one project. -->
|
||||
@@ -0,0 +1,61 @@
|
||||
# {{Project Name}} — Plan
|
||||
|
||||
<!-- ============================================================
|
||||
PLAN FILE
|
||||
============================================================
|
||||
File name: plans/<slug>-plan.md
|
||||
|
||||
This is a freeform architecture/design document. It is NOT
|
||||
synced to SQLite. Agents read it at session start for context.
|
||||
It is the right place for:
|
||||
- High-level vision and goals
|
||||
- Architecture decisions (with rationale)
|
||||
- Phase breakdowns and milestones
|
||||
- Open questions and tradeoffs
|
||||
- Anything too long or structural for a task line
|
||||
|
||||
There is no required format. The sections below are suggested
|
||||
starting points — delete or rename them freely.
|
||||
============================================================ -->
|
||||
|
||||
## Vision
|
||||
|
||||
<!-- What is this project trying to accomplish? What does success look like?
|
||||
One paragraph. Be concrete. -->
|
||||
|
||||
## Architecture
|
||||
|
||||
<!-- How is the system structured? Key components, data flows, external dependencies.
|
||||
Diagrams (ASCII or Mermaid) welcome. -->
|
||||
|
||||
## Decisions
|
||||
|
||||
<!-- Resolved design decisions. Format: short title + rationale.
|
||||
Example:
|
||||
| # | Decision | Resolution |
|
||||
|---|---|---|
|
||||
| 1 | Auth strategy | Use OAuth2 PKCE — avoids storing client secrets server-side |
|
||||
| 2 | Storage | SQLite for local, S3 for media — keeps infra simple |
|
||||
-->
|
||||
|
||||
## Open Questions
|
||||
|
||||
<!-- Unresolved questions or tradeoffs still being evaluated.
|
||||
Remove items as they are resolved (move to Decisions above).
|
||||
Example:
|
||||
- Should we paginate the feed at 20 or 50 items?
|
||||
- Redis vs. in-process cache for rate limiting?
|
||||
-->
|
||||
|
||||
## Phases / Milestones
|
||||
|
||||
<!-- Optional. Break down the project into phases or milestones.
|
||||
Example:
|
||||
### Phase 1 — MVP
|
||||
- Core sync logic
|
||||
- Basic CLI
|
||||
|
||||
### Phase 2 — Polish
|
||||
- Error handling
|
||||
- Tests
|
||||
-->
|
||||
@@ -0,0 +1,57 @@
|
||||
# {{Project Name}} — Tasks
|
||||
|
||||
<!-- ============================================================
|
||||
TASK FILE FORMAT
|
||||
============================================================
|
||||
|
||||
File name: tasks/<slug>-tasks.md
|
||||
One file per project. Slug matches the ## heading in PROJECTS.md.
|
||||
|
||||
TASK LINE FORMAT:
|
||||
- [x| ] (task:<slug>-NNN) [<priority>] [<owner>] <title>
|
||||
|
||||
FIELDS:
|
||||
[ ] / [x] Open / completed. The sync engine drives status
|
||||
from the section header, not this checkbox.
|
||||
Use [x] for Done (and optionally Pending Validation).
|
||||
|
||||
(task:<id>) Task ID. Format: <slug>-NNN (zero-padded, sequential).
|
||||
Custom prefixes are allowed but tooling assumes this pattern.
|
||||
|
||||
[<priority>] REQUIRED. Must come BEFORE the owner tag.
|
||||
Defaults: P0 (critical) | P1 (high) | P2 (normal) | P3 (low) | P9 (someday)
|
||||
|
||||
[<owner>] Optional. Agent or model tag (e.g., codex, sonnet, claude).
|
||||
|
||||
<title> Human-readable task title.
|
||||
|
||||
NOTES (optional, immediately below task line):
|
||||
- note: <text>
|
||||
⚠️ Notes are one-way in v1: removing a note in markdown does
|
||||
not clear it in the DB. This is a known limitation.
|
||||
|
||||
SECTION HEADERS (fixed — do not rename):
|
||||
## In Progress → DB status: in_progress
|
||||
## Pending Validation → DB status: pending_validation
|
||||
## Backlog → DB status: backlog
|
||||
## Blocked → DB status: blocked
|
||||
## Done → DB status: done
|
||||
|
||||
TAG ORDER RULE:
|
||||
Priority tag MUST come before owner tag. The parser is positional.
|
||||
✅ - [ ] (task:myproject-007) [P1] [codex] Implement search
|
||||
❌ - [ ] (task:myproject-007) [codex] [P1] Implement search
|
||||
============================================================ -->
|
||||
|
||||
## In Progress
|
||||
|
||||
## Pending Validation
|
||||
|
||||
## Backlog
|
||||
|
||||
<!-- Example task — replace with your own and assign the next sequential ID -->
|
||||
- [ ] (task:{{slug}}-001) [P2] First task for this project
|
||||
|
||||
## Blocked
|
||||
|
||||
## Done
|
||||
Reference in New Issue
Block a user