agentweaver 0.1.16 → 0.1.17

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 (40) hide show
  1. package/README.md +50 -10
  2. package/dist/artifacts.js +73 -3
  3. package/dist/doctor/checks/executors.js +2 -2
  4. package/dist/flow-state.js +138 -1
  5. package/dist/index.js +175 -61
  6. package/dist/interactive/controller.js +56 -23
  7. package/dist/interactive/ink/index.js +22 -1
  8. package/dist/interactive/tree.js +2 -2
  9. package/dist/pipeline/auto-flow.js +9 -6
  10. package/dist/pipeline/context.js +6 -5
  11. package/dist/pipeline/declarative-flows.js +39 -20
  12. package/dist/pipeline/flow-catalog.js +36 -14
  13. package/dist/pipeline/flow-specs/auto-common.json +1 -0
  14. package/dist/pipeline/flow-specs/auto-golang.json +27 -1
  15. package/dist/pipeline/flow-specs/design-review/design-review-loop.json +13 -1
  16. package/dist/pipeline/flow-specs/plan.json +4 -2
  17. package/dist/pipeline/launch-profile-config.js +30 -18
  18. package/dist/pipeline/node-contract.js +1 -0
  19. package/dist/pipeline/node-registry.js +74 -5
  20. package/dist/pipeline/nodes/flow-run-node.js +188 -173
  21. package/dist/pipeline/nodes/llm-prompt-node.js +15 -33
  22. package/dist/pipeline/plugin-loader.js +389 -0
  23. package/dist/pipeline/plugin-types.js +1 -0
  24. package/dist/pipeline/registry.js +71 -4
  25. package/dist/pipeline/spec-compiler.js +1 -0
  26. package/dist/pipeline/spec-loader.js +14 -0
  27. package/dist/pipeline/spec-validator.js +6 -0
  28. package/dist/pipeline/value-resolver.js +2 -1
  29. package/dist/plugin-sdk.js +1 -0
  30. package/dist/runtime/artifact-registry.js +3 -0
  31. package/dist/runtime/execution-routing.js +25 -19
  32. package/dist/runtime/interactive-execution-routing.js +66 -57
  33. package/docs/example/.flows/examples/claude-example.json +50 -0
  34. package/docs/example/.plugins/claude-example-plugin/index.js +149 -0
  35. package/docs/example/.plugins/claude-example-plugin/plugin.json +8 -0
  36. package/docs/examples/.flows/claude-example.json +50 -0
  37. package/docs/examples/.plugins/claude-example-plugin/index.js +149 -0
  38. package/docs/examples/.plugins/claude-example-plugin/plugin.json +8 -0
  39. package/docs/plugin-sdk.md +731 -0
  40. package/package.json +6 -2
@@ -23,6 +23,8 @@ const HELP_TEXT = renderMarkdownToTerminal([
23
23
  "q / Ctrl+C exit",
24
24
  ].join("\n"));
25
25
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
26
+ const SPINNER_INTERVAL_MS = 200;
27
+ const LOG_FLUSH_INTERVAL_MS = 120;
26
28
  function clamp(value, min, max) {
27
29
  return Math.min(max, Math.max(min, value));
28
30
  }
@@ -428,7 +430,8 @@ export class InteractiveSessionController {
428
430
  return "Flow structure is not available.";
429
431
  }
430
432
  if (selectedItem.kind === "folder") {
431
- const kindLabel = selectedItem.pathSegments[0] === "custom" ? "project-local" : "built-in";
433
+ const rootName = selectedItem.pathSegments[0];
434
+ const kindLabel = rootName === "custom" ? "project-local" : rootName === "global" ? "global" : "built-in";
432
435
  return [
433
436
  `Flow folder '${selectedItem.pathSegments.join("/")}'.`,
434
437
  "",
@@ -440,8 +443,8 @@ export class InteractiveSessionController {
440
443
  const description = flow.description?.trim() || "No description available for this flow.";
441
444
  const details = [
442
445
  `Path: ${flow.treePath.join("/")}`,
443
- `Source: ${flow.source === "project-local" ? "project-local" : "built-in"}`,
444
- flow.source === "project-local" && flow.sourcePath ? `File: ${flow.sourcePath}` : "",
446
+ `Source: ${flow.source === "project-local" ? "project-local" : flow.source === "global" ? "global" : "built-in"}`,
447
+ flow.source !== "built-in" && flow.sourcePath ? `File: ${flow.sourcePath}` : "",
445
448
  ]
446
449
  .filter((line) => line.length > 0)
447
450
  .join("\n");
@@ -488,11 +491,13 @@ export class InteractiveSessionController {
488
491
  ? "Stop"
489
492
  : action === "resume"
490
493
  ? "Resume"
491
- : action === "restart"
492
- ? "Restart"
493
- : action === "ok"
494
- ? "OK"
495
- : "Cancel";
494
+ : action === "continue"
495
+ ? "Continue"
496
+ : action === "restart"
497
+ ? "Restart"
498
+ : action === "ok"
499
+ ? "OK"
500
+ : "Cancel";
496
501
  return session.selectedAction === action ? `[ ${label} ]` : ` ${label} `;
497
502
  })
498
503
  .join(" ");
@@ -759,10 +764,20 @@ export class InteractiveSessionController {
759
764
  this.confirmSession = {
760
765
  kind: "run",
761
766
  flowId: selectedItem.flow.id,
762
- resumeAvailable: confirmation.resumeAvailable,
763
- hasExistingState: confirmation.hasExistingState,
767
+ availability: {
768
+ hasExistingState: confirmation.hasExistingState,
769
+ resume: confirmation.resume.available,
770
+ continue: confirmation.continue.available,
771
+ restart: confirmation.restart.available,
772
+ },
764
773
  details: confirmation.details ?? null,
765
- selectedAction: confirmation.resumeAvailable ? "resume" : confirmation.hasExistingState ? "restart" : "ok",
774
+ selectedAction: confirmation.resume.available
775
+ ? "resume"
776
+ : confirmation.continue.available
777
+ ? "continue"
778
+ : confirmation.restart.available
779
+ ? "restart"
780
+ : "ok",
766
781
  };
767
782
  this.emitChange();
768
783
  }
@@ -774,8 +789,12 @@ export class InteractiveSessionController {
774
789
  this.confirmSession = {
775
790
  kind: "interrupt",
776
791
  flowId,
777
- resumeAvailable: true,
778
- hasExistingState: true,
792
+ availability: {
793
+ hasExistingState: true,
794
+ resume: true,
795
+ continue: false,
796
+ restart: false,
797
+ },
779
798
  details: "The current flow will be stopped. State will be saved and can be continued via Resume.",
780
799
  selectedAction: "stop",
781
800
  };
@@ -791,8 +810,12 @@ export class InteractiveSessionController {
791
810
  this.confirmSession = {
792
811
  kind: "exit",
793
812
  flowId: null,
794
- resumeAvailable: false,
795
- hasExistingState: false,
813
+ availability: {
814
+ hasExistingState: false,
815
+ resume: false,
816
+ continue: false,
817
+ restart: false,
818
+ },
796
819
  details,
797
820
  selectedAction: "ok",
798
821
  };
@@ -808,11 +831,17 @@ export class InteractiveSessionController {
808
831
  if (this.confirmSession.kind === "exit") {
809
832
  return ["ok", "cancel"];
810
833
  }
811
- return this.confirmSession.resumeAvailable
812
- ? ["resume", "restart", "cancel"]
813
- : this.confirmSession.hasExistingState
814
- ? ["restart", "cancel"]
815
- : ["ok", "cancel"];
834
+ const actions = [];
835
+ if (this.confirmSession.availability.resume) {
836
+ actions.push("resume");
837
+ }
838
+ if (this.confirmSession.availability.continue) {
839
+ actions.push("continue");
840
+ }
841
+ if (this.confirmSession.availability.restart) {
842
+ actions.push("restart");
843
+ }
844
+ return actions.length > 0 ? [...actions, "cancel"] : ["ok", "cancel"];
816
845
  }
817
846
  moveConfirmSelection(delta) {
818
847
  if (!this.confirmSession) {
@@ -850,7 +879,11 @@ export class InteractiveSessionController {
850
879
  return;
851
880
  }
852
881
  const flowId = session.flowId ?? this.state.selectedFlowId;
853
- const launchMode = session.selectedAction === "resume" ? "resume" : "restart";
882
+ const launchMode = session.selectedAction === "resume"
883
+ ? "resume"
884
+ : session.selectedAction === "continue"
885
+ ? "continue"
886
+ : "restart";
854
887
  this.confirmSession = null;
855
888
  this.setBusy(true, flowId);
856
889
  this.clearFlowFailure(flowId);
@@ -895,7 +928,7 @@ export class InteractiveSessionController {
895
928
  this.spinnerTimer = setInterval(() => {
896
929
  this.state.spinnerFrame = (this.state.spinnerFrame + 1) % SPINNER_FRAMES.length;
897
930
  this.emitChange();
898
- }, 120);
931
+ }, SPINNER_INTERVAL_MS);
899
932
  return;
900
933
  }
901
934
  if (!running && this.spinnerTimer) {
@@ -1278,7 +1311,7 @@ export class InteractiveSessionController {
1278
1311
  this.logFlushTimer = setTimeout(() => {
1279
1312
  this.logFlushTimer = null;
1280
1313
  this.flushPendingLogLines();
1281
- }, 50);
1314
+ }, LOG_FLUSH_INTERVAL_MS);
1282
1315
  }
1283
1316
  flushPendingLogLines() {
1284
1317
  if (this.pendingLogLines.length === 0) {
@@ -370,14 +370,35 @@ function createInkApp(react, ink, controller) {
370
370
  const { Fragment, createElement, useEffect, useState } = react;
371
371
  const { Box, Text, useInput, useStdout } = ink;
372
372
  const Panel = createPanelComponent(react, ink);
373
+ const LOG_REPAINT_DEBOUNCE_MS = 100;
373
374
  const App = () => {
374
375
  const [, setVersion] = useState(0);
375
376
  useEffect(() => {
376
- const unsubscribe = controller.subscribe(() => {
377
+ let logRepaintTimer = null;
378
+ const flushRepaint = () => {
379
+ if (logRepaintTimer) {
380
+ clearTimeout(logRepaintTimer);
381
+ logRepaintTimer = null;
382
+ }
377
383
  setVersion((previous) => previous + 1);
384
+ };
385
+ const unsubscribe = controller.subscribe((event) => {
386
+ if (event.type === "log") {
387
+ if (logRepaintTimer) {
388
+ return;
389
+ }
390
+ logRepaintTimer = setTimeout(() => {
391
+ flushRepaint();
392
+ }, LOG_REPAINT_DEBOUNCE_MS);
393
+ return;
394
+ }
395
+ flushRepaint();
378
396
  });
379
397
  controller.mount();
380
398
  return () => {
399
+ if (logRepaintTimer) {
400
+ clearTimeout(logRepaintTimer);
401
+ }
381
402
  unsubscribe();
382
403
  controller.destroy();
383
404
  };
@@ -75,7 +75,7 @@ export function buildFlowTree(flows) {
75
75
  children: sortNodes(node.children),
76
76
  }
77
77
  : node);
78
- const orderedRootNames = ["custom", "default"];
78
+ const orderedRootNames = ["global", "custom", "default"];
79
79
  const sortedRoots = [...roots.values()].sort((left, right) => {
80
80
  const leftIndex = orderedRootNames.indexOf(left.name);
81
81
  const rightIndex = orderedRootNames.indexOf(right.name);
@@ -143,7 +143,7 @@ export function collectInitiallyExpandedFolderKeys(flowTree) {
143
143
  if (node.kind !== "folder") {
144
144
  continue;
145
145
  }
146
- const expandedByDefault = node.pathSegments.length === 1 && node.name === "default";
146
+ const expandedByDefault = node.pathSegments.length === 1 && (node.name === "default" || node.name === "global");
147
147
  if (expandedByDefault) {
148
148
  keys.push(node.key);
149
149
  }
@@ -1,9 +1,12 @@
1
1
  import { loadDeclarativeFlow } from "./declarative-flows.js";
2
- let cachedAutoGolangFlow = null;
3
- export function loadAutoGolangFlow() {
4
- if (cachedAutoGolangFlow) {
5
- return cachedAutoGolangFlow;
2
+ const cachedAutoGolangFlows = new Map();
3
+ export async function loadAutoGolangFlow(options = {}) {
4
+ const cacheKey = options.registryContext?.cacheKey ?? `cwd:${options.cwd ?? process.cwd()}`;
5
+ const cached = cachedAutoGolangFlows.get(cacheKey);
6
+ if (cached) {
7
+ return cached;
6
8
  }
7
- cachedAutoGolangFlow = loadDeclarativeFlow({ source: "built-in", fileName: "auto-golang.json" });
8
- return cachedAutoGolangFlow;
9
+ const flow = await loadDeclarativeFlow({ source: "built-in", fileName: "auto-golang.json" }, options);
10
+ cachedAutoGolangFlows.set(cacheKey, flow);
11
+ return flow;
9
12
  }
@@ -1,8 +1,8 @@
1
1
  import process from "node:process";
2
2
  import { getOutputAdapter } from "../tui.js";
3
- import { createNodeRegistry } from "./node-registry.js";
4
- import { createExecutorRegistry } from "./registry.js";
5
- export function createPipelineContext(input) {
3
+ import { createPipelineRegistryContext } from "./plugin-loader.js";
4
+ export async function createPipelineContext(input) {
5
+ const registryContext = input.registryContext ?? await createPipelineRegistryContext(process.cwd());
6
6
  return {
7
7
  issueKey: input.issueKey,
8
8
  jiraRef: input.jiraRef,
@@ -13,8 +13,9 @@ export function createPipelineContext(input) {
13
13
  verbose: input.verbose,
14
14
  mdLang: input.mdLang ?? null,
15
15
  runtime: input.runtime,
16
- executors: createExecutorRegistry(),
17
- nodes: createNodeRegistry(),
16
+ executors: registryContext.executors,
17
+ nodes: registryContext.nodes,
18
+ registryContext,
18
19
  ...(input.setSummary ? { setSummary: input.setSummary } : {}),
19
20
  ...(input.requestUserInput ? { requestUserInput: input.requestUserInput } : {}),
20
21
  ...(input.executionRouting ? { executionRouting: input.executionRouting } : {}),
@@ -1,27 +1,31 @@
1
1
  import path from "node:path";
2
- import { createNodeRegistry } from "./node-registry.js";
3
- import { createExecutorRegistry } from "./registry.js";
2
+ import { createPipelineRegistryContext } from "./plugin-loader.js";
4
3
  import { compileFlowSpec } from "./spec-compiler.js";
5
- import { listBuiltInFlowSpecFiles, listProjectFlowSpecFiles, loadFlowSpecSync, projectFlowSpecsDir, resolveBuiltInFlowSpecPath } from "./spec-loader.js";
4
+ import { globalFlowSpecsDir, listBuiltInFlowSpecFiles, listGlobalFlowSpecFiles, listProjectFlowSpecFiles, loadFlowSpecSync, projectFlowSpecsDir, resolveBuiltInFlowSpecPath, } from "./spec-loader.js";
6
5
  import { validateExpandedPhases, validateFlowSpec } from "./spec-validator.js";
7
6
  const cache = new Map();
8
7
  function toFlowSpecSource(ref) {
9
- return ref.source === "built-in" ? { source: "built-in", fileName: ref.fileName } : { source: "project-local", filePath: ref.filePath };
8
+ return ref.source === "built-in"
9
+ ? { source: "built-in", fileName: ref.fileName }
10
+ : { source: ref.source, filePath: ref.filePath };
10
11
  }
11
- function cacheKey(ref) {
12
- return ref.source === "built-in" ? `built-in:${ref.fileName}` : `project-local:${path.resolve(ref.filePath)}`;
12
+ function cacheKey(ref, registryContext) {
13
+ const flowKey = ref.source === "built-in"
14
+ ? `built-in:${ref.fileName}`
15
+ : `${ref.source}:${path.resolve(ref.filePath)}`;
16
+ return `${registryContext.cacheKey}:${flowKey}`;
13
17
  }
14
- export function loadDeclarativeFlow(flow) {
18
+ export async function loadDeclarativeFlow(flow, options = {}) {
15
19
  const ref = typeof flow === "string" ? { source: "built-in", fileName: flow } : flow;
16
- const cached = cache.get(cacheKey(ref));
20
+ const cwd = path.resolve(options.cwd ?? options.registryContext?.cwd ?? process.cwd());
21
+ const registryContext = options.registryContext ?? await createPipelineRegistryContext(cwd);
22
+ const cached = cache.get(cacheKey(ref, registryContext));
17
23
  if (cached) {
18
24
  return cached;
19
25
  }
20
26
  const spec = loadFlowSpecSync(toFlowSpecSource(ref));
21
- const nodeRegistry = createNodeRegistry();
22
- const executorRegistry = createExecutorRegistry();
23
- validateFlowSpec(spec, nodeRegistry, executorRegistry, {
24
- resolveFlowByName: (fileName) => resolveNamedDeclarativeFlowRef(fileName, process.cwd()),
27
+ validateFlowSpec(spec, registryContext.nodes, registryContext.executors, {
28
+ resolveFlowByName: (fileName) => resolveNamedDeclarativeFlowRef(fileName, cwd),
25
29
  });
26
30
  const phases = compileFlowSpec(spec);
27
31
  validateExpandedPhases(phases);
@@ -36,33 +40,48 @@ export function loadDeclarativeFlow(flow) {
36
40
  fileName: ref.source === "built-in" ? ref.fileName : path.basename(ref.filePath),
37
41
  absolutePath: ref.source === "built-in" ? resolveBuiltInFlowSpecPath(ref.fileName) : path.resolve(ref.filePath),
38
42
  };
39
- cache.set(cacheKey(ref), loaded);
43
+ cache.set(cacheKey(ref, registryContext), loaded);
40
44
  return loaded;
41
45
  }
42
46
  export function resolveNamedDeclarativeFlowRef(fileName, cwd) {
43
47
  const projectMatches = listProjectFlowSpecFiles(cwd).filter((candidate) => path.basename(candidate) === fileName);
48
+ const globalMatches = listGlobalFlowSpecFiles().filter((candidate) => path.basename(candidate) === fileName);
44
49
  const builtInMatches = listBuiltInFlowSpecFiles().filter((candidate) => path.basename(candidate) === fileName);
45
- if (projectMatches.length > 0 && builtInMatches.length > 0) {
46
- throw new Error(`Ambiguous nested flow '${fileName}': both built-in and project-local specs exist in ${projectFlowSpecsDir(cwd)}.`);
50
+ const matches = [
51
+ ...builtInMatches.map((candidate) => ({ source: "built-in", candidate })),
52
+ ...globalMatches.map((candidate) => ({ source: "global", candidate })),
53
+ ...projectMatches.map((candidate) => ({ source: "project-local", candidate })),
54
+ ];
55
+ if (matches.length > 1) {
56
+ throw new Error(`Ambiguous nested flow '${fileName}': matches exist in built-in flows, ${globalFlowSpecsDir()}, or ${projectFlowSpecsDir(cwd)}. Use unique nested flow file names.`);
47
57
  }
48
58
  if (projectMatches.length > 1) {
49
59
  throw new Error(`Ambiguous project-local flow '${fileName}' in ${projectFlowSpecsDir(cwd)}.`);
50
60
  }
61
+ if (globalMatches.length > 1) {
62
+ throw new Error(`Ambiguous global flow '${fileName}' in ${globalFlowSpecsDir()}.`);
63
+ }
51
64
  if (builtInMatches.length > 1) {
52
65
  throw new Error(`Ambiguous built-in flow '${fileName}'. Use unique nested flow file names.`);
53
66
  }
54
67
  if (projectMatches[0]) {
55
68
  return { source: "project-local", filePath: projectMatches[0] };
56
69
  }
70
+ if (globalMatches[0]) {
71
+ return { source: "global", filePath: globalMatches[0] };
72
+ }
57
73
  if (builtInMatches[0]) {
58
74
  return { source: "built-in", fileName: builtInMatches[0] };
59
75
  }
60
76
  throw new Error(`Nested flow '${fileName}' was not found.`);
61
77
  }
62
- export function loadNamedDeclarativeFlow(fileName, cwd) {
63
- return loadDeclarativeFlow(resolveNamedDeclarativeFlowRef(fileName, cwd));
78
+ export async function loadNamedDeclarativeFlow(fileName, cwd, options = {}) {
79
+ return loadDeclarativeFlow(resolveNamedDeclarativeFlowRef(fileName, cwd), {
80
+ cwd,
81
+ ...(options.registryContext ? { registryContext: options.registryContext } : {}),
82
+ });
64
83
  }
65
- export function collectFlowRoutingGroups(flow, cwd, visited = new Set()) {
84
+ export async function collectFlowRoutingGroups(flow, cwd, visited = new Set(), options = {}) {
66
85
  if (visited.has(flow.absolutePath)) {
67
86
  return [];
68
87
  }
@@ -80,8 +99,8 @@ export function collectFlowRoutingGroups(flow, cwd, visited = new Set()) {
80
99
  if (!nestedFlowName || !("const" in nestedFlowName) || typeof nestedFlowName.const !== "string") {
81
100
  continue;
82
101
  }
83
- const nestedFlow = loadNamedDeclarativeFlow(nestedFlowName.const, cwd);
84
- for (const nestedGroup of collectFlowRoutingGroups(nestedFlow, cwd, visited)) {
102
+ const nestedFlow = await loadNamedDeclarativeFlow(nestedFlowName.const, cwd, options);
103
+ for (const nestedGroup of await collectFlowRoutingGroups(nestedFlow, cwd, visited, options)) {
85
104
  groups.add(nestedGroup);
86
105
  }
87
106
  }
@@ -2,7 +2,7 @@ import path from "node:path";
2
2
  import { TaskRunnerError } from "../errors.js";
3
3
  import { loadAutoGolangFlow } from "./auto-flow.js";
4
4
  import { collectFlowRoutingGroups, loadDeclarativeFlow } from "./declarative-flows.js";
5
- import { listBuiltInFlowSpecFiles, listProjectFlowSpecFiles, projectFlowSpecsDir } from "./spec-loader.js";
5
+ import { globalFlowSpecsDir, listBuiltInFlowSpecFiles, listGlobalFlowSpecFiles, listProjectFlowSpecFiles, projectFlowSpecsDir } from "./spec-loader.js";
6
6
  export const BUILT_IN_COMMAND_FLOW_IDS = [
7
7
  "auto-golang",
8
8
  "auto-common",
@@ -58,11 +58,13 @@ function builtInCommandIdForFile(fileName) {
58
58
  }
59
59
  return null;
60
60
  }
61
- function loadBuiltInCatalogEntry(fileName) {
61
+ async function loadBuiltInCatalogEntry(fileName, options) {
62
62
  const commandId = builtInCommandIdForFile(fileName);
63
63
  const relativePath = fileName.replace(/\.json$/i, "").split(/[\\/]+/).filter((segment) => segment.length > 0);
64
64
  const id = commandId ?? relativePath.join("/");
65
- const flow = id === "auto-golang" ? loadAutoGolangFlow() : loadDeclarativeFlow({ source: "built-in", fileName });
65
+ const flow = id === "auto-golang"
66
+ ? await loadAutoGolangFlow(options)
67
+ : await loadDeclarativeFlow({ source: "built-in", fileName }, options);
66
68
  return {
67
69
  id,
68
70
  source: "built-in",
@@ -72,8 +74,8 @@ function loadBuiltInCatalogEntry(fileName) {
72
74
  flow,
73
75
  };
74
76
  }
75
- function loadProjectCatalogEntry(cwd, filePath) {
76
- const flow = loadDeclarativeFlow({ source: "project-local", filePath });
77
+ async function loadProjectCatalogEntry(cwd, filePath, options) {
78
+ const flow = await loadDeclarativeFlow({ source: "project-local", filePath }, { ...options, cwd });
77
79
  const relativeFilePath = path.relative(projectFlowSpecsDir(cwd), path.resolve(filePath));
78
80
  const relativePathWithoutExt = relativeFilePath.replace(/\.json$/i, "");
79
81
  const relativeSegments = relativePathWithoutExt.split(path.sep).filter((segment) => segment.length > 0);
@@ -86,10 +88,30 @@ function loadProjectCatalogEntry(cwd, filePath) {
86
88
  flow,
87
89
  };
88
90
  }
89
- export function loadInteractiveFlowCatalog(cwd) {
90
- const entries = listBuiltInFlowSpecFiles().map((fileName) => loadBuiltInCatalogEntry(fileName));
91
+ async function loadGlobalCatalogEntry(filePath, options) {
92
+ const flow = await loadDeclarativeFlow({ source: "global", filePath }, options);
93
+ const relativeFilePath = path.relative(globalFlowSpecsDir(), path.resolve(filePath));
94
+ const relativePathWithoutExt = relativeFilePath.replace(/\.json$/i, "");
95
+ const relativeSegments = relativePathWithoutExt.split(path.sep).filter((segment) => segment.length > 0);
96
+ return {
97
+ id: relativeSegments.join("/"),
98
+ source: "global",
99
+ fileName: path.basename(filePath),
100
+ absolutePath: path.resolve(filePath),
101
+ treePath: ["global", ...relativeSegments],
102
+ flow,
103
+ };
104
+ }
105
+ export async function loadInteractiveFlowCatalog(cwd, options = {}) {
106
+ const entries = [];
107
+ for (const fileName of listBuiltInFlowSpecFiles()) {
108
+ entries.push(await loadBuiltInCatalogEntry(fileName, { ...options, cwd }));
109
+ }
110
+ for (const filePath of listGlobalFlowSpecFiles()) {
111
+ entries.push(await loadGlobalCatalogEntry(filePath, { ...options, cwd }));
112
+ }
91
113
  for (const filePath of listProjectFlowSpecFiles(cwd)) {
92
- entries.push(loadProjectCatalogEntry(cwd, filePath));
114
+ entries.push(await loadProjectCatalogEntry(cwd, filePath, { ...options, cwd }));
93
115
  }
94
116
  const visibleEntries = entries.filter((entry) => entry.flow.catalogVisibility !== "hidden");
95
117
  const byId = new Map();
@@ -111,13 +133,13 @@ export function isBuiltInCommandFlowId(flowId) {
111
133
  export function toDeclarativeFlowRef(entry) {
112
134
  return entry.source === "built-in"
113
135
  ? { source: "built-in", fileName: entry.fileName }
114
- : { source: "project-local", filePath: entry.absolutePath };
136
+ : { source: entry.source, filePath: entry.absolutePath };
115
137
  }
116
138
  export function flowRoutingKey(entry) {
117
- return entry.source === "project-local"
118
- ? `project-local:${entry.absolutePath}`
119
- : `built-in:${entry.id}`;
139
+ return entry.source === "built-in"
140
+ ? `built-in:${entry.id}`
141
+ : `${entry.source}:${entry.absolutePath}`;
120
142
  }
121
- export function flowRoutingGroups(entry, cwd) {
122
- return collectFlowRoutingGroups(entry.flow, cwd);
143
+ export async function flowRoutingGroups(entry, cwd, options = {}) {
144
+ return collectFlowRoutingGroups(entry.flow, cwd, new Set(), options);
123
145
  }
@@ -68,6 +68,7 @@
68
68
  "fileName": { "const": "design-review-loop.json" },
69
69
  "labelText": { "const": "Running design-review loop" },
70
70
  "taskKey": { "ref": "params.taskKey" },
71
+ "baseIteration": { "ref": "params.designReviewBaseIteration" },
71
72
  "workspaceDir": { "ref": "params.workspaceDir" },
72
73
  "extraPrompt": { "ref": "params.extraPrompt" },
73
74
  "llmExecutor": { "ref": "params.llmExecutor" },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "kind": "auto-flow",
3
3
  "version": 1,
4
- "description": "End-to-end resumable pipeline for Go projects. Runs the full sequence: Jira fetch → task source normalization → plan → implement → linter loop → test loop → review loop → final linter loop → final test loop. Supports --from to restart from a specific phase and auto-status/auto-reset for state management.",
4
+ "description": "End-to-end resumable pipeline for Go projects. Runs the full sequence: Jira fetch → task source normalization → plan → design-review loop → implement → linter loop → test loop → review loop → final linter loop → final test loop. Supports --from to restart from a specific phase and auto-status/auto-reset for state management.",
5
5
  "constants": {
6
6
  "autoReviewFixExtraPrompt": "Fix only blockers, criticals, and important findings"
7
7
  },
@@ -61,6 +61,32 @@
61
61
  }
62
62
  ]
63
63
  },
64
+ {
65
+ "id": "design_review_loop",
66
+ "steps": [
67
+ {
68
+ "id": "run_design_review_loop",
69
+ "node": "flow-run",
70
+ "params": {
71
+ "fileName": { "const": "design-review-loop.json" },
72
+ "labelText": { "const": "Running design-review loop" },
73
+ "taskKey": { "ref": "params.taskKey" },
74
+ "baseIteration": { "ref": "params.designReviewBaseIteration" },
75
+ "workspaceDir": { "ref": "params.workspaceDir" },
76
+ "extraPrompt": { "ref": "params.extraPrompt" },
77
+ "llmExecutor": { "ref": "params.llmExecutor" },
78
+ "llmModel": { "ref": "params.llmModel" }
79
+ },
80
+ "stopFlowIf": {
81
+ "equals": [
82
+ { "ref": "steps.design_review_loop.run_design_review_loop.value.executionState.terminationOutcome" },
83
+ { "const": "stopped" }
84
+ ]
85
+ },
86
+ "stopFlowOutcome": "stopped"
87
+ }
88
+ ]
89
+ },
64
90
  {
65
91
  "id": "implement",
66
92
  "steps": [
@@ -23,6 +23,12 @@
23
23
  "template": "Running design review (iteration ${iteration})"
24
24
  },
25
25
  "taskKey": { "ref": "params.taskKey" },
26
+ "iteration": {
27
+ "add": [
28
+ { "ref": "params.baseIteration" },
29
+ { "add": [{ "ref": "repeat.iteration" }, { "const": -1 }] }
30
+ ]
31
+ },
26
32
  "workspaceDir": { "ref": "params.workspaceDir" },
27
33
  "extraPrompt": { "ref": "params.extraPrompt" },
28
34
  "llmExecutor": { "ref": "params.llmExecutor" },
@@ -33,7 +39,13 @@
33
39
  "id": "check_design_review_verdict",
34
40
  "node": "design-review-verdict",
35
41
  "params": {
36
- "taskKey": { "ref": "params.taskKey" }
42
+ "taskKey": { "ref": "params.taskKey" },
43
+ "iteration": {
44
+ "add": [
45
+ { "ref": "params.baseIteration" },
46
+ { "add": [{ "ref": "repeat.iteration" }, { "const": -1 }] }
47
+ ]
48
+ }
37
49
  }
38
50
  },
39
51
  {
@@ -258,7 +258,8 @@
258
258
  "requiredArtifacts": {
259
259
  "artifactList": {
260
260
  "kind": "plan-artifacts",
261
- "taskKey": { "ref": "params.taskKey" }
261
+ "taskKey": { "ref": "params.taskKey" },
262
+ "iteration": { "ref": "params.planIteration" }
262
263
  }
263
264
  }
264
265
  },
@@ -269,7 +270,8 @@
269
270
  "paths": {
270
271
  "artifactList": {
271
272
  "kind": "plan-artifacts",
272
- "taskKey": { "ref": "params.taskKey" }
273
+ "taskKey": { "ref": "params.taskKey" },
274
+ "iteration": { "ref": "params.planIteration" }
273
275
  }
274
276
  },
275
277
  "message": "Plan mode did not produce the required artifacts."
@@ -1,32 +1,44 @@
1
- export const LLM_EXECUTOR_IDS = ["codex", "opencode"];
2
- export const ALLOWED_MODELS_BY_EXECUTOR = {
3
- codex: ["gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex"],
4
- opencode: ["opencode/minimax-m2.5-free", "minimax-coding-plan/MiniMax-M2.7", "zhipuai-coding-plan/glm-5.1", "zhipuai-coding-plan/glm-4.7"],
5
- };
1
+ import { createExecutorRegistry } from "./registry.js";
2
+ const BUILT_IN_EXECUTOR_REGISTRY = createExecutorRegistry();
6
3
  export const DEFAULT_EXECUTOR = "opencode";
7
- export const DEFAULT_MODEL_BY_EXECUTOR = {
8
- codex: "gpt-5.4",
9
- opencode: "minimax-coding-plan/MiniMax-M2.7",
10
- };
11
4
  export const DEFAULT_LAUNCH_PROFILE = {
12
5
  executor: DEFAULT_EXECUTOR,
13
- model: DEFAULT_MODEL_BY_EXECUTOR[DEFAULT_EXECUTOR],
6
+ model: "minimax-coding-plan/MiniMax-M2.7",
14
7
  };
15
- export function defaultModelForExecutor(executor) {
16
- return DEFAULT_MODEL_BY_EXECUTOR[executor];
8
+ function registryOrBuiltIn(executors) {
9
+ return executors ?? BUILT_IN_EXECUTOR_REGISTRY;
10
+ }
11
+ export function llmExecutorIds(executors) {
12
+ return registryOrBuiltIn(executors).llmExecutors().map((entry) => entry.id);
13
+ }
14
+ export function defaultModelForExecutor(executor, executors) {
15
+ const routing = registryOrBuiltIn(executors).getRouting(executor);
16
+ if (!routing || routing.kind !== "llm") {
17
+ throw new Error(`Unsupported llm executor '${executor}'.`);
18
+ }
19
+ return routing.defaultModel;
20
+ }
21
+ export function isLlmExecutorId(value, executors) {
22
+ const routing = registryOrBuiltIn(executors).getRouting(value);
23
+ return routing?.kind === "llm";
17
24
  }
18
- export function isLlmExecutorId(value) {
19
- return LLM_EXECUTOR_IDS.includes(value);
25
+ export function isAllowedModelForExecutor(executor, model, executors) {
26
+ const routing = registryOrBuiltIn(executors).getRouting(executor);
27
+ return routing?.kind === "llm" ? routing.models.includes(model) : false;
20
28
  }
21
- export function isAllowedModelForExecutor(executor, model) {
22
- return ALLOWED_MODELS_BY_EXECUTOR[executor].includes(model);
29
+ export function allowedModelsForExecutor(executor, executors) {
30
+ const routing = registryOrBuiltIn(executors).getRouting(executor);
31
+ if (!routing || routing.kind !== "llm") {
32
+ throw new Error(`Unsupported llm executor '${executor}'.`);
33
+ }
34
+ return [...routing.models];
23
35
  }
24
- export function resolveLaunchProfile(selection, fallback = DEFAULT_LAUNCH_PROFILE) {
36
+ export function resolveLaunchProfile(selection, fallback = DEFAULT_LAUNCH_PROFILE, executors) {
25
37
  const executor = selection.executor === "default" ? fallback.executor : selection.executor;
26
38
  const model = selection.model === "default"
27
39
  ? selection.executor === "default"
28
40
  ? fallback.model
29
- : defaultModelForExecutor(executor)
41
+ : defaultModelForExecutor(executor, executors)
30
42
  : selection.model;
31
43
  return {
32
44
  executor,
@@ -0,0 +1 @@
1
+ export {};