@kynetic-ai/spec 0.7.0 → 0.9.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.
Files changed (86) hide show
  1. package/dist/agents/adapters.d.ts +2 -0
  2. package/dist/agents/adapters.d.ts.map +1 -1
  3. package/dist/agents/adapters.js +18 -0
  4. package/dist/agents/adapters.js.map +1 -1
  5. package/dist/agents/spawner.d.ts +2 -0
  6. package/dist/agents/spawner.d.ts.map +1 -1
  7. package/dist/agents/spawner.js +4 -2
  8. package/dist/agents/spawner.js.map +1 -1
  9. package/dist/cli/commands/ralph.d.ts +48 -0
  10. package/dist/cli/commands/ralph.d.ts.map +1 -1
  11. package/dist/cli/commands/ralph.js +344 -86
  12. package/dist/cli/commands/ralph.js.map +1 -1
  13. package/dist/cli/commands/session/commands.d.ts.map +1 -1
  14. package/dist/cli/commands/session/commands.js +8 -0
  15. package/dist/cli/commands/session/commands.js.map +1 -1
  16. package/dist/cli/commands/session/compact.d.ts +13 -0
  17. package/dist/cli/commands/session/compact.d.ts.map +1 -0
  18. package/dist/cli/commands/session/compact.js +207 -0
  19. package/dist/cli/commands/session/compact.js.map +1 -0
  20. package/dist/cli/commands/session/log.d.ts +2 -0
  21. package/dist/cli/commands/session/log.d.ts.map +1 -1
  22. package/dist/cli/commands/session/log.js +12 -2
  23. package/dist/cli/commands/session/log.js.map +1 -1
  24. package/dist/cli/commands/setup-seeding.d.ts +6 -3
  25. package/dist/cli/commands/setup-seeding.d.ts.map +1 -1
  26. package/dist/cli/commands/setup-seeding.js +20 -4
  27. package/dist/cli/commands/setup-seeding.js.map +1 -1
  28. package/dist/cli/commands/setup.d.ts +3 -2
  29. package/dist/cli/commands/setup.d.ts.map +1 -1
  30. package/dist/cli/commands/setup.js +10 -90
  31. package/dist/cli/commands/setup.js.map +1 -1
  32. package/dist/cli/commands/skill-install.d.ts.map +1 -1
  33. package/dist/cli/commands/skill-install.js +104 -1
  34. package/dist/cli/commands/skill-install.js.map +1 -1
  35. package/dist/lib/codex-config.d.ts +14 -0
  36. package/dist/lib/codex-config.d.ts.map +1 -0
  37. package/dist/lib/codex-config.js +88 -0
  38. package/dist/lib/codex-config.js.map +1 -0
  39. package/dist/parser/agent-detection.d.ts +14 -0
  40. package/dist/parser/agent-detection.d.ts.map +1 -0
  41. package/dist/parser/agent-detection.js +118 -0
  42. package/dist/parser/agent-detection.js.map +1 -0
  43. package/dist/parser/setup-status.d.ts +4 -3
  44. package/dist/parser/setup-status.d.ts.map +1 -1
  45. package/dist/parser/setup-status.js +4 -10
  46. package/dist/parser/setup-status.js.map +1 -1
  47. package/dist/parser/shadow.d.ts.map +1 -1
  48. package/dist/parser/shadow.js +22 -31
  49. package/dist/parser/shadow.js.map +1 -1
  50. package/dist/parser/skill-render.d.ts +23 -1
  51. package/dist/parser/skill-render.d.ts.map +1 -1
  52. package/dist/parser/skill-render.js +126 -17
  53. package/dist/parser/skill-render.js.map +1 -1
  54. package/dist/ralph/subagent.d.ts +2 -0
  55. package/dist/ralph/subagent.d.ts.map +1 -1
  56. package/dist/ralph/subagent.js +2 -0
  57. package/dist/ralph/subagent.js.map +1 -1
  58. package/dist/ralph/wrap-up.d.ts +2 -0
  59. package/dist/ralph/wrap-up.d.ts.map +1 -1
  60. package/dist/ralph/wrap-up.js +1 -0
  61. package/dist/ralph/wrap-up.js.map +1 -1
  62. package/dist/sessions/store.d.ts +67 -0
  63. package/dist/sessions/store.d.ts.map +1 -1
  64. package/dist/sessions/store.js +396 -16
  65. package/dist/sessions/store.js.map +1 -1
  66. package/package.json +2 -1
  67. package/plugin/.claude-plugin/marketplace.json +1 -1
  68. package/plugin/.claude-plugin/plugin.json +1 -1
  69. package/plugin/plugins/kspec/skills/create-workflow/SKILL.md +10 -0
  70. package/plugin/plugins/kspec/skills/plan/SKILL.md +10 -0
  71. package/plugin/plugins/kspec/skills/review/SKILL.md +2 -0
  72. package/plugin/plugins/kspec/skills/task-work/SKILL.md +10 -0
  73. package/plugin/plugins/kspec/skills/triage-automation/SKILL.md +1 -0
  74. package/templates/skills/create-workflow/SKILL.md +12 -2
  75. package/templates/skills/manifest.yaml +11 -0
  76. package/templates/skills/observations/SKILL.md +2 -2
  77. package/templates/skills/plan/SKILL.md +15 -5
  78. package/templates/skills/reflect/SKILL.md +1 -1
  79. package/templates/skills/review/SKILL.md +4 -2
  80. package/templates/skills/task-work/SKILL.md +16 -6
  81. package/templates/skills/triage/SKILL.md +1 -1
  82. package/templates/skills/triage/docs/inbox.md +1 -1
  83. package/templates/skills/triage/docs/observations.md +1 -1
  84. package/templates/skills/triage-automation/SKILL.md +2 -1
  85. package/templates/skills/triage-inbox/SKILL.md +3 -3
  86. package/templates/skills/writing-specs/SKILL.md +6 -6
@@ -5,6 +5,7 @@
5
5
  * Uses session event storage for full audit trail and streaming output.
6
6
  */
7
7
  import { spawn, spawnSync } from "node:child_process";
8
+ import { createWriteStream } from "node:fs";
8
9
  import * as fs from "node:fs/promises";
9
10
  import { createRequire } from "node:module";
10
11
  import * as path from "node:path";
@@ -15,9 +16,10 @@ const require = createRequire(import.meta.url);
15
16
  const { version: packageVersion } = require("../../../package.json");
16
17
  import { registerAdapter, resolveAdapter, } from "../../agents/index.js";
17
18
  import { spawnAndInitialize } from "../../agents/spawner.js";
18
- import { initContext, loadAllItems, loadAllTasks, ReferenceIndex, } from "../../parser/index.js";
19
+ import { initContext, loadAllItems, loadMetaContext, loadAllTasks, ReferenceIndex, } from "../../parser/index.js";
20
+ import { resolveSkillReferenceTokensForPlatform } from "../../parser/skill-render.js";
19
21
  import { buildWrapUpContext, createCliRenderer, createTranslator, DEFAULT_SUBAGENT_PREFIX, DEFAULT_WRAPUP_TIMEOUT, RALPH_PROMPT_TIMEOUT, runSubagent, runWrapUpAgent, WRAPUP_AGENT_PREFIX, } from "../../ralph/index.js";
20
- import { appendEvent, closeSession, createSessionWithBudget, getSessionBudgetPath, injectEnvForAdapter, isEndLoopRequested, removeEnvForAdapter, requestEndLoop, resetBudget, saveSessionContext, } from "../../sessions/index.js";
22
+ import { appendEvent, closeSession, createSessionWithBudget, getSessionBudgetPath, getSessionDir, injectEnvForAdapter, isEndLoopRequested, removeEnvForAdapter, requestEndLoop, resetBudget, saveSessionContext, } from "../../sessions/index.js";
21
23
  import { errors } from "../../strings/index.js";
22
24
  import { getCurrentBranch } from "../../utils/git.js";
23
25
  import { EXIT_CODES } from "../exit-codes.js";
@@ -91,7 +93,79 @@ async function allExplicitTasksDone(ctx, scope) {
91
93
  });
92
94
  return { done, statuses };
93
95
  }
94
- // ─── Prompt Template ─────────────────────────────────────────────────────────
96
+ const FALLBACK_CORE_SKILLS = new Set(["task-work", "reflect", "review"]);
97
+ const ADAPTER_VALIDATION_PROBES = [["--help"], ["--version"]];
98
+ const TERMINAL_PREVIEW_MAX_BYTES = 64 * 1024;
99
+ const TOOL_OUTPUT_DIR = "tool-output";
100
+ /**
101
+ * Map adapter IDs to prompt rendering platforms.
102
+ */
103
+ export function getPromptPlatformForAdapter(adapterId) {
104
+ switch (adapterId) {
105
+ case "claude-agent-acp":
106
+ case "claude-code-acp":
107
+ return "claude-code";
108
+ case "codex-acp":
109
+ return "codex";
110
+ default:
111
+ return "unknown";
112
+ }
113
+ }
114
+ /**
115
+ * Build skill origin map from meta skills.
116
+ */
117
+ async function loadSkillOriginsForRalph(ctx) {
118
+ const meta = await loadMetaContext(ctx);
119
+ const origins = new Map();
120
+ for (const skill of meta.skills) {
121
+ origins.set(skill.id, skill.origin);
122
+ }
123
+ // Fallback for core skills frequently used by ralph, even if core skills
124
+ // were not loaded into project meta for any reason.
125
+ for (const coreSkill of FALLBACK_CORE_SKILLS) {
126
+ if (!origins.has(coreSkill)) {
127
+ origins.set(coreSkill, "core");
128
+ }
129
+ }
130
+ return origins;
131
+ }
132
+ /**
133
+ * Normalize legacy literal invocation syntax for a target platform.
134
+ * Keeps backward compatibility for existing slash-style config values.
135
+ */
136
+ function normalizeLegacyInvocation(invocation, platform) {
137
+ if (platform === "codex") {
138
+ if (/^\/kspec:([a-z0-9][a-z0-9-]*)$/.test(invocation)) {
139
+ return invocation.replace(/^\/kspec:([a-z0-9][a-z0-9-]*)$/, (_m, skillId) => `$kspec-${skillId}`);
140
+ }
141
+ if (/^\/([a-z0-9][a-z0-9-]*)$/.test(invocation)) {
142
+ return invocation.replace(/^\/([a-z0-9][a-z0-9-]*)$/, (_m, skillId) => `$${skillId}`);
143
+ }
144
+ }
145
+ if (platform === "claude-code") {
146
+ if (/^\$kspec-([a-z0-9][a-z0-9-]*)$/.test(invocation)) {
147
+ return invocation.replace(/^\$kspec-([a-z0-9][a-z0-9-]*)$/, (_m, skillId) => `/kspec:${skillId}`);
148
+ }
149
+ if (/^\$([a-z0-9][a-z0-9-]*)$/.test(invocation)) {
150
+ return invocation.replace(/^\$([a-z0-9][a-z0-9-]*)$/, (_m, skillId) => `/${skillId}`);
151
+ }
152
+ }
153
+ return invocation;
154
+ }
155
+ /**
156
+ * Resolve configured skill invocation string for a specific platform.
157
+ * Supports portable {skill:<id>} syntax and legacy literal strings.
158
+ */
159
+ export function resolveRalphSkillInvocation(invocation, platform, skillOrigins) {
160
+ if (platform === "unknown") {
161
+ return invocation;
162
+ }
163
+ const tokenResolved = resolveSkillReferenceTokensForPlatform(invocation, platform, skillOrigins);
164
+ if (tokenResolved !== invocation) {
165
+ return tokenResolved;
166
+ }
167
+ return normalizeLegacyInvocation(invocation, platform);
168
+ }
95
169
  // AC: @ralph-skill-delegation ac-1, ac-2, ac-3
96
170
  function buildTaskWorkPrompt(sessionCtx, iteration, maxLoops, sessionId, skillTaskWork, focus, explicitTaskScope) {
97
171
  const focusSection = focus
@@ -174,46 +248,182 @@ ${isFinal
174
248
  Exit when reflection is complete.
175
249
  `;
176
250
  }
177
- // ─── Streaming Output ────────────────────────────────────────────────────────
178
- // Translator and renderer are created per-session in the action handler.
179
- // This allows the architecture to be reused by future TUI renderers.
180
- // ─── Adapter Validation ──────────────────────────────────────────────────────
181
- // AC: @ralph-adapter-validation valid-adapter-proceeds, invalid-adapter-error, validation-before-spawn
251
+ /**
252
+ * Check whether an adapter package appears to be installed and executable.
253
+ * Uses multiple non-installing probes because CLIs differ on supported flags.
254
+ */
255
+ export function isAdapterPackageAvailable(adapterPackage, runner = spawnSync) {
256
+ for (const probeArgs of ADAPTER_VALIDATION_PROBES) {
257
+ const result = runner("npx", ["--no-install", adapterPackage, ...probeArgs], {
258
+ encoding: "utf-8",
259
+ stdio: "pipe",
260
+ });
261
+ if (result.status === 0) {
262
+ return true;
263
+ }
264
+ }
265
+ return false;
266
+ }
182
267
  /**
183
268
  * Validate that the specified ACP adapter package exists.
184
- * Uses npx --no-install to check both global and local node_modules.
269
+ * Uses npx --no-install probes to check both global and local node_modules.
185
270
  *
186
271
  * @throws {Error} Never throws - exits process with code 3 if validation fails
187
272
  */
188
273
  function validateAdapter(adapterPackage) {
189
- // Use npx --no-install with --version to check if package exists
190
- // This checks both global and local node_modules, handles scoped packages
191
- const result = spawnSync("npx", ["--no-install", adapterPackage, "--version"], {
192
- encoding: "utf-8",
193
- stdio: "pipe",
194
- });
195
- if (result.status !== 0) {
274
+ if (!isAdapterPackageAvailable(adapterPackage)) {
196
275
  error(`Adapter package not found: ${adapterPackage}. Install with: npm install -g ${adapterPackage}`);
197
276
  process.exit(EXIT_CODES.NOT_FOUND);
198
277
  }
199
278
  }
279
+ function sanitizeToolCallId(toolCallId) {
280
+ const raw = String(toolCallId).trim();
281
+ if (!raw) {
282
+ return "tool-call";
283
+ }
284
+ return raw.replace(/[^a-zA-Z0-9._-]/g, "_");
285
+ }
286
+ function updateStreamPreview(state, chunk, maxPreviewBytes) {
287
+ state.bytes += chunk.length;
288
+ const remaining = maxPreviewBytes - state.previewBytes;
289
+ if (remaining <= 0) {
290
+ state.truncated = true;
291
+ return;
292
+ }
293
+ if (chunk.length > remaining) {
294
+ state.previewParts.push(chunk.subarray(0, remaining).toString("utf-8"));
295
+ state.previewBytes += remaining;
296
+ state.truncated = true;
297
+ return;
298
+ }
299
+ state.previewParts.push(chunk.toString("utf-8"));
300
+ state.previewBytes += chunk.length;
301
+ }
302
+ function closeStream(stream) {
303
+ if (!stream) {
304
+ return Promise.resolve();
305
+ }
306
+ return new Promise((resolve, reject) => {
307
+ const onError = (err) => {
308
+ stream.off("finish", onFinish);
309
+ reject(err);
310
+ };
311
+ const onFinish = () => {
312
+ stream.off("error", onError);
313
+ resolve();
314
+ };
315
+ stream.once("error", onError);
316
+ stream.once("finish", onFinish);
317
+ stream.end();
318
+ });
319
+ }
320
+ /**
321
+ * Execute terminal/run request with bounded in-memory preview and streamed
322
+ * session artifacts for full stdout/stderr retention.
323
+ */
324
+ export async function runTerminalCommandWithArtifacts(options) {
325
+ const previewMaxBytes = options.previewMaxBytes ?? TERMINAL_PREVIEW_MAX_BYTES;
326
+ const shouldWriteArtifacts = Boolean(options.specDir && options.sessionId);
327
+ let stdoutPath;
328
+ let stderrPath;
329
+ if (shouldWriteArtifacts) {
330
+ const outputDir = path.join(getSessionDir(options.specDir, options.sessionId), TOOL_OUTPUT_DIR);
331
+ await fs.mkdir(outputDir, { recursive: true });
332
+ const safeToolCallId = sanitizeToolCallId(options.toolCallId);
333
+ stdoutPath = path.join(outputDir, `${safeToolCallId}.stdout.log`);
334
+ stderrPath = path.join(outputDir, `${safeToolCallId}.stderr.log`);
335
+ }
336
+ const stdoutState = {
337
+ bytes: 0,
338
+ previewBytes: 0,
339
+ previewParts: [],
340
+ truncated: false,
341
+ stream: stdoutPath ? createWriteStream(stdoutPath) : undefined,
342
+ };
343
+ const stderrState = {
344
+ bytes: 0,
345
+ previewBytes: 0,
346
+ previewParts: [],
347
+ truncated: false,
348
+ stream: stderrPath ? createWriteStream(stderrPath) : undefined,
349
+ };
350
+ return await new Promise((resolve, reject) => {
351
+ let settled = false;
352
+ const child = spawn(options.command, [], {
353
+ cwd: options.cwd,
354
+ shell: true,
355
+ timeout: options.timeout,
356
+ });
357
+ const finalize = async (exitCode, errorMessage) => {
358
+ if (settled) {
359
+ return;
360
+ }
361
+ settled = true;
362
+ if (errorMessage) {
363
+ const errChunk = Buffer.from(errorMessage, "utf-8");
364
+ stderrState.stream?.write(errChunk);
365
+ updateStreamPreview(stderrState, errChunk, previewMaxBytes);
366
+ }
367
+ try {
368
+ await Promise.all([
369
+ closeStream(stdoutState.stream),
370
+ closeStream(stderrState.stream),
371
+ ]);
372
+ }
373
+ catch (streamErr) {
374
+ reject(streamErr);
375
+ return;
376
+ }
377
+ resolve({
378
+ stdout: stdoutState.previewParts.join(""),
379
+ stderr: stderrState.previewParts.join(""),
380
+ exitCode,
381
+ stdout_path: stdoutPath,
382
+ stderr_path: stderrPath,
383
+ stdout_bytes: stdoutState.bytes,
384
+ stderr_bytes: stderrState.bytes,
385
+ preview_truncated: stdoutState.truncated || stderrState.truncated,
386
+ });
387
+ };
388
+ child.stdout?.on("data", (data) => {
389
+ const chunk = Buffer.isBuffer(data)
390
+ ? data
391
+ : Buffer.from(String(data), "utf-8");
392
+ stdoutState.stream?.write(chunk);
393
+ updateStreamPreview(stdoutState, chunk, previewMaxBytes);
394
+ });
395
+ child.stderr?.on("data", (data) => {
396
+ const chunk = Buffer.isBuffer(data)
397
+ ? data
398
+ : Buffer.from(String(data), "utf-8");
399
+ stderrState.stream?.write(chunk);
400
+ updateStreamPreview(stderrState, chunk, previewMaxBytes);
401
+ });
402
+ child.on("close", (code) => {
403
+ void finalize(code ?? 1);
404
+ });
405
+ child.on("error", (err) => {
406
+ void finalize(1, err.message);
407
+ });
408
+ });
409
+ }
200
410
  // ─── Tool Request Handler ────────────────────────────────────────────────────
201
411
  /**
202
412
  * Handle tool requests from ACP agent.
203
413
  * Implements file operations, terminal commands, and permission handling.
204
414
  */
205
- async function handleRequest(client, id, method, params, yolo) {
415
+ async function handleRequest(client, id, method, params, options) {
206
416
  try {
207
417
  switch (method) {
208
418
  case "session/request_permission": {
209
419
  const p = params;
210
420
  // In yolo mode, auto-approve all permissions
211
421
  // In normal mode, would need to implement permission UI
212
- const options = p.options || [];
213
- if (yolo) {
422
+ const permissionOptions = p.options || [];
423
+ if (options.yolo) {
214
424
  // Find an "allow" option (prefer allow_always, then allow_once)
215
- const allowOption = options.find((o) => o.kind === "allow_always") ||
216
- options.find((o) => o.kind === "allow_once");
425
+ const allowOption = permissionOptions.find((o) => o.kind === "allow_always") ||
426
+ permissionOptions.find((o) => o.kind === "allow_once");
217
427
  if (allowOption) {
218
428
  client.respondPermission(id, {
219
429
  outcome: { outcome: "selected", optionId: allowOption.optionId },
@@ -250,26 +460,13 @@ async function handleRequest(client, id, method, params, yolo) {
250
460
  const command = p.command;
251
461
  const cwd = p.cwd || process.cwd();
252
462
  const timeout = p.timeout || 60000;
253
- const result = await new Promise((resolve) => {
254
- const child = spawn(command, [], {
255
- cwd,
256
- shell: true,
257
- timeout,
258
- });
259
- let stdout = "";
260
- let stderr = "";
261
- child.stdout?.on("data", (data) => {
262
- stdout += data.toString();
263
- });
264
- child.stderr?.on("data", (data) => {
265
- stderr += data.toString();
266
- });
267
- child.on("close", (code) => {
268
- resolve({ stdout, stderr, exitCode: code ?? 1 });
269
- });
270
- child.on("error", (err) => {
271
- resolve({ stdout, stderr: err.message, exitCode: 1 });
272
- });
463
+ const result = await runTerminalCommandWithArtifacts({
464
+ command,
465
+ cwd,
466
+ timeout,
467
+ toolCallId: id,
468
+ specDir: options.specDir,
469
+ sessionId: options.sessionId,
273
470
  });
274
471
  // Using generic respond() since this is a custom method
275
472
  client.respond(id, result);
@@ -488,11 +685,16 @@ async function processPendingReviewTasks(ctx, adapter, pendingReviewTasks, optio
488
685
  const result = await runSubagent(adapter, subagentCtx, {
489
686
  timeout: options.subagentTimeout,
490
687
  outputPrefix: DEFAULT_SUBAGENT_PREFIX,
491
- skillName: ctx.config.ralph.skills.pr_review,
688
+ skillName: options.prReviewSkillName,
492
689
  }, {
493
690
  yolo: options.yolo,
494
691
  cwd: options.cwd,
495
- handleRequest: (client, reqId, method, params) => handleRequest(client, reqId, method, params, options.yolo),
692
+ extraArgs: options.autoApproveArgs,
693
+ handleRequest: (client, reqId, method, params) => handleRequest(client, reqId, method, params, {
694
+ yolo: options.yolo,
695
+ specDir: options.specDir,
696
+ sessionId: options.sessionId,
697
+ }),
496
698
  });
497
699
  if (result.timedOut) {
498
700
  // AC: @ralph-subagent-spawning ac-9
@@ -612,6 +814,8 @@ export function registerRalphCommand(program) {
612
814
  .option("--no-yolo", "Require normal permission prompts")
613
815
  .option("--subagent-timeout <minutes>", "Review subagent timeout in minutes", "20")
614
816
  .option("--adapter <id>", "Agent adapter to use", "claude-agent-acp")
817
+ .option("--worker-adapter <id>", "Adapter for task-work agent (overrides --adapter)")
818
+ .option("--reviewer-adapter <id>", "Adapter for review subagent (overrides --adapter)")
615
819
  .option("--adapter-cmd <cmd>", "Custom adapter command (for testing)")
616
820
  .option("--restart-every <n>", "Restart agent every N iterations to prevent OOM (0 = never)", "10")
617
821
  .option("--focus <instructions>", "Focus instructions included in every iteration prompt")
@@ -679,28 +883,32 @@ export function registerRalphCommand(program) {
679
883
  registerAdapter("custom", customAdapter);
680
884
  options.adapter = "custom";
681
885
  }
682
- // Resolve adapter
683
- const adapter = resolveAdapter(options.adapter);
684
- // Validate adapter package exists before proceeding
685
- // Skip validation for:
686
- // - Custom adapters (--adapter-cmd)
687
- // - Non-npx adapters
688
- // - Dry-run mode with default adapter (doesn't spawn agent, default may not be installed in CI)
689
- // Note: If user explicitly specifies --adapter, validate even in dry-run to catch typos
690
- // Accept both new and deprecated adapter names
691
- const isDefaultAdapter = options.adapter === "claude-agent-acp" ||
692
- options.adapter === "claude-code-acp";
693
- const skipValidation = options.adapterCmd ||
694
- adapter.command !== "npx" ||
695
- !adapter.args[0] ||
696
- (options.dryRun && isDefaultAdapter);
697
- if (!skipValidation) {
698
- validateAdapter(adapter.args[0]);
699
- }
700
- // Add yolo flag to adapter args if needed (accept both new and deprecated names)
701
- if (options.yolo && isDefaultAdapter) {
702
- adapter.args = [...adapter.args, "--dangerously-skip-permissions"];
886
+ // AC: @ralph-per-role-adapters ac-3, ac-4, ac-5
887
+ // Resolve per-role adapters with precedence: role flag > --adapter > default
888
+ const workerAdapterId = options.workerAdapter ?? options.adapter;
889
+ const reviewerAdapterId = options.reviewerAdapter ?? options.adapter;
890
+ const workerAdapter = resolveAdapter(workerAdapterId);
891
+ const reviewerAdapter = resolveAdapter(reviewerAdapterId);
892
+ // AC: @ralph-per-role-adapters ac-6, ac-9, ac-11
893
+ // Validate adapter packages deduplicate when same ID
894
+ const adapterIdsToValidate = new Set([workerAdapterId, reviewerAdapterId]);
895
+ for (const id of adapterIdsToValidate) {
896
+ const resolved = resolveAdapter(id);
897
+ const isDefault = id === "claude-agent-acp" || id === "claude-code-acp";
898
+ const skip = resolved.command !== "npx" ||
899
+ !resolved.args[0] ||
900
+ (options.dryRun && isDefault);
901
+ if (!skip) {
902
+ validateAdapter(resolved.args[0]);
903
+ }
703
904
  }
905
+ // Build auto-approve extra args per adapter (applied per-spawn to prevent cross-role leakage)
906
+ const workerAutoApproveArgs = options.yolo
907
+ ? workerAdapter.autoApproveArgs
908
+ : undefined;
909
+ const reviewerAutoApproveArgs = options.yolo
910
+ ? reviewerAdapter.autoApproveArgs
911
+ : undefined;
704
912
  const restartInfo = restartEvery > 0 ? `, restart every ${restartEvery}` : "";
705
913
  const maxTasksInfo = maxTasks === 0 ? "unlimited" : `${maxTasks}`;
706
914
  // Initialize kspec context early to validate --tasks
@@ -717,10 +925,19 @@ export function registerRalphCommand(program) {
717
925
  process.exit(EXIT_CODES.VALIDATION_FAILED);
718
926
  }
719
927
  }
928
+ const skillOrigins = await loadSkillOriginsForRalph(ctx);
929
+ const workerPromptPlatform = getPromptPlatformForAdapter(workerAdapterId);
930
+ const reviewerPromptPlatform = getPromptPlatformForAdapter(reviewerAdapterId);
931
+ const workerTaskWorkSkill = resolveRalphSkillInvocation(ctx.config.ralph.skills.task_work, workerPromptPlatform, skillOrigins);
932
+ const workerReflectSkill = resolveRalphSkillInvocation(ctx.config.ralph.skills.reflect, workerPromptPlatform, skillOrigins);
933
+ const reviewerPrReviewSkill = resolveRalphSkillInvocation(ctx.config.ralph.skills.pr_review, reviewerPromptPlatform, skillOrigins);
720
934
  const taskScopeInfo = explicitTaskScope
721
935
  ? `, tasks=${explicitTaskScope.refs.join(",")}`
722
936
  : "";
723
- info(`Starting ralph loop (adapter=${options.adapter}, max ${maxLoops} iterations, ${maxRetries} retries, ${maxFailures} max failures${restartInfo}, max-tasks=${maxTasksInfo}${taskScopeInfo})`);
937
+ const adapterInfo = workerAdapterId === reviewerAdapterId
938
+ ? `adapter=${workerAdapterId}`
939
+ : `worker=${workerAdapterId}, reviewer=${reviewerAdapterId}`;
940
+ info(`Starting ralph loop (${adapterInfo}, max ${maxLoops} iterations, ${maxRetries} retries, ${maxFailures} max failures${restartInfo}, max-tasks=${maxTasksInfo}${taskScopeInfo})`);
724
941
  if (options.focus) {
725
942
  info(`Focus: ${options.focus}`);
726
943
  }
@@ -738,12 +955,20 @@ export function registerRalphCommand(program) {
738
955
  // Create session with budget. When maxTasks=0 (unlimited), no budget.json is created.
739
956
  await createSessionWithBudget(specDir, {
740
957
  id: sessionId,
741
- agent_type: options.adapter,
958
+ agent_type: workerAdapterId,
742
959
  budget: maxTasks,
743
960
  });
744
- // Adapter ID for harness-specific env injection/cleanup.
745
- // Declared before try/finally so signal handlers and finally block can access it.
746
- const adapterId = options.adapter || "claude-agent-acp";
961
+ // AC: @ralph-per-role-adapters ac-6, ac-7
962
+ // Adapter IDs for harness-specific env injection/cleanup.
963
+ // Deduplicate by harness target, not just adapter ID. claude-code-acp is
964
+ // an alias for claude-agent-acp — both inject to the same Claude Code
965
+ // settings file. Without normalization, injecting twice would clobber the
966
+ // previousValue and break cleanup restoration.
967
+ const normalizeForEnv = (id) => id === "claude-code-acp" ? "claude-agent-acp" : id;
968
+ const uniqueAdapterIds = [...new Set([
969
+ normalizeForEnv(workerAdapterId),
970
+ normalizeForEnv(reviewerAdapterId),
971
+ ])];
747
972
  // Everything after session creation is wrapped in try/finally to guarantee
748
973
  // budget cleanup even if pre-loop setup (event logging, signal handlers) throws.
749
974
  // AC: @ralph-session-budget-integration ac-session-close-all-paths
@@ -753,7 +978,9 @@ export function registerRalphCommand(program) {
753
978
  let exitReason = null;
754
979
  let lastIterationCtx = null;
755
980
  let lastErrorMessage;
756
- let previousEnvValue; // For restoring pre-existing KSPEC_SESSION_ID
981
+ // AC: @ralph-per-role-adapters ac-7
982
+ // Track previous env values per adapter for cleanup restoration
983
+ const previousEnvValues = new Map();
757
984
  const recentTaskRefs = [];
758
985
  const sessionIterationMap = new Map();
759
986
  // Signal handler refs — declared here so finally can remove them
@@ -772,7 +999,7 @@ export function registerRalphCommand(program) {
772
999
  await Promise.all([
773
1000
  fs.unlink(getSessionBudgetPath(specDir, sessionId)).catch(() => { }),
774
1001
  closeSession(specDir, sessionId, "abandoned", `Received ${signal}`),
775
- removeEnvForAdapter(adapterId, previousEnvValue),
1002
+ ...uniqueAdapterIds.map((id) => removeEnvForAdapter(id, previousEnvValues.get(id))),
776
1003
  ]);
777
1004
  }
778
1005
  catch {
@@ -792,19 +1019,24 @@ export function registerRalphCommand(program) {
792
1019
  // AC: @ralph-session-budget-integration ac-session-close-all-paths
793
1020
  process.on("SIGINT", sigintHandler);
794
1021
  process.on("SIGTERM", sigtermHandler);
795
- // Inject KSPEC_SESSION_ID into agent harness config so it reaches child
796
- // processes. Inside try/finally so cleanup runs even if injection fails.
1022
+ // AC: @ralph-per-role-adapters ac-6, ac-7
1023
+ // Inject KSPEC_SESSION_ID into agent harness config for each unique adapter.
797
1024
  // Process env alone is insufficient — some harnesses (e.g., Claude Code)
798
1025
  // sandbox child processes and don't forward arbitrary parent env vars.
799
1026
  // AC: @ralph-session-budget-integration ac-env-inject
800
- const injectionResult = await injectEnvForAdapter(adapterId, sessionId);
801
- previousEnvValue = injectionResult?.previousValue;
802
- // Log session start
1027
+ for (const id of uniqueAdapterIds) {
1028
+ const injectionResult = await injectEnvForAdapter(id, sessionId);
1029
+ previousEnvValues.set(id, injectionResult?.previousValue);
1030
+ }
1031
+ // AC: @ralph-per-role-adapters ac-12
1032
+ // Log session start with both adapter IDs
803
1033
  await appendEvent(specDir, {
804
1034
  session_id: sessionId,
805
1035
  type: "session.start",
806
1036
  data: {
807
- adapter: options.adapter,
1037
+ adapter: workerAdapterId,
1038
+ workerAdapter: workerAdapterId,
1039
+ reviewerAdapter: reviewerAdapterId,
808
1040
  maxLoops,
809
1041
  maxRetries,
810
1042
  maxFailures,
@@ -841,14 +1073,19 @@ export function registerRalphCommand(program) {
841
1073
  sessionCtx = filterByExplicitTasks(sessionCtx, explicitTaskScope);
842
1074
  }
843
1075
  // AC: @ralph-subagent-spawning ac-8 - Process pending_review tasks BEFORE main iteration
1076
+ // AC: @ralph-per-role-adapters ac-2 - Use reviewer adapter for review subagents
844
1077
  // This wraps consecutiveFailures in an object so it can be mutated by the helper
845
1078
  const failureTracker = { count: consecutiveFailures };
846
- const continueLoop = await processPendingReviewTasks(ctx, adapter, sessionCtx.pending_review_tasks, {
1079
+ const continueLoop = await processPendingReviewTasks(ctx, reviewerAdapter, sessionCtx.pending_review_tasks, {
847
1080
  yolo: options.yolo,
848
1081
  maxRetries,
849
1082
  maxFailures,
850
1083
  cwd: process.cwd(),
1084
+ specDir,
1085
+ sessionId,
851
1086
  subagentTimeout: subagentTimeout * 60 * 1000,
1087
+ autoApproveArgs: reviewerAutoApproveArgs,
1088
+ prReviewSkillName: reviewerPrReviewSkill,
852
1089
  }, failureTracker);
853
1090
  consecutiveFailures = failureTracker.count;
854
1091
  if (!continueLoop) {
@@ -902,16 +1139,22 @@ export function registerRalphCommand(program) {
902
1139
  const iterationStartTime = new Date();
903
1140
  // Build prompts - task-work first, then reflect
904
1141
  // AC: @cli-ralph ac-21 - Include explicit task scope in prompt
905
- const taskWorkPrompt = buildTaskWorkPrompt(currentCtx, iteration, maxLoops, sessionId, ctx.config.ralph.skills.task_work, options.focus, explicitTaskScope);
906
- const reflectPrompt = buildReflectPrompt(iteration, maxLoops, sessionId, ctx.config.ralph.skills.reflect);
1142
+ const taskWorkPrompt = buildTaskWorkPrompt(currentCtx, iteration, maxLoops, sessionId, workerTaskWorkSkill, options.focus, explicitTaskScope);
1143
+ const reflectPrompt = buildReflectPrompt(iteration, maxLoops, sessionId, workerReflectSkill);
907
1144
  // AC: @cli-ralph ac-21
1145
+ // AC: @ralph-per-role-adapters ac-10
908
1146
  if (options.dryRun) {
909
1147
  console.log(chalk.yellow("=== DRY RUN - Configuration ===\n"));
1148
+ console.log(` worker-adapter: ${workerAdapterId}`);
1149
+ console.log(` reviewer-adapter: ${reviewerAdapterId}`);
910
1150
  console.log(` max-loops: ${maxLoops}`);
911
1151
  console.log(` max-tasks: ${maxTasks === 0 ? "unlimited" : maxTasks}`);
912
1152
  console.log(` max-retries: ${maxRetries}`);
913
1153
  console.log(` max-failures: ${maxFailures}`);
914
1154
  console.log(` restart-every: ${restartEvery === 0 ? "never" : restartEvery}`);
1155
+ console.log(` worker-task-work-skill: ${workerTaskWorkSkill}`);
1156
+ console.log(` worker-reflect-skill: ${workerReflectSkill}`);
1157
+ console.log(` reviewer-pr-review-skill: ${reviewerPrReviewSkill}`);
915
1158
  if (explicitTaskScope) {
916
1159
  console.log(` explicit-tasks: ${explicitTaskScope.refs.join(", ")}`);
917
1160
  }
@@ -945,12 +1188,15 @@ export function registerRalphCommand(program) {
945
1188
  }
946
1189
  try {
947
1190
  // Spawn agent if not already running
1191
+ // AC: @ralph-per-role-adapters ac-1 - Use worker adapter for task-work
948
1192
  if (!agent) {
949
1193
  info("Spawning ACP agent...");
950
1194
  // AC: @ralph-session-budget-integration ac-env-inject
951
- agent = await spawnAndInitialize(adapter, {
1195
+ // AC: @ralph-adapter-auto-approve ac-1, ac-2, ac-3
1196
+ agent = await spawnAndInitialize(workerAdapter, {
952
1197
  cwd: process.cwd(),
953
1198
  env: { KSPEC_SESSION_ID: sessionId },
1199
+ extraArgs: workerAutoApproveArgs,
954
1200
  clientOptions: {
955
1201
  clientInfo: {
956
1202
  name: "kspec-ralph",
@@ -984,7 +1230,11 @@ export function registerRalphCommand(program) {
984
1230
  // Set up tool request handler
985
1231
  agent.client.on("request", (reqId, method, params) => {
986
1232
  // biome-ignore lint/style/noNonNullAssertion: agent is guaranteed to exist when callback is registered
987
- handleRequest(agent.client, reqId, method, params, options.yolo).catch((err) => {
1233
+ handleRequest(agent.client, reqId, method, params, {
1234
+ yolo: options.yolo,
1235
+ specDir,
1236
+ sessionId,
1237
+ }).catch((err) => {
988
1238
  // biome-ignore lint/style/noNonNullAssertion: agent is guaranteed to exist when callback is registered
989
1239
  agent.client.respondError(reqId, -32000, err.message);
990
1240
  });
@@ -1127,9 +1377,11 @@ export function registerRalphCommand(program) {
1127
1377
  agent = null;
1128
1378
  }
1129
1379
  // AC: @ralph-session-budget-integration ac-session-close-all-paths
1130
- // Clean up budget file and harness env injection when session ends
1380
+ // AC: @ralph-per-role-adapters ac-7 - Clean up env for all unique adapters
1131
1381
  await fs.unlink(getSessionBudgetPath(specDir, sessionId)).catch(() => { });
1132
- await removeEnvForAdapter(adapterId, previousEnvValue);
1382
+ for (const id of uniqueAdapterIds) {
1383
+ await removeEnvForAdapter(id, previousEnvValues.get(id));
1384
+ }
1133
1385
  // Clean up session env vars
1134
1386
  delete process.env.KSPEC_RALPH_SESSION;
1135
1387
  delete process.env.KSPEC_SESSION_ID;
@@ -1147,10 +1399,16 @@ export function registerRalphCommand(program) {
1147
1399
  maxLoops, inProgressTasks, pendingReviewTasks, recentTaskRefs, process.cwd(), lastErrorMessage);
1148
1400
  info(`Exit reason: ${exitReason}`);
1149
1401
  info(`Working tree: ${wrapUpCtx.workingTree.clean ? "clean" : "has uncommitted changes"}`);
1150
- const wrapUpResult = await runWrapUpAgent(adapter, wrapUpCtx, {
1402
+ // AC: @ralph-per-role-adapters ac-8 - Wrap-up uses worker adapter
1403
+ const wrapUpResult = await runWrapUpAgent(workerAdapter, wrapUpCtx, {
1151
1404
  yolo: options.yolo,
1152
1405
  cwd: process.cwd(),
1153
- handleRequest: (client, reqId, method, params) => handleRequest(client, reqId, method, params, options.yolo),
1406
+ extraArgs: workerAutoApproveArgs,
1407
+ handleRequest: (client, reqId, method, params) => handleRequest(client, reqId, method, params, {
1408
+ yolo: options.yolo,
1409
+ specDir,
1410
+ sessionId,
1411
+ }),
1154
1412
  }, DEFAULT_WRAPUP_TIMEOUT);
1155
1413
  // Log wrap-up result
1156
1414
  await appendEvent(specDir, {