@axplusb/kepler 2.2.0 → 2.3.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.
@@ -0,0 +1,92 @@
1
+ /**
2
+ * cache_control breakpoints for Anthropic prompt caching (PRD-071 Phase 2).
3
+ *
4
+ * Shared by every direct-API call site that talks to Anthropic — LocalAgent
5
+ * (Anthropic direct + OpenRouter passthrough) and agent-loop's Task sub-agents.
6
+ *
7
+ * Anthropic allows 4 breakpoints per request; we spend 3:
8
+ * 1) End of system prompt (1h TTL — persistent across long-idle sessions)
9
+ * 2) Last tool schema (1h TTL — persistent)
10
+ * 3) Second-to-last user message (5min TTL — rolls each turn, cheaper to write)
11
+ *
12
+ * The 4th slot stays reserved (attachments, future retrieval prefix).
13
+ *
14
+ * Extended 1-hour TTL is a beta — pass ANTHROPIC_BETA_HEADER on the request
15
+ * whenever any block carries ttl:'1h'.
16
+ */
17
+
18
+ export const ANTHROPIC_BETA_HEADER = 'extended-cache-ttl-2025-04-11';
19
+
20
+ const CACHE_1H = { type: 'ephemeral', ttl: '1h' };
21
+ const CACHE_5M = { type: 'ephemeral' };
22
+
23
+ /**
24
+ * Turn a `system` string into a content-block array with a cache_control
25
+ * breakpoint on the tail. If the caller already passed blocks, returns them
26
+ * unchanged. Undefined / non-string inputs pass through as-is.
27
+ */
28
+ export function cacheableSystem(systemPrompt) {
29
+ if (Array.isArray(systemPrompt)) return systemPrompt;
30
+ if (!systemPrompt || typeof systemPrompt !== 'string') return systemPrompt;
31
+ return [{ type: 'text', text: systemPrompt, cache_control: CACHE_1H }];
32
+ }
33
+
34
+ /**
35
+ * Return a copy of `tools` with cache_control on the LAST tool. Anthropic
36
+ * caches system + tools as one prefix from that breakpoint, so this single
37
+ * marker covers the whole tool schema regardless of length.
38
+ */
39
+ export function cacheableTools(tools) {
40
+ if (!Array.isArray(tools) || tools.length === 0) return tools || [];
41
+ const out = tools.slice();
42
+ const last = out[out.length - 1];
43
+ out[out.length - 1] = { ...last, cache_control: CACHE_1H };
44
+ return out;
45
+ }
46
+
47
+ /**
48
+ * Tag the SECOND-to-last user message with a 5-min cache_control breakpoint.
49
+ * Leaves the last turn write-through so the next round extends the cache
50
+ * instead of re-writing it. Returns messages unchanged when there aren't
51
+ * enough user turns yet (< 2).
52
+ *
53
+ * Handles both content shapes: string (wrapped into blocks) and block array
54
+ * (tagged on the last block).
55
+ */
56
+ export function withMessageBreakpoint(messages) {
57
+ if (!Array.isArray(messages) || messages.length < 2) return messages;
58
+ const userIdx = [];
59
+ for (let i = 0; i < messages.length; i++) {
60
+ if (messages[i].role === 'user') userIdx.push(i);
61
+ }
62
+ if (userIdx.length < 2) return messages;
63
+ const targetIdx = userIdx[userIdx.length - 2];
64
+ const msg = messages[targetIdx];
65
+
66
+ if (typeof msg.content === 'string') {
67
+ return messages.map((m, i) => i === targetIdx ? {
68
+ ...m,
69
+ content: [{ type: 'text', text: m.content, cache_control: CACHE_5M }],
70
+ } : m);
71
+ }
72
+
73
+ if (Array.isArray(msg.content) && msg.content.length > 0) {
74
+ const blocks = msg.content.slice();
75
+ const last = blocks[blocks.length - 1];
76
+ blocks[blocks.length - 1] = { ...last, cache_control: CACHE_5M };
77
+ return messages.map((m, i) => i === targetIdx ? { ...m, content: blocks } : m);
78
+ }
79
+
80
+ return messages;
81
+ }
82
+
83
+ /**
84
+ * True if the given model id needs Anthropic-style explicit cache_control
85
+ * (vs OpenAI/DeepSeek which auto-cache). Handles bare Claude ids and
86
+ * OpenRouter's `anthropic/*` prefix.
87
+ */
88
+ export function needsExplicitCacheControl(model) {
89
+ if (!model) return false;
90
+ const m = model.toLowerCase();
91
+ return m.startsWith('claude') || m.startsWith('anthropic/');
92
+ }
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { TarangStreamClient } from './stream-client.mjs';
15
15
  import { createToolExecutor } from './tool-executor.mjs';
16
+ import { buildWorkScope } from './work-scope.mjs';
16
17
  import { persistProjectArtifacts } from './project-artifacts.mjs';
17
18
  import { TarangAuth } from '../auth/tarang-auth.mjs';
18
19
  import { ApprovalManager } from './approval.mjs';
@@ -26,7 +27,7 @@ import { ApprovalManager } from './approval.mjs';
26
27
  * @param {number} [opts.maxCost] - abort if cost exceeds this USD amount
27
28
  * @param {boolean} [opts.verbose] - show progress on stderr
28
29
  */
29
- export async function runHeadless({ instruction, model, timeout = 300, maxCost, verbose = false }) {
30
+ export async function runHeadless({ instruction, model, timeout = 300, maxCost, verbose = false, cacheReport = null, local = false }) {
30
31
  const startTime = Date.now();
31
32
 
32
33
  const log = (msg) => {
@@ -51,13 +52,41 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
51
52
  // Auto-approve everything — no prompts
52
53
  const approval = new ApprovalManager({ autoApprove: true });
53
54
 
54
- // ── Stream client ──
55
- const client = new TarangStreamClient({
56
- baseUrl: creds.backendUrl,
57
- token: creds.token,
58
- toolExecutor,
59
- approvalManager: approval,
60
- });
55
+ // ── Client selection ──
56
+ // PRD-071 Phase 2 measurement — --local forces the CLI-side LocalAgent path,
57
+ // bypassing the backend so we can exercise the cache_control wiring we
58
+ // just added to _callClaude / _callOpenRouter. Model comes from the
59
+ // --model flag (which overrides settings dynamically for benchmarking).
60
+ let client;
61
+ if (local) {
62
+ const { LocalAgent } = await import('./local-agent.mjs');
63
+ const localModel = model || creds.models?.local || 'anthropic/claude-sonnet-4';
64
+ const orKey = process.env.OPENROUTER_API_KEY || creds.openRouterKey;
65
+ const anthKey = process.env.ANTHROPIC_API_KEY || creds.anthropicKey;
66
+ if (!orKey && !anthKey) {
67
+ emit({ type: 'error', error: '--local requires OPENROUTER_API_KEY or ANTHROPIC_API_KEY' });
68
+ process.exit(1);
69
+ }
70
+ client = {
71
+ execute: (instr, ctx) => new LocalAgent({
72
+ apiKey: anthKey,
73
+ openRouterKey: orKey,
74
+ model: localModel,
75
+ toolExecutor,
76
+ verbose,
77
+ cwd: process.cwd(),
78
+ maxTurns: 50,
79
+ }).execute(instr, ctx),
80
+ };
81
+ log(`Local mode: ${localModel}`);
82
+ } else {
83
+ client = new TarangStreamClient({
84
+ baseUrl: creds.backendUrl,
85
+ token: creds.token,
86
+ toolExecutor,
87
+ approvalManager: approval,
88
+ });
89
+ }
61
90
 
62
91
  // ── Timeout ──
63
92
  const timeoutMs = timeout * 1000;
@@ -70,10 +99,16 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
70
99
  // ── Execute ──
71
100
  emit({ type: 'start', timestamp: Date.now(), instruction, model: model || 'default', cwd: process.cwd() });
72
101
 
102
+ const projectResources = toolExecutor.getProjectResources();
73
103
  const execContext = {
74
104
  cwd: process.cwd(),
75
105
  freeswim: true,
76
- project_resources: toolExecutor.getProjectResources(),
106
+ project_resources: projectResources,
107
+ work_scope: buildWorkScope({
108
+ instruction,
109
+ cwd: process.cwd(),
110
+ projectResources,
111
+ }),
77
112
  agent_context: toolExecutor.getAgentContext(),
78
113
  };
79
114
  if (model) execContext.model_override = model;
@@ -219,6 +254,47 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
219
254
  content_length: finalContent.length,
220
255
  });
221
256
 
257
+ // PRD-071 §1.5 — cache summary for benchmark harness. Machine-readable,
258
+ // one file per run. Fields match what benchmark/cache-check.sh already
259
+ // computes (input, cache_read, cache_write, rate) so the shell script
260
+ // becomes a thin reader instead of re-doing the arithmetic.
261
+ if (cacheReport && usage) {
262
+ const cacheRead = usage.cache_read || 0;
263
+ const cacheWrite = usage.cache_write || 0;
264
+ const inputT = usage.input_tokens || 0;
265
+ // Two conventions in the wild:
266
+ // OpenAI/DeepSeek: input_tokens INCLUDES cached tokens
267
+ // → hit_rate = cache_read / input_tokens
268
+ // Anthropic: input_tokens EXCLUDES cache reads AND writes
269
+ // → hit_rate = cache_read / (input + cache_read + cache_write)
270
+ // Report both. Also expose `cache_hit_rate_pct` as the "sane" number
271
+ // — auto-detects convention by whether cache_read > input_tokens.
272
+ const rateOpenAI = inputT > 0 ? Math.round((cacheRead / inputT) * 100) : 0;
273
+ const anthropicDenom = inputT + cacheRead + cacheWrite;
274
+ const rateAnthropic = anthropicDenom > 0 ? Math.round((cacheRead / anthropicDenom) * 100) : 0;
275
+ const rateAuto = cacheRead > inputT ? rateAnthropic : rateOpenAI;
276
+ const report = {
277
+ schema: 'kepler.cache-report/1',
278
+ model: model || 'default',
279
+ input_tokens: inputT,
280
+ output_tokens: usage.output_tokens || 0,
281
+ cache_read_tokens: cacheRead,
282
+ cache_write_tokens: cacheWrite,
283
+ cache_hit_rate_pct: rateAuto,
284
+ cache_hit_rate_openai_pct: rateOpenAI,
285
+ cache_hit_rate_anthropic_pct: rateAnthropic,
286
+ duration_s: Math.round(durationS * 10) / 10,
287
+ cost_usd: totalCost,
288
+ };
289
+ try {
290
+ const fs = await import('node:fs');
291
+ fs.writeFileSync(cacheReport, JSON.stringify(report, null, 2) + '\n');
292
+ log(`Cache report written: ${cacheReport}`);
293
+ } catch (err) {
294
+ log(`Cache report write failed: ${err.message}`);
295
+ }
296
+ }
297
+
222
298
  log(`Done: ${toolCount} tools, ${durationS.toFixed(1)}s, $${totalCost.toFixed(3)}`);
223
299
 
224
300
  // Write final content to stderr so it's human-readable (stdout is JSONL)
@@ -6,6 +6,14 @@
6
6
 
7
7
  import { ContextRetriever } from '../context/retriever.mjs';
8
8
  import { createStagnationTracker, stagnationMessage } from './stagnation.mjs';
9
+ import { PromptCache } from './cache.mjs';
10
+ import {
11
+ ANTHROPIC_BETA_HEADER,
12
+ cacheableSystem,
13
+ cacheableTools,
14
+ withMessageBreakpoint,
15
+ needsExplicitCacheControl,
16
+ } from './cache-control.mjs';
9
17
 
10
18
  const MAX_ITERATIONS = 50;
11
19
 
@@ -209,12 +217,14 @@ export class LocalAgent {
209
217
  this.stagnationDetection = stagnationDetection;
210
218
  this.stagnationThreshold = stagnationThreshold;
211
219
  this._cancelled = false;
220
+ this.promptCache = new PromptCache();
212
221
  }
213
222
 
214
223
  async *execute(instruction, context = {}) {
215
224
  this._cancelled = false;
216
225
  const startTime = Date.now();
217
226
  let toolCount = 0;
227
+ const usageTotals = { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 };
218
228
 
219
229
  yield { type: 'status', data: { message: `Local mode: ${this.model}` } };
220
230
 
@@ -252,7 +262,15 @@ export class LocalAgent {
252
262
  return;
253
263
  }
254
264
 
255
- const { content, stopReason } = response;
265
+ const { content, stopReason, usage } = response;
266
+
267
+ if (usage) {
268
+ this.promptCache.updateStats(usage);
269
+ usageTotals.input_tokens += usage.input_tokens || 0;
270
+ usageTotals.output_tokens += usage.output_tokens || 0;
271
+ usageTotals.cache_read_tokens += usage.cache_read_input_tokens || 0;
272
+ usageTotals.cache_creation_tokens += usage.cache_creation_input_tokens || 0;
273
+ }
256
274
 
257
275
  // Process content blocks
258
276
  let hasToolUse = false;
@@ -303,13 +321,29 @@ export class LocalAgent {
303
321
 
304
322
  if (!hasToolUse || stopReason === 'end_turn') {
305
323
  const duration = (Date.now() - startTime) / 1000;
306
- yield { type: 'complete', data: { summary: 'Done (local)', changes: toolCount, duration_s: duration } };
324
+ yield {
325
+ type: 'complete',
326
+ data: {
327
+ summary: 'Done (local)',
328
+ changes: toolCount,
329
+ duration_s: duration,
330
+ usage: _buildLocalUsageEnvelope(this.model, usageTotals),
331
+ },
332
+ };
307
333
  return;
308
334
  }
309
335
  }
310
336
 
311
337
  yield { type: 'error', data: { message: `Max turns (${this.maxTurns}) reached.` } };
312
- yield { type: 'complete', data: { summary: 'Aborted (max turns)', changes: toolCount, duration_s: (Date.now() - startTime) / 1000 } };
338
+ yield {
339
+ type: 'complete',
340
+ data: {
341
+ summary: 'Aborted (max turns)',
342
+ changes: toolCount,
343
+ duration_s: (Date.now() - startTime) / 1000,
344
+ usage: _buildLocalUsageEnvelope(this.model, usageTotals),
345
+ },
346
+ };
313
347
  }
314
348
 
315
349
  async _callLLM(systemPrompt, messages, tools) {
@@ -333,18 +367,26 @@ export class LocalAgent {
333
367
  }
334
368
 
335
369
  async _callClaude(systemPrompt, messages, tools) {
370
+ // PRD-071 Phase 2 — cache_control breakpoints for Anthropic direct.
371
+ // Extended 1-hour TTL beta on the persistent prefix (system + tools).
372
+ // Message history breakpoint stays at default 5-min TTL.
373
+ const cachedSystem = cacheableSystem(systemPrompt);
374
+ const cachedTools = cacheableTools(tools);
375
+ const cachedMessages = withMessageBreakpoint(messages);
376
+
336
377
  const resp = await fetch('https://api.anthropic.com/v1/messages', {
337
378
  method: 'POST',
338
379
  headers: {
339
380
  'x-api-key': this.apiKey,
340
381
  'anthropic-version': '2023-06-01',
382
+ 'anthropic-beta': ANTHROPIC_BETA_HEADER,
341
383
  'content-type': 'application/json',
342
384
  },
343
385
  body: JSON.stringify({
344
386
  model: this.model,
345
- system: systemPrompt,
346
- messages,
347
- tools: tools.length > 0 ? tools : undefined,
387
+ system: cachedSystem,
388
+ messages: cachedMessages,
389
+ tools: cachedTools.length > 0 ? cachedTools : undefined,
348
390
  max_tokens: 8192,
349
391
  }),
350
392
  });
@@ -353,7 +395,7 @@ export class LocalAgent {
353
395
  throw new Error(`Claude API ${resp.status}: ${text.slice(0, 200)}`);
354
396
  }
355
397
  const data = await resp.json();
356
- return { content: data.content || [], stopReason: data.stop_reason };
398
+ return { content: data.content || [], stopReason: data.stop_reason, usage: data.usage || null };
357
399
  }
358
400
 
359
401
  async _callOpenRouter(systemPrompt, messages, tools) {
@@ -363,17 +405,43 @@ export class LocalAgent {
363
405
  model = `anthropic/${model}`;
364
406
  }
365
407
 
366
- const orMessages = [{ role: 'system', content: systemPrompt }, ...messages];
408
+ // PRD-071 Phase 2 for anthropic/* models we need explicit cache_control
409
+ // breakpoints (OpenRouter relays them through to Anthropic). OpenAI +
410
+ // DeepSeek auto-cache, so the string system prompt path is fine there.
411
+ const isAnthropic = needsExplicitCacheControl(model);
412
+ const systemForOR = isAnthropic
413
+ ? { role: 'system', content: cacheableSystem(systemPrompt) }
414
+ : { role: 'system', content: systemPrompt };
415
+ const messagesForOR = isAnthropic ? withMessageBreakpoint(messages) : messages;
416
+ const orMessages = [systemForOR, ...messagesForOR];
417
+
418
+ // Same tool-schema shape as before, but tag the last tool for Anthropic
419
+ // (OpenRouter passes cache_control on function tools through).
420
+ const orTools = tools.length > 0
421
+ ? tools.map(t => ({
422
+ type: 'function',
423
+ function: { name: t.name, description: t.description, parameters: t.input_schema },
424
+ }))
425
+ : [];
426
+ const finalTools = isAnthropic ? cacheableTools(orTools) : orTools;
427
+
428
+ const headers = {
429
+ 'Authorization': `Bearer ${this.openRouterKey}`,
430
+ 'Content-Type': 'application/json',
431
+ };
432
+ // OpenRouter forwards `anthropic-beta` to Anthropic upstreams.
433
+ if (isAnthropic) headers['anthropic-beta'] = ANTHROPIC_BETA_HEADER;
434
+
367
435
  const resp = await fetch('https://openrouter.ai/api/v1/chat/completions', {
368
436
  method: 'POST',
369
- headers: {
370
- 'Authorization': `Bearer ${this.openRouterKey}`,
371
- 'Content-Type': 'application/json',
372
- },
437
+ headers,
373
438
  body: JSON.stringify({
374
439
  model,
375
440
  messages: orMessages,
376
- tools: tools.length > 0 ? tools.map(t => ({ type: 'function', function: { name: t.name, description: t.description, parameters: t.input_schema } })) : undefined,
441
+ tools: finalTools.length > 0 ? finalTools : undefined,
442
+ // Ask OpenRouter to include upstream cache accounting in the
443
+ // usage payload so PromptCache.updateStats() sees real numbers.
444
+ usage: { include: true },
377
445
  }),
378
446
  });
379
447
  if (!resp.ok) {
@@ -389,7 +457,11 @@ export class LocalAgent {
389
457
  content.push({ type: 'tool_use', id: tc.id, name: tc.function.name, input: JSON.parse(tc.function.arguments || '{}') });
390
458
  }
391
459
  }
392
- return { content, stopReason: choice?.finish_reason === 'stop' ? 'end_turn' : 'tool_use' };
460
+ return {
461
+ content,
462
+ stopReason: choice?.finish_reason === 'stop' ? 'end_turn' : 'tool_use',
463
+ usage: _normalizeOpenRouterUsage(data.usage),
464
+ };
393
465
  }
394
466
 
395
467
  _buildToolDefs() {
@@ -427,3 +499,38 @@ export class LocalAgent {
427
499
 
428
500
  cancel() { this._cancelled = true; }
429
501
  }
502
+
503
+ // Shape the accumulated per-turn totals into the same envelope the remote
504
+ // SSE `complete` event uses, so repl.mjs:837 can consume both modes with the
505
+ // same code path. `models[0].role = 'local'` distinguishes single-agent
506
+ // local mode from remote's Coder/Explorer/Planner breakdown.
507
+ function _buildLocalUsageEnvelope(model, totals) {
508
+ return {
509
+ total_input_tokens: totals.input_tokens,
510
+ total_output_tokens: totals.output_tokens,
511
+ models: [{
512
+ model,
513
+ role: 'local',
514
+ input_tokens: totals.input_tokens,
515
+ output_tokens: totals.output_tokens,
516
+ cache_read_tokens: totals.cache_read_tokens,
517
+ cache_creation_tokens: totals.cache_creation_tokens,
518
+ }],
519
+ };
520
+ }
521
+
522
+ // Normalize OpenRouter usage into Anthropic's field names so downstream
523
+ // consumers (PromptCache, pricing.calculateCost) don't branch on shape.
524
+ // OpenRouter returns OpenAI-style: prompt_tokens, completion_tokens,
525
+ // prompt_tokens_details.cached_tokens. When the underlying model is
526
+ // Anthropic, OpenRouter also relays cache_read_input_tokens verbatim.
527
+ function _normalizeOpenRouterUsage(usage) {
528
+ if (!usage) return null;
529
+ const cachedFromOpenAI = usage.prompt_tokens_details?.cached_tokens || 0;
530
+ return {
531
+ input_tokens: usage.prompt_tokens || 0,
532
+ output_tokens: usage.completion_tokens || 0,
533
+ cache_read_input_tokens: usage.cache_read_input_tokens || cachedFromOpenAI || 0,
534
+ cache_creation_input_tokens: usage.cache_creation_input_tokens || 0,
535
+ };
536
+ }
@@ -67,6 +67,57 @@ export function appendTask({ cwd = process.cwd(), list = 'backlog', text }) {
67
67
  return { list: normalized, path: filePath, text: taskText };
68
68
  }
69
69
 
70
+ export function updateTask({ cwd = process.cwd(), list = 'active', index, text, checked } = {}) {
71
+ const normalized = normalizeList(list);
72
+ const taskIndex = normalizeTaskIndex(index);
73
+ ensureTaskFiles({ cwd });
74
+ const filePath = taskFilePath(cwd, normalized);
75
+ const content = readText(filePath);
76
+ const tasks = parseTaskMarkdown(content, normalized);
77
+ const task = tasks[taskIndex - 1];
78
+ if (!task) throw new Error(`No task ${taskIndex} in ${normalized}`);
79
+
80
+ const lines = content.split(/\r?\n/);
81
+ const nextText = String(text ?? task.text).trim();
82
+ if (!nextText) throw new Error('Task text is required');
83
+ const nextChecked = checked === undefined ? task.checked : Boolean(checked);
84
+ lines[task.line - 1] = taskLine(nextText, normalized, nextChecked);
85
+ fs.writeFileSync(filePath, lines.join('\n'));
86
+ return { list: normalized, path: filePath, index: taskIndex, previous: task, text: nextText, checked: nextChecked };
87
+ }
88
+
89
+ export function removeTask({ cwd = process.cwd(), list = 'active', index } = {}) {
90
+ const normalized = normalizeList(list);
91
+ const taskIndex = normalizeTaskIndex(index);
92
+ ensureTaskFiles({ cwd });
93
+ const filePath = taskFilePath(cwd, normalized);
94
+ const content = readText(filePath);
95
+ const tasks = parseTaskMarkdown(content, normalized);
96
+ const task = tasks[taskIndex - 1];
97
+ if (!task) throw new Error(`No task ${taskIndex} in ${normalized}`);
98
+
99
+ const lines = content.split(/\r?\n/);
100
+ lines.splice(task.line - 1, 1);
101
+ fs.writeFileSync(filePath, lines.join('\n'));
102
+ return { list: normalized, path: filePath, index: taskIndex, task };
103
+ }
104
+
105
+ export function moveTask({ cwd = process.cwd(), from = 'active', index, to = 'done', text } = {}) {
106
+ const source = normalizeList(from);
107
+ const target = normalizeList(to);
108
+ const removed = removeTask({ cwd, list: source, index });
109
+ const taskText = String(text ?? removed.task.text).trim();
110
+ const appended = appendTask({ cwd, list: target, text: taskText });
111
+ return {
112
+ from: source,
113
+ to: target,
114
+ index: removed.index,
115
+ text: taskText,
116
+ sourcePath: removed.path,
117
+ targetPath: appended.path,
118
+ };
119
+ }
120
+
70
121
  export function taskCounts(board) {
71
122
  const lists = board?.lists || {};
72
123
  return Object.fromEntries(
@@ -74,7 +125,7 @@ export function taskCounts(board) {
74
125
  );
75
126
  }
76
127
 
77
- function normalizeList(value) {
128
+ export function normalizeList(value) {
78
129
  const key = String(value || '').toLowerCase();
79
130
  if (key === 'todo' || key === 'pending') return 'backlog';
80
131
  if (key === 'current' || key === 'doing') return 'active';
@@ -83,6 +134,21 @@ function normalizeList(value) {
83
134
  throw new Error(`Unknown task list: ${value}`);
84
135
  }
85
136
 
137
+ function normalizeTaskIndex(value) {
138
+ const n = Number(value);
139
+ if (!Number.isInteger(n) || n < 1) throw new Error('Task index must be a positive number');
140
+ return n;
141
+ }
142
+
143
+ function taskFilePath(cwd, list) {
144
+ return path.join(cwd, '.kepler', 'tasks', TASK_FILES[list]);
145
+ }
146
+
147
+ function taskLine(text, list, checked = false) {
148
+ const mark = checked || list === 'done' ? 'x' : ' ';
149
+ return `- [${mark}] ${text}`;
150
+ }
151
+
86
152
  function readText(filePath) {
87
153
  try {
88
154
  return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : '';