@kontextmind/kxm 0.6.0 → 0.7.10

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 (175) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.kxm/agents/coordinator.yaml +9 -0
  3. package/.kxm/agents/critic-arch.yaml +13 -0
  4. package/.kxm/agents/critic-cli.yaml +13 -0
  5. package/.kxm/agents/implementer.yaml +13 -0
  6. package/.kxm/gates.yaml +8 -0
  7. package/.kxm/producers.yaml +22 -0
  8. package/.kxm/project.yaml +15 -0
  9. package/.kxm/roles/writer.yaml +7 -0
  10. package/.kxm/workflows/default.yaml +47 -0
  11. package/CHANGELOG.md +39 -7
  12. package/README.md +1 -0
  13. package/docs/README.md +5 -0
  14. package/docs/adr/ADR-0002-browser-automation-steel-doks.md +103 -0
  15. package/docs/agent-skills.md +135 -0
  16. package/docs/architecture.md +1 -1
  17. package/docs/assignment-runner.md +21 -8
  18. package/docs/browser-automation.md +116 -0
  19. package/docs/configuration.md +11 -2
  20. package/docs/getting-started.md +21 -0
  21. package/docs/kb/how-credentials-retrieved-safely.md +31 -0
  22. package/docs/kb/how-to-capture-and-annotate-section.md +60 -0
  23. package/docs/kb/how-to-connect-playwright-to-steel.md +54 -0
  24. package/docs/kb/how-to-recover-expired-session-or-orphan.md +54 -0
  25. package/docs/kb/how-to-resume-after-mfa.md +28 -0
  26. package/docs/kb/how-to-take-over-session.md +32 -0
  27. package/docs/kb/why-authentication-disappeared.md +32 -0
  28. package/docs/kb/why-automation-opened-different-browser.md +32 -0
  29. package/docs/kb/why-session-viewer-cannot-control.md +31 -0
  30. package/docs/kxm-handbook.md +3 -3
  31. package/docs/operations.md +24 -0
  32. package/docs/operator-pi-packages.md +63 -0
  33. package/docs/prompts/browser-annotate-feedback.md +41 -0
  34. package/docs/prompts/browser-diagnose-recover.md +38 -0
  35. package/docs/prompts/browser-explore.md +42 -0
  36. package/docs/prompts/browser-repro-fix.md +48 -0
  37. package/docs/prompts/browser-start.md +41 -0
  38. package/docs/prompts/browser-takeover.md +50 -0
  39. package/docs/skills/repo-work-delivery.md +107 -0
  40. package/docs/skills.md +2 -0
  41. package/docs/test-matrix.md +4 -3
  42. package/docs/troubleshooting.md +41 -1
  43. package/docs/vnext/validation.md +9 -0
  44. package/docs/webhook-workflows.md +2 -2
  45. package/examples/README.md +1 -1
  46. package/package.json +16 -17
  47. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  48. package/plugins/kxm/README.md +1 -1
  49. package/plugins/kxm/dist/cli.js +41620 -35578
  50. package/plugins/kxm/dist/core.js +271 -34
  51. package/plugins/kxm/dist/extension.js +7759 -86
  52. package/plugins/kxm/dist/mcp-server.js +75 -21
  53. package/plugins/kxm/dist/runtime.js +8218 -2328
  54. package/plugins/kxm/dist/server.js +3125 -2260
  55. package/plugins/kxm/dist/vnext-runtime-supervisor.js +5961 -661
  56. package/plugins/kxm/package.json +1 -1
  57. package/plugins/kxm/skills/SUITE.md +5 -0
  58. package/plugins/kxm/skills/hints.json +103 -0
  59. package/plugins/kxm/skills/kxm/SKILL.md +30 -83
  60. package/plugins/kxm/skills/kxm-browser-annotate/SKILL.md +90 -0
  61. package/plugins/kxm/skills/kxm-browser-auth/SKILL.md +47 -0
  62. package/plugins/kxm/skills/kxm-browser-diagnostics/SKILL.md +48 -0
  63. package/plugins/kxm/skills/kxm-browser-explore/SKILL.md +48 -0
  64. package/plugins/kxm/skills/kxm-browser-session/SKILL.md +94 -0
  65. package/plugins/kxm/skills/kxm-browser-takeover/SKILL.md +87 -0
  66. package/plugins/kxm/skills/kxm-browser-verify/SKILL.md +71 -0
  67. package/plugins/kxm/skills/kxm-context-memory/SKILL.md +69 -0
  68. package/plugins/kxm/skills/kxm-definitions/SKILL.md +65 -0
  69. package/plugins/kxm/skills/kxm-harness-auth/SKILL.md +34 -0
  70. package/plugins/kxm/skills/kxm-harvest/SKILL.md +48 -0
  71. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +43 -0
  72. package/plugins/kxm/skills/kxm-insights/SKILL.md +48 -0
  73. package/plugins/kxm/skills/kxm-mind/SKILL.md +59 -0
  74. package/plugins/kxm/skills/kxm-peer/SKILL.md +110 -0
  75. package/plugins/kxm/skills/kxm-project-setup/SKILL.md +42 -0
  76. package/plugins/kxm/skills/kxm-projects/SKILL.md +43 -0
  77. package/plugins/kxm/skills/kxm-protocol/SKILL.md +66 -0
  78. package/plugins/kxm/skills/kxm-query/SKILL.md +45 -0
  79. package/plugins/kxm/skills/kxm-routing-improve/SKILL.md +30 -0
  80. package/plugins/kxm/skills/kxm-runs/SKILL.md +29 -0
  81. package/plugins/kxm/skills/kxm-setup/SKILL.md +55 -0
  82. package/plugins/kxm/skills/kxm-skill-lifecycle/SKILL.md +31 -0
  83. package/plugins/kxm/skills/kxm-tasks/SKILL.md +33 -0
  84. package/plugins/kxm/skills/kxm-triage/SKILL.md +47 -0
  85. package/plugins/kxm/skills/kxm-work/SKILL.md +44 -0
  86. package/plugins/kxm/skills/kxm-workflow/SKILL.md +45 -0
  87. package/plugins/kxm/src/autocomplete.ts +9 -3
  88. package/plugins/kxm/src/browser.ts +603 -0
  89. package/plugins/kxm/src/cli/context-skills.ts +373 -0
  90. package/plugins/kxm/src/cli/hub.ts +614 -0
  91. package/plugins/kxm/src/cli/roles.ts +615 -0
  92. package/plugins/kxm/src/cli/system.ts +906 -0
  93. package/plugins/kxm/src/cli/tasks.ts +364 -0
  94. package/plugins/kxm/src/cli/types.ts +270 -0
  95. package/plugins/kxm/src/cli/vnext.ts +698 -0
  96. package/plugins/kxm/src/cli/workflows.ts +699 -0
  97. package/plugins/kxm/src/cli.ts +362 -2849
  98. package/plugins/kxm/src/commands.ts +150 -8
  99. package/plugins/kxm/src/completion-install.ts +223 -0
  100. package/plugins/kxm/src/config.ts +7 -4
  101. package/plugins/kxm/src/context-packet.ts +172 -0
  102. package/plugins/kxm/src/database.ts +1 -1
  103. package/plugins/kxm/src/extension.ts +36 -1
  104. package/plugins/kxm/src/external-effects.ts +357 -8
  105. package/plugins/kxm/src/hub-env.ts +193 -0
  106. package/plugins/kxm/src/hub.ts +2 -4
  107. package/plugins/kxm/src/improve.ts +72 -0
  108. package/plugins/kxm/src/init-guide-setup.ts +547 -0
  109. package/plugins/kxm/src/local-snapshot.ts +1 -1
  110. package/plugins/kxm/src/mcp-server.ts +1 -1
  111. package/plugins/kxm/src/model-inventory.ts +127 -0
  112. package/plugins/kxm/src/modes.ts +348 -0
  113. package/plugins/kxm/src/policy-draft.d.mts +55 -0
  114. package/plugins/kxm/src/policy-draft.mjs +565 -0
  115. package/plugins/kxm/src/price-calc.ts +17 -18
  116. package/plugins/kxm/src/prices.ts +32 -16
  117. package/plugins/kxm/src/producers.ts +71 -0
  118. package/plugins/kxm/src/protocol.ts +111 -0
  119. package/plugins/kxm/src/restricted-yaml.d.mts +31 -0
  120. package/plugins/kxm/src/restricted-yaml.mjs +145 -0
  121. package/plugins/kxm/src/role.ts +710 -0
  122. package/plugins/kxm/src/routing.ts +99 -1
  123. package/plugins/kxm/src/runtime.ts +4 -0
  124. package/plugins/kxm/src/safety-integrity.ts +76 -0
  125. package/plugins/kxm/src/session-work.ts +9 -2
  126. package/plugins/kxm/src/sqlite.ts +76 -0
  127. package/plugins/kxm/src/ssh-remote.ts +560 -0
  128. package/plugins/kxm/src/store.ts +1 -1
  129. package/plugins/kxm/src/studio-layout.ts +660 -17
  130. package/plugins/kxm/src/subagent-control.ts +312 -0
  131. package/plugins/kxm/src/suggest.ts +7 -13
  132. package/plugins/kxm/src/telemetry.ts +82 -0
  133. package/plugins/kxm/src/tui.ts +140 -0
  134. package/plugins/kxm/src/vnext-bindings.ts +1 -1
  135. package/plugins/kxm/src/vnext-config.ts +53 -111
  136. package/plugins/kxm/src/vnext-engine-command.ts +2 -0
  137. package/plugins/kxm/src/vnext-engine.ts +214 -62
  138. package/plugins/kxm/src/vnext-harness.ts +336 -84
  139. package/plugins/kxm/src/vnext-oneshot-evidence.ts +117 -0
  140. package/plugins/kxm/src/vnext-oneshot-process.ts +187 -0
  141. package/plugins/kxm/src/vnext-oneshot-producer.ts +182 -224
  142. package/plugins/kxm/src/vnext-pi-producer.ts +11 -7
  143. package/plugins/kxm/src/vnext-runtime-store.ts +36 -2
  144. package/plugins/kxm/src/vnext-runtime-supervisor.ts +122 -5
  145. package/plugins/kxm/src/vnext-runtime.ts +14 -0
  146. package/plugins/kxm/src/workflow-manager.ts +392 -0
  147. package/plugins/kxm/src/workflow-tui.ts +255 -0
  148. package/plugins/kxm/src/workflow.ts +144 -0
  149. package/schemas/policy-draft/README.md +17 -0
  150. package/schemas/policy-draft/model.v2.schema.json +140 -0
  151. package/schemas/policy-draft/role.v2.schema.json +91 -0
  152. package/schemas/vnext/modes.schema.json +56 -0
  153. package/schemas/vnext/role.schema.json +76 -0
  154. package/schemas/vnext/run-event.schema.json +1 -0
  155. package/scripts/assignment-run.d.mts +1 -1
  156. package/scripts/assignment-run.mjs +44 -35
  157. package/scripts/check-generated.mjs +33 -9
  158. package/scripts/emit-codex-artifacts.mjs +255 -11
  159. package/scripts/harness-run.d.mts +12 -4
  160. package/scripts/harness-run.mjs +65 -17
  161. package/scripts/kxm-bump-version.mjs +146 -0
  162. package/scripts/kxm-hub.mjs +150 -2
  163. package/scripts/kxm-publish-npm.mjs +3 -1
  164. package/scripts/kxm-release-github.mjs +3 -1
  165. package/scripts/kxm.mjs +0 -0
  166. package/scripts/native-critic.d.mts +5 -0
  167. package/scripts/native-critic.mjs +60 -0
  168. package/.kxm/config/README.md +0 -5
  169. package/.kxm/config/agents.json +0 -43
  170. package/.kxm/config/env.example +0 -56
  171. package/.kxm/config/update.example.yaml +0 -9
  172. package/.kxm/config/workflows/fix.json +0 -160
  173. package/.kxm/config/workflows/jira-development.json +0 -116
  174. package/.kxm/config/workflows/provenance-quorum.json +0 -150
  175. package/.kxm/config/workflows/v04-dogfood.json +0 -72
@@ -5,7 +5,6 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
5
5
  import { dirname, isAbsolute, join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { loadVnextProject, VnextConfigError, type VnextConfigOptions } from "./vnext-config.ts";
8
-
9
8
  const repoRoot = resolve(fileURLToPath(new URL("../../../", import.meta.url)));
10
9
  import {
11
10
  VnextRuntimeRegistry,
@@ -23,7 +22,9 @@ import {
23
22
  rebuildVnextRunProjection,
24
23
  type VnextRuntimeContext,
25
24
  } from "./vnext-runtime.ts";
26
- import { recoverVnextRun } from "./vnext-engine.ts";
25
+ import { createVnextOneShotProducer } from "./vnext-oneshot-producer.ts";
26
+ import { isProducerAdmitted } from "./producers.ts";
27
+ import { VnextRunScheduler, createVnextSimulatedProducer, driveVnextRun, pinVnextCompiledPlan, recoverVnextRun, startVnextRun } from "./vnext-engine.ts";
27
28
 
28
29
  /* ------------------------------------------------------------------ *
29
30
  * Token management
@@ -321,6 +322,7 @@ async function startVnextRuntimeSupervisorInner(
321
322
  const runtimeId = `rtm_${createHash("sha256").update(`${paths.stateRoot}\0${process.pid}\0${now()}\0${randomBytes(16).toString("hex")}`, "utf8").digest("hex").slice(0, 24)}`;
322
323
  const requestedPort = requestedPortOption ?? 0;
323
324
  let activeRuntimeId = runtimeId;
325
+ const activeDrives = new Map<string, Promise<unknown>>();
324
326
 
325
327
  const contexts = new Map<string, VnextRuntimeContext>();
326
328
  const contextFor = (projectRoot: string): VnextRuntimeContext => {
@@ -394,7 +396,7 @@ async function startVnextRuntimeSupervisorInner(
394
396
  return;
395
397
  }
396
398
 
397
- const runMatch = /^\/v1\/runs\/([A-Za-z0-9_-]+)(?:\/(events|cancel|signal|wait))?$/.exec(url.pathname);
399
+ const runMatch = /^\/v1\/runs\/([A-Za-z0-9_-]+)(?:\/(events|cancel|signal|wait|drive))?$/.exec(url.pathname);
398
400
  if (runMatch) {
399
401
  const runId = runMatch[1] as string;
400
402
  const sub = runMatch[2];
@@ -404,11 +406,122 @@ async function startVnextRuntimeSupervisorInner(
404
406
  return;
405
407
  }
406
408
  const context = contextFor(projectRoot);
409
+ const bundle = loadVnextProject(projectRoot, {});
407
410
  if (request.method === "GET" && !sub) {
408
411
  const projected = rebuildVnextRunProjection(context, runId);
409
412
  sendJson(response, 200, { ok: true, run: projected });
410
413
  return;
411
414
  }
415
+ if (request.method === "POST" && sub === "drive") {
416
+ const body = await readJsonBody(request);
417
+ if (body.mode !== "simulated" && body.mode !== "live") {
418
+ sendJson(response, 400, { ok: false, error: "runtime_request_invalid", message: "drive mode must be simulated or live" });
419
+ return;
420
+ }
421
+ const run = context.eventStore.run(runId);
422
+ if (!run) throw runtimeError("run_unknown", runId, "run not found");
423
+ if (activeDrives.has(runId)) {
424
+ sendJson(response, 409, { ok: false, error: "run_busy", message: `run ${runId} is already executing` });
425
+ return;
426
+ }
427
+ if (run.status === "completed" || run.status === "failed" || run.status === "cancelled" || run.status === "cancelling") {
428
+ sendJson(response, 409, { ok: false, error: "run_busy", message: `run ${runId} is already ${run.status}` });
429
+ return;
430
+ }
431
+ let releaseActive = () => {};
432
+ const activePromise = new Promise<void>((resolve) => { releaseActive = resolve; });
433
+ activeDrives.set(runId, activePromise);
434
+ const cleanupActive = () => {
435
+ activeDrives.delete(runId);
436
+ releaseActive();
437
+ };
438
+
439
+ if (run.status === "created") {
440
+ pinVnextCompiledPlan(context, bundle, runId);
441
+ const plan = startVnextRun(context, runId, { allowLimits: true });
442
+ if (plan.handoff) {
443
+ cleanupActive();
444
+ sendJson(response, 409, { ok: false, error: "run_handoff_required", handoff: plan.handoff });
445
+ return;
446
+ }
447
+ }
448
+ const producer = body.mode === "live"
449
+ ? createVnextOneShotProducer({
450
+ projectRoot,
451
+ defaultHarness: String(bundle.project.value.defaultHarness ?? "pi"),
452
+ resolveHarness: (agentId) => {
453
+ const agent = bundle.agents.get(agentId);
454
+ return typeof agent?.value.harness === "string" ? agent.value.harness : undefined;
455
+ },
456
+ resolveModel: (agentId) => {
457
+ const agent = bundle.agents.get(agentId);
458
+ const model = agent?.value.model;
459
+ if (!model || typeof model !== "object" || Array.isArray(model)) return undefined;
460
+ const value = model as Record<string, unknown>;
461
+ const provider = typeof value.provider === "string" ? value.provider : undefined;
462
+ const modelName = typeof value.model === "string" ? value.model : undefined;
463
+ if (!provider || !modelName || !isProducerAdmitted(projectRoot, `${provider}/${modelName}`)) {
464
+ throw new Error("producer_route_not_admitted");
465
+ }
466
+ return { provider, model: modelName };
467
+ },
468
+ })
469
+ : (() => {
470
+ const delayMs = typeof body.delayMs === "number" && body.delayMs > 0 ? body.delayMs : 0;
471
+ return createVnextSimulatedProducer(async () => {
472
+ if (delayMs > 0) {
473
+ await new Promise((r) => setTimeout(r, delayMs));
474
+ }
475
+ return { outcome: "passed" };
476
+ });
477
+ })();
478
+
479
+ let drivePromise: Promise<unknown> | undefined;
480
+ let earlyError: unknown;
481
+ try {
482
+ const scheduler = VnextRunScheduler.for(context, bundle);
483
+ drivePromise = scheduler.enqueue(runId, producer, { allowLimits: true, liveMode: body.mode === "live" });
484
+ drivePromise.catch((err) => { earlyError = err; });
485
+ } catch (err) {
486
+ earlyError = err;
487
+ }
488
+
489
+ // Yield microtask to catch synchronous duplicate queue check (e.g. run_busy)
490
+ await Promise.resolve();
491
+
492
+ if (earlyError) {
493
+ cleanupActive();
494
+ const errStr = String(earlyError);
495
+ if (errStr.includes("run_busy") || errStr.includes("scheduler_policy_conflict")) {
496
+ if ("close" in producer && typeof producer.close === "function") {
497
+ try { await producer.close(); } catch { /* ignore */ }
498
+ }
499
+ sendJson(response, 409, { ok: false, error: "run_busy", message: `run ${runId} is already admitted or queued` });
500
+ return;
501
+ }
502
+ throw earlyError;
503
+ }
504
+
505
+ void drivePromise!.finally(async () => {
506
+ cleanupActive();
507
+ if ("close" in producer && typeof producer.close === "function") {
508
+ try { await producer.close(); } catch { /* ignore */ }
509
+ }
510
+ }).catch(() => {
511
+ // Derived finally() re-rejects when the admitted drive or cleanup
512
+ // throws; void does not consume that. Keep the request 202.
513
+ });
514
+
515
+ sendJson(response, 202, {
516
+ ok: true,
517
+ status: "accepted",
518
+ runId,
519
+ poll: `/v1/runs/${runId}`,
520
+ mode: body.mode,
521
+ });
522
+ return;
523
+ }
524
+
412
525
  if (request.method === "GET" && sub === "events") {
413
526
  const after = Number.parseInt(url.searchParams.get("after") ?? "0", 10);
414
527
  const events = context.eventStore.events(runId, Number.isFinite(after) && after >= 0 ? after : 0, 500);
@@ -429,9 +542,10 @@ async function startVnextRuntimeSupervisorInner(
429
542
  if (!run) throw runtimeError("run_unknown", runId, "run not found");
430
543
  const state = foldStoredVnextRun(context, run);
431
544
  let unblocked = false;
432
- if (state.status === "blocked_uncertain" || state.currentStep?.effectState === "blocked_uncertain") {
545
+ if (state.status === "blocked_uncertain" || state.status === "cancelling" || state.currentStep?.effectState === "blocked_uncertain") {
546
+ const action = body.action === "cancel" || body.action === "fail" || body.action === "retry" || body.action === "unblock" ? body.action : "unblock";
433
547
  const rec = recoverVnextRun(context, runId, {
434
- action: "unblock",
548
+ action,
435
549
  reason: typeof body.summary === "string" ? body.summary : `signal_${body.signalKey ?? "callback"}`,
436
550
  });
437
551
  unblocked = rec.unblocked;
@@ -543,6 +657,9 @@ async function startVnextRuntimeSupervisorInner(
543
657
  stopping = true;
544
658
  clearInterval(heartbeat);
545
659
  try { registry.markStopping(process.pid, now()); } catch { /* best effort */ }
660
+ if (activeDrives.size > 0) {
661
+ await Promise.allSettled([...activeDrives.values()]);
662
+ }
546
663
  for (const context of contexts.values()) closeVnextRuntimeContext(context);
547
664
  contexts.clear();
548
665
  const closed = new Promise<void>((resolveStop) => server.close(() => resolveStop()));
@@ -159,6 +159,19 @@ export function computeVnextMemoryRevision(
159
159
  return `ctxrev_${hash.digest("hex")}`;
160
160
  }
161
161
 
162
+ export function checkMemoryRevisionDrift(
163
+ pinnedRevision: string,
164
+ bundle: VnextProjectBundle,
165
+ options?: VnextMemoryRevisionOptions,
166
+ ): { drifted: boolean; currentRevision: string; pinnedRevision: string } {
167
+ const currentRevision = computeVnextMemoryRevision(bundle, options);
168
+ return {
169
+ drifted: currentRevision !== pinnedRevision,
170
+ currentRevision,
171
+ pinnedRevision,
172
+ };
173
+ }
174
+
162
175
  export function vnextPolicyRevisions(
163
176
  bundle: VnextProjectBundle,
164
177
  options?: VnextMemoryRevisionOptions,
@@ -423,6 +436,7 @@ export function acceptVnextRun(
423
436
  result: { runId, homeRuntimeId: context.homeRuntimeId, status: "created" },
424
437
  recordedAt: now,
425
438
  });
439
+ context.eventStore.putRunPrompt(runId, request.prompt);
426
440
  return { accepted: true, idempotent: false, run, event };
427
441
  });
428
442
  }
@@ -0,0 +1,392 @@
1
+ /**
2
+ * KXM Workflow Management Subsystem.
3
+ * Supports add, remove, modify, list, and inspection of workflow YAML definitions.
4
+ * Handles global (~/.config/kxm/workflows/) and local (.kxm/workflows/) scoping.
5
+ */
6
+
7
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
+ import { dirname, join, resolve } from "node:path";
9
+ import { parse, stringify } from "yaml";
10
+ import { repoConfigDirectory, userConfigDirectory } from "./config.ts";
11
+
12
+ export interface WorkflowDefSummary {
13
+ id: string;
14
+ description: string;
15
+ scope: "global" | "local" | "overridden";
16
+ filePath: string;
17
+ stepCount: number;
18
+ roles: string[];
19
+ }
20
+
21
+ export const WORKFLOW_TEMPLATES: Record<string, Record<string, unknown>> = {
22
+ "implement-and-verify": {
23
+ schema: "kxm.workflow.v1",
24
+ description: "Standard implement and verify workflow",
25
+ coordinator: "coordinator",
26
+ limits: {
27
+ maxTransitions: 8,
28
+ },
29
+ steps: [
30
+ {
31
+ id: "implement",
32
+ kind: "agent",
33
+ role: "writer",
34
+ maxAttempts: 2,
35
+ on: {
36
+ passed: "verify",
37
+ failed: {
38
+ target: "$terminal",
39
+ terminalStatus: "failed",
40
+ },
41
+ },
42
+ },
43
+ {
44
+ id: "verify",
45
+ kind: "gate",
46
+ gate: "verify-gate",
47
+ expect: "pass",
48
+ maxAttempts: 1,
49
+ on: {
50
+ passed: {
51
+ target: "$terminal",
52
+ terminalStatus: "completed",
53
+ },
54
+ failed: {
55
+ target: "implement",
56
+ maxTransitions: 2,
57
+ },
58
+ },
59
+ },
60
+ ],
61
+ },
62
+ "dual-critic-review": {
63
+ schema: "kxm.workflow.v1",
64
+ description: "Dual-critic review workflow with independent Fable architecture and Sol CLI critics",
65
+ coordinator: "coordinator",
66
+ limits: {
67
+ maxTransitions: 12,
68
+ },
69
+ steps: [
70
+ {
71
+ id: "implement",
72
+ kind: "agent",
73
+ role: "writer",
74
+ maxAttempts: 2,
75
+ on: {
76
+ passed: "review-arch",
77
+ failed: {
78
+ target: "$terminal",
79
+ terminalStatus: "failed",
80
+ },
81
+ },
82
+ },
83
+ {
84
+ id: "review-arch",
85
+ kind: "agent",
86
+ role: "critic-arch",
87
+ maxAttempts: 2,
88
+ on: {
89
+ passed: "review-cli",
90
+ failed: {
91
+ target: "implement",
92
+ maxTransitions: 2,
93
+ },
94
+ },
95
+ },
96
+ {
97
+ id: "review-cli",
98
+ kind: "agent",
99
+ role: "critic-cli",
100
+ maxAttempts: 2,
101
+ on: {
102
+ passed: "verify",
103
+ failed: {
104
+ target: "implement",
105
+ maxTransitions: 2,
106
+ },
107
+ },
108
+ },
109
+ {
110
+ id: "verify",
111
+ kind: "gate",
112
+ gate: "verify-gate",
113
+ expect: "pass",
114
+ maxAttempts: 1,
115
+ on: {
116
+ passed: {
117
+ target: "$terminal",
118
+ terminalStatus: "completed",
119
+ },
120
+ failed: {
121
+ target: "implement",
122
+ maxTransitions: 2,
123
+ },
124
+ },
125
+ },
126
+ ],
127
+ },
128
+ "spec-and-plan": {
129
+ schema: "kxm.workflow.v1",
130
+ description: "Specification and architecture breakdown planning workflow",
131
+ coordinator: "coordinator",
132
+ limits: {
133
+ maxTransitions: 6,
134
+ },
135
+ steps: [
136
+ {
137
+ id: "plan",
138
+ kind: "agent",
139
+ role: "planner",
140
+ maxAttempts: 2,
141
+ on: {
142
+ passed: "review-arch",
143
+ failed: {
144
+ target: "$terminal",
145
+ terminalStatus: "failed",
146
+ },
147
+ },
148
+ },
149
+ {
150
+ id: "review-arch",
151
+ kind: "agent",
152
+ role: "critic-arch",
153
+ maxAttempts: 2,
154
+ on: {
155
+ passed: {
156
+ target: "$terminal",
157
+ terminalStatus: "completed",
158
+ },
159
+ failed: {
160
+ target: "plan",
161
+ maxTransitions: 2,
162
+ },
163
+ },
164
+ },
165
+ ],
166
+ },
167
+ };
168
+
169
+ export const DEFAULT_WORKFLOW_TEMPLATE = WORKFLOW_TEMPLATES["implement-and-verify"]!;
170
+
171
+ export function workflowsDirectory(
172
+ scope: "global" | "local",
173
+ repoRoot = process.cwd(),
174
+ userConfigDir?: string,
175
+ ): string {
176
+ if (scope === "global") {
177
+ return join(userConfigDirectory(userConfigDir), "workflows");
178
+ }
179
+ return join(repoConfigDirectory(repoRoot), "workflows");
180
+ }
181
+
182
+ export function ensureWorkflowsDirectory(
183
+ scope: "global" | "local",
184
+ repoRoot = process.cwd(),
185
+ userConfigDir?: string,
186
+ ): string {
187
+ const dir = workflowsDirectory(scope, repoRoot, userConfigDir);
188
+ if (!existsSync(dir)) {
189
+ mkdirSync(dir, { recursive: true });
190
+ }
191
+ return dir;
192
+ }
193
+
194
+ export function parseWorkflowFile(filePath: string): Record<string, unknown> | undefined {
195
+ if (!existsSync(filePath)) return undefined;
196
+ try {
197
+ const raw = readFileSync(filePath, "utf8");
198
+ const parsed = parse(raw) as Record<string, unknown>;
199
+ if (!parsed || typeof parsed !== "object") return undefined;
200
+ return parsed;
201
+ } catch {
202
+ return undefined;
203
+ }
204
+ }
205
+
206
+ export function listWorkflowDefinitions(options: {
207
+ scope?: "all" | "global" | "local" | undefined;
208
+ repoRoot?: string | undefined;
209
+ userConfigDir?: string | undefined;
210
+ } = {}): WorkflowDefSummary[] {
211
+ const scopeFilter = options.scope ?? "all";
212
+ const repoRoot = options.repoRoot ?? process.cwd();
213
+ const globalDir = workflowsDirectory("global", repoRoot, options.userConfigDir);
214
+ const localDir = workflowsDirectory("local", repoRoot, options.userConfigDir);
215
+
216
+ const localDefs = new Map<string, { def: Record<string, unknown>; filePath: string }>();
217
+ if (scopeFilter !== "global" && existsSync(localDir)) {
218
+ for (const entry of readdirSync(localDir)) {
219
+ if (entry.endsWith(".yaml") || entry.endsWith(".yml")) {
220
+ const filePath = join(localDir, entry);
221
+ const def = parseWorkflowFile(filePath);
222
+ if (def) {
223
+ const id = (def.id as string) || entry.replace(/\.ya?ml$/i, "");
224
+ localDefs.set(id, { def, filePath });
225
+ }
226
+ }
227
+ }
228
+ }
229
+
230
+ const globalDefs = new Map<string, { def: Record<string, unknown>; filePath: string }>();
231
+ if (scopeFilter !== "local" && existsSync(globalDir)) {
232
+ for (const entry of readdirSync(globalDir)) {
233
+ if (entry.endsWith(".yaml") || entry.endsWith(".yml")) {
234
+ const filePath = join(globalDir, entry);
235
+ const def = parseWorkflowFile(filePath);
236
+ if (def) {
237
+ const id = (def.id as string) || entry.replace(/\.ya?ml$/i, "");
238
+ globalDefs.set(id, { def, filePath });
239
+ }
240
+ }
241
+ }
242
+ }
243
+
244
+ const extractRoles = (def: Record<string, unknown>): string[] => {
245
+ const roles: string[] = [];
246
+ const steps = Array.isArray(def.steps) ? def.steps : [];
247
+ for (const step of steps) {
248
+ if (step && typeof step === "object") {
249
+ const role = (step as Record<string, unknown>).role || (step as Record<string, unknown>).agent;
250
+ if (typeof role === "string" && !roles.includes(role)) {
251
+ roles.push(role);
252
+ }
253
+ }
254
+ }
255
+ return roles;
256
+ };
257
+
258
+ const result: WorkflowDefSummary[] = [];
259
+
260
+ for (const [id, { def, filePath }] of localDefs) {
261
+ const isOverridden = globalDefs.has(id);
262
+ const steps = Array.isArray(def.steps) ? def.steps : [];
263
+ result.push({
264
+ id,
265
+ description: typeof def.description === "string" ? def.description : "",
266
+ scope: isOverridden ? "overridden" : "local",
267
+ filePath,
268
+ stepCount: steps.length,
269
+ roles: extractRoles(def),
270
+ });
271
+ }
272
+
273
+ for (const [id, { def, filePath }] of globalDefs) {
274
+ if (scopeFilter === "global" || !localDefs.has(id)) {
275
+ const steps = Array.isArray(def.steps) ? def.steps : [];
276
+ result.push({
277
+ id,
278
+ description: typeof def.description === "string" ? def.description : "",
279
+ scope: "global",
280
+ filePath,
281
+ stepCount: steps.length,
282
+ roles: extractRoles(def),
283
+ });
284
+ }
285
+ }
286
+
287
+ result.sort((a, b) => a.id.localeCompare(b.id));
288
+ return result;
289
+ }
290
+
291
+ export function getWorkflowDefinition(
292
+ workflowId: string,
293
+ options: {
294
+ scope?: "all" | "global" | "local" | undefined;
295
+ repoRoot?: string | undefined;
296
+ userConfigDir?: string | undefined;
297
+ } = {},
298
+ ): { workflow: Record<string, unknown>; scope: "global" | "local"; filePath: string } | undefined {
299
+ const scope = options.scope ?? "all";
300
+ const repoRoot = options.repoRoot ?? process.cwd();
301
+
302
+ // 1. Check local first
303
+ if (scope !== "global") {
304
+ const localDir = workflowsDirectory("local", repoRoot, options.userConfigDir);
305
+ const localFile = join(localDir, `${workflowId}.yaml`);
306
+ const parsed = parseWorkflowFile(localFile);
307
+ if (parsed) return { workflow: parsed, scope: "local", filePath: localFile };
308
+ }
309
+
310
+ // 2. Check global
311
+ if (scope !== "local") {
312
+ const globalDir = workflowsDirectory("global", repoRoot, options.userConfigDir);
313
+ const globalFile = join(globalDir, `${workflowId}.yaml`);
314
+ const parsed = parseWorkflowFile(globalFile);
315
+ if (parsed) return { workflow: parsed, scope: "global", filePath: globalFile };
316
+ }
317
+
318
+ return undefined;
319
+ }
320
+
321
+ export function addWorkflowDefinition(
322
+ workflowId: string,
323
+ content: Record<string, unknown> | string = DEFAULT_WORKFLOW_TEMPLATE,
324
+ options: {
325
+ scope?: "global" | "local" | undefined;
326
+ repoRoot?: string | undefined;
327
+ userConfigDir?: string | undefined;
328
+ overwrite?: boolean | undefined;
329
+ } = {},
330
+ ): { id: string; filePath: string; scope: "global" | "local" } {
331
+ const scope = options.scope ?? "local";
332
+ const repoRoot = options.repoRoot ?? process.cwd();
333
+ const dir = ensureWorkflowsDirectory(scope, repoRoot, options.userConfigDir);
334
+ const filePath = join(dir, `${workflowId}.yaml`);
335
+
336
+ if (existsSync(filePath) && !options.overwrite) {
337
+ throw new Error(`workflow_already_exists: workflow '${workflowId}' already exists at ${filePath}`);
338
+ }
339
+
340
+ const payload = typeof content === "string" ? content : stringify(content);
341
+ writeFileSync(filePath, payload, "utf8");
342
+ return { id: workflowId, filePath, scope };
343
+ }
344
+
345
+ export function removeWorkflowDefinition(
346
+ workflowId: string,
347
+ options: {
348
+ scope?: "global" | "local" | undefined;
349
+ repoRoot?: string | undefined;
350
+ userConfigDir?: string | undefined;
351
+ } = {},
352
+ ): { id: string; removed: boolean; filePath: string; scope: "global" | "local" } {
353
+ const scope = options.scope ?? "local";
354
+ const repoRoot = options.repoRoot ?? process.cwd();
355
+ const dir = workflowsDirectory(scope, repoRoot, options.userConfigDir);
356
+ const filePath = join(dir, `${workflowId}.yaml`);
357
+
358
+ if (!existsSync(filePath)) {
359
+ throw new Error(`workflow_not_found: workflow '${workflowId}' not found in ${scope} directory (${filePath})`);
360
+ }
361
+
362
+ rmSync(filePath);
363
+ return { id: workflowId, removed: true, filePath, scope };
364
+ }
365
+
366
+ export function modifyWorkflowDefinition(
367
+ workflowId: string,
368
+ updates: Record<string, unknown>,
369
+ options: {
370
+ scope?: "global" | "local" | undefined;
371
+ repoRoot?: string | undefined;
372
+ userConfigDir?: string | undefined;
373
+ } = {},
374
+ ): { id: string; workflow: Record<string, unknown>; filePath: string; scope: "global" | "local" } {
375
+ const target = getWorkflowDefinition(workflowId, { scope: options.scope, repoRoot: options.repoRoot, userConfigDir: options.userConfigDir });
376
+ if (!target) {
377
+ throw new Error(`workflow_not_found: workflow '${workflowId}' does not exist`);
378
+ }
379
+
380
+ const updated: Record<string, unknown> = {
381
+ ...target.workflow,
382
+ ...updates,
383
+ };
384
+
385
+ const scope = options.scope ?? target.scope;
386
+ const repoRoot = options.repoRoot ?? process.cwd();
387
+ const dir = ensureWorkflowsDirectory(scope, repoRoot, options.userConfigDir);
388
+ const filePath = join(dir, `${workflowId}.yaml`);
389
+
390
+ writeFileSync(filePath, stringify(updated), "utf8");
391
+ return { id: workflowId, workflow: updated, filePath, scope };
392
+ }