@xxkeefer/mrkl 0.1.0 → 0.2.6

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 +250 -0
  2. package/dist/cli.mjs +213 -31
  3. package/package.json +14 -8
package/README.md ADDED
@@ -0,0 +1,250 @@
1
+ <p align="center">
2
+ <h1 align="center">mrkl</h1>
3
+ <p align="center">
4
+ Lightweight CLI for structured markdown task tracking.
5
+ <br />
6
+ Track work in your repo, not in a separate app.
7
+ </p>
8
+ </p>
9
+
10
+ <p align="center">
11
+ <a href="https://www.npmjs.com/package/@xxkeefer/mrkl"><img src="https://img.shields.io/npm/v/@xxkeefer/mrkl" alt="npm version" /></a>
12
+ <a href="https://github.com/xxKeefer/mrkl/blob/main/LICENSE"><img src="https://img.shields.io/github/license/xxKeefer/mrkl" alt="license" /></a>
13
+ <a href="https://github.com/xxKeefer/mrkl"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs welcome" /></a>
14
+ </p>
15
+
16
+ ---
17
+
18
+ ## Why mrkl?
19
+
20
+ 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
+
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
27
+
28
+ ## Install
29
+
30
+ ```sh
31
+ pnpm add -g @xxkeefer/mrkl
32
+ ```
33
+
34
+ Or use without installing:
35
+
36
+ ```sh
37
+ npx @xxkeefer/mrkl init MY_PROJECT
38
+ ```
39
+
40
+ ## Quick Start
41
+
42
+ ```sh
43
+ # Initialize in your project root
44
+ mrkl init PROJ
45
+
46
+ # Create tasks
47
+ mrkl create feat "user authentication"
48
+ mrkl create fix "login redirect loop" --desc "Users get stuck after OAuth callback"
49
+ mrkl create feat "dark mode" --ac "toggle in settings" --ac "persists across sessions"
50
+
51
+ # View active tasks
52
+ mrkl list
53
+ # PROJ-001 feat todo user authentication
54
+ # PROJ-002 fix todo login redirect loop
55
+ # PROJ-003 feat todo dark mode
56
+
57
+ # Filter by type or status
58
+ mrkl list --type fix
59
+ mrkl list --status todo
60
+
61
+ # Archive a completed task
62
+ mrkl done PROJ-001
63
+ ```
64
+
65
+ ## Commands
66
+
67
+ ### `mrkl init <prefix>`
68
+
69
+ Initializes mrkl in the current directory.
70
+
71
+ | Argument | Description |
72
+ |----------|-------------|
73
+ | `prefix` | Project prefix for task IDs (e.g., `PROJ`, `API`, `WEB`) |
74
+
75
+ Creates:
76
+ - `.config/mrkl/mrkl.toml` — project configuration
77
+ - `.config/mrkl/mrkl_counter` — auto-incrementing ID tracker
78
+ - `.tasks/` — active task directory
79
+ - `.tasks/.archive/` — completed task storage
80
+
81
+ Safe to run multiple times — existing config and counter are preserved.
82
+
83
+ ### `mrkl create <type> <title> [options]`
84
+
85
+ Creates a new task file.
86
+
87
+ | Argument | Description |
88
+ |----------|-------------|
89
+ | `type` | Task type (see [Task Types](#task-types)) |
90
+ | `title` | Short description of the task |
91
+
92
+ | Option | Description |
93
+ |--------|-------------|
94
+ | `--desc <text>` | Detailed description |
95
+ | `--ac <text>` | Acceptance criterion (repeatable) |
96
+
97
+ ```sh
98
+ mrkl create feat "search functionality" \
99
+ --desc "Full-text search across all documents" \
100
+ --ac "search bar in header" \
101
+ --ac "results update as you type" \
102
+ --ac "highlights matching terms"
103
+ ```
104
+
105
+ ### `mrkl list [options]`
106
+
107
+ Lists all active tasks.
108
+
109
+ | Option | Description |
110
+ |--------|-------------|
111
+ | `--type <type>` | Filter by task type |
112
+ | `--status <status>` | Filter by status (`todo`, `in-progress`, `done`) |
113
+
114
+ Non-conforming markdown files in the tasks directory are silently skipped.
115
+
116
+ ### `mrkl done <id>`
117
+
118
+ Archives a completed task.
119
+
120
+ | Argument | Description |
121
+ |----------|-------------|
122
+ | `id` | Task ID to archive (e.g., `PROJ-001`) |
123
+
124
+ Moves the task file to `.tasks/.archive/` and sets its status to `done`.
125
+
126
+ ## Task Types
127
+
128
+ mrkl uses [conventional commit](https://www.conventionalcommits.org/) types:
129
+
130
+ | Type | Purpose |
131
+ |------|---------|
132
+ | `feat` | New feature |
133
+ | `fix` | Bug fix |
134
+ | `chore` | Maintenance |
135
+ | `docs` | Documentation |
136
+ | `perf` | Performance improvement |
137
+ | `refactor` | Code restructuring |
138
+ | `test` | Testing |
139
+ | `ci` | CI/CD changes |
140
+ | `build` | Build system changes |
141
+ | `style` | Code style/formatting |
142
+
143
+ ## Task File Format
144
+
145
+ Each task is a markdown file with YAML frontmatter:
146
+
147
+ ```
148
+ .tasks/PROJ-001 feat - user authentication.md
149
+ ```
150
+
151
+ ```markdown
152
+ ---
153
+ id: PROJ-001
154
+ type: feat
155
+ status: todo
156
+ created: '2026-03-01'
157
+ ---
158
+ ## Description
159
+
160
+ Implement user authentication with OAuth2.
161
+
162
+ ## Acceptance Criteria
163
+
164
+ - [ ] login page renders
165
+ - [ ] OAuth flow completes
166
+ - [ ] session persists across refreshes
167
+ ```
168
+
169
+ The format is intentionally simple — edit task files directly when you need to update descriptions, change status, or check off criteria.
170
+
171
+ ## Project Structure
172
+
173
+ After initialization, mrkl adds the following to your project:
174
+
175
+ ```
176
+ your-project/
177
+ .config/mrkl/
178
+ mrkl.toml # project configuration
179
+ mrkl_counter # current task number
180
+ .tasks/
181
+ PROJ-001 feat - first task.md
182
+ PROJ-002 fix - second task.md
183
+ .archive/
184
+ PROJ-000 chore - archived task.md
185
+ ```
186
+
187
+ Commit `.config/mrkl/` and `.tasks/` to version control. They're designed to be tracked alongside your code.
188
+
189
+ ## Team Workflow
190
+
191
+ 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.**
192
+
193
+ 1. **Plan** — Create tasks on a `planning/` branch, merge to main via PR
194
+ 2. **Execute** — Branch feature work from main (which has all tasks)
195
+ 3. **Ad-hoc** — Mid-sprint tasks follow the same pattern at smaller scale
196
+
197
+ ```sh
198
+ # Sprint planning
199
+ git checkout -b planning/sprint-3 main
200
+ mrkl create feat "user authentication"
201
+ mrkl create fix "login redirect loop"
202
+ # commit, PR, merge to main
203
+
204
+ # Feature work (branch from main after planning merges)
205
+ git checkout -b feature/MRKL-019_user-auth main
206
+ # ... do the work ...
207
+ mrkl done MRKL-019
208
+ # commit, PR, merge to main
209
+ ```
210
+
211
+ 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.
212
+
213
+ ## Configuration
214
+
215
+ Configuration lives in `.config/mrkl/mrkl.toml` (or `mrkl.toml` at the project root):
216
+
217
+ ```toml
218
+ prefix = "PROJ"
219
+ tasks_dir = ".tasks"
220
+ ```
221
+
222
+ | Key | Default | Description |
223
+ |-----|---------|-------------|
224
+ | `prefix` | *(required)* | Project prefix for task IDs |
225
+ | `tasks_dir` | `".tasks"` | Directory for task files |
226
+
227
+ ## Development
228
+
229
+ ```sh
230
+ git clone https://github.com/xxKeefer/mrkl.git
231
+ cd mrkl
232
+ pnpm install
233
+
234
+ # Run tests
235
+ pnpm test
236
+
237
+ # Run CLI in development
238
+ pnpm tsx src/cli.ts list
239
+
240
+ # Build
241
+ pnpm build
242
+ ```
243
+
244
+ ## Contributing
245
+
246
+ Contributions are welcome! See **[CONTRIBUTING.md](CONTRIBUTING.md)** for branch protection rules, merge strategy, and development setup.
247
+
248
+ ## License
249
+
250
+ [MIT](LICENSE)
package/dist/cli.mjs CHANGED
@@ -1,9 +1,45 @@
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 } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { stringify, parse as parse$1 } from 'smol-toml';
7
+ import matter from 'gray-matter';
4
8
 
5
- function initConfig(_dir, _opts) {
6
- throw new Error("not implemented");
9
+ const CONFIG_PATHS = [
10
+ join(".config", "mrkl", "mrkl.toml"),
11
+ "mrkl.toml"
12
+ ];
13
+ function loadConfig(dir) {
14
+ const configPath = CONFIG_PATHS.map((p) => join(dir, p)).find((p) => existsSync(p));
15
+ if (!configPath) {
16
+ throw new Error(`mrkl.toml not found in ${dir}`);
17
+ }
18
+ const raw = readFileSync(configPath, "utf-8");
19
+ const parsed = parse$1(raw);
20
+ return {
21
+ prefix: parsed.prefix,
22
+ tasks_dir: parsed.tasks_dir ?? ".tasks"
23
+ };
24
+ }
25
+ function initConfig(dir, opts) {
26
+ const configDir = join(dir, ".config", "mrkl");
27
+ const configPath = join(configDir, "mrkl.toml");
28
+ if (!existsSync(configPath)) {
29
+ const config2 = {
30
+ prefix: opts?.prefix ?? "TASK",
31
+ tasks_dir: opts?.tasks_dir ?? ".tasks"
32
+ };
33
+ mkdirSync(configDir, { recursive: true });
34
+ writeFileSync(configPath, stringify(config2));
35
+ }
36
+ const config = loadConfig(dir);
37
+ const tasksDir = join(dir, config.tasks_dir);
38
+ mkdirSync(join(tasksDir, ".archive"), { recursive: true });
39
+ const counterPath = join(dir, ".config", "mrkl", "mrkl_counter");
40
+ if (!existsSync(counterPath)) {
41
+ writeFileSync(counterPath, "0");
42
+ }
7
43
  }
8
44
 
9
45
  const initCommand = defineCommand({
@@ -19,21 +55,143 @@ const initCommand = defineCommand({
19
55
  },
20
56
  run({ args }) {
21
57
  const dir = process.cwd();
22
- initConfig(dir, { prefix: args.prefix });
23
- consola.success("mrkl initialized");
58
+ try {
59
+ initConfig(dir, { prefix: args.prefix });
60
+ consola.success("mrkl initialized");
61
+ } catch (err) {
62
+ consola.error(String(err.message));
63
+ process.exit(1);
64
+ }
24
65
  }
25
66
  });
26
67
 
27
- function createTask(_opts) {
28
- throw new Error("not implemented");
68
+ const COUNTER_FILE = join(".config", "mrkl", "mrkl_counter");
69
+ function nextId(dir) {
70
+ const filePath = join(dir, COUNTER_FILE);
71
+ const current = existsSync(filePath) ? parseInt(readFileSync(filePath, "utf-8").trim(), 10) : 0;
72
+ const next = current + 1;
73
+ writeFileSync(filePath, String(next));
74
+ return next;
29
75
  }
30
- function listTasks(_filter) {
31
- throw new Error("not implemented");
76
+
77
+ function render(task) {
78
+ const frontmatter = {
79
+ id: task.id,
80
+ type: task.type,
81
+ status: task.status,
82
+ created: task.created
83
+ };
84
+ const sections = [];
85
+ sections.push("## Description");
86
+ sections.push("");
87
+ sections.push(task.description || "");
88
+ sections.push("");
89
+ sections.push("## Acceptance Criteria");
90
+ sections.push("");
91
+ for (const item of task.acceptance_criteria) {
92
+ sections.push(`- [ ] ${item}`);
93
+ }
94
+ const body = sections.join("\n") + "\n";
95
+ return matter.stringify(body, frontmatter);
32
96
  }
33
- function archiveTask(_dir, _id) {
34
- throw new Error("not implemented");
97
+ function parse(content, filename) {
98
+ const { data, content: body } = matter(content);
99
+ const titleMatch = filename.replace(/\.md$/, "").match(/^\S+\s+\S+\s+-\s+(.+)$/);
100
+ const title = titleMatch ? titleMatch[1] : "";
101
+ const acRegex = /^- \[[ x]\] (.+)$/gm;
102
+ const acceptance_criteria = [];
103
+ let match;
104
+ while ((match = acRegex.exec(body)) !== null) {
105
+ acceptance_criteria.push(match[1]);
106
+ }
107
+ const descMatch = body.match(/## Description\n\n([\s\S]*?)(?:\n\n## Acceptance Criteria|$)/);
108
+ const description = descMatch ? descMatch[1].trim() : "";
109
+ return {
110
+ id: data.id,
111
+ type: data.type,
112
+ status: data.status,
113
+ created: data.created,
114
+ title,
115
+ description,
116
+ acceptance_criteria
117
+ };
35
118
  }
36
119
 
120
+ function normalizeTitle(raw) {
121
+ const result = raw.trim().toLowerCase().replace(/[/\\]/g, "-").replace(/[<>:"|?*\x00-\x1f]/g, "").replace(/-{2,}/g, "-").replace(/ {2,}/g, " ").replace(/^-+|-+$/g, "");
122
+ if (!result) throw new Error("Title is empty after normalisation");
123
+ return result;
124
+ }
125
+ function createTask(opts) {
126
+ const config = loadConfig(opts.dir);
127
+ const num = nextId(opts.dir);
128
+ const id = `${config.prefix}-${String(num).padStart(3, "0")}`;
129
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
130
+ const task = {
131
+ id,
132
+ type: opts.type,
133
+ status: "todo",
134
+ created: today,
135
+ title: normalizeTitle(opts.title),
136
+ description: opts.description ?? "",
137
+ acceptance_criteria: opts.acceptance_criteria ?? []
138
+ };
139
+ const filename = `${id} ${task.type} - ${task.title}.md`;
140
+ const tasksDir = join(opts.dir, config.tasks_dir);
141
+ writeFileSync(join(tasksDir, filename), render(task));
142
+ return task;
143
+ }
144
+ function listTasks(filter) {
145
+ const config = loadConfig(filter.dir);
146
+ const tasksDir = join(filter.dir, config.tasks_dir);
147
+ const files = readdirSync(tasksDir).filter(
148
+ (f) => f.endsWith(".md") && !f.startsWith(".")
149
+ );
150
+ let tasks = files.flatMap((f) => {
151
+ try {
152
+ const content = readFileSync(join(tasksDir, f), "utf-8");
153
+ const task = parse(content, f);
154
+ if (!task.id || !task.type || !task.status) return [];
155
+ return [task];
156
+ } catch {
157
+ return [];
158
+ }
159
+ });
160
+ if (filter.type) tasks = tasks.filter((t) => t.type === filter.type);
161
+ if (filter.status) tasks = tasks.filter((t) => t.status === filter.status);
162
+ return tasks;
163
+ }
164
+ function archiveTask(dir, id) {
165
+ const config = loadConfig(dir);
166
+ const tasksDir = join(dir, config.tasks_dir);
167
+ const file = readdirSync(tasksDir).find(
168
+ (f) => f.endsWith(".md") && f.startsWith(id)
169
+ );
170
+ if (!file) {
171
+ throw new Error(`Task ${id} not found`);
172
+ }
173
+ const filePath = join(tasksDir, file);
174
+ const content = readFileSync(filePath, "utf-8");
175
+ const task = parse(content, file);
176
+ task.status = "done";
177
+ const archivePath = join(tasksDir, ".archive", file);
178
+ writeFileSync(archivePath, render(task));
179
+ unlinkSync(filePath);
180
+ }
181
+
182
+ const TASK_TYPES = [
183
+ "feat",
184
+ "fix",
185
+ "chore",
186
+ "docs",
187
+ "perf",
188
+ "refactor",
189
+ "test",
190
+ "ci",
191
+ "build",
192
+ "style"
193
+ ];
194
+
37
195
  const createCommand = defineCommand({
38
196
  meta: {
39
197
  name: "create",
@@ -60,14 +218,24 @@ const createCommand = defineCommand({
60
218
  }
61
219
  },
62
220
  run({ args }) {
63
- process.cwd();
64
- const task = createTask({
65
- type: args.type,
66
- title: args.title,
67
- description: args.desc,
68
- acceptance_criteria: args.ac ? [args.ac] : void 0
69
- });
70
- consola.success(`Created ${task.id}: ${task.title}`);
221
+ if (!TASK_TYPES.includes(args.type)) {
222
+ consola.error(`Invalid type "${args.type}". Must be one of: ${TASK_TYPES.join(", ")}`);
223
+ process.exit(1);
224
+ }
225
+ const dir = process.cwd();
226
+ try {
227
+ const task = createTask({
228
+ dir,
229
+ type: args.type,
230
+ title: args.title,
231
+ description: args.desc,
232
+ acceptance_criteria: args.ac ? Array.isArray(args.ac) ? args.ac : [args.ac] : void 0
233
+ });
234
+ consola.success(`Created ${task.id}: ${task.title}`);
235
+ } catch (err) {
236
+ consola.error(String(err.message));
237
+ process.exit(1);
238
+ }
71
239
  }
72
240
  });
73
241
 
@@ -87,17 +255,23 @@ const listCommand = defineCommand({
87
255
  }
88
256
  },
89
257
  run({ args }) {
90
- process.cwd();
91
- const tasks = listTasks({
92
- type: args.type,
93
- status: args.status
94
- });
95
- if (tasks.length === 0) {
96
- consola.info("No tasks found");
97
- return;
98
- }
99
- for (const task of tasks) {
100
- consola.log(`${task.id} ${task.type.padEnd(10)} ${task.status.padEnd(12)} ${task.title}`);
258
+ const dir = process.cwd();
259
+ try {
260
+ const tasks = listTasks({
261
+ dir,
262
+ type: args.type,
263
+ status: args.status
264
+ });
265
+ if (tasks.length === 0) {
266
+ consola.info("No tasks found");
267
+ return;
268
+ }
269
+ for (const task of tasks) {
270
+ consola.log(`${task.id} ${task.type.padEnd(10)} ${task.status.padEnd(12)} ${task.title}`);
271
+ }
272
+ } catch (err) {
273
+ consola.error(String(err.message));
274
+ process.exit(1);
101
275
  }
102
276
  }
103
277
  });
@@ -116,8 +290,13 @@ const doneCommand = defineCommand({
116
290
  },
117
291
  run({ args }) {
118
292
  const dir = process.cwd();
119
- archiveTask(dir, args.id);
120
- consola.success(`Archived ${args.id}`);
293
+ try {
294
+ archiveTask(dir, args.id);
295
+ consola.success(`Archived ${args.id}`);
296
+ } catch (err) {
297
+ consola.error(String(err.message));
298
+ process.exit(1);
299
+ }
121
300
  }
122
301
  });
123
302
 
@@ -130,8 +309,11 @@ const main = defineCommand({
130
309
  subCommands: {
131
310
  init: initCommand,
132
311
  create: createCommand,
312
+ c: createCommand,
133
313
  list: listCommand,
134
314
  done: doneCommand
135
315
  }
136
316
  });
137
317
  runMain(main);
318
+
319
+ export { main };
package/package.json CHANGED
@@ -1,16 +1,11 @@
1
1
  {
2
2
  "name": "@xxkeefer/mrkl",
3
- "version": "0.1.0",
3
+ "version": "0.2.6",
4
4
  "type": "module",
5
5
  "description": "Lightweight CLI tool for structured markdown task tracking",
6
6
  "bin": {
7
7
  "mrkl": "./dist/cli.mjs"
8
8
  },
9
- "scripts": {
10
- "build": "unbuild",
11
- "dev": "node --import tsx src/cli.ts",
12
- "test": "vitest run"
13
- },
14
9
  "keywords": [
15
10
  "cli",
16
11
  "task",
@@ -18,7 +13,9 @@
18
13
  "markdown"
19
14
  ],
20
15
  "license": "MIT",
21
- "files": ["dist"],
16
+ "files": [
17
+ "dist"
18
+ ],
22
19
  "repository": {
23
20
  "type": "git",
24
21
  "url": "git+https://github.com/xxKeefer/mrkl.git"
@@ -35,8 +32,17 @@
35
32
  },
36
33
  "devDependencies": {
37
34
  "@types/node": "^25.3.3",
35
+ "changelogen": "^0.6.2",
38
36
  "typescript": "^5.7.0",
39
37
  "unbuild": "^3.3.1",
40
38
  "vitest": "^3.0.0"
39
+ },
40
+ "scripts": {
41
+ "build": "unbuild",
42
+ "dev": "node --import tsx src/cli.ts",
43
+ "test": "vitest run",
44
+ "release": "./scripts/release.sh",
45
+ "changelog": "changelogen",
46
+ "changelog:preview": "changelogen --no-output"
41
47
  }
42
- }
48
+ }