@xxkeefer/mrkl 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -32
- package/dist/cli.mjs +254 -22
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<p align="center">
|
|
2
2
|
<h1 align="center">mrkl</h1>
|
|
3
3
|
<p align="center">
|
|
4
|
+
📝 <i>mrkl, rhymes with sparkle</i> ✨
|
|
5
|
+
<br />
|
|
4
6
|
Lightweight CLI for structured markdown task tracking.
|
|
5
7
|
<br />
|
|
6
8
|
Track work in your repo, not in a separate app.
|
|
@@ -15,17 +17,17 @@
|
|
|
15
17
|
|
|
16
18
|
---
|
|
17
19
|
|
|
18
|
-
## Why mrkl?
|
|
20
|
+
## Why mrkl? 🤔
|
|
19
21
|
|
|
20
22
|
Most task trackers live outside your codebase. mrkl keeps tasks as markdown files right alongside your code — version-controlled, greppable, and readable by both humans and AI agents.
|
|
21
23
|
|
|
22
|
-
- **No external service** — tasks live in `.tasks/` as structured markdown
|
|
23
|
-
- **Git-native** — commit, branch, and diff your tasks like any other file
|
|
24
|
-
- **AI-agent friendly** — consistent YAML frontmatter makes tasks easy to parse programmatically
|
|
25
|
-
- **Conventional commits vocabulary** — task types mirror what you already use (`feat`, `fix`, `chore`, etc.)
|
|
26
|
-
- **Zero config** — one command to set up, sensible defaults for everything
|
|
24
|
+
- 🗂️ **No external service** — tasks live in `.tasks/` as structured markdown
|
|
25
|
+
- 🌿 **Git-native** — commit, branch, and diff your tasks like any other file
|
|
26
|
+
- 🤖 **AI-agent friendly** — consistent YAML frontmatter makes tasks easy to parse programmatically
|
|
27
|
+
- 📏 **Conventional commits vocabulary** — task types mirror what you already use (`feat`, `fix`, `chore`, etc.)
|
|
28
|
+
- ⚡ **Zero config** — one command to set up, sensible defaults for everything
|
|
27
29
|
|
|
28
|
-
## Install
|
|
30
|
+
## Install 📦
|
|
29
31
|
|
|
30
32
|
```sh
|
|
31
33
|
pnpm add -g @xxkeefer/mrkl
|
|
@@ -37,7 +39,7 @@ Or use without installing:
|
|
|
37
39
|
npx @xxkeefer/mrkl init MY_PROJECT
|
|
38
40
|
```
|
|
39
41
|
|
|
40
|
-
## Quick Start
|
|
42
|
+
## Quick Start 🚀
|
|
41
43
|
|
|
42
44
|
```sh
|
|
43
45
|
# Initialize in your project root
|
|
@@ -60,9 +62,26 @@ mrkl list --status todo
|
|
|
60
62
|
|
|
61
63
|
# Archive a completed task
|
|
62
64
|
mrkl done PROJ-001
|
|
65
|
+
|
|
66
|
+
# All commands have short aliases
|
|
67
|
+
mrkl c feat "dark mode" # create
|
|
68
|
+
mrkl ls --type fix # list
|
|
69
|
+
mrkl d PROJ-001 # done
|
|
70
|
+
mrkl x PROJ-002 # close
|
|
63
71
|
```
|
|
64
72
|
|
|
65
|
-
## Commands
|
|
73
|
+
## Commands 🛠️
|
|
74
|
+
|
|
75
|
+
| Command | Alias | Description |
|
|
76
|
+
|---------|-------|-------------|
|
|
77
|
+
| `init` | `i` | Initialize mrkl in the current project |
|
|
78
|
+
| `create` | `c` | Create a new task |
|
|
79
|
+
| `list` | `ls` | List active tasks |
|
|
80
|
+
| `done` | `d` | Mark a task as done and archive it |
|
|
81
|
+
| `close` | `x` | Close a task (won't do, duplicate, etc.) and archive it |
|
|
82
|
+
| `prune` | `p` | Delete archived tasks created on or before a given date |
|
|
83
|
+
| `migrate_prior_verbose` | — | Migrate legacy verbose-filename tasks to frontmatter-based format |
|
|
84
|
+
| `install-skills` | — | Install bundled Claude Code skills |
|
|
66
85
|
|
|
67
86
|
### `mrkl init <prefix>`
|
|
68
87
|
|
|
@@ -89,10 +108,10 @@ Creates a new task file.
|
|
|
89
108
|
| `type` | Task type (see [Task Types](#task-types)) |
|
|
90
109
|
| `title` | Short description of the task |
|
|
91
110
|
|
|
92
|
-
| Option | Description |
|
|
93
|
-
|
|
94
|
-
| `--desc <text>` | Detailed description |
|
|
95
|
-
| `--ac <text>` | Acceptance criterion (repeatable) |
|
|
111
|
+
| Option | Alias | Description |
|
|
112
|
+
|--------|-------|-------------|
|
|
113
|
+
| `--desc <text>` | `-d` | Detailed description |
|
|
114
|
+
| `--ac <text>` | `-a` | Acceptance criterion (repeatable) |
|
|
96
115
|
|
|
97
116
|
```sh
|
|
98
117
|
mrkl create feat "search functionality" \
|
|
@@ -102,14 +121,16 @@ mrkl create feat "search functionality" \
|
|
|
102
121
|
--ac "highlights matching terms"
|
|
103
122
|
```
|
|
104
123
|
|
|
124
|
+
Running `mrkl create` with no arguments enters **interactive mode**, prompting for type, title, description, and acceptance criteria.
|
|
125
|
+
|
|
105
126
|
### `mrkl list [options]`
|
|
106
127
|
|
|
107
128
|
Lists all active tasks.
|
|
108
129
|
|
|
109
|
-
| Option | Description |
|
|
110
|
-
|
|
111
|
-
| `--type <type>` | Filter by task type |
|
|
112
|
-
| `--status <status>` | Filter by status (`todo`, `in-progress`, `done`) |
|
|
130
|
+
| Option | Alias | Description |
|
|
131
|
+
|--------|-------|-------------|
|
|
132
|
+
| `--type <type>` | `-t` | Filter by task type |
|
|
133
|
+
| `--status <status>` | `-s` | Filter by status (`todo`, `in-progress`, `done`) |
|
|
113
134
|
|
|
114
135
|
Non-conforming markdown files in the tasks directory are silently skipped.
|
|
115
136
|
|
|
@@ -123,6 +144,57 @@ Archives a completed task.
|
|
|
123
144
|
|
|
124
145
|
Moves the task file to `.tasks/.archive/` and sets its status to `done`.
|
|
125
146
|
|
|
147
|
+
### `mrkl close <id>`
|
|
148
|
+
|
|
149
|
+
Closes a task that won't be done — duplicates, out-of-scope work, etc.
|
|
150
|
+
|
|
151
|
+
| Argument | Description |
|
|
152
|
+
|----------|-------------|
|
|
153
|
+
| `id` | Task ID to close (e.g., `PROJ-002`) |
|
|
154
|
+
|
|
155
|
+
Sets the task status to `closed` and moves it to `.tasks/.archive/`.
|
|
156
|
+
|
|
157
|
+
### `mrkl prune <date> [options]`
|
|
158
|
+
|
|
159
|
+
Permanently deletes archived tasks created on or before a cutoff date.
|
|
160
|
+
|
|
161
|
+
| Argument | Description |
|
|
162
|
+
|----------|-------------|
|
|
163
|
+
| `date` | Cutoff date (`YYYY-MM-DD` or `YYYYMMDD`) |
|
|
164
|
+
|
|
165
|
+
| Option | Alias | Description |
|
|
166
|
+
|--------|-------|-------------|
|
|
167
|
+
| `--force` | `-f` | Skip confirmation prompt |
|
|
168
|
+
|
|
169
|
+
Shows a confirmation prompt listing tasks to be deleted unless `--force` is used.
|
|
170
|
+
|
|
171
|
+
```sh
|
|
172
|
+
# Delete archived tasks from January or earlier
|
|
173
|
+
mrkl prune 2026-01-31
|
|
174
|
+
|
|
175
|
+
# Skip confirmation
|
|
176
|
+
mrkl prune 2026-01-31 --force
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### `mrkl migrate_prior_verbose`
|
|
180
|
+
|
|
181
|
+
Migrates task files from the legacy verbose-filename format to the current format. This is a **one-time migration** for projects that were using mrkl before v0.4.0.
|
|
182
|
+
|
|
183
|
+
**What it does:**
|
|
184
|
+
|
|
185
|
+
1. Scans all task files in `.tasks/` and `.tasks/.archive/`
|
|
186
|
+
2. Extracts the title from the verbose filename (e.g., `PROJ-001 feat - user auth.md`)
|
|
187
|
+
3. Writes the title into YAML frontmatter
|
|
188
|
+
4. If `verbose_files = false` (default): renames files to short format (`PROJ-001.md`)
|
|
189
|
+
5. If `verbose_files = true`: keeps verbose filenames as-is
|
|
190
|
+
|
|
191
|
+
```sh
|
|
192
|
+
mrkl migrate_prior_verbose
|
|
193
|
+
# ✅ Migrated 12 file(s), skipped 3 file(s).
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
> **Note:** After upgrading to v0.4.0+, existing task files will fail to parse until migrated. Run this command once to fix them.
|
|
197
|
+
|
|
126
198
|
### `mrkl install-skills`
|
|
127
199
|
|
|
128
200
|
Installs bundled Claude Code skills into the current project.
|
|
@@ -131,7 +203,7 @@ Copies skill directories from the mrkl package into `.claude/skills/` so they ar
|
|
|
131
203
|
|
|
132
204
|
```sh
|
|
133
205
|
mrkl install-skills
|
|
134
|
-
#
|
|
206
|
+
# 🧩 Installed plan-from-task
|
|
135
207
|
```
|
|
136
208
|
|
|
137
209
|
Currently ships with:
|
|
@@ -140,7 +212,7 @@ Currently ships with:
|
|
|
140
212
|
|-------|-------------|
|
|
141
213
|
| `plan-from-task` | Generate and execute implementation plans from mrkl task files |
|
|
142
214
|
|
|
143
|
-
## Task Types
|
|
215
|
+
## Task Types 🏷️
|
|
144
216
|
|
|
145
217
|
mrkl uses [conventional commit](https://www.conventionalcommits.org/) types:
|
|
146
218
|
|
|
@@ -157,17 +229,18 @@ mrkl uses [conventional commit](https://www.conventionalcommits.org/) types:
|
|
|
157
229
|
| `build` | Build system changes |
|
|
158
230
|
| `style` | Code style/formatting |
|
|
159
231
|
|
|
160
|
-
## Task File Format
|
|
232
|
+
## Task File Format 📄
|
|
161
233
|
|
|
162
|
-
Each task is a markdown file with YAML frontmatter:
|
|
234
|
+
Each task is a markdown file with YAML frontmatter. By default, filenames use the short format:
|
|
163
235
|
|
|
164
236
|
```
|
|
165
|
-
.tasks/PROJ-001
|
|
237
|
+
.tasks/PROJ-001.md
|
|
166
238
|
```
|
|
167
239
|
|
|
168
240
|
```markdown
|
|
169
241
|
---
|
|
170
242
|
id: PROJ-001
|
|
243
|
+
title: user authentication
|
|
171
244
|
type: feat
|
|
172
245
|
status: todo
|
|
173
246
|
created: '2026-03-01'
|
|
@@ -183,9 +256,15 @@ Implement user authentication with OAuth2.
|
|
|
183
256
|
- [ ] session persists across refreshes
|
|
184
257
|
```
|
|
185
258
|
|
|
186
|
-
|
|
259
|
+
With `verbose_files = true`, filenames include the type and title:
|
|
260
|
+
|
|
261
|
+
```
|
|
262
|
+
.tasks/PROJ-001 feat - user authentication.md
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
The `title` is always stored in frontmatter regardless of filename format. Edit task files directly when you need to update descriptions, change status, or check off criteria.
|
|
187
266
|
|
|
188
|
-
## Project Structure
|
|
267
|
+
## Project Structure 🗂️
|
|
189
268
|
|
|
190
269
|
After initialization, mrkl adds the following to your project:
|
|
191
270
|
|
|
@@ -195,15 +274,15 @@ your-project/
|
|
|
195
274
|
mrkl.toml # project configuration
|
|
196
275
|
mrkl_counter # current task number
|
|
197
276
|
.tasks/
|
|
198
|
-
PROJ-001
|
|
199
|
-
PROJ-002
|
|
277
|
+
PROJ-001.md
|
|
278
|
+
PROJ-002.md
|
|
200
279
|
.archive/
|
|
201
|
-
PROJ-000
|
|
280
|
+
PROJ-000.md
|
|
202
281
|
```
|
|
203
282
|
|
|
204
283
|
Commit `.config/mrkl/` and `.tasks/` to version control. They're designed to be tracked alongside your code.
|
|
205
284
|
|
|
206
|
-
## Team Workflow
|
|
285
|
+
## Team Workflow 👥
|
|
207
286
|
|
|
208
287
|
When using mrkl with **git worktrees** or **protected branches**, task IDs can conflict if multiple branches create tasks concurrently. The fix is a simple convention: **separate planning from execution.**
|
|
209
288
|
|
|
@@ -227,21 +306,23 @@ mrkl done MRKL-019
|
|
|
227
306
|
|
|
228
307
|
The counter only increments on planning branches — one at a time — so IDs never conflict. See **[docs/workflow.md](docs/workflow.md)** for the full guide with examples and edge cases.
|
|
229
308
|
|
|
230
|
-
## Configuration
|
|
309
|
+
## Configuration ⚙️
|
|
231
310
|
|
|
232
311
|
Configuration lives in `.config/mrkl/mrkl.toml` (or `mrkl.toml` at the project root):
|
|
233
312
|
|
|
234
313
|
```toml
|
|
235
314
|
prefix = "PROJ"
|
|
236
315
|
tasks_dir = ".tasks"
|
|
316
|
+
verbose_files = false
|
|
237
317
|
```
|
|
238
318
|
|
|
239
319
|
| Key | Default | Description |
|
|
240
320
|
|-----|---------|-------------|
|
|
241
321
|
| `prefix` | *(required)* | Project prefix for task IDs |
|
|
242
322
|
| `tasks_dir` | `".tasks"` | Directory for task files |
|
|
323
|
+
| `verbose_files` | `false` | Use verbose filenames (`PROJ-001 feat - title.md` vs `PROJ-001.md`) |
|
|
243
324
|
|
|
244
|
-
## Development
|
|
325
|
+
## Development 🧑💻
|
|
245
326
|
|
|
246
327
|
```sh
|
|
247
328
|
git clone https://github.com/xxKeefer/mrkl.git
|
|
@@ -258,10 +339,10 @@ pnpm tsx src/cli.ts list
|
|
|
258
339
|
pnpm build
|
|
259
340
|
```
|
|
260
341
|
|
|
261
|
-
## Contributing
|
|
342
|
+
## Contributing 🤝
|
|
262
343
|
|
|
263
344
|
Contributions are welcome! See **[CONTRIBUTING.md](CONTRIBUTING.md)** for branch protection rules, merge strategy, and development setup.
|
|
264
345
|
|
|
265
|
-
## License
|
|
346
|
+
## License 📜
|
|
266
347
|
|
|
267
348
|
[MIT](LICENSE)
|
package/dist/cli.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { defineCommand, runMain } from 'citty';
|
|
3
3
|
import consola from 'consola';
|
|
4
|
-
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync, statSync } from 'node:fs';
|
|
5
|
-
import { join, dirname } from 'node:path';
|
|
4
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync, renameSync, statSync } from 'node:fs';
|
|
5
|
+
import { join, basename, dirname } from 'node:path';
|
|
6
6
|
import { stringify, parse as parse$1 } from 'smol-toml';
|
|
7
7
|
import matter from 'gray-matter';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
@@ -20,7 +20,8 @@ function loadConfig(dir) {
|
|
|
20
20
|
const parsed = parse$1(raw);
|
|
21
21
|
return {
|
|
22
22
|
prefix: parsed.prefix,
|
|
23
|
-
tasks_dir: parsed.tasks_dir ?? ".tasks"
|
|
23
|
+
tasks_dir: parsed.tasks_dir ?? ".tasks",
|
|
24
|
+
verbose_files: parsed.verbose_files ?? false
|
|
24
25
|
};
|
|
25
26
|
}
|
|
26
27
|
function initConfig(dir, opts) {
|
|
@@ -29,7 +30,8 @@ function initConfig(dir, opts) {
|
|
|
29
30
|
if (!existsSync(configPath)) {
|
|
30
31
|
const config2 = {
|
|
31
32
|
prefix: opts?.prefix ?? "TASK",
|
|
32
|
-
tasks_dir: opts?.tasks_dir ?? ".tasks"
|
|
33
|
+
tasks_dir: opts?.tasks_dir ?? ".tasks",
|
|
34
|
+
verbose_files: opts?.verbose_files ?? false
|
|
33
35
|
};
|
|
34
36
|
mkdirSync(configDir, { recursive: true });
|
|
35
37
|
writeFileSync(configPath, stringify(config2));
|
|
@@ -58,7 +60,7 @@ const initCommand = defineCommand({
|
|
|
58
60
|
const dir = process.cwd();
|
|
59
61
|
try {
|
|
60
62
|
initConfig(dir, { prefix: args.prefix });
|
|
61
|
-
consola.success("mrkl initialized");
|
|
63
|
+
consola.success("\u{1F389} mrkl initialized");
|
|
62
64
|
} catch (err) {
|
|
63
65
|
consola.error(String(err.message));
|
|
64
66
|
process.exit(1);
|
|
@@ -78,6 +80,7 @@ function nextId(dir) {
|
|
|
78
80
|
function render(task) {
|
|
79
81
|
const frontmatter = {
|
|
80
82
|
id: task.id,
|
|
83
|
+
title: task.title,
|
|
81
84
|
type: task.type,
|
|
82
85
|
status: task.status,
|
|
83
86
|
created: task.created
|
|
@@ -97,8 +100,12 @@ function render(task) {
|
|
|
97
100
|
}
|
|
98
101
|
function parse(content, filename) {
|
|
99
102
|
const { data, content: body } = matter(content);
|
|
100
|
-
const
|
|
101
|
-
|
|
103
|
+
const title = data.title;
|
|
104
|
+
if (!title) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
"Task file missing title in frontmatter. Run 'mrkl migrate_prior_verbose' to fix."
|
|
107
|
+
);
|
|
108
|
+
}
|
|
102
109
|
const acRegex = /^- \[[ x]\] (.+)$/gm;
|
|
103
110
|
const acceptance_criteria = [];
|
|
104
111
|
let match;
|
|
@@ -137,7 +144,7 @@ function createTask(opts) {
|
|
|
137
144
|
description: opts.description ?? "",
|
|
138
145
|
acceptance_criteria: opts.acceptance_criteria ?? []
|
|
139
146
|
};
|
|
140
|
-
const filename = `${id} ${task.type} - ${task.title}.md`;
|
|
147
|
+
const filename = config.verbose_files ? `${id} ${task.type} - ${task.title}.md` : `${id}.md`;
|
|
141
148
|
const tasksDir = join(opts.dir, config.tasks_dir);
|
|
142
149
|
writeFileSync(join(tasksDir, filename), render(task));
|
|
143
150
|
return task;
|
|
@@ -145,9 +152,7 @@ function createTask(opts) {
|
|
|
145
152
|
function listTasks(filter) {
|
|
146
153
|
const config = loadConfig(filter.dir);
|
|
147
154
|
const tasksDir = join(filter.dir, config.tasks_dir);
|
|
148
|
-
const files = readdirSync(tasksDir).filter(
|
|
149
|
-
(f) => f.endsWith(".md") && !f.startsWith(".")
|
|
150
|
-
);
|
|
155
|
+
const files = readdirSync(tasksDir).filter((f) => f.endsWith(".md") && !f.startsWith("."));
|
|
151
156
|
let tasks = files.flatMap((f) => {
|
|
152
157
|
try {
|
|
153
158
|
const content = readFileSync(join(tasksDir, f), "utf-8");
|
|
@@ -174,12 +179,76 @@ function archiveTask(dir, id) {
|
|
|
174
179
|
}
|
|
175
180
|
const filePath = join(tasksDir, file);
|
|
176
181
|
const content = readFileSync(filePath, "utf-8");
|
|
177
|
-
const task = parse(content
|
|
182
|
+
const task = parse(content);
|
|
178
183
|
task.status = "done";
|
|
179
184
|
const archivePath = join(tasksDir, ".archive", file);
|
|
180
185
|
writeFileSync(archivePath, render(task));
|
|
181
186
|
unlinkSync(filePath);
|
|
182
187
|
}
|
|
188
|
+
function parseCutoffDate(input) {
|
|
189
|
+
const normalized = input.replace(/^(\d{4})(\d{2})(\d{2})$/, "$1-$2-$3");
|
|
190
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
|
|
191
|
+
throw new Error(`Invalid date format: "${input}". Expected YYYY-MM-DD or YYYYMMDD.`);
|
|
192
|
+
}
|
|
193
|
+
const [y, m, d] = normalized.split("-").map(Number);
|
|
194
|
+
const date = new Date(y, m - 1, d);
|
|
195
|
+
if (date.getFullYear() !== y || date.getMonth() !== m - 1 || date.getDate() !== d) {
|
|
196
|
+
throw new Error(`Invalid date: "${input}" is not a real calendar date.`);
|
|
197
|
+
}
|
|
198
|
+
return normalized;
|
|
199
|
+
}
|
|
200
|
+
function normalizeCreatedDate(created) {
|
|
201
|
+
if (created instanceof Date) {
|
|
202
|
+
return created.toISOString().slice(0, 10);
|
|
203
|
+
}
|
|
204
|
+
return String(created);
|
|
205
|
+
}
|
|
206
|
+
function pruneTasks(dir, cutoff) {
|
|
207
|
+
const config = loadConfig(dir);
|
|
208
|
+
const archiveDir = join(dir, config.tasks_dir, ".archive");
|
|
209
|
+
if (!existsSync(archiveDir)) {
|
|
210
|
+
return { deleted: [], total: 0 };
|
|
211
|
+
}
|
|
212
|
+
const files = readdirSync(archiveDir).filter((f) => f.endsWith(".md") && !f.startsWith("."));
|
|
213
|
+
const deleted = [];
|
|
214
|
+
for (const f of files) {
|
|
215
|
+
try {
|
|
216
|
+
const content = readFileSync(join(archiveDir, f), "utf-8");
|
|
217
|
+
const task = parse(content, f);
|
|
218
|
+
const created = normalizeCreatedDate(task.created);
|
|
219
|
+
if (created <= cutoff) {
|
|
220
|
+
deleted.push({ id: task.id, title: task.title, created, filename: f });
|
|
221
|
+
}
|
|
222
|
+
} catch {
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return { deleted, total: files.length };
|
|
226
|
+
}
|
|
227
|
+
function executePrune(dir, filenames) {
|
|
228
|
+
const config = loadConfig(dir);
|
|
229
|
+
const archiveDir = join(dir, config.tasks_dir, ".archive");
|
|
230
|
+
for (const f of filenames) {
|
|
231
|
+
unlinkSync(join(archiveDir, f));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function closeTask(dir, id) {
|
|
235
|
+
const config = loadConfig(dir);
|
|
236
|
+
const tasksDir = join(dir, config.tasks_dir);
|
|
237
|
+
const idUpper = id.toUpperCase();
|
|
238
|
+
const file = readdirSync(tasksDir).find(
|
|
239
|
+
(f) => f.endsWith(".md") && f.toUpperCase().startsWith(idUpper)
|
|
240
|
+
);
|
|
241
|
+
if (!file) {
|
|
242
|
+
throw new Error(`Task ${id} not found`);
|
|
243
|
+
}
|
|
244
|
+
const filePath = join(tasksDir, file);
|
|
245
|
+
const content = readFileSync(filePath, "utf-8");
|
|
246
|
+
const task = parse(content);
|
|
247
|
+
task.status = "closed";
|
|
248
|
+
const archivePath = join(tasksDir, ".archive", file);
|
|
249
|
+
writeFileSync(archivePath, render(task));
|
|
250
|
+
unlinkSync(filePath);
|
|
251
|
+
}
|
|
183
252
|
|
|
184
253
|
const TASK_TYPES = [
|
|
185
254
|
"feat",
|
|
@@ -206,7 +275,7 @@ async function promptForTask(dir) {
|
|
|
206
275
|
});
|
|
207
276
|
if (typeof title === "symbol") process.exit(0);
|
|
208
277
|
if (!title.trim()) {
|
|
209
|
-
consola.error("Title cannot be empty");
|
|
278
|
+
consola.error("\u274C Title cannot be empty");
|
|
210
279
|
process.exit(1);
|
|
211
280
|
}
|
|
212
281
|
const desc = await consola.prompt("Description (optional, enter to skip)", {
|
|
@@ -220,7 +289,7 @@ async function promptForTask(dir) {
|
|
|
220
289
|
criteria.length === 0 ? "Acceptance criterion (Esc to skip)" : `Criterion #${criteria.length + 1} (Esc to finish)`,
|
|
221
290
|
{ type: "text" }
|
|
222
291
|
);
|
|
223
|
-
if (typeof ac
|
|
292
|
+
if (typeof ac !== "string") break;
|
|
224
293
|
if (ac.trim()) criteria.push(ac.trim());
|
|
225
294
|
}
|
|
226
295
|
return {
|
|
@@ -262,7 +331,7 @@ const createCommand = defineCommand({
|
|
|
262
331
|
const dir = process.cwd();
|
|
263
332
|
const interactive = !args.type && !args.title;
|
|
264
333
|
if (!interactive && (!args.type || !args.title)) {
|
|
265
|
-
consola.error("Both type and title are required, or omit both for interactive mode");
|
|
334
|
+
consola.error("\u274C Both type and title are required, or omit both for interactive mode");
|
|
266
335
|
process.exit(1);
|
|
267
336
|
}
|
|
268
337
|
try {
|
|
@@ -270,7 +339,7 @@ const createCommand = defineCommand({
|
|
|
270
339
|
dir,
|
|
271
340
|
type: (() => {
|
|
272
341
|
if (!TASK_TYPES.includes(args.type)) {
|
|
273
|
-
consola.error(
|
|
342
|
+
consola.error(`\u274C Invalid type "${args.type}". Must be one of: ${TASK_TYPES.join(", ")}`);
|
|
274
343
|
process.exit(1);
|
|
275
344
|
}
|
|
276
345
|
return args.type;
|
|
@@ -280,7 +349,7 @@ const createCommand = defineCommand({
|
|
|
280
349
|
acceptance_criteria: args.ac ? Array.isArray(args.ac) ? args.ac : [args.ac] : void 0
|
|
281
350
|
};
|
|
282
351
|
const task = createTask(opts);
|
|
283
|
-
consola.success(
|
|
352
|
+
consola.success(`\u{1F4DD} Created ${task.id}: ${task.title}`);
|
|
284
353
|
} catch (err) {
|
|
285
354
|
consola.error(String(err.message));
|
|
286
355
|
process.exit(1);
|
|
@@ -314,7 +383,7 @@ const listCommand = defineCommand({
|
|
|
314
383
|
status: args.status
|
|
315
384
|
});
|
|
316
385
|
if (tasks.length === 0) {
|
|
317
|
-
consola.info("No tasks found");
|
|
386
|
+
consola.info("\u{1F4ED} No tasks found");
|
|
318
387
|
return;
|
|
319
388
|
}
|
|
320
389
|
for (const task of tasks) {
|
|
@@ -343,7 +412,165 @@ const doneCommand = defineCommand({
|
|
|
343
412
|
const dir = process.cwd();
|
|
344
413
|
try {
|
|
345
414
|
archiveTask(dir, args.id);
|
|
346
|
-
consola.success(
|
|
415
|
+
consola.success(`\u2705 Archived ${args.id}`);
|
|
416
|
+
} catch (err) {
|
|
417
|
+
consola.error(String(err.message));
|
|
418
|
+
process.exit(1);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const pruneCommand = defineCommand({
|
|
424
|
+
meta: {
|
|
425
|
+
name: "prune",
|
|
426
|
+
description: "Delete archived tasks created on or before a given date"
|
|
427
|
+
},
|
|
428
|
+
args: {
|
|
429
|
+
date: {
|
|
430
|
+
type: "positional",
|
|
431
|
+
description: "Cutoff date (YYYY-MM-DD or YYYYMMDD)",
|
|
432
|
+
required: true
|
|
433
|
+
},
|
|
434
|
+
force: {
|
|
435
|
+
type: "boolean",
|
|
436
|
+
alias: "f",
|
|
437
|
+
description: "Skip confirmation prompt",
|
|
438
|
+
default: false
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
async run({ args }) {
|
|
442
|
+
const dir = process.cwd();
|
|
443
|
+
let cutoff;
|
|
444
|
+
try {
|
|
445
|
+
cutoff = parseCutoffDate(args.date);
|
|
446
|
+
} catch (err) {
|
|
447
|
+
consola.error(String(err.message));
|
|
448
|
+
process.exit(1);
|
|
449
|
+
}
|
|
450
|
+
const result = pruneTasks(dir, cutoff);
|
|
451
|
+
if (result.deleted.length === 0) {
|
|
452
|
+
consola.info(`\u{1F4ED} No archived tasks found on or before ${cutoff}`);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
consola.info(`\u{1F50D} Found ${result.deleted.length} task(s) to prune:`);
|
|
456
|
+
for (const task of result.deleted) {
|
|
457
|
+
consola.log(` ${task.id} \u2014 ${task.title} (${task.created})`);
|
|
458
|
+
}
|
|
459
|
+
if (!args.force) {
|
|
460
|
+
const confirm = await consola.prompt("Delete these tasks?", {
|
|
461
|
+
type: "confirm"
|
|
462
|
+
});
|
|
463
|
+
if (typeof confirm === "symbol" || !confirm) {
|
|
464
|
+
consola.info("\u{1F44B} Aborted");
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
executePrune(dir, result.deleted.map((t) => t.filename));
|
|
469
|
+
consola.success(`\u{1F9F9} Pruned ${result.deleted.length} archived task(s)`);
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
const closeCommand = defineCommand({
|
|
474
|
+
meta: {
|
|
475
|
+
name: "close",
|
|
476
|
+
description: "Close a task (won't do, duplicate, etc.) and archive it"
|
|
477
|
+
},
|
|
478
|
+
args: {
|
|
479
|
+
id: {
|
|
480
|
+
type: "positional",
|
|
481
|
+
description: "Task ID to close (e.g., VON-001)",
|
|
482
|
+
required: true
|
|
483
|
+
}
|
|
484
|
+
},
|
|
485
|
+
run({ args }) {
|
|
486
|
+
const dir = process.cwd();
|
|
487
|
+
try {
|
|
488
|
+
closeTask(dir, args.id);
|
|
489
|
+
consola.success(`\u{1F6AB} Closed ${args.id}`);
|
|
490
|
+
} catch (err) {
|
|
491
|
+
consola.error(String(err.message));
|
|
492
|
+
process.exit(1);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
const VERBOSE_REGEX = /^(\S+)\s+(\S+)\s+-\s+(.+)$/;
|
|
498
|
+
function migrateDir(dirPath, verboseFiles) {
|
|
499
|
+
let migrated = 0;
|
|
500
|
+
let skipped = 0;
|
|
501
|
+
const warnings = [];
|
|
502
|
+
let files;
|
|
503
|
+
try {
|
|
504
|
+
files = readdirSync(dirPath).filter((f) => f.endsWith(".md") && !f.startsWith("."));
|
|
505
|
+
} catch {
|
|
506
|
+
return { migrated, skipped, warnings };
|
|
507
|
+
}
|
|
508
|
+
for (const file of files) {
|
|
509
|
+
const filePath = join(dirPath, file);
|
|
510
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
511
|
+
const { data, content: body } = matter(raw);
|
|
512
|
+
if (data.title) {
|
|
513
|
+
skipped++;
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
const stem = basename(file, ".md");
|
|
517
|
+
const match = stem.match(VERBOSE_REGEX);
|
|
518
|
+
if (!match) {
|
|
519
|
+
warnings.push(`Could not extract title from filename: ${file}`);
|
|
520
|
+
skipped++;
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
const title = match[3];
|
|
524
|
+
const task = {
|
|
525
|
+
id: data.id,
|
|
526
|
+
type: data.type,
|
|
527
|
+
status: data.status,
|
|
528
|
+
created: data.created instanceof Date ? data.created.toISOString().slice(0, 10) : String(data.created),
|
|
529
|
+
title,
|
|
530
|
+
description: "",
|
|
531
|
+
acceptance_criteria: []
|
|
532
|
+
};
|
|
533
|
+
const descMatch = body.match(/## Description\n\n([\s\S]*?)(?:\n\n## Acceptance Criteria|$)/);
|
|
534
|
+
task.description = descMatch ? descMatch[1].trim() : "";
|
|
535
|
+
const acRegex = /^- \[[ x]\] (.+)$/gm;
|
|
536
|
+
let acMatch;
|
|
537
|
+
while ((acMatch = acRegex.exec(body)) !== null) {
|
|
538
|
+
task.acceptance_criteria.push(acMatch[1]);
|
|
539
|
+
}
|
|
540
|
+
writeFileSync(filePath, render(task));
|
|
541
|
+
if (!verboseFiles) {
|
|
542
|
+
const newName = `${data.id}.md`;
|
|
543
|
+
if (file !== newName) {
|
|
544
|
+
renameSync(filePath, join(dirPath, newName));
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
migrated++;
|
|
548
|
+
}
|
|
549
|
+
return { migrated, skipped, warnings };
|
|
550
|
+
}
|
|
551
|
+
const migrateCommand = defineCommand({
|
|
552
|
+
meta: {
|
|
553
|
+
name: "migrate",
|
|
554
|
+
description: "Migrate task files to include title in frontmatter"
|
|
555
|
+
},
|
|
556
|
+
run() {
|
|
557
|
+
const dir = process.cwd();
|
|
558
|
+
try {
|
|
559
|
+
const config = loadConfig(dir);
|
|
560
|
+
const tasksDir = join(dir, config.tasks_dir);
|
|
561
|
+
const archiveDir = join(tasksDir, ".archive");
|
|
562
|
+
const active = migrateDir(tasksDir, config.verbose_files);
|
|
563
|
+
const archived = migrateDir(archiveDir, config.verbose_files);
|
|
564
|
+
const totalMigrated = active.migrated + archived.migrated;
|
|
565
|
+
const totalSkipped = active.skipped + archived.skipped;
|
|
566
|
+
const allWarnings = [...active.warnings, ...archived.warnings];
|
|
567
|
+
consola.success(`Migrated ${totalMigrated} file(s), skipped ${totalSkipped} file(s).`);
|
|
568
|
+
for (const w of allWarnings) {
|
|
569
|
+
consola.warn(w);
|
|
570
|
+
}
|
|
571
|
+
if (totalMigrated > 0) {
|
|
572
|
+
consola.info("Breaking change: title is now stored in frontmatter. Old parsers may need updating.");
|
|
573
|
+
}
|
|
347
574
|
} catch (err) {
|
|
348
575
|
consola.error(String(err.message));
|
|
349
576
|
process.exit(1);
|
|
@@ -385,21 +612,21 @@ const installSkillsCommand = defineCommand({
|
|
|
385
612
|
const packageRoot = findPackageRoot();
|
|
386
613
|
const skillsDir = join(packageRoot, "skills");
|
|
387
614
|
if (!existsSync(skillsDir)) {
|
|
388
|
-
consola.error("No skills directory found in mrkl package");
|
|
615
|
+
consola.error("\u274C No skills directory found in mrkl package");
|
|
389
616
|
process.exit(1);
|
|
390
617
|
}
|
|
391
618
|
const skills = readdirSync(skillsDir).filter(
|
|
392
619
|
(f) => statSync(join(skillsDir, f)).isDirectory()
|
|
393
620
|
);
|
|
394
621
|
if (skills.length === 0) {
|
|
395
|
-
consola.info("No skills to install");
|
|
622
|
+
consola.info("\u{1F4ED} No skills to install");
|
|
396
623
|
return;
|
|
397
624
|
}
|
|
398
625
|
for (const skill of skills) {
|
|
399
626
|
const src = join(skillsDir, skill);
|
|
400
627
|
const target = join(dest, skill);
|
|
401
628
|
copyDirSync(src, target);
|
|
402
|
-
consola.success(
|
|
629
|
+
consola.success(`\u{1F9E9} Installed ${skill}`);
|
|
403
630
|
}
|
|
404
631
|
} catch (err) {
|
|
405
632
|
consola.error(String(err.message));
|
|
@@ -423,6 +650,11 @@ const main = defineCommand({
|
|
|
423
650
|
ls: listCommand,
|
|
424
651
|
done: doneCommand,
|
|
425
652
|
d: doneCommand,
|
|
653
|
+
prune: pruneCommand,
|
|
654
|
+
p: pruneCommand,
|
|
655
|
+
close: closeCommand,
|
|
656
|
+
x: closeCommand,
|
|
657
|
+
migrate_prior_verbose: migrateCommand,
|
|
426
658
|
"install-skills": installSkillsCommand
|
|
427
659
|
}
|
|
428
660
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xxkeefer/mrkl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Lightweight CLI tool for structured markdown task tracking",
|
|
6
6
|
"bin": {
|
|
@@ -34,13 +34,14 @@
|
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@types/node": "^25.3.3",
|
|
36
36
|
"changelogen": "^0.6.2",
|
|
37
|
+
"tsx": "^4.21.0",
|
|
37
38
|
"typescript": "^5.7.0",
|
|
38
39
|
"unbuild": "^3.3.1",
|
|
39
40
|
"vitest": "^3.0.0"
|
|
40
41
|
},
|
|
41
42
|
"scripts": {
|
|
42
43
|
"build": "unbuild",
|
|
43
|
-
"dev": "
|
|
44
|
+
"dev": "tsx src/cli.ts",
|
|
44
45
|
"test": "vitest run",
|
|
45
46
|
"changelog": "changelogen --output",
|
|
46
47
|
"changelog:preview": "changelogen --no-output"
|