karajan-code 2.7.1 → 2.7.3

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.
@@ -4,12 +4,15 @@ import { computeBaseRef, generateDiff } from "../review/diff-generator.js";
4
4
  import { buildReviewerPrompt } from "../prompts/reviewer.js";
5
5
  import { resolveRole } from "../config.js";
6
6
  import { resolveReviewProfile } from "../review/profiles.js";
7
+ import { withCliRunLog } from "../utils/cli-run-log.js";
7
8
 
8
9
  export async function reviewCommand({ task, config, logger, baseRef }) {
9
- const reviewerRole = resolveRole(config, "reviewer");
10
- await assertAgentsAvailable([reviewerRole.provider]);
11
- logger.info(`Reviewer (${reviewerRole.provider}) starting...`);
12
- const reviewer = createAgent(reviewerRole.provider, config, logger);
10
+ return withCliRunLog("review", { projectDir: config?.projectDir, logger }, async ({ runLog }) => {
11
+ const reviewerRole = resolveRole(config, "reviewer");
12
+ await assertAgentsAvailable([reviewerRole.provider]);
13
+ logger.info(`Reviewer (${reviewerRole.provider}) starting...`);
14
+ runLog.logText(`[reviewer] provider=${reviewerRole.provider} baseRef=${baseRef || "(auto)"}`);
15
+ const reviewer = createAgent(reviewerRole.provider, config, logger);
13
16
 
14
17
  let diff;
15
18
  if (config.ci?.enabled) {
@@ -75,4 +78,7 @@ export async function reviewCommand({ task, config, logger, baseRef }) {
75
78
  logger.warn(`CI dispatch failed (non-blocking): ${err.message}`);
76
79
  }
77
80
  }
81
+ runLog.logText(`[reviewer] finished (exit=${result.exitCode})`);
82
+ return { ok: true };
83
+ });
78
84
  }
@@ -3,6 +3,7 @@ import readline from "node:readline";
3
3
  import { runFlow } from "../orchestrator.js";
4
4
  import { assertAgentsAvailable } from "../agents/availability.js";
5
5
  import { createActivityLog } from "../activity-log.js";
6
+ import { withCliRunLog } from "../utils/cli-run-log.js";
6
7
  import { printHeader } from "../utils/display/header.js";
7
8
  import { printEvent } from "../utils/display/event-handlers.js";
8
9
  import { resolveRole } from "../config.js";
@@ -57,32 +58,41 @@ export async function runCommandHandler({ task, config, logger, flags }) {
57
58
  // Quiet mode is the default; --verbose disables it
58
59
  const quietMode = config.output?.quiet !== false;
59
60
 
60
- const emitter = new EventEmitter();
61
- let activityLog = null;
61
+ return withCliRunLog("run", { projectDir: config?.projectDir, logger }, async ({ runLog, forwardProgress }) => {
62
+ runLog.logText(`[kj_run] task="${String(task).slice(0, 80)}..."`);
62
63
 
63
- emitter.on("progress", (event) => {
64
- if (!activityLog && event.sessionId) {
65
- activityLog = createActivityLog(event.sessionId);
66
- logger.onLog((entry) => activityLog.write(entry));
67
- }
64
+ const emitter = new EventEmitter();
65
+ let activityLog = null;
68
66
 
69
- if (activityLog) {
70
- activityLog.writeEvent(event);
71
- }
67
+ // Mirror every progress event into .kj/run.log so kj-tail works identically
68
+ // whether the command came from the MCP server or the CLI (KJC-TSK-0327 follow-up).
69
+ forwardProgress(emitter);
70
+
71
+ emitter.on("progress", (event) => {
72
+ if (!activityLog && event.sessionId) {
73
+ activityLog = createActivityLog(event.sessionId);
74
+ logger.onLog((entry) => activityLog.write(entry));
75
+ }
76
+
77
+ if (activityLog) {
78
+ activityLog.writeEvent(event);
79
+ }
80
+
81
+ if (!jsonMode) {
82
+ printEvent(event, { quiet: quietMode });
83
+ }
84
+ });
72
85
 
73
86
  if (!jsonMode) {
74
- printEvent(event, { quiet: quietMode });
87
+ printHeader({ task: task, config });
75
88
  }
76
- });
77
-
78
- if (!jsonMode) {
79
- printHeader({ task: task, config });
80
- }
81
89
 
82
- const askQuestion = createCliAskQuestion();
83
- const result = await runFlow({ task: task, config, logger, flags, emitter, askQuestion, pgTaskId: pgCardId || null, pgProject: pgProject || null });
90
+ const askQuestion = createCliAskQuestion();
91
+ const result = await runFlow({ task: task, config, logger, flags, emitter, askQuestion, pgTaskId: pgCardId || null, pgProject: pgProject || null });
84
92
 
85
- if (jsonMode) {
86
- console.log(JSON.stringify(result, null, 2));
87
- }
93
+ if (jsonMode) {
94
+ console.log(JSON.stringify(result, null, 2));
95
+ }
96
+ return { ok: !result?.paused && result?.approved !== false };
97
+ });
88
98
  }
@@ -34,30 +34,36 @@ function formatTriage(result) {
34
34
  }
35
35
 
36
36
  export async function triageCommand({ task, config, logger, json }) {
37
- const triageRole = resolveRole(config, "triage");
38
- await assertAgentsAvailable([triageRole.provider]);
39
- logger.info(`Triage (${triageRole.provider}) starting...`);
37
+ const { withCliRunLog } = await import("../utils/cli-run-log.js");
38
+ return withCliRunLog("triage", { projectDir: config?.projectDir, logger }, async ({ runLog }) => {
39
+ const triageRole = resolveRole(config, "triage");
40
+ await assertAgentsAvailable([triageRole.provider]);
41
+ logger.info(`Triage (${triageRole.provider}) starting...`);
42
+ runLog.logText(`[triage] provider=${triageRole.provider}`);
40
43
 
41
- const agent = createAgent(triageRole.provider, config, logger);
42
- const prompt = buildTriagePrompt({ task });
43
- const onOutput = ({ line }) => process.stdout.write(`${line}\n`);
44
- const result = await agent.runTask({ prompt, onOutput, role: "triage" });
44
+ const agent = createAgent(triageRole.provider, config, logger);
45
+ const prompt = buildTriagePrompt({ task });
46
+ const onOutput = ({ line }) => process.stdout.write(`${line}\n`);
47
+ const result = await agent.runTask({ prompt, onOutput, role: "triage" });
45
48
 
46
- if (!result.ok) {
47
- throw new Error(result.error || result.output || "Triage failed");
48
- }
49
+ if (!result.ok) {
50
+ throw new Error(result.error || result.output || "Triage failed");
51
+ }
49
52
 
50
- const parsed = parseMaybeJsonString(result.output);
53
+ const parsed = parseMaybeJsonString(result.output);
54
+ if (parsed?.level) runLog.logText(`[triage] level=${parsed.level} roles=${(parsed.roles || []).join(",")}`);
51
55
 
52
- if (json) {
53
- console.log(JSON.stringify(parsed || result.output, null, 2));
54
- return;
55
- }
56
+ if (json) {
57
+ console.log(JSON.stringify(parsed || result.output, null, 2));
58
+ return { ok: true };
59
+ }
56
60
 
57
- if (parsed?.level) {
58
- console.log(formatTriage(parsed));
59
- } else {
60
- console.log(result.output);
61
- }
62
- logger.info("Triage completed.");
61
+ if (parsed?.level) {
62
+ console.log(formatTriage(parsed));
63
+ } else {
64
+ console.log(result.output);
65
+ }
66
+ logger.info("Triage completed.");
67
+ return { ok: true };
68
+ });
63
69
  }
@@ -53,6 +53,18 @@ export async function runDirectRole({
53
53
  await assertAgentsAvailable([role.provider]);
54
54
 
55
55
  const projectDir = await resolveProjectDir(server, args.projectDir);
56
+
57
+ // Allow taskFile as alternative to inline task — works for every direct
58
+ // role (discover/triage/researcher/architect/audit/impeccable/...) without
59
+ // per-handler wiring. The mutation is in-place on the role's runInput /
60
+ // initContext since they are already built with `task: a.task`.
61
+ if ((!runInput?.task || runInput.task === undefined) && args.taskFile) {
62
+ const { readTaskFile } = await import("../utils/task-file.js");
63
+ const loaded = await readTaskFile(args.taskFile, { projectDir });
64
+ if (runInput) runInput.task = loaded;
65
+ if (initContext) initContext.task = loaded;
66
+ args.task = loaded;
67
+ }
56
68
  const runLog = createRunLog(projectDir);
57
69
  const startMsg = logStartMsg || `[kj_${effectiveStage}] started`;
58
70
  runLog.logText(startMsg);
@@ -56,6 +56,8 @@ export async function handlePlanDirect(a, server, extra) {
56
56
  await assertAgentsAvailable([plannerRole.provider]);
57
57
 
58
58
  const projectDir = await resolveProjectDir(server, a.projectDir);
59
+ const { ensureTaskFromFile } = await import("../shared-helpers.js");
60
+ await ensureTaskFromFile(a, projectDir);
59
61
  const runLog = createRunLog(projectDir);
60
62
  const silenceTimeoutMs = Number(config?.session?.max_agent_silence_minutes) > 0
61
63
  ? Math.round(Number(config.session.max_agent_silence_minutes) * 60 * 1000)
@@ -210,6 +212,8 @@ export async function handleCodeDirect(a, server, extra) {
210
212
  await assertAgentsAvailable([coderRole.provider]);
211
213
 
212
214
  const projectDir = await resolveProjectDir(server, a.projectDir);
215
+ const { ensureTaskFromFile } = await import("../shared-helpers.js");
216
+ await ensureTaskFromFile(a, projectDir);
213
217
  const runLog = createRunLog(projectDir);
214
218
  runLog.logText(`[kj_code] started — provider=${coderRole.provider}`);
215
219
  const emitter = buildDirectEmitter(server, runLog, extra);
@@ -261,6 +265,8 @@ export async function handleReviewDirect(a, server, extra) {
261
265
  await assertAgentsAvailable([reviewerRole.provider]);
262
266
 
263
267
  const projectDir = await resolveProjectDir(server, a.projectDir);
268
+ const { ensureTaskFromFile } = await import("../shared-helpers.js");
269
+ await ensureTaskFromFile(a, projectDir);
264
270
  const runLog = createRunLog(projectDir);
265
271
  runLog.logText(`[kj_review] started — provider=${reviewerRole.provider}`);
266
272
  const emitter = buildDirectEmitter(server, runLog, extra);
@@ -267,8 +267,14 @@ export async function handleResume(a, server, extra) {
267
267
  }
268
268
 
269
269
  export async function handleRun(a, server, extra) {
270
+ const { ensureTaskFromFile, resolveProjectDir: _rpd } = await import("../shared-helpers.js");
271
+ try {
272
+ await ensureTaskFromFile(a, a.projectDir || (await _rpd(server, a.projectDir).catch(() => null)));
273
+ } catch (err) {
274
+ return failPayload(`taskFile read failed: ${err.message}`);
275
+ }
270
276
  if (!a.task) {
271
- return failPayload("Missing required field: task");
277
+ return failPayload("Missing required field: task (or pass taskFile with a .md path)");
272
278
  }
273
279
  if (a.taskType) {
274
280
  const validTypes = new Set(["sw", "infra", "doc", "add-tests", "refactor"]);
@@ -21,6 +21,29 @@ export { classifyError } from "./error-classifiers.js";
21
21
  * Resolve the user's project directory.
22
22
  * Priority: 1) explicit projectDir param, 2) MCP roots, 3) error with instructions.
23
23
  */
24
+ /**
25
+ * Resolve the `task` argument for MCP handlers. If the caller passed
26
+ * `a.taskFile` (instead of or in addition to `a.task`), read the file and
27
+ * set `a.task` to its trimmed content. When both are given, the explicit
28
+ * `a.task` wins (symmetric with CLI `resolveTaskInput`).
29
+ *
30
+ * Mutates `a` in place so downstream handlers keep reading `a.task`
31
+ * without any change. Returns `a` for convenience.
32
+ *
33
+ * @param {Object} a
34
+ * @param {string} [projectDir] - used to resolve relative file paths
35
+ * @returns {Promise<Object>} - the same `a`, with `a.task` filled in
36
+ */
37
+ export async function ensureTaskFromFile(a, projectDir) {
38
+ if (!a) return a;
39
+ const hasTask = typeof a.task === "string" && a.task.trim().length > 0;
40
+ const hasFile = typeof a.taskFile === "string" && a.taskFile.trim().length > 0;
41
+ if (hasTask || !hasFile) return a;
42
+ const { readTaskFile } = await import("../utils/task-file.js");
43
+ a.task = await readTaskFile(a.taskFile, { projectDir });
44
+ return a;
45
+ }
46
+
24
47
  export async function resolveProjectDir(server, explicitProjectDir) {
25
48
  if (explicitProjectDir) return explicitProjectDir;
26
49
 
package/src/mcp/tools.js CHANGED
@@ -54,9 +54,9 @@ export const tools = [
54
54
  "Run the full Karajan pipeline. IMPORTANT: Pass the user's task description exactly as they wrote it. Do NOT group, split, reorder, or modify tasks yourself. Karajan handles decomposition, role activation, iteration, and quality gates internally. Do NOT override pipeline parameters (enableHuReviewer, mode, methodology) unless the user explicitly requested it. If you have observations about the task, use kj_suggest instead.",
55
55
  inputSchema: {
56
56
  type: "object",
57
- required: ["task"],
58
57
  properties: {
59
- task: { type: "string", description: "Task description for the coder (can include a Planning Game card ID like KJC-TSK-0042)" },
58
+ task: { type: "string", description: "Task description for the coder (can include a Planning Game card ID like KJC-TSK-0042). Either `task` or `taskFile` is required." },
59
+ taskFile: { type: "string", description: "Alternative to `task`: path to a .md (or any text file) whose contents become the task description. Relative paths resolve against projectDir." },
60
60
  plan: { type: "string", description: "Plan ID from kj_plan. Loads persisted plan context and skips researcher/architect/planner stages." },
61
61
  projectDir: { type: "string", description: "Absolute path to the project directory. Required when KJ MCP server runs from a different directory than the target project." },
62
62
  pgTask: { type: "string", description: "Planning Game card ID (e.g., KJC-TSK-0042). If provided, fetches full card details as task context and updates card status on completion." },
@@ -195,9 +195,9 @@ export const tools = [
195
195
  description: "Run coder-only mode. Pass the user's task exactly as described. Do NOT split or reinterpret the task.",
196
196
  inputSchema: {
197
197
  type: "object",
198
- required: ["task"],
199
198
  properties: {
200
- task: { type: "string" },
199
+ task: { type: "string", description: "Task description. Either `task` or `taskFile` is required." },
200
+ taskFile: { type: "string", description: "Alternative to `task`: path to a .md/text file." },
201
201
  coder: { type: "string" },
202
202
  coderModel: { type: "string" },
203
203
  projectDir: { type: "string", description: "Absolute path to the project directory" },
@@ -210,9 +210,9 @@ export const tools = [
210
210
  description: "Run reviewer-only mode against current diff. Do NOT filter or pre-process the diff before passing it.",
211
211
  inputSchema: {
212
212
  type: "object",
213
- required: ["task"],
214
213
  properties: {
215
- task: { type: "string" },
214
+ task: { type: "string", description: "Task description. Either `task` or `taskFile` is required." },
215
+ taskFile: { type: "string", description: "Alternative to `task`: path to a .md/text file." },
216
216
  reviewer: { type: "string" },
217
217
  reviewerModel: { type: "string" },
218
218
  baseRef: { type: "string" },
@@ -236,9 +236,9 @@ export const tools = [
236
236
  description: "Generate implementation plan for a task. Pass the task as the user described it. Karajan's planner decides the approach.",
237
237
  inputSchema: {
238
238
  type: "object",
239
- required: ["task"],
240
239
  properties: {
241
- task: { type: "string" },
240
+ task: { type: "string", description: "Task description. Either `task` or `taskFile` is required." },
241
+ taskFile: { type: "string", description: "Alternative to `task`: path to a .md/text file." },
242
242
  planner: { type: "string" },
243
243
  plannerModel: { type: "string" },
244
244
  coder: { type: "string", description: "Legacy alias for planner" },
@@ -253,9 +253,9 @@ export const tools = [
253
253
  description: "Analyze a task for gaps, ambiguities, and missing information before execution. Returns a verdict (ready/needs_validation) with structured gap list. Can read task details from Planning Game if pgTask is provided.",
254
254
  inputSchema: {
255
255
  type: "object",
256
- required: ["task"],
257
256
  properties: {
258
- task: { type: "string", description: "Task description to analyze for gaps" },
257
+ task: { type: "string", description: "Task description to analyze for gaps. Either `task` or `taskFile` is required." },
258
+ taskFile: { type: "string", description: "Alternative to `task`: path to a .md/text file." },
259
259
  mode: { type: "string", enum: ["gaps", "momtest", "wendel", "classify", "jtbd"], description: "Discovery mode: gaps (default), momtest (Mom Test questions), wendel (behavior change checklist), classify (START/STOP/DIFFERENT), or jtbd (Jobs-to-be-Done)" },
260
260
  context: { type: "string", description: "Additional context for the analysis (e.g., research output)" },
261
261
  pgTask: { type: "string", description: "Planning Game card ID (e.g., KJC-TSK-0042). If provided, fetches full card details as additional context." },
@@ -270,9 +270,9 @@ export const tools = [
270
270
  description: "Classify task complexity and recommend which pipeline roles to activate. Returns level (trivial/simple/medium/complex), taskType, recommended roles, and optional decomposition.",
271
271
  inputSchema: {
272
272
  type: "object",
273
- required: ["task"],
274
273
  properties: {
275
- task: { type: "string", description: "Task description to classify" },
274
+ task: { type: "string", description: "Task description to classify. Either `task` or `taskFile` is required." },
275
+ taskFile: { type: "string", description: "Alternative to `task`: path to a .md/text file." },
276
276
  projectDir: { type: "string", description: "Absolute path to the project directory" },
277
277
  kjHome: { type: "string" }
278
278
  }
@@ -283,9 +283,9 @@ export const tools = [
283
283
  description: "Research the codebase for a task. Identifies affected files, patterns, constraints, prior decisions, risks, and test coverage.",
284
284
  inputSchema: {
285
285
  type: "object",
286
- required: ["task"],
287
286
  properties: {
288
- task: { type: "string", description: "Task description to research" },
287
+ task: { type: "string", description: "Task description to research. Either `task` or `taskFile` is required." },
288
+ taskFile: { type: "string", description: "Alternative to `task`: path to a .md/text file." },
289
289
  projectDir: { type: "string", description: "Absolute path to the project directory" },
290
290
  kjHome: { type: "string" }
291
291
  }
@@ -296,9 +296,9 @@ export const tools = [
296
296
  description: "Design solution architecture for a task. Returns layers, patterns, data model, API contracts, tradeoffs, and a verdict (ready/needs_clarification).",
297
297
  inputSchema: {
298
298
  type: "object",
299
- required: ["task"],
300
299
  properties: {
301
- task: { type: "string", description: "Task description to architect" },
300
+ task: { type: "string", description: "Task description to architect. Either `task` or `taskFile` is required." },
301
+ taskFile: { type: "string", description: "Alternative to `task`: path to a .md/text file." },
302
302
  context: { type: "string", description: "Additional context (e.g., researcher output)" },
303
303
  projectDir: { type: "string", description: "Absolute path to the project directory" },
304
304
  kjHome: { type: "string" }
@@ -311,6 +311,8 @@ export const tools = [
311
311
  inputSchema: {
312
312
  type: "object",
313
313
  properties: {
314
+ task: { type: "string", description: "Optional focus for the audit. If absent (and no taskFile), defaults to a full-codebase analysis." },
315
+ taskFile: { type: "string", description: "Alternative to `task`: path to a .md/text file with the audit focus." },
314
316
  projectDir: { type: "string", description: "Absolute path to the project to audit" },
315
317
  dimensions: { type: "string", description: "Comma-separated dimensions to analyze: security,codeQuality,performance,architecture,testing (default: all)" },
316
318
  kjHome: { type: "string" }
@@ -452,6 +452,14 @@ async function finalizeApprovedSession({ config, gitCtx, task, logger, session,
452
452
  finishedAt: new Date().toISOString(),
453
453
  brainDecisions: Array.isArray(session.brainDecisions) ? session.brainDecisions.length : 0,
454
454
  solomonInvocations: Array.isArray(session.solomonRulings) ? session.solomonRulings.length : 0,
455
+ // KJC-TSK-0327: surface skill-source decisions in the summary so the
456
+ // post-mortem shows which process skills (addyosmani) and stack skills
457
+ // (OpenSkills) actually shaped the role prompts.
458
+ skills: {
459
+ addyosmani: session.addyosmaniSkills,
460
+ installed: session.autoInstalledSkills,
461
+ recommended: session.skillsRecommended,
462
+ },
455
463
  }, { logger });
456
464
  logger.info(`Session journal written to ${journalDir}`);
457
465
  } catch (err) {
@@ -26,6 +26,10 @@ import path from "node:path";
26
26
  * @property {string} [finishedAt] - ISO timestamp (optional)
27
27
  * @property {number} [solomonInvocations]
28
28
  * @property {number} [brainDecisions]
29
+ * @property {Object} [skills] - skill sources used this session (KJC-TSK-0327)
30
+ * @property {Object} [skills.addyosmani] - { available, action, resolvedSlugs, reason }
31
+ * @property {string[]} [skills.installed] - OpenSkills installed this run (session.autoInstalledSkills)
32
+ * @property {string[]} [skills.recommended] - wouldHaveUsed when OpenSkills CLI missing
29
33
  */
30
34
 
31
35
  function esc(text) {
@@ -103,6 +107,48 @@ function renderBreakdownByRole(breakdown) {
103
107
  return ["| Role | Tokens | Cost |", "|---|---|---|", ...rows].join("\n");
104
108
  }
105
109
 
110
+ /**
111
+ * Render the Skills section: which process skills (addyosmani) were resolved
112
+ * for the task, which OpenSkills were installed, and which skills would have
113
+ * helped but couldn't be installed (graceful-degradation path).
114
+ *
115
+ * Returns null when no skill activity happened (no addyosmani, no installs,
116
+ * no recommendations) so the section is elided.
117
+ */
118
+ function renderSkills(skills) {
119
+ if (!skills || typeof skills !== "object") return null;
120
+ const addy = skills.addyosmani;
121
+ const installed = Array.isArray(skills.installed) ? skills.installed : [];
122
+ const recommended = Array.isArray(skills.recommended) ? skills.recommended : [];
123
+
124
+ const hasAddy = addy && (addy.available === true || addy.available === false);
125
+ if (!hasAddy && installed.length === 0 && recommended.length === 0) return null;
126
+
127
+ const lines = [];
128
+
129
+ if (hasAddy) {
130
+ if (addy.available) {
131
+ const action = addy.action || "ready";
132
+ const slugs = Array.isArray(addy.resolvedSlugs) ? addy.resolvedSlugs : [];
133
+ lines.push(`- **addyosmani/agent-skills** (process, 1st source): ${esc(action)}` +
134
+ (slugs.length > 0 ? ` — resolved slugs: ${slugs.map((s) => `\`${esc(s)}\``).join(", ")}` : " — no role/task-specific slugs resolved"));
135
+ } else {
136
+ const reason = addy.reason ? ` — ${esc(addy.reason)}` : "";
137
+ lines.push(`- **addyosmani/agent-skills**: unavailable${reason}`);
138
+ }
139
+ }
140
+
141
+ if (installed.length > 0) {
142
+ lines.push(`- **OpenSkills installed**: ${installed.map((s) => `\`${esc(s)}\``).join(", ")}`);
143
+ }
144
+
145
+ if (recommended.length > 0) {
146
+ lines.push(`- **OpenSkills recommended** (CLI missing, would-have-used): ${recommended.map((s) => `\`${esc(s)}\``).join(", ")}`);
147
+ }
148
+
149
+ return lines.join("\n");
150
+ }
151
+
106
152
  function renderJournalLinks(files) {
107
153
  if (!Array.isArray(files) || files.length === 0) return "_No additional journal files._";
108
154
  return files.map((f) => `- [${esc(f)}](./${f})`).join("\n");
@@ -146,6 +192,11 @@ export function buildSummaryMarkdown(input) {
146
192
  sections.push("", "## Budget breakdown (by role)", "", breakdown);
147
193
  }
148
194
 
195
+ const skillsSection = renderSkills(input.skills);
196
+ if (skillsSection) {
197
+ sections.push("", "## Skills Used", "", skillsSection);
198
+ }
199
+
149
200
  sections.push("", "## Commits", "", renderCommits(input.commits));
150
201
 
151
202
  sections.push("", "## Journal files", "", renderJournalLinks(input.files));
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Shared run.log wrapper for CLI commands.
3
+ *
4
+ * Every MCP handler (`kj_run`, `kj_audit`, `kj_code`, `kj_review`, ...) writes
5
+ * progress + lifecycle events to `<projectDir>/.kj/run.log` via
6
+ * `createRunLog()`. `kj-tail` reads that file in real time to show pipeline
7
+ * activity. Until this module, the CLI (`kj run`, `kj audit`, ...) did NOT
8
+ * write that log at all, so running the same command as a shell (vs MCP)
9
+ * produced silence in `kj-tail`. This helper closes that asymmetry: CLI
10
+ * commands that opt in get the same run.log behavior as MCP.
11
+ *
12
+ * Usage pattern:
13
+ *
14
+ * import { withCliRunLog } from "../utils/cli-run-log.js";
15
+ *
16
+ * export async function fooCommand({ task, config, logger }) {
17
+ * return withCliRunLog("foo", { projectDir: config.projectDir }, async ({ runLog, forwardProgress }) => {
18
+ * runLog.logText(`[kj_foo] args — task="${task.slice(0, 80)}"`);
19
+ *
20
+ * const emitter = new EventEmitter();
21
+ * forwardProgress(emitter); // every emitter.emit("progress", event) also lands in run.log
22
+ *
23
+ * // ... run the work ...
24
+ * return { ok: true };
25
+ * });
26
+ * }
27
+ *
28
+ * The wrapper guarantees:
29
+ * - `.kj/run.log` is created/truncated at the start.
30
+ * - A `[kj_<cmd>] started` line is written immediately so kj-tail shows the
31
+ * command began (even if no emitter events follow — e.g. `kj audit` which
32
+ * streams agent output directly).
33
+ * - A `[kj_<cmd>] finished` (or `failed — <error>`) line is written at the
34
+ * end, even if the inner function throws.
35
+ * - The file descriptor is always closed.
36
+ * - `forwardProgress(emitter)` is a no-op when the command doesn't use an
37
+ * EventEmitter (e.g. one-shot agent calls). Commands that do emit
38
+ * progress events (like `kj run`) get the same per-event coverage as MCP.
39
+ */
40
+
41
+ import { createRunLog } from "./run-log.js";
42
+
43
+ /**
44
+ * Wrap a CLI command body with run.log lifecycle management.
45
+ *
46
+ * @template T
47
+ * @param {string} commandName - short slug, e.g. "run", "audit", "code"
48
+ * @param {Object} opts
49
+ * @param {string} [opts.projectDir] - project root; defaults to `process.cwd()`
50
+ * @param {Object} [opts.logger] - optional logger for close-time warnings
51
+ * @param {(ctx: {
52
+ * runLog: ReturnType<typeof createRunLog>,
53
+ * forwardProgress: (emitter: import("node:events").EventEmitter) => void,
54
+ * }) => Promise<T>} fn
55
+ * @returns {Promise<T>}
56
+ */
57
+ export async function withCliRunLog(commandName, opts, fn) {
58
+ const projectDir = opts?.projectDir || process.cwd();
59
+ const runLog = createRunLog(projectDir);
60
+
61
+ runLog.logText(`[kj_${commandName}] started (cli)`);
62
+
63
+ const forwardProgress = (emitter) => {
64
+ if (!emitter || typeof emitter.on !== "function") return;
65
+ emitter.on("progress", (event) => {
66
+ try {
67
+ runLog.logEvent(event);
68
+ } catch { /* best-effort */ }
69
+ });
70
+ };
71
+
72
+ try {
73
+ const result = await fn({ runLog, forwardProgress });
74
+ const ok = result && result.ok !== false;
75
+ runLog.logText(`[kj_${commandName}] finished — ok=${ok}`);
76
+ return result;
77
+ } catch (err) {
78
+ runLog.logText(`[kj_${commandName}] failed — ${err?.message || String(err)}`);
79
+ throw err;
80
+ } finally {
81
+ runLog.close();
82
+ }
83
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Load a task description from a file (.md / .txt / .markdown / any plain text).
3
+ *
4
+ * Motivation: for anything beyond a one-line task, writing a `kj run "long
5
+ * multi-paragraph prompt ..."` becomes painful — escaping quotes, losing
6
+ * markdown formatting, no history. `kj run --task-file my-task.md` reads the
7
+ * file's contents and uses them as the task description. Same for `kj plan`,
8
+ * `kj audit`, `kj code`, and their MCP counterparts (via the `taskFile`
9
+ * argument).
10
+ *
11
+ * Behaviour:
12
+ * - `taskFile` may be relative (resolved against `process.cwd()` by default,
13
+ * or against `projectDir` if supplied) or absolute.
14
+ * - Accepts any text file; markdown is the expected common case but we do
15
+ * not require a specific extension.
16
+ * - Trimmed of leading/trailing whitespace so agents don't get empty lines
17
+ * around the task.
18
+ * - `maxBytes` (default 256 KiB) guards against accidentally passing a huge
19
+ * file (log dumps, PDFs). Agents choke on very long prompts anyway.
20
+ * - Precedence with the positional `task` arg is decided by the caller:
21
+ * this helper only loads, never merges. Convention used across commands:
22
+ * when BOTH are provided, the positional task wins and the file is a
23
+ * fallback — with a log warning so the user notices.
24
+ */
25
+
26
+ import fs from "node:fs/promises";
27
+ import path from "node:path";
28
+
29
+ export const DEFAULT_TASK_FILE_MAX_BYTES = 256 * 1024;
30
+
31
+ /**
32
+ * @param {string} filePath - relative or absolute
33
+ * @param {Object} [opts]
34
+ * @param {string} [opts.projectDir] - base dir for relative paths; defaults to process.cwd()
35
+ * @param {number} [opts.maxBytes] - size cap; default 256 KiB
36
+ * @returns {Promise<string>} - the trimmed file contents (task text)
37
+ * @throws - Error with a user-readable message when the file is
38
+ * missing, unreadable, empty, or over the size cap.
39
+ */
40
+ export async function readTaskFile(filePath, opts = {}) {
41
+ if (!filePath || typeof filePath !== "string") {
42
+ throw new Error("readTaskFile: path must be a non-empty string");
43
+ }
44
+ const { projectDir, maxBytes = DEFAULT_TASK_FILE_MAX_BYTES } = opts;
45
+ const base = projectDir || process.cwd();
46
+ const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(base, filePath);
47
+
48
+ let stat;
49
+ try {
50
+ stat = await fs.stat(resolved);
51
+ } catch (err) {
52
+ throw new Error(`Task file not found: ${resolved} (${err.code || err.message})`);
53
+ }
54
+ if (!stat.isFile()) {
55
+ throw new Error(`Task file is not a regular file: ${resolved}`);
56
+ }
57
+ if (stat.size > maxBytes) {
58
+ const kb = Math.round(stat.size / 1024);
59
+ const capKb = Math.round(maxBytes / 1024);
60
+ throw new Error(
61
+ `Task file is too large: ${resolved} (${kb} KiB > ${capKb} KiB cap). ` +
62
+ `Summarise the task or raise the limit with the caller's maxBytes option.`
63
+ );
64
+ }
65
+
66
+ const raw = await fs.readFile(resolved, "utf8");
67
+ const trimmed = raw.trim();
68
+ if (!trimmed) {
69
+ throw new Error(`Task file is empty (or whitespace-only): ${resolved}`);
70
+ }
71
+ return trimmed;
72
+ }
73
+
74
+ /**
75
+ * Resolve the task text a command should use, given both positional `task`
76
+ * and an optional `taskFile`. Single source of truth for precedence across
77
+ * CLI and MCP entry points.
78
+ *
79
+ * Rules:
80
+ * - Neither provided → throw (caller decides the UX; typically propagates).
81
+ * - Only `task` → returns it, untouched.
82
+ * - Only `taskFile` → reads and returns it.
83
+ * - Both → `task` wins; emits a warning through `logger.warn` (if given).
84
+ *
85
+ * @param {Object} args
86
+ * @param {string|undefined} args.task
87
+ * @param {string|undefined} args.taskFile
88
+ * @param {string} [args.projectDir]
89
+ * @param {Object} [args.logger] - optional; uses `logger.warn(msg)` only
90
+ * @returns {Promise<string>}
91
+ */
92
+ export async function resolveTaskInput({ task, taskFile, projectDir, logger } = {}) {
93
+ const hasTask = typeof task === "string" && task.trim().length > 0;
94
+ const hasFile = typeof taskFile === "string" && taskFile.trim().length > 0;
95
+
96
+ if (!hasTask && !hasFile) {
97
+ throw new Error("No task provided. Pass a task argument or --task-file <path>.");
98
+ }
99
+ if (hasTask && hasFile) {
100
+ logger?.warn?.(
101
+ `Both task and --task-file given; using the positional task and ignoring file: ${taskFile}`
102
+ );
103
+ return task.trim();
104
+ }
105
+ if (hasFile) {
106
+ return readTaskFile(taskFile, { projectDir });
107
+ }
108
+ return task.trim();
109
+ }