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 CHANGED
@@ -1,241 +1,160 @@
1
- # Cairn 🦮
1
+ # Cairn
2
2
 
3
- AI-native project management where markdown files are the source of truth.
3
+ Project management for AI agents. Markdown files are the source of truth.
4
4
 
5
- ## Quick Start
5
+ ## Setup
6
6
 
7
7
  ```bash
8
8
  npm install -g cairn-work
9
9
  cairn onboard
10
10
  ```
11
11
 
12
- That's it! Cairn will detect your AI agent (Clawdbot, Claude Code, Cursor, etc.) and configure everything automatically.
12
+ This creates a workspace and writes two context files your agent reads automatically:
13
13
 
14
- ## What is Cairn?
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
- Cairn is a project management system designed for working with AI agents. Instead of databases and web UIs, Cairn uses simple markdown files that both you and your AI agent can read and edit.
17
+ No agent-specific configuration. Any AI agent that can read files is ready to go.
17
18
 
18
- **Key features:**
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
- ## Installation
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
- ### Test Locally
36
-
37
- ```bash
38
- git clone https://github.com/gregoryehill/cairn-cli.git
39
- cd cairn-cli
40
- bun install
41
- bun link
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
- ## Commands
45
-
46
- ### `cairn onboard`
37
+ ### The workflow
47
38
 
48
- Set up Cairn and configure your AI agent automatically.
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
- ```bash
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
- ### `cairn init`
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
- Initialize workspace without agent configuration.
45
+ 4. **You always know where things stand** — statuses are the shared language. The agent is accountable for keeping them accurate.
59
46
 
60
- ```bash
61
- cairn init # Create workspace in current directory
62
- cairn init --path /custom # Custom location
63
- ```
47
+ ### Statuses
64
48
 
65
- ### `cairn create`
49
+ `pending` · `next_up` · `in_progress` · `review` · `blocked` · `completed`
66
50
 
67
- Create projects and tasks.
51
+ ### Autonomy
68
52
 
69
- ```bash
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
- # Create a task
74
- cairn create task "Set up database" \\
75
- --project launch-my-app
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
- ### `cairn doctor`
61
+ Default is `draft` — the agent works but you approve before anything ships.
79
62
 
80
- Check workspace health and fix issues.
81
-
82
- ```bash
83
- cairn doctor
84
- ```
63
+ ## Commands
85
64
 
86
- ### `cairn update-skill`
65
+ ### `cairn onboard`
87
66
 
88
- Update agent skill documentation.
67
+ Set up workspace and write agent context files.
89
68
 
90
69
  ```bash
91
- cairn update-skill # Update all detected agents
92
- cairn update-skill --agent cursor # Specific agent
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 update`
75
+ ### `cairn create`
96
76
 
97
- Check for and install CLI updates.
77
+ Create projects and tasks. Always pass real content — the CLI enforces `--description` and `--objective`.
98
78
 
99
79
  ```bash
100
- cairn update # Check npm for latest version and prompt to upgrade
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
- ## Supported Agents
104
-
105
- Cairn auto-detects and configures:
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
- ### Project → Task
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
- ## Updates
94
+ Check workspace health — verifies folder structure, `CLAUDE.md`, and `.cairn/planning.md` exist.
157
95
 
158
96
  ```bash
159
- cairn update
97
+ cairn doctor
160
98
  ```
161
99
 
162
- Checks npm for the latest version and prompts to upgrade.
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
- npm update -g cairn
105
+ cairn update-skill
167
106
  ```
168
107
 
169
- Updates only affect the CLI and agent skills. Your workspace files are never touched.
170
-
171
- ## Configuration
108
+ ### `cairn update`
172
109
 
173
- Cairn uses sensible defaults:
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
- export CAIRN_WORKSPACE=/custom/path
113
+ cairn update
181
114
  ```
182
115
 
183
- ## Philosophy
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
- ### Agent not detected
118
+ All files use YAML frontmatter + markdown sections.
193
119
 
194
- ```bash
195
- cairn doctor # Check setup
196
- cairn onboard --force # Re-run onboarding
197
- ```
198
-
199
- ### Workspace issues
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
- ```bash
202
- cairn doctor # Auto-fix common issues
203
- cairn init --path ~/cairn # Recreate structure
129
+ ## Why This Matters
130
+ ## Success Criteria
131
+ ## Context
132
+ ## Work Log
204
133
  ```
205
134
 
206
- ### Skill not updating
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
- ```bash
209
- cairn update-skill # Refresh agent skill
144
+ ## Objective
145
+ ## Work Log
210
146
  ```
211
147
 
212
- ## Development
148
+ The agent logs all work in the `## Work Log` section with timestamps and its name.
213
149
 
214
- ```bash
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
- bun test
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 © Gregory Hill
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')
@@ -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: draft
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.7.1",
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
- "ora": "^8.0.0",
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",