@axplusb/kepler 2.0.7 → 2.2.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.
@@ -18,6 +18,7 @@
18
18
 
19
19
  export const TIERS = Object.freeze({
20
20
  READ: 'read',
21
+ SENSITIVE_READ: 'sensitive-read',
21
22
  LOCAL_EDIT: 'local-edit',
22
23
  SHELL_SAFE: 'shell-safe',
23
24
  SHELL_MEDIUM: 'shell-medium',
@@ -35,6 +36,7 @@ export const TIERS = Object.freeze({
35
36
  */
36
37
  export const BEHAVIOR = Object.freeze({
37
38
  [TIERS.READ]: 'auto',
39
+ [TIERS.SENSITIVE_READ]: 'prompt-explicit',
38
40
  [TIERS.LOCAL_EDIT]: 'auto-with-undo',
39
41
  [TIERS.SHELL_SAFE]: 'auto',
40
42
  [TIERS.SHELL_MEDIUM]: 'prompt-safe',
@@ -58,6 +60,15 @@ const READ_TOOLS = new Set([
58
60
  'validate_file', 'validate_structure',
59
61
  ]);
60
62
 
63
+ const READ_PATH_KEYS = [
64
+ 'path', 'file_path', 'file', 'target',
65
+ 'pattern', 'glob',
66
+ ];
67
+
68
+ const READ_PATH_ARRAY_KEYS = [
69
+ 'paths', 'file_paths', 'files', 'targets', 'patterns', 'globs',
70
+ ];
71
+
61
72
  const LOCAL_EDIT_TOOLS = new Set([
62
73
  'edit_file', 'write_file', 'write_project',
63
74
  ]);
@@ -77,7 +88,7 @@ const SHELL_SAFE_RE = [
77
88
  // `cd` / `pushd` / `popd` only change the process working directory; if
78
89
  // chained with something dangerous, the multi-segment classifier still
79
90
  // catches the danger (`cd /x && rm -rf .` → SHELL_DANGEROUS).
80
- /^\s*(cd|pushd|popd|ls|cat|head|tail|less|more|wc|file|stat|tree|find|grep|rg|ag|fd|echo|printf|pwd|whoami|date|which|type|env|printenv|uname|hostname|id|df|du|uptime|free|top|ps|lsof)\b/i,
91
+ /^\s*(cd|pushd|popd|ls|cat|head|tail|less|more|wc|file|stat|tree|find|grep|rg|ag|fd|sed|echo|printf|pwd|whoami|date|which|type|env|printenv|uname|hostname|id|df|du|uptime|free|top|ps|lsof)\b/i,
81
92
  // mkdir -p / touch are creation primitives but harmless in scope.
82
93
  /^\s*mkdir\s+-p\b/i,
83
94
  /^\s*touch\s/i,
@@ -111,6 +122,8 @@ const SHELL_DANGEROUS_RE = [
111
122
  /\bcurl\b.*\|\s*(sh|bash|zsh)/i,
112
123
  /\bwget\b.*\|\s*(sh|bash|zsh)/i,
113
124
  /\beval\s+["'$(]/i,
125
+ /\bfind\s+\/(?:\s|$)/i,
126
+ /\bfind\b.*\s-(?:exec|execdir|ok|okdir|delete|fprint|fprint0|fls|fprintf)\b/i,
114
127
  /\bkubectl\s+delete/i,
115
128
  /\bdocker\s+(rm|rmi|system\s+prune|volume\s+rm|network\s+rm)/i,
116
129
  /\bdrop\s+(table|database|schema)/i,
@@ -132,6 +145,8 @@ const SHELL_MEDIUM_RE = [
132
145
  /^\s*make(\s|$)/i,
133
146
  /^\s*git\s+(commit|push|pull|merge|rebase|fetch|checkout(?!\s+\.)|cherry-pick|revert|tag|stash(?!\s+drop))/i,
134
147
  /^\s*docker\s+(build|run|exec|compose|pull|push|tag)/i,
148
+ /^\s*sed\b.*\s-i\S*(?:\s|$)/i,
149
+ /^\s*sed\b.*\s--in-place(?:=|\s|$)/i,
135
150
  ];
136
151
 
137
152
  export function classifyShell(command) {
@@ -141,6 +156,8 @@ export function classifyShell(command) {
141
156
  // Dangerous wins over safe — never let a safe-looking prefix mask `&& rm -rf`.
142
157
  if (SHELL_DANGEROUS_RE.some(re => re.test(cmd))) return TIERS.SHELL_DANGEROUS;
143
158
 
159
+ if (hasUnsafeOutputRedirection(cmd)) return TIERS.SHELL_MEDIUM;
160
+
144
161
  // For a chained command, classify each segment and take the riskiest —
145
162
  // never let a safe-looking prefix mask `&& npm install` or worse.
146
163
  if (/&&|\|\||;|\|(?!\|)/.test(cmd)) {
@@ -161,6 +178,31 @@ export function classifyShell(command) {
161
178
  return TIERS.SHELL_MEDIUM;
162
179
  }
163
180
 
181
+ function hasUnsafeOutputRedirection(cmd) {
182
+ let inSingle = false;
183
+ let inDouble = false;
184
+ let escaped = false;
185
+ for (let i = 0; i < cmd.length; i++) {
186
+ const ch = cmd[i];
187
+ if (escaped) { escaped = false; continue; }
188
+ if (ch === '\\' && !inSingle) { escaped = true; continue; }
189
+ if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; }
190
+ if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; }
191
+ if (inSingle || inDouble || ch !== '>') continue;
192
+
193
+ const prev = cmd[i - 1] || '';
194
+ const next = cmd[i + 1] || '';
195
+ if (next === '&') continue;
196
+ let cursor = next === '>' ? i + 2 : i + 1;
197
+ while (/\s/.test(cmd[cursor] || '')) cursor++;
198
+ const target = cmd.slice(cursor).match(/^[^\s;&|]+/)?.[0] || '';
199
+ if (target === '/dev/null') continue;
200
+ if (prev === '2' && target === '/dev/null') continue;
201
+ return true;
202
+ }
203
+ return false;
204
+ }
205
+
164
206
  function splitShellSegments(cmd) {
165
207
  // Split on top-level &&, ||, ;, | — naive but enough for the classifier.
166
208
  return cmd.split(/&&|\|\||;|\|/).map(s => s.trim()).filter(Boolean);
@@ -168,6 +210,7 @@ function splitShellSegments(cmd) {
168
210
 
169
211
  const TIER_ORDER = [
170
212
  TIERS.READ,
213
+ TIERS.SENSITIVE_READ,
171
214
  TIERS.SHELL_SAFE,
172
215
  TIERS.LOCAL_EDIT,
173
216
  TIERS.NETWORK,
@@ -191,7 +234,9 @@ function riskier(a, b) {
191
234
  export function classify(tool, args = {}) {
192
235
  if (!tool) return TIERS.SHELL_MEDIUM;
193
236
 
194
- if (READ_TOOLS.has(tool)) return TIERS.READ;
237
+ if (READ_TOOLS.has(tool)) {
238
+ return isSensitiveRead(args) ? TIERS.SENSITIVE_READ : TIERS.READ;
239
+ }
195
240
  if (LOCAL_EDIT_TOOLS.has(tool)) return TIERS.LOCAL_EDIT;
196
241
  if (DESTRUCTIVE_TOOLS.has(tool)) return TIERS.DESTRUCTIVE;
197
242
  if (NETWORK_TOOLS.has(tool)) return TIERS.NETWORK;
@@ -220,6 +265,7 @@ export function classify(tool, args = {}) {
220
265
  export function label(tier) {
221
266
  switch (tier) {
222
267
  case TIERS.READ: return 'READ';
268
+ case TIERS.SENSITIVE_READ: return 'SENSITIVE-READ';
223
269
  case TIERS.LOCAL_EDIT: return 'LOCAL-EDIT';
224
270
  case TIERS.SHELL_SAFE: return 'SHELL-SAFE';
225
271
  case TIERS.SHELL_MEDIUM: return 'SHELL-MEDIUM';
@@ -243,3 +289,43 @@ export function requiresExplicitApproval(tier) {
243
289
  export function requiresCheckpoint(tier) {
244
290
  return behavior(tier) === 'auto-with-undo';
245
291
  }
292
+
293
+ export function isSensitiveRead(args = {}) {
294
+ return extractReadPaths(args).some(isSensitiveReadPath);
295
+ }
296
+
297
+ export function isSensitiveReadPath(filePath = '') {
298
+ const normalized = String(filePath || '')
299
+ .replace(/\\/g, '/')
300
+ .replace(/\/+/g, '/')
301
+ .replace(/^\.\//, '')
302
+ .trim();
303
+ if (!normalized) return false;
304
+
305
+ const parts = normalized.split('/').filter(Boolean);
306
+ const base = parts[parts.length - 1] || normalized;
307
+ if (base === '.env') return true;
308
+ if (/\.pem$/i.test(base)) return true;
309
+ return parts.includes('secrets');
310
+ }
311
+
312
+ function extractReadPaths(args = {}) {
313
+ const paths = [];
314
+ for (const key of READ_PATH_KEYS) {
315
+ if (typeof args[key] === 'string') paths.push(args[key]);
316
+ }
317
+ for (const key of READ_PATH_ARRAY_KEYS) {
318
+ const value = args[key];
319
+ if (!Array.isArray(value)) continue;
320
+ for (const item of value) {
321
+ if (typeof item === 'string') {
322
+ paths.push(item);
323
+ } else if (item && typeof item === 'object') {
324
+ for (const nestedKey of READ_PATH_KEYS) {
325
+ if (typeof item[nestedKey] === 'string') paths.push(item[nestedKey]);
326
+ }
327
+ }
328
+ }
329
+ }
330
+ return paths;
331
+ }
@@ -67,6 +67,9 @@ const HIGH_RISK_COMMANDS = [
67
67
  /\brm\s/, // ALL rm commands require explicit approval
68
68
  /\bunlink\s/, // unlink
69
69
  /\brmdir\s/, // rmdir
70
+ /\bkill\s/, // process termination requires explicit approval
71
+ /\bpkill\s/, // process termination by pattern requires explicit approval
72
+ /\bxargs\s+kill\b/, // process termination via piped pid list
70
73
  /git\s+push\s+--force/,
71
74
  /git\s+reset\s+--hard/,
72
75
  /git\s+clean\s+-[fd]/,
@@ -210,16 +210,54 @@ export class SessionManager {
210
210
  * @returns {{ role: string, content: string }[]}
211
211
  */
212
212
  loadMessages(sessionId) {
213
+ return this.loadConversation(sessionId).messages;
214
+ }
215
+
216
+ /**
217
+ * Load the header and all messages from a session conversation file.
218
+ * @param {string} sessionId
219
+ * @returns {{ header: object|null, messages: { role: string, content: string }[] }}
220
+ */
221
+ loadConversation(sessionId) {
213
222
  const filePath = this._conversationPath(sessionId);
214
- if (!fs.existsSync(filePath)) return [];
223
+ if (!fs.existsSync(filePath)) return { header: null, messages: [] };
215
224
 
216
225
  const lines = fs.readFileSync(filePath, 'utf-8').split('\n').filter(Boolean);
217
- return lines
226
+ const entries = lines
218
227
  .map(line => {
219
228
  try { return JSON.parse(line); } catch { return null; }
220
229
  })
221
- .filter(entry => entry && entry.role) // skip header (type=header, no role)
230
+ .filter(Boolean);
231
+ const header = entries.find(entry => entry.type === 'header') || null;
232
+ const messages = entries
233
+ .filter(entry => entry.role) // skip header (type=header, no role)
222
234
  .map(entry => ({ role: entry.role, content: entry.content }));
235
+ return { header, messages };
236
+ }
237
+
238
+ /**
239
+ * Activate an existing conversation as the current session so additional
240
+ * turns append to the same JSONL file.
241
+ * @param {string} sessionId
242
+ * @param {object|null} header
243
+ * @param {{ role: string, content: string }[]} messages
244
+ */
245
+ activateSession(sessionId, header = null, messages = []) {
246
+ this._ensureDirs();
247
+ this.currentState = {
248
+ instruction: header?.instruction || messages.find(m => m.role === 'user')?.content || '',
249
+ started_at: header?.started_at || new Date().toISOString(),
250
+ status: 'running',
251
+ task_id: null,
252
+ job_id: null,
253
+ session_id: sessionId,
254
+ tool_count: 0,
255
+ turn_count: messages.filter(m => m.role === 'user').length,
256
+ events: [],
257
+ resumed_at: new Date().toISOString(),
258
+ };
259
+ this._writeState();
260
+ return this.currentState;
223
261
  }
224
262
 
225
263
  /**
@@ -249,29 +287,32 @@ export class SessionManager {
249
287
  instruction: header?.instruction || '',
250
288
  startedAt: header?.started_at || '',
251
289
  project: header?.project_name || '',
290
+ projectPath: header?.project || '',
252
291
  };
253
292
  }
254
293
 
255
294
  /**
256
295
  * List sessions that have conversation history (resumable).
257
296
  * Reads metadata from JSONL header line — no cross-referencing needed.
258
- * @param {number} [limit=10]
259
- * @returns {Array<{ sessionId, instruction, startedAt, project, messageCount }>}
297
+ * @param {number} [limit=Infinity]
298
+ * @returns {Array<{ sessionId, instruction, startedAt, updatedAt, project, messageCount }>}
260
299
  */
261
- listResumable(limit = 10) {
300
+ listResumable(limit = Infinity) {
262
301
  if (!fs.existsSync(this.conversationsDir)) return [];
263
302
 
264
- // Sort by modification time (most recent first)
303
+ // Sort by latest activity (conversation file mtime) so resumed sessions
304
+ // move back to the top. The UI displays this same timestamp.
265
305
  const files = fs.readdirSync(this.conversationsDir)
266
306
  .filter(f => f.endsWith('.jsonl'))
267
307
  .map(f => ({
268
308
  name: f,
269
309
  mtime: fs.statSync(path.join(this.conversationsDir, f)).mtimeMs,
270
310
  }))
271
- .sort((a, b) => b.mtime - a.mtime)
272
- .slice(0, limit);
311
+ .sort((a, b) => b.mtime - a.mtime);
312
+
313
+ const limited = Number.isFinite(limit) ? files.slice(0, limit) : files;
273
314
 
274
- return files.map(({ name }) => {
315
+ return limited.map(({ name, mtime }) => {
275
316
  const sessionId = name.replace('.jsonl', '');
276
317
  const convPath = path.join(this.conversationsDir, name);
277
318
  const lines = fs.readFileSync(convPath, 'utf-8').split('\n').filter(Boolean);
@@ -284,7 +325,9 @@ export class SessionManager {
284
325
  sessionId,
285
326
  instruction: header?.instruction || '(no instruction)',
286
327
  startedAt: header?.started_at || '',
328
+ updatedAt: new Date(mtime).toISOString(),
287
329
  project: header?.project_name || '',
330
+ projectPath: header?.project || '',
288
331
  messageCount,
289
332
  };
290
333
  });
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { sendCallback, sendSkippedCallback, sendApprovalDecision } from './callback-client.mjs';
13
13
  import { ApprovalManager } from './approval.mjs';
14
+ import { rateLimitErrorMessage } from './rate-limit-display.mjs';
14
15
 
15
16
  export const EVENT_TYPES = Object.freeze({
16
17
  // Phase 1 — handled
@@ -57,18 +58,26 @@ export class TarangStreamClient {
57
58
  * @param {Object} opts.toolExecutor - { execute(name, args) }
58
59
  * @param {boolean} [opts.verbose=false]
59
60
  */
60
- constructor({ baseUrl, token, toolExecutor, verbose = false, approvalManager = null }) {
61
+ constructor({ baseUrl, token, toolExecutor, verbose = false, approvalManager = null, product = null }) {
61
62
  this.baseUrl = (baseUrl || '').replace(/\/$/, '');
62
63
  this.token = token;
63
64
  this.toolExecutor = toolExecutor;
64
65
  this.verbose = verbose;
65
66
  this.approval = approvalManager || new ApprovalManager();
67
+ this.product = product || process.env.TARANG_PRODUCT || process.env.KEPLER_PRODUCT || 'kepler';
66
68
  this.currentTaskId = null;
67
69
  this.sessionId = null; // Set by backend on first turn, reused on subsequent turns
68
70
  this._cancelled = false;
69
71
  this._paused = false;
70
72
  }
71
73
 
74
+ _headers(extra = {}) {
75
+ const headers = { ...extra };
76
+ if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
77
+ if (this.product) headers['X-Product'] = this.product;
78
+ return headers;
79
+ }
80
+
72
81
  /**
73
82
  * Execute an instruction via SSE stream.
74
83
  * Yields parsed events. Client-side tool requests are shown, executed
@@ -87,11 +96,10 @@ export class TarangStreamClient {
87
96
  if (messages && messages.length > 0) body.messages = messages;
88
97
  if (this.sessionId) body.session_id = this.sessionId;
89
98
 
90
- const headers = {
99
+ const headers = this._headers({
91
100
  'Accept': 'text/event-stream',
92
101
  'Content-Type': 'application/json',
93
- };
94
- if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
102
+ });
95
103
 
96
104
  // Abort controller so cancel() can break out of a stalled reader
97
105
  // instead of waiting for the next SSE event to notice _cancelled.
@@ -118,6 +126,27 @@ export class TarangStreamClient {
118
126
  yield { type: EVENT_TYPES.ERROR, data: { message: 'Authentication failed. Run `kepler login` to re-authenticate.', fatal: true } };
119
127
  return;
120
128
  }
129
+ if (response.status === 429) {
130
+ const text = await response.text().catch(() => '');
131
+ let payload = null;
132
+ try {
133
+ payload = text ? JSON.parse(text) : null;
134
+ } catch {
135
+ payload = { detail: { message: text } };
136
+ }
137
+ const detail = payload?.detail && typeof payload.detail === 'object' ? payload.detail : payload;
138
+ yield {
139
+ type: EVENT_TYPES.ERROR,
140
+ data: {
141
+ message: rateLimitErrorMessage(payload),
142
+ code: detail?.code || 'rate_limited',
143
+ retry_after: detail?.retry_after ?? detail?.rate_limit?.retry_after,
144
+ rate_limit: detail?.rate_limit || null,
145
+ fatal: true,
146
+ },
147
+ };
148
+ return;
149
+ }
121
150
  if (!response.ok) {
122
151
  const text = await response.text().catch(() => 'Unknown error');
123
152
  yield { type: EVENT_TYPES.ERROR, data: { message: `Backend error ${response.status}: ${text}`, fatal: true } };
@@ -307,7 +336,7 @@ export class TarangStreamClient {
307
336
  }
308
337
 
309
338
  // Use the same ApprovalManager for consistent UX
310
- const { approved, reason: denyReason } = await this.approval.check(
339
+ const { approved, reason: denyReason, scope: approvedScope } = await this.approval.check(
311
340
  tool,
312
341
  args || {},
313
342
  true,
@@ -323,6 +352,8 @@ export class TarangStreamClient {
323
352
  scope = 'all';
324
353
  } else if (this.approval.approvedToolTypes.has(tool)) {
325
354
  scope = 'type';
355
+ } else if (approvedScope) {
356
+ scope = String(approvedScope).toLowerCase();
326
357
  } else {
327
358
  scope = 'once';
328
359
  }
@@ -357,7 +388,7 @@ export class TarangStreamClient {
357
388
  try {
358
389
  await fetch(`${this.baseUrl}/api/cancel/${this.currentTaskId}`, {
359
390
  method: 'POST',
360
- headers: { 'Authorization': `Bearer ${this.token}` },
391
+ headers: this._headers(),
361
392
  });
362
393
  } catch { /* best effort */ }
363
394
  }
@@ -373,7 +404,7 @@ export class TarangStreamClient {
373
404
  if (this.currentTaskId) {
374
405
  await fetch(`${this.baseUrl}/api/pause/${this.currentTaskId}`, {
375
406
  method: 'POST',
376
- headers: { 'Authorization': `Bearer ${this.token}` },
407
+ headers: this._headers(),
377
408
  });
378
409
  }
379
410
  }
@@ -384,12 +415,40 @@ export class TarangStreamClient {
384
415
  const body = instruction ? JSON.stringify({ instruction }) : undefined;
385
416
  await fetch(`${this.baseUrl}/api/resume/${this.currentTaskId}`, {
386
417
  method: 'POST',
387
- headers: {
388
- 'Authorization': `Bearer ${this.token}`,
418
+ headers: this._headers({
389
419
  'Content-Type': 'application/json',
390
- },
420
+ }),
391
421
  body,
392
422
  });
393
423
  }
394
424
  }
425
+
426
+ /**
427
+ * Summarize a prior transcript for resume continuity.
428
+ *
429
+ * @param {Array<{role:string,content:string}>} messages
430
+ * @param {Object} [opts]
431
+ * @returns {Promise<{summary:string,source:string,model:string}>}
432
+ */
433
+ async summarizeSession(messages, opts = {}) {
434
+ const response = await fetch(`${this.baseUrl}/api/summarize/session`, {
435
+ method: 'POST',
436
+ headers: this._headers({
437
+ 'Content-Type': 'application/json',
438
+ 'Accept': 'application/json',
439
+ }),
440
+ body: JSON.stringify({
441
+ messages,
442
+ session_id: opts.sessionId || null,
443
+ project_path: opts.projectPath || null,
444
+ max_tokens: opts.maxTokens || 800,
445
+ }),
446
+ signal: AbortSignal.timeout(opts.timeoutMs || 15000),
447
+ });
448
+ if (!response.ok) {
449
+ const text = await response.text().catch(() => '');
450
+ throw new Error(`summary request failed (${response.status})${text ? `: ${text.slice(0, 200)}` : ''}`);
451
+ }
452
+ return await response.json();
453
+ }
395
454
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * System Prompt Builder — loads and merges CLAUDE.md files.
2
+ * System Prompt Builder — loads and merges CLAUDE.md and KEPLER.md files.
3
3
  *
4
4
  * Features:
5
5
  * - Loads CLAUDE.md from: ~/.claude/CLAUDE.md, project root, parent dirs
@@ -10,6 +10,7 @@
10
10
  import fs from 'fs';
11
11
  import path from 'path';
12
12
  import os from 'os';
13
+ import { loadKeplerMemory } from '../config/memory-loader.mjs';
13
14
 
14
15
  /**
15
16
  * Load all CLAUDE.md files and merge them in order.
@@ -88,6 +89,10 @@ export function buildSystemPrompt({ cwd, tools, override, addDirs } = {}) {
88
89
  parts.push(f.content);
89
90
  }
90
91
 
92
+ for (const f of loadKeplerMemory({ cwd })) {
93
+ parts.push(f.content);
94
+ }
95
+
91
96
  // The static prefix is the base prompt + CLAUDE.md content (cacheable)
92
97
  const staticPrefix = parts.join('\n\n');
93
98
 
@@ -0,0 +1,130 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+
4
+ export const TASK_FILES = Object.freeze({
5
+ active: 'active.md',
6
+ backlog: 'backlog.md',
7
+ blocked: 'blocked.md',
8
+ done: 'done.md',
9
+ });
10
+
11
+ const DEFAULT_CONTENT = Object.freeze({
12
+ 'README.md': '# Kepler Tasks\n\nChecklist files are read every turn.\n',
13
+ 'active.md': '# Active\n\n',
14
+ 'backlog.md': '# Backlog\n\n',
15
+ 'blocked.md': '# Blocked\n\n',
16
+ 'done.md': '# Done\n\n',
17
+ });
18
+
19
+ export function ensureTaskFiles({ cwd = process.cwd() } = {}) {
20
+ const dir = path.join(cwd, '.kepler', 'tasks');
21
+ fs.mkdirSync(dir, { recursive: true });
22
+ const written = [];
23
+ for (const [name, content] of Object.entries(DEFAULT_CONTENT)) {
24
+ const filePath = path.join(dir, name);
25
+ if (fs.existsSync(filePath)) continue;
26
+ fs.writeFileSync(filePath, content);
27
+ written.push(filePath);
28
+ }
29
+ return { dir, written };
30
+ }
31
+
32
+ export function loadTaskBoard({ cwd = process.cwd(), create = false } = {}) {
33
+ const dir = path.join(cwd, '.kepler', 'tasks');
34
+ if (create) ensureTaskFiles({ cwd });
35
+ const lists = {};
36
+ for (const [list, fileName] of Object.entries(TASK_FILES)) {
37
+ const filePath = path.join(dir, fileName);
38
+ const content = readText(filePath);
39
+ lists[list] = {
40
+ list,
41
+ fileName,
42
+ path: filePath,
43
+ exists: fs.existsSync(filePath),
44
+ content,
45
+ tasks: parseTaskMarkdown(content, list),
46
+ };
47
+ }
48
+
49
+ const planPath = path.join(cwd, '.kepler', 'plan.md');
50
+ const goalPath = path.join(cwd, '.kepler', 'goal.md');
51
+ return {
52
+ dir,
53
+ lists,
54
+ plan: { path: planPath, exists: fs.existsSync(planPath), content: readText(planPath) },
55
+ goal: { path: goalPath, exists: fs.existsSync(goalPath), content: readText(goalPath) },
56
+ };
57
+ }
58
+
59
+ export function appendTask({ cwd = process.cwd(), list = 'backlog', text }) {
60
+ const normalized = normalizeList(list);
61
+ const taskText = String(text || '').trim();
62
+ if (!taskText) throw new Error('Task text is required');
63
+ const { dir } = ensureTaskFiles({ cwd });
64
+ const filePath = path.join(dir, TASK_FILES[normalized]);
65
+ const prefix = normalized === 'done' ? '- [x]' : '- [ ]';
66
+ fs.appendFileSync(filePath, `${prefix} ${taskText}\n`);
67
+ return { list: normalized, path: filePath, text: taskText };
68
+ }
69
+
70
+ export function taskCounts(board) {
71
+ const lists = board?.lists || {};
72
+ return Object.fromEntries(
73
+ Object.entries(TASK_FILES).map(([list]) => [list, lists[list]?.tasks?.length || 0]),
74
+ );
75
+ }
76
+
77
+ function normalizeList(value) {
78
+ const key = String(value || '').toLowerCase();
79
+ if (key === 'todo' || key === 'pending') return 'backlog';
80
+ if (key === 'current' || key === 'doing') return 'active';
81
+ if (key === 'complete' || key === 'completed') return 'done';
82
+ if (key in TASK_FILES) return key;
83
+ throw new Error(`Unknown task list: ${value}`);
84
+ }
85
+
86
+ function readText(filePath) {
87
+ try {
88
+ return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : '';
89
+ } catch {
90
+ return '';
91
+ }
92
+ }
93
+
94
+ export function parseTaskMarkdown(content, list = 'backlog') {
95
+ const tasks = [];
96
+ let section = '';
97
+ const lines = String(content || '').split(/\r?\n/);
98
+ for (let i = 0; i < lines.length; i++) {
99
+ const line = lines[i];
100
+ const heading = line.match(/^#{1,6}\s+(.+?)\s*$/);
101
+ if (heading) {
102
+ section = heading[1].trim();
103
+ continue;
104
+ }
105
+
106
+ const checkbox = line.match(/^\s*[-*]\s+\[([ xX!-])\]\s+(.+?)\s*$/);
107
+ if (checkbox) {
108
+ tasks.push({
109
+ list,
110
+ line: i + 1,
111
+ section,
112
+ checked: checkbox[1].toLowerCase() === 'x',
113
+ text: checkbox[2].trim(),
114
+ });
115
+ continue;
116
+ }
117
+
118
+ const bullet = line.match(/^\s*[-*]\s+(.+?)\s*$/);
119
+ if (bullet && !bullet[1].startsWith('`')) {
120
+ tasks.push({
121
+ list,
122
+ line: i + 1,
123
+ section,
124
+ checked: list === 'done',
125
+ text: bullet[1].trim(),
126
+ });
127
+ }
128
+ }
129
+ return tasks;
130
+ }