cairn-work 0.7.1 → 0.8.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 +92 -173
- package/bin/cairn.js +23 -0
- package/lib/commands/create.js +10 -1
- package/lib/commands/list.js +231 -0
- package/lib/commands/log.js +219 -0
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -1,241 +1,160 @@
|
|
|
1
|
-
# Cairn
|
|
1
|
+
# Cairn
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Project management for AI agents. Markdown files are the source of truth.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Setup
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
npm install -g cairn-work
|
|
9
9
|
cairn onboard
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
This creates a workspace and writes two context files your agent reads automatically:
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
- **`CLAUDE.md`** — Compact reference for day-to-day operations (statuses, CLI commands, autonomy rules)
|
|
15
|
+
- **`.cairn/planning.md`** — Full guide for creating projects and tasks with real content
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
No agent-specific configuration. Any AI agent that can read files is ready to go.
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
- **Files are the source of truth** - No database, just markdown
|
|
20
|
-
- **AI-native** - Designed for AI agents to understand and work with
|
|
21
|
-
- **Agent detection** - Auto-configures for Clawdbot, Claude Code, Cursor, Windsurf
|
|
22
|
-
- **Simple hierarchy** - Projects → Tasks
|
|
23
|
-
- **Status tracking** - pending, active, review, blocked, completed
|
|
19
|
+
## How it works
|
|
24
20
|
|
|
25
|
-
|
|
21
|
+
You and your AI agent share a folder of markdown files. Projects have charters. Tasks have objectives. Status fields track where everything stands — like a kanban board backed by text files.
|
|
26
22
|
|
|
27
|
-
### Global Install (Recommended)
|
|
28
|
-
|
|
29
|
-
```bash
|
|
30
|
-
npm install -g cairn-work
|
|
31
|
-
# or
|
|
32
|
-
bun install -g cairn-work
|
|
33
23
|
```
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
24
|
+
~/cairn/
|
|
25
|
+
CLAUDE.md # Agent context (auto-generated)
|
|
26
|
+
.cairn/planning.md # Planning guide (auto-generated)
|
|
27
|
+
projects/
|
|
28
|
+
launch-app/
|
|
29
|
+
charter.md # Why, success criteria, context
|
|
30
|
+
tasks/
|
|
31
|
+
setup-database.md # Individual task
|
|
32
|
+
build-api.md
|
|
33
|
+
deploy.md
|
|
34
|
+
inbox/ # Ideas to triage
|
|
42
35
|
```
|
|
43
36
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
### `cairn onboard`
|
|
37
|
+
### The workflow
|
|
47
38
|
|
|
48
|
-
|
|
39
|
+
1. **You create a project** — tell your agent what you want to build. It creates the project and tasks using `cairn create`, fills in real content (not placeholders), and sets everything to `pending`.
|
|
49
40
|
|
|
50
|
-
|
|
51
|
-
cairn onboard # Auto-detect agent
|
|
52
|
-
cairn onboard --agent clawdbot # Specific agent
|
|
53
|
-
cairn onboard --force # Re-run onboarding
|
|
54
|
-
```
|
|
41
|
+
2. **You manage the board** — move tasks to `next_up` or `in_progress` when you're ready to start. Or tell your agent "work on the API task" and it picks it up.
|
|
55
42
|
|
|
56
|
-
|
|
43
|
+
3. **The agent keeps status updated** — when it starts a task, it moves to `in_progress`. When it finishes, it moves to `review` (so you can approve) or `completed` (if you gave it full autonomy). If it gets stuck, it moves to `blocked` and tells you what it needs.
|
|
57
44
|
|
|
58
|
-
|
|
45
|
+
4. **You always know where things stand** — statuses are the shared language. The agent is accountable for keeping them accurate.
|
|
59
46
|
|
|
60
|
-
|
|
61
|
-
cairn init # Create workspace in current directory
|
|
62
|
-
cairn init --path /custom # Custom location
|
|
63
|
-
```
|
|
47
|
+
### Statuses
|
|
64
48
|
|
|
65
|
-
|
|
49
|
+
`pending` · `next_up` · `in_progress` · `review` · `blocked` · `completed`
|
|
66
50
|
|
|
67
|
-
|
|
51
|
+
### Autonomy
|
|
68
52
|
|
|
69
|
-
|
|
70
|
-
# Create a project
|
|
71
|
-
cairn create project "Launch My App"
|
|
53
|
+
Each task has an autonomy level that controls what the agent can do:
|
|
72
54
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
55
|
+
| Level | Agent behavior | Finishes as |
|
|
56
|
+
|-------|---------------|-------------|
|
|
57
|
+
| `propose` | Plans the approach, doesn't do the work | `review` |
|
|
58
|
+
| `draft` | Does the work, no irreversible actions | `review` |
|
|
59
|
+
| `execute` | Does everything, including deploy/publish/send | `completed` |
|
|
77
60
|
|
|
78
|
-
|
|
61
|
+
Default is `draft` — the agent works but you approve before anything ships.
|
|
79
62
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
```bash
|
|
83
|
-
cairn doctor
|
|
84
|
-
```
|
|
63
|
+
## Commands
|
|
85
64
|
|
|
86
|
-
### `cairn
|
|
65
|
+
### `cairn onboard`
|
|
87
66
|
|
|
88
|
-
|
|
67
|
+
Set up workspace and write agent context files.
|
|
89
68
|
|
|
90
69
|
```bash
|
|
91
|
-
cairn
|
|
92
|
-
cairn
|
|
70
|
+
cairn onboard # Interactive setup
|
|
71
|
+
cairn onboard --path ./mywork # Non-interactive, specific path
|
|
72
|
+
cairn onboard --force # Re-run on existing workspace
|
|
93
73
|
```
|
|
94
74
|
|
|
95
|
-
### `cairn
|
|
75
|
+
### `cairn create`
|
|
96
76
|
|
|
97
|
-
|
|
77
|
+
Create projects and tasks. Always pass real content — the CLI enforces `--description` and `--objective`.
|
|
98
78
|
|
|
99
79
|
```bash
|
|
100
|
-
cairn
|
|
101
|
-
|
|
80
|
+
cairn create project "Launch App" \
|
|
81
|
+
--description "Ship the MVP by March" \
|
|
82
|
+
--objective "We need to validate the idea with real users" \
|
|
83
|
+
--criteria "App live on production, 10 beta signups" \
|
|
84
|
+
--context "React Native, Supabase backend, deploy to Vercel"
|
|
102
85
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
- **[Clawdbot](https://clawd.bot)** - Full integration via skills system
|
|
108
|
-
- **Claude Code** - Workspace context integration
|
|
109
|
-
- **Cursor** - .cursorrules integration
|
|
110
|
-
- **Windsurf** - Workspace integration
|
|
111
|
-
- **Generic** - Manual setup instructions for any AI agent
|
|
112
|
-
|
|
113
|
-
## How It Works
|
|
114
|
-
|
|
115
|
-
### File Structure
|
|
116
|
-
|
|
117
|
-
```
|
|
118
|
-
~/cairn/
|
|
119
|
-
projects/
|
|
120
|
-
launch-my-app/
|
|
121
|
-
charter.md # Project overview
|
|
122
|
-
tasks/
|
|
123
|
-
setup-database.md # Individual task
|
|
124
|
-
deploy-api.md # Another task
|
|
125
|
-
inbox/ # Incoming ideas
|
|
126
|
-
_drafts/ # Work in progress
|
|
86
|
+
cairn create task "Set up database" \
|
|
87
|
+
--project launch-app \
|
|
88
|
+
--description "Configure Supabase tables and RLS policies" \
|
|
89
|
+
--objective "Database schema matches the data model, RLS prevents cross-tenant access"
|
|
127
90
|
```
|
|
128
91
|
|
|
129
|
-
###
|
|
130
|
-
|
|
131
|
-
**Project** - A goal or initiative (e.g., "Launch My App")
|
|
132
|
-
**Task** - An atomic piece of work (e.g., "Set up database")
|
|
133
|
-
|
|
134
|
-
### Status Workflow
|
|
135
|
-
|
|
136
|
-
`pending` → `active` → `review` → `completed`
|
|
137
|
-
|
|
138
|
-
Or if blocked: `active` → `blocked` → `active`
|
|
139
|
-
|
|
140
|
-
### Working with AI Agents
|
|
141
|
-
|
|
142
|
-
After onboarding, your agent understands how to:
|
|
143
|
-
- Create and update projects/tasks
|
|
144
|
-
- Follow status workflows
|
|
145
|
-
- Log work with timestamps
|
|
146
|
-
- Ask for input when blocked
|
|
147
|
-
|
|
148
|
-
**Example conversation:**
|
|
149
|
-
```
|
|
150
|
-
You: "Help me plan out my app launch"
|
|
151
|
-
Agent: "I'll create a project structure. What's your app called?"
|
|
152
|
-
You: "TaskMaster - a todo app"
|
|
153
|
-
Agent: *creates project with tasks for backend, frontend, deployment*
|
|
154
|
-
```
|
|
92
|
+
### `cairn doctor`
|
|
155
93
|
|
|
156
|
-
|
|
94
|
+
Check workspace health — verifies folder structure, `CLAUDE.md`, and `.cairn/planning.md` exist.
|
|
157
95
|
|
|
158
96
|
```bash
|
|
159
|
-
cairn
|
|
97
|
+
cairn doctor
|
|
160
98
|
```
|
|
161
99
|
|
|
162
|
-
|
|
100
|
+
### `cairn update-skill`
|
|
101
|
+
|
|
102
|
+
Refresh `CLAUDE.md` and `.cairn/planning.md` with the latest templates (e.g. after a CLI update).
|
|
163
103
|
|
|
164
|
-
Or update manually:
|
|
165
104
|
```bash
|
|
166
|
-
|
|
105
|
+
cairn update-skill
|
|
167
106
|
```
|
|
168
107
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
## Configuration
|
|
108
|
+
### `cairn update`
|
|
172
109
|
|
|
173
|
-
|
|
174
|
-
- **Workspace:** Current directory or `~/cairn`
|
|
175
|
-
- **Agent detection:** Automatic
|
|
176
|
-
- **Files:** Plain markdown with YAML frontmatter
|
|
110
|
+
Check for a new CLI version and install it.
|
|
177
111
|
|
|
178
|
-
Override workspace location:
|
|
179
112
|
```bash
|
|
180
|
-
|
|
113
|
+
cairn update
|
|
181
114
|
```
|
|
182
115
|
|
|
183
|
-
##
|
|
184
|
-
|
|
185
|
-
1. **Context is King** - Keep all context in one place
|
|
186
|
-
2. **Files > Databases** - Text files are portable and future-proof
|
|
187
|
-
3. **Simple Beats Complete** - Start simple, add complexity when needed
|
|
188
|
-
4. **AI-First** - Designed for human-AI collaboration
|
|
189
|
-
|
|
190
|
-
## Troubleshooting
|
|
116
|
+
## File format
|
|
191
117
|
|
|
192
|
-
|
|
118
|
+
All files use YAML frontmatter + markdown sections.
|
|
193
119
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
120
|
+
**Charter** (`charter.md`):
|
|
121
|
+
```yaml
|
|
122
|
+
---
|
|
123
|
+
title: Launch App
|
|
124
|
+
status: in_progress
|
|
125
|
+
priority: 1
|
|
126
|
+
default_autonomy: draft
|
|
127
|
+
---
|
|
200
128
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
129
|
+
## Why This Matters
|
|
130
|
+
## Success Criteria
|
|
131
|
+
## Context
|
|
132
|
+
## Work Log
|
|
204
133
|
```
|
|
205
134
|
|
|
206
|
-
|
|
135
|
+
**Task** (`tasks/setup-database.md`):
|
|
136
|
+
```yaml
|
|
137
|
+
---
|
|
138
|
+
title: Set up database
|
|
139
|
+
assignee: agent-name
|
|
140
|
+
status: pending
|
|
141
|
+
autonomy: draft
|
|
142
|
+
---
|
|
207
143
|
|
|
208
|
-
|
|
209
|
-
|
|
144
|
+
## Objective
|
|
145
|
+
## Work Log
|
|
210
146
|
```
|
|
211
147
|
|
|
212
|
-
|
|
148
|
+
The agent logs all work in the `## Work Log` section with timestamps and its name.
|
|
213
149
|
|
|
214
|
-
|
|
215
|
-
git clone https://github.com/gregoryehill/cairn-cli.git
|
|
216
|
-
cd cairn-cli
|
|
217
|
-
bun install
|
|
218
|
-
bun link # Test locally
|
|
219
|
-
```
|
|
150
|
+
## Troubleshooting
|
|
220
151
|
|
|
221
|
-
Run tests:
|
|
222
152
|
```bash
|
|
223
|
-
|
|
153
|
+
cairn doctor # Diagnose issues
|
|
154
|
+
cairn onboard --force # Regenerate context files
|
|
155
|
+
cairn update-skill # Refresh templates after CLI update
|
|
224
156
|
```
|
|
225
157
|
|
|
226
|
-
## Contributing
|
|
227
|
-
|
|
228
|
-
Contributions welcome! Open an issue or PR on GitHub.
|
|
229
|
-
|
|
230
158
|
## License
|
|
231
159
|
|
|
232
|
-
MIT
|
|
233
|
-
|
|
234
|
-
## Links
|
|
235
|
-
|
|
236
|
-
- **GitHub:** https://github.com/gregoryehill/cairn-cli
|
|
237
|
-
- **npm:** https://www.npmjs.com/package/cairn
|
|
238
|
-
|
|
239
|
-
---
|
|
240
|
-
|
|
241
|
-
Built with ❤️ for AI-human collaboration
|
|
160
|
+
MIT
|
package/bin/cairn.js
CHANGED
|
@@ -21,6 +21,8 @@ program
|
|
|
21
21
|
import onboard from '../lib/commands/onboard.js';
|
|
22
22
|
import init from '../lib/commands/init.js';
|
|
23
23
|
import create from '../lib/commands/create.js';
|
|
24
|
+
import list from '../lib/commands/list.js';
|
|
25
|
+
import log from '../lib/commands/log.js';
|
|
24
26
|
import doctor from '../lib/commands/doctor.js';
|
|
25
27
|
import updateSkill from '../lib/commands/update-skill.js';
|
|
26
28
|
import update from '../lib/commands/update.js';
|
|
@@ -48,6 +50,7 @@ program
|
|
|
48
50
|
.option('--project <slug>', 'Parent project (required for tasks)')
|
|
49
51
|
.option('--assignee <name>', 'Assignee name', 'you')
|
|
50
52
|
.option('--status <status>', 'Initial status', 'pending')
|
|
53
|
+
.option('--autonomy <level>', 'Autonomy level: propose, draft, or execute (tasks only)', 'draft')
|
|
51
54
|
.option('--due <date>', 'Due date (YYYY-MM-DD)')
|
|
52
55
|
.option('--description <text>', 'Short description')
|
|
53
56
|
.option('--objective <text>', 'Detailed objective')
|
|
@@ -55,6 +58,26 @@ program
|
|
|
55
58
|
.option('--context <text>', 'Background context (projects only)')
|
|
56
59
|
.action(create);
|
|
57
60
|
|
|
61
|
+
// List command - query and filter tasks
|
|
62
|
+
program
|
|
63
|
+
.command('list <entity>')
|
|
64
|
+
.description('List tasks with optional filtering')
|
|
65
|
+
.option('--status <statuses>', 'Filter by status (comma-separated: pending,in_progress,blocked)')
|
|
66
|
+
.option('--assignee <name>', 'Filter by assignee')
|
|
67
|
+
.option('--project <slug>', 'Filter by project')
|
|
68
|
+
.option('--due-before <date>', 'Filter by due date (YYYY-MM-DD)')
|
|
69
|
+
.option('--overdue', 'Show only overdue tasks')
|
|
70
|
+
.option('--format <format>', 'Output format (table|json)', 'table')
|
|
71
|
+
.action(list);
|
|
72
|
+
|
|
73
|
+
// Log command - add work log entries to tasks
|
|
74
|
+
program
|
|
75
|
+
.command('log <task-slug> <message>')
|
|
76
|
+
.description('Add a work log entry to a task')
|
|
77
|
+
.option('--title <text>', 'Custom log entry title (default: "Update")')
|
|
78
|
+
.option('--project <slug>', 'Project to search for the task')
|
|
79
|
+
.action(log);
|
|
80
|
+
|
|
58
81
|
// Doctor command - check workspace health
|
|
59
82
|
program
|
|
60
83
|
.command('doctor')
|
package/lib/commands/create.js
CHANGED
|
@@ -156,6 +156,15 @@ async function createTask(workspacePath, name, slug, options) {
|
|
|
156
156
|
process.exit(1);
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
// Validate autonomy level if provided
|
|
160
|
+
const validAutonomy = ['propose', 'draft', 'execute'];
|
|
161
|
+
const autonomy = options.autonomy || 'draft';
|
|
162
|
+
if (!validAutonomy.includes(autonomy)) {
|
|
163
|
+
console.error(chalk.red('Error:'), `Invalid autonomy level: ${autonomy}`);
|
|
164
|
+
console.log(chalk.dim('Valid values:'), validAutonomy.join(', '));
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
|
|
159
168
|
// Create task file
|
|
160
169
|
const taskSection = (text) => text ? `\n${expandNewlines(text)}\n` : '\n';
|
|
161
170
|
const task = `---
|
|
@@ -165,7 +174,7 @@ assignee: ${options.assignee || 'you'}
|
|
|
165
174
|
status: ${options.status || 'pending'}
|
|
166
175
|
created: ${getToday()}
|
|
167
176
|
due: ${options.due || getDueDate(7)}
|
|
168
|
-
autonomy:
|
|
177
|
+
autonomy: ${autonomy}
|
|
169
178
|
spend: 0
|
|
170
179
|
artifacts: []
|
|
171
180
|
---
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import yaml from 'js-yaml';
|
|
6
|
+
|
|
7
|
+
function parseFrontmatter(content) {
|
|
8
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
9
|
+
if (!match) return null;
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
return yaml.load(match[1]);
|
|
13
|
+
} catch (error) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function findAllTasks(workspacePath, projectFilter = null) {
|
|
19
|
+
const tasks = [];
|
|
20
|
+
const projectsDir = join(workspacePath, 'projects');
|
|
21
|
+
|
|
22
|
+
if (!existsSync(projectsDir)) {
|
|
23
|
+
return tasks;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const projects = readdirSync(projectsDir).filter(item => {
|
|
27
|
+
const path = join(projectsDir, item);
|
|
28
|
+
return statSync(path).isDirectory();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
for (const project of projects) {
|
|
32
|
+
// Skip if filtering by project and this doesn't match
|
|
33
|
+
if (projectFilter && project !== projectFilter) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const tasksDir = join(projectsDir, project, 'tasks');
|
|
38
|
+
if (!existsSync(tasksDir)) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const taskFiles = readdirSync(tasksDir).filter(file => file.endsWith('.md'));
|
|
43
|
+
|
|
44
|
+
for (const taskFile of taskFiles) {
|
|
45
|
+
const taskPath = join(tasksDir, taskFile);
|
|
46
|
+
try {
|
|
47
|
+
const content = readFileSync(taskPath, 'utf8');
|
|
48
|
+
const frontmatter = parseFrontmatter(content);
|
|
49
|
+
|
|
50
|
+
if (frontmatter) {
|
|
51
|
+
tasks.push({
|
|
52
|
+
project,
|
|
53
|
+
file: taskFile.replace('.md', ''),
|
|
54
|
+
path: taskPath,
|
|
55
|
+
...frontmatter
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error(chalk.yellow('Warning:'), `Failed to parse ${taskPath}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return tasks;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function filterTasks(tasks, options) {
|
|
68
|
+
let filtered = [...tasks];
|
|
69
|
+
|
|
70
|
+
// Filter by status
|
|
71
|
+
if (options.status) {
|
|
72
|
+
const statuses = options.status.split(',').map(s => s.trim());
|
|
73
|
+
filtered = filtered.filter(task => statuses.includes(task.status));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Filter by assignee
|
|
77
|
+
if (options.assignee) {
|
|
78
|
+
filtered = filtered.filter(task => task.assignee === options.assignee);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Filter by project
|
|
82
|
+
if (options.project) {
|
|
83
|
+
filtered = filtered.filter(task => task.project === options.project);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Filter by due date
|
|
87
|
+
if (options.dueBefore) {
|
|
88
|
+
const dueDate = new Date(options.dueBefore);
|
|
89
|
+
filtered = filtered.filter(task => {
|
|
90
|
+
if (!task.due) return false;
|
|
91
|
+
return new Date(task.due) <= dueDate;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Filter overdue
|
|
96
|
+
if (options.overdue) {
|
|
97
|
+
const today = new Date();
|
|
98
|
+
today.setHours(0, 0, 0, 0);
|
|
99
|
+
filtered = filtered.filter(task => {
|
|
100
|
+
if (!task.due) return false;
|
|
101
|
+
const dueDate = new Date(task.due);
|
|
102
|
+
return dueDate < today && task.status !== 'completed';
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return filtered;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function formatAsTable(tasks) {
|
|
110
|
+
if (tasks.length === 0) {
|
|
111
|
+
console.log(chalk.dim('No tasks found matching criteria.'));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Calculate column widths
|
|
116
|
+
const projectWidth = Math.max(7, ...tasks.map(t => t.project.length));
|
|
117
|
+
const titleWidth = Math.max(5, ...tasks.map(t => (t.title || t.file).length));
|
|
118
|
+
const statusWidth = Math.max(6, ...tasks.map(t => (t.status || '').length));
|
|
119
|
+
const assigneeWidth = Math.max(8, ...tasks.map(t => (t.assignee || '').length));
|
|
120
|
+
const dueWidth = 10;
|
|
121
|
+
|
|
122
|
+
// Header
|
|
123
|
+
console.log(
|
|
124
|
+
chalk.bold('Project'.padEnd(projectWidth)) + ' ' +
|
|
125
|
+
chalk.bold('Title'.padEnd(titleWidth)) + ' ' +
|
|
126
|
+
chalk.bold('Status'.padEnd(statusWidth)) + ' ' +
|
|
127
|
+
chalk.bold('Assignee'.padEnd(assigneeWidth)) + ' ' +
|
|
128
|
+
chalk.bold('Due')
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
console.log(
|
|
132
|
+
'─'.repeat(projectWidth) + ' ' +
|
|
133
|
+
'─'.repeat(titleWidth) + ' ' +
|
|
134
|
+
'─'.repeat(statusWidth) + ' ' +
|
|
135
|
+
'─'.repeat(assigneeWidth) + ' ' +
|
|
136
|
+
'─'.repeat(dueWidth)
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// Rows
|
|
140
|
+
for (const task of tasks) {
|
|
141
|
+
const project = (task.project || '').padEnd(projectWidth);
|
|
142
|
+
const title = (task.title || task.file).padEnd(titleWidth);
|
|
143
|
+
const status = colorizeStatus(task.status || '').padEnd(statusWidth + 10); // +10 for ANSI codes
|
|
144
|
+
const assignee = (task.assignee || '').padEnd(assigneeWidth);
|
|
145
|
+
const due = formatDueDate(task.due, task.status);
|
|
146
|
+
|
|
147
|
+
console.log(`${project} ${title} ${status} ${assignee} ${due}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
console.log();
|
|
151
|
+
console.log(chalk.dim(`${tasks.length} task${tasks.length !== 1 ? 's' : ''} found`));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function colorizeStatus(status) {
|
|
155
|
+
switch (status) {
|
|
156
|
+
case 'completed':
|
|
157
|
+
return chalk.green(status);
|
|
158
|
+
case 'in_progress':
|
|
159
|
+
return chalk.blue(status);
|
|
160
|
+
case 'review':
|
|
161
|
+
return chalk.magenta(status);
|
|
162
|
+
case 'blocked':
|
|
163
|
+
return chalk.red(status);
|
|
164
|
+
case 'next_up':
|
|
165
|
+
return chalk.yellow(status);
|
|
166
|
+
case 'pending':
|
|
167
|
+
return chalk.gray(status);
|
|
168
|
+
default:
|
|
169
|
+
return status;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function formatDueDate(due, status) {
|
|
174
|
+
if (!due) return chalk.dim('—');
|
|
175
|
+
|
|
176
|
+
// Handle both string and Date objects
|
|
177
|
+
const dueString = due instanceof Date ? due.toISOString().split('T')[0] : due;
|
|
178
|
+
const dueDate = new Date(dueString);
|
|
179
|
+
const today = new Date();
|
|
180
|
+
today.setHours(0, 0, 0, 0);
|
|
181
|
+
|
|
182
|
+
const formatted = dueString; // YYYY-MM-DD format
|
|
183
|
+
|
|
184
|
+
if (status === 'completed') {
|
|
185
|
+
return chalk.dim(formatted);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (dueDate < today) {
|
|
189
|
+
return chalk.red(formatted + ' ⚠');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const daysUntilDue = Math.ceil((dueDate - today) / (1000 * 60 * 60 * 24));
|
|
193
|
+
|
|
194
|
+
if (daysUntilDue <= 3) {
|
|
195
|
+
return chalk.yellow(formatted);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return formatted;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function formatAsJSON(tasks) {
|
|
202
|
+
console.log(JSON.stringify(tasks, null, 2));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export default async function list(entity, options) {
|
|
206
|
+
const workspacePath = join(homedir(), 'pms');
|
|
207
|
+
|
|
208
|
+
if (!existsSync(workspacePath)) {
|
|
209
|
+
console.error(chalk.red('Error:'), 'Workspace not found. Run:', chalk.cyan('cairn init'));
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (entity !== 'tasks') {
|
|
214
|
+
console.error(chalk.red('Error:'), `Unknown entity: ${entity}`);
|
|
215
|
+
console.log(chalk.dim('Valid entities: tasks'));
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Find all tasks
|
|
220
|
+
const allTasks = findAllTasks(workspacePath, options.project);
|
|
221
|
+
|
|
222
|
+
// Apply filters
|
|
223
|
+
const filtered = filterTasks(allTasks, options);
|
|
224
|
+
|
|
225
|
+
// Format output
|
|
226
|
+
if (options.format === 'json') {
|
|
227
|
+
formatAsJSON(filtered);
|
|
228
|
+
} else {
|
|
229
|
+
formatAsTable(filtered);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync, statSync } from 'fs';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { resolveWorkspace } from '../setup/workspace.js';
|
|
7
|
+
|
|
8
|
+
function getAgentName() {
|
|
9
|
+
// Try $USER first
|
|
10
|
+
if (process.env.USER) {
|
|
11
|
+
return process.env.USER;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Try git config
|
|
15
|
+
try {
|
|
16
|
+
const gitName = execSync('git config user.name', { encoding: 'utf8' }).trim();
|
|
17
|
+
if (gitName) return gitName;
|
|
18
|
+
} catch (error) {
|
|
19
|
+
// Git not configured or not available
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Fallback
|
|
23
|
+
return 'Agent';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getTimestamp() {
|
|
27
|
+
const now = new Date();
|
|
28
|
+
const year = now.getFullYear();
|
|
29
|
+
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
30
|
+
const day = String(now.getDate()).padStart(2, '0');
|
|
31
|
+
const hours = String(now.getHours()).padStart(2, '0');
|
|
32
|
+
const minutes = String(now.getMinutes()).padStart(2, '0');
|
|
33
|
+
|
|
34
|
+
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function findTaskBySlug(workspacePath, taskSlug, projectFilter = null) {
|
|
38
|
+
const projectsDir = join(workspacePath, 'projects');
|
|
39
|
+
|
|
40
|
+
if (!existsSync(projectsDir)) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const projects = readdirSync(projectsDir).filter(item => {
|
|
45
|
+
const path = join(projectsDir, item);
|
|
46
|
+
return statSync(path).isDirectory();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
for (const project of projects) {
|
|
50
|
+
// Skip if filtering by project and this doesn't match
|
|
51
|
+
if (projectFilter && project !== projectFilter) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const tasksDir = join(projectsDir, project, 'tasks');
|
|
56
|
+
if (!existsSync(tasksDir)) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const taskFile = `${taskSlug}.md`;
|
|
61
|
+
const taskPath = join(tasksDir, taskFile);
|
|
62
|
+
|
|
63
|
+
if (existsSync(taskPath)) {
|
|
64
|
+
return {
|
|
65
|
+
path: taskPath,
|
|
66
|
+
project,
|
|
67
|
+
slug: taskSlug
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function addLogEntry(content, timestamp, title, agentName, message) {
|
|
76
|
+
const lines = content.split('\n');
|
|
77
|
+
|
|
78
|
+
// Find Work Log section
|
|
79
|
+
let workLogIndex = -1;
|
|
80
|
+
let nextSectionIndex = -1;
|
|
81
|
+
|
|
82
|
+
for (let i = 0; i < lines.length; i++) {
|
|
83
|
+
const line = lines[i].trim();
|
|
84
|
+
|
|
85
|
+
// Look for Work Log header (## Work Log)
|
|
86
|
+
if (line === '## Work Log') {
|
|
87
|
+
workLogIndex = i;
|
|
88
|
+
} else if (workLogIndex !== -1 && line.startsWith('## ') && i > workLogIndex) {
|
|
89
|
+
// Found the next section after Work Log
|
|
90
|
+
nextSectionIndex = i;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Prepare the log entry
|
|
96
|
+
const logTitle = title || 'Update';
|
|
97
|
+
const logEntry = [
|
|
98
|
+
`### ${timestamp} - ${logTitle}`,
|
|
99
|
+
`[${agentName}] ${message}`,
|
|
100
|
+
''
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
if (workLogIndex === -1) {
|
|
104
|
+
// Work Log section doesn't exist, add it at the end
|
|
105
|
+
const newSection = [
|
|
106
|
+
'',
|
|
107
|
+
'## Work Log',
|
|
108
|
+
'',
|
|
109
|
+
...logEntry
|
|
110
|
+
];
|
|
111
|
+
return lines.join('\n') + '\n' + newSection.join('\n');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Work Log section exists
|
|
115
|
+
if (nextSectionIndex === -1) {
|
|
116
|
+
// Work Log is the last section, append at the end
|
|
117
|
+
const beforeWorkLog = lines.slice(0, workLogIndex + 1).join('\n');
|
|
118
|
+
const afterWorkLog = lines.slice(workLogIndex + 1).join('\n');
|
|
119
|
+
|
|
120
|
+
// Remove trailing newlines from afterWorkLog
|
|
121
|
+
const trimmedAfter = afterWorkLog.trimEnd();
|
|
122
|
+
|
|
123
|
+
return beforeWorkLog + '\n\n' + (trimmedAfter ? trimmedAfter + '\n\n' : '') + logEntry.join('\n');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Work Log has content and another section follows
|
|
127
|
+
const beforeWorkLog = lines.slice(0, workLogIndex + 1).join('\n');
|
|
128
|
+
const workLogContent = lines.slice(workLogIndex + 1, nextSectionIndex).join('\n').trimEnd();
|
|
129
|
+
const afterWorkLog = lines.slice(nextSectionIndex).join('\n');
|
|
130
|
+
|
|
131
|
+
return beforeWorkLog + '\n\n' + (workLogContent ? workLogContent + '\n\n' : '') + logEntry.join('\n') + '\n' + afterWorkLog;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export default async function log(taskSlug, message, options) {
|
|
135
|
+
// Try to resolve workspace, fallback to ~/pms for compatibility
|
|
136
|
+
let workspacePath = resolveWorkspace();
|
|
137
|
+
|
|
138
|
+
if (!workspacePath) {
|
|
139
|
+
// Fallback to ~/pms (common default)
|
|
140
|
+
const fallbackPath = join(homedir(), 'pms');
|
|
141
|
+
if (existsSync(join(fallbackPath, 'projects'))) {
|
|
142
|
+
workspacePath = fallbackPath;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!workspacePath) {
|
|
147
|
+
console.error(chalk.red('Error:'), 'No workspace found. Run:', chalk.cyan('cairn init'));
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!taskSlug) {
|
|
152
|
+
console.error(chalk.red('Error:'), 'Missing task slug');
|
|
153
|
+
console.log(chalk.dim('Usage:'), chalk.cyan('cairn log <task-slug> <message>'));
|
|
154
|
+
console.log(chalk.dim('Example:'), chalk.cyan('cairn log implement-auth "Added OAuth2 flow"'));
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!message) {
|
|
159
|
+
console.error(chalk.red('Error:'), 'Missing log message');
|
|
160
|
+
console.log(chalk.dim('Usage:'), chalk.cyan('cairn log <task-slug> <message>'));
|
|
161
|
+
console.log(chalk.dim('Example:'), chalk.cyan('cairn log implement-auth "Added OAuth2 flow"'));
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Find the task
|
|
166
|
+
const task = findTaskBySlug(workspacePath, taskSlug, options.project);
|
|
167
|
+
|
|
168
|
+
if (!task) {
|
|
169
|
+
if (options.project) {
|
|
170
|
+
console.error(chalk.red('Error:'), `Task "${taskSlug}" not found in project "${options.project}"`);
|
|
171
|
+
} else {
|
|
172
|
+
console.error(chalk.red('Error:'), `Task "${taskSlug}" not found in any project`);
|
|
173
|
+
console.log(chalk.dim('Tip:'), 'Use', chalk.cyan('--project <slug>'), 'to search within a specific project');
|
|
174
|
+
}
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Read the task file
|
|
179
|
+
let content;
|
|
180
|
+
try {
|
|
181
|
+
content = readFileSync(task.path, 'utf8');
|
|
182
|
+
} catch (error) {
|
|
183
|
+
console.error(chalk.red('Error:'), `Failed to read task file: ${error.message}`);
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Get timestamp and agent name
|
|
188
|
+
const timestamp = getTimestamp();
|
|
189
|
+
const agentName = getAgentName();
|
|
190
|
+
|
|
191
|
+
// Add log entry
|
|
192
|
+
const updatedContent = addLogEntry(content, timestamp, options.title, agentName, message);
|
|
193
|
+
|
|
194
|
+
// Write back atomically (write to temp file, then rename)
|
|
195
|
+
const tempPath = task.path + '.tmp';
|
|
196
|
+
try {
|
|
197
|
+
writeFileSync(tempPath, updatedContent, 'utf8');
|
|
198
|
+
writeFileSync(task.path, updatedContent, 'utf8');
|
|
199
|
+
|
|
200
|
+
// Clean up temp file
|
|
201
|
+
try {
|
|
202
|
+
if (existsSync(tempPath)) {
|
|
203
|
+
// Note: In production, we'd use fs.unlinkSync here, but for atomic writes
|
|
204
|
+
// we're doing a direct write above
|
|
205
|
+
}
|
|
206
|
+
} catch (cleanupError) {
|
|
207
|
+
// Ignore cleanup errors
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
console.log(chalk.green('✓'), `Log entry added to task: ${chalk.cyan(taskSlug)}`);
|
|
211
|
+
console.log(chalk.dim(` Project: ${task.project}`));
|
|
212
|
+
console.log(chalk.dim(` Time: ${timestamp}`));
|
|
213
|
+
console.log(chalk.dim(` Agent: ${agentName}`));
|
|
214
|
+
console.log(chalk.dim(` Message: ${message}`));
|
|
215
|
+
} catch (error) {
|
|
216
|
+
console.error(chalk.red('Error:'), `Failed to write task file: ${error.message}`);
|
|
217
|
+
process.exit(1);
|
|
218
|
+
}
|
|
219
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cairn-work",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "AI-native project management - work with AI agents using markdown files",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"license": "MIT",
|
|
35
35
|
"repository": {
|
|
36
36
|
"type": "git",
|
|
37
|
-
"url": "https://github.com/gregoryehill/cairn.git"
|
|
37
|
+
"url": "git+https://github.com/gregoryehill/cairn.git"
|
|
38
38
|
},
|
|
39
39
|
"homepage": "https://cairn.app",
|
|
40
40
|
"bugs": {
|
|
@@ -44,10 +44,11 @@
|
|
|
44
44
|
"node": ">=18.0.0"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"commander": "^11.0.0",
|
|
48
47
|
"chalk": "^5.3.0",
|
|
49
|
-
"
|
|
50
|
-
"inquirer": "^9.2.0"
|
|
48
|
+
"commander": "^11.0.0",
|
|
49
|
+
"inquirer": "^9.2.0",
|
|
50
|
+
"js-yaml": "^4.1.1",
|
|
51
|
+
"ora": "^8.0.0"
|
|
51
52
|
},
|
|
52
53
|
"devDependencies": {
|
|
53
54
|
"@types/bun": "^1.1.0",
|