@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.
Files changed (3) hide show
  1. package/README.md +113 -32
  2. package/dist/cli.mjs +254 -22
  3. 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
- # Installed plan-from-task
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 feat - user authentication.md
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
- The format is intentionally simple edit task files directly when you need to update descriptions, change status, or check off criteria.
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 feat - first task.md
199
- PROJ-002 fix - second task.md
277
+ PROJ-001.md
278
+ PROJ-002.md
200
279
  .archive/
201
- PROJ-000 chore - archived task.md
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 titleMatch = filename.replace(/\.md$/, "").match(/^\S+\s+\S+\s+-\s+(.+)$/);
101
- const title = titleMatch ? titleMatch[1] : "";
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, file);
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 === "symbol") break;
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(`Invalid type "${args.type}". Must be one of: ${TASK_TYPES.join(", ")}`);
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(`Created ${task.id}: ${task.title}`);
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(`Archived ${args.id}`);
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(`Installed ${skill}`);
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.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": "node --import tsx src/cli.ts",
44
+ "dev": "tsx src/cli.ts",
44
45
  "test": "vitest run",
45
46
  "changelog": "changelogen --output",
46
47
  "changelog:preview": "changelogen --no-output"