cloving 1.4.0 → 1.5.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 (167) hide show
  1. package/README.md +100 -15
  2. package/dist/src/broker/actor/authority-health.d.ts +9 -0
  3. package/dist/src/broker/actor/authority-health.js +24 -0
  4. package/dist/src/broker/actor/authority-runtime.d.ts +4 -2
  5. package/dist/src/broker/actor/authority-runtime.js +29 -20
  6. package/dist/src/broker/actor/authority-session.d.ts +6 -19
  7. package/dist/src/broker/actor/authority-session.js +66 -88
  8. package/dist/src/broker/actor/authority-settings.d.ts +2 -1
  9. package/dist/src/broker/actor/authority-settings.js +4 -1
  10. package/dist/src/broker/actor/authority-upgrades.d.ts +5 -1
  11. package/dist/src/broker/actor/authority-upgrades.js +2 -1
  12. package/dist/src/broker/actor/authority-watchdog.d.ts +6 -2
  13. package/dist/src/broker/actor/authority-watchdog.js +31 -9
  14. package/dist/src/broker/actor/authority.js +3 -1
  15. package/dist/src/broker/actor/projection-memory.d.ts +43 -0
  16. package/dist/src/broker/actor/projection-memory.js +207 -0
  17. package/dist/src/broker/actor/runtime-parts.d.ts +5 -0
  18. package/dist/src/broker/actor/runtime-parts.js +8 -3
  19. package/dist/src/broker/authority-restore.js +1 -1
  20. package/dist/src/broker/effect-upgrade.d.ts +1 -0
  21. package/dist/src/broker/effect-upgrade.js +4 -1
  22. package/dist/src/cli/arguments.js +1 -0
  23. package/dist/src/cli/client.d.ts +3 -1
  24. package/dist/src/cli/client.js +4 -4
  25. package/dist/src/cli/command-requests.d.ts +30 -1
  26. package/dist/src/cli/command-requests.js +43 -2
  27. package/dist/src/cli/commands.js +57 -48
  28. package/dist/src/cli/config-notice.js +5 -2
  29. package/dist/src/cli/doctor-retention.d.ts +2 -0
  30. package/dist/src/cli/doctor-retention.js +93 -0
  31. package/dist/src/cli/doctor.js +5 -0
  32. package/dist/src/cli/init-prompt.js +1 -1
  33. package/dist/src/cli/mcp-server.js +4 -4
  34. package/dist/src/cli/mcp-tools.d.ts +14 -14
  35. package/dist/src/cli/mcp-tools.js +212 -50
  36. package/dist/src/cli/queue-arguments.d.ts +15 -0
  37. package/dist/src/cli/queue-arguments.js +31 -0
  38. package/dist/src/cli/queue-render.d.ts +3 -0
  39. package/dist/src/cli/queue-render.js +26 -0
  40. package/dist/src/cli/reload-render.d.ts +2 -0
  41. package/dist/src/cli/reload-render.js +12 -0
  42. package/dist/src/cli/render.js +37 -8
  43. package/dist/src/cli/report-github-reader.js +1 -1
  44. package/dist/src/cli/runtime-arguments.d.ts +17 -3
  45. package/dist/src/cli/runtime-arguments.js +23 -19
  46. package/dist/src/cli/toolchain.d.ts +1 -0
  47. package/dist/src/cli/toolchain.js +1 -0
  48. package/dist/src/cli/usage.d.ts +2 -2
  49. package/dist/src/cli/usage.js +40 -5
  50. package/dist/src/cli/watch-api-document.d.ts +430 -0
  51. package/dist/src/cli/watch-api-document.js +291 -0
  52. package/dist/src/cli/watch-api-run.d.ts +56 -0
  53. package/dist/src/cli/watch-api-run.js +112 -0
  54. package/dist/src/cli/watch-api.d.ts +17 -0
  55. package/dist/src/cli/watch-api.js +230 -0
  56. package/dist/src/cli/watch-header-view.d.ts +1 -0
  57. package/dist/src/cli/watch-header-view.js +9 -1
  58. package/dist/src/cli/watch-model.d.ts +1 -0
  59. package/dist/src/cli/watch-model.js +3 -0
  60. package/dist/src/cli/watch-page.js +3 -1
  61. package/dist/src/cli/watch-reads.d.ts +1 -0
  62. package/dist/src/cli/watch-reads.js +6 -5
  63. package/dist/src/cli/watch-render.d.ts +1 -0
  64. package/dist/src/cli/watch-server.js +18 -4
  65. package/dist/src/cli.js +50 -43
  66. package/dist/src/configuration-display.js +2 -0
  67. package/dist/src/configuration-schema.d.ts +5 -0
  68. package/dist/src/daemon/claude-recovery.js +13 -9
  69. package/dist/src/daemon/claude-worker.d.ts +3 -3
  70. package/dist/src/daemon/claude-worker.js +10 -11
  71. package/dist/src/daemon/daemon-adapters.d.ts +7 -0
  72. package/dist/src/daemon/daemon-adapters.js +7 -3
  73. package/dist/src/daemon/daemon-loops.d.ts +2 -0
  74. package/dist/src/daemon/daemon-loops.js +17 -11
  75. package/dist/src/daemon/daemon.js +71 -60
  76. package/dist/src/daemon/github-attach.js +77 -4
  77. package/dist/src/daemon/github-calls.js +1 -1
  78. package/dist/src/daemon/github-methods.js +6 -2
  79. package/dist/src/daemon/issue-queue.d.ts +3 -0
  80. package/dist/src/daemon/issue-queue.js +66 -73
  81. package/dist/src/daemon/merge-turns.d.ts +10 -0
  82. package/dist/src/daemon/merge-turns.js +37 -0
  83. package/dist/src/daemon/queue-control.d.ts +13 -0
  84. package/dist/src/daemon/queue-control.js +86 -0
  85. package/dist/src/daemon/queue-journal.d.ts +20 -0
  86. package/dist/src/daemon/queue-journal.js +89 -0
  87. package/dist/src/daemon/record-archive.d.ts +19 -0
  88. package/dist/src/daemon/record-archive.js +82 -0
  89. package/dist/src/daemon/record-sweep.d.ts +24 -0
  90. package/dist/src/daemon/record-sweep.js +147 -0
  91. package/dist/src/daemon/retrying-turn.js +4 -4
  92. package/dist/src/daemon/settings-reload.d.ts +26 -0
  93. package/dist/src/daemon/settings-reload.js +82 -0
  94. package/dist/src/daemon/turn-details.d.ts +4 -0
  95. package/dist/src/daemon/turn-details.js +33 -0
  96. package/dist/src/daemon/worker-profile.d.ts +1 -0
  97. package/dist/src/daemon/worker-profile.js +4 -0
  98. package/dist/src/daemon/workflow-generations.d.ts +7 -4
  99. package/dist/src/daemon/workflow-generations.js +22 -14
  100. package/dist/src/daemon/workflow-reload.d.ts +41 -0
  101. package/dist/src/daemon/workflow-reload.js +244 -0
  102. package/dist/src/daemon/workflow-replay.d.ts +2 -1
  103. package/dist/src/daemon/workflow-replay.js +7 -3
  104. package/dist/src/doctor-report.d.ts +2 -2
  105. package/dist/src/doctor-report.js +2 -0
  106. package/dist/src/private-config.schema.json +31 -0
  107. package/dist/src/project-settings.d.ts +15 -1
  108. package/dist/src/project-settings.js +33 -1
  109. package/dist/src/queue-state.d.ts +26 -0
  110. package/dist/src/queue-state.js +34 -0
  111. package/dist/src/record-retention.d.ts +46 -0
  112. package/dist/src/record-retention.js +81 -0
  113. package/dist/src/service/active-tickets.js +5 -0
  114. package/dist/src/service/dispatcher.d.ts +4 -0
  115. package/dist/src/service/dispatcher.js +33 -0
  116. package/dist/src/service/queue-handlers.d.ts +4 -0
  117. package/dist/src/service/queue-handlers.js +32 -0
  118. package/dist/src/service/registry.d.ts +28 -2
  119. package/dist/src/service/registry.js +48 -0
  120. package/dist/src/service/replies.d.ts +16 -9
  121. package/dist/src/service/replies.js +5 -2
  122. package/dist/src/service/run-handlers.js +15 -10
  123. package/dist/src/service/service.d.ts +7 -3
  124. package/dist/src/service/service.js +16 -31
  125. package/dist/src/service/worker-totals.d.ts +4 -0
  126. package/dist/src/service/worker-totals.js +7 -0
  127. package/dist/src/service/workflow-reload.d.ts +35 -0
  128. package/dist/src/service/workflow-reload.js +52 -0
  129. package/dist/src/storage/decision-snapshots.d.ts +1 -2
  130. package/dist/src/storage/decision-snapshots.js +1 -7
  131. package/dist/src/storage/domain-changes.d.ts +1 -1
  132. package/dist/src/storage/domain-changes.js +1 -0
  133. package/dist/src/storage/domain-schema.d.ts +4 -1
  134. package/dist/src/storage/domain-schema.js +6 -2
  135. package/dist/src/storage/effect-schema.d.ts +1 -0
  136. package/dist/src/storage/effect-schema.js +4 -0
  137. package/dist/src/storage/projection-changes.d.ts +12 -0
  138. package/dist/src/storage/projection-changes.js +194 -0
  139. package/dist/src/storage/projection-lookups.d.ts +7 -0
  140. package/dist/src/storage/projection-lookups.js +30 -0
  141. package/dist/src/workflow-declaration.d.ts +1 -0
  142. package/dist/src/workflow-declaration.js +3 -0
  143. package/dist/tools/compile-workflows.js +3 -0
  144. package/dist/tools/project-configuration.d.ts +7 -2
  145. package/dist/tools/project-configuration.js +30 -1
  146. package/dist/tools/self-host-processes.js +37 -13
  147. package/dist/tools/self-host-project.d.ts +2 -0
  148. package/dist/tools/self-host-project.js +21 -3
  149. package/dist/tools/self-host.js +34 -24
  150. package/dist/tools/workflow-candidate-program.d.ts +4 -2
  151. package/dist/tools/workflow-candidate-program.js +30 -11
  152. package/dist/tools/workflow-candidate-source.d.ts +1 -0
  153. package/dist/tools/workflow-candidate-source.js +11 -3
  154. package/package.json +2 -2
  155. package/templates/develop/README.md +81 -4
  156. package/templates/develop/develop/flow-tests.ts +1 -1
  157. package/templates/develop/develop/interruption-tests.ts +157 -0
  158. package/templates/develop/develop/local.ts +1 -0
  159. package/templates/develop/develop/option-tests.ts +138 -9
  160. package/templates/develop/develop/red-green.ts +55 -0
  161. package/templates/develop/develop/report.ts +31 -11
  162. package/templates/develop/develop/round.ts +1 -0
  163. package/templates/develop/develop/scenarios.ts +27 -0
  164. package/templates/develop/develop/turn.ts +26 -6
  165. package/templates/develop/develop.declaration.json +1 -1
  166. package/templates/develop/develop.test.ts +4 -0
  167. package/templates/develop/template.json +29 -2
@@ -113,6 +113,19 @@ function operatorDeclaration(staged, compiled) {
113
113
  functionName: workflow.workflowId.slice(workflow.workflowId.lastIndexOf("//") + 2),
114
114
  };
115
115
  }
116
+ async function recordProject(settings) {
117
+ const { project, recordedProject } = settings;
118
+ if (recordedProject === undefined)
119
+ return;
120
+ for (const name of [
121
+ "flow.mjs.__wf_tmp.js.debug.json",
122
+ "steps.mjs.debug.json",
123
+ ]) {
124
+ const path = join(project, "artifacts", name);
125
+ const text = await readFile(path, "utf8");
126
+ await writeFile(path, text.replaceAll(`${project}/`, `${recordedProject}/`));
127
+ }
128
+ }
116
129
  async function writeManifests(project, compiled) {
117
130
  const manifests = {};
118
131
  for (const [functionName, manifest] of Object.entries(compiled)) {
@@ -126,6 +139,13 @@ async function writeManifests(project, compiled) {
126
139
  return manifests;
127
140
  }
128
141
  export async function preparedProject(settings) {
142
+ const prepared = await preparedBundle(settings);
143
+ const modules = join(dirname(settings.project), "workflow-bundles/node_modules");
144
+ await rm(modules, { force: true });
145
+ await symlink(join(settings.project, "node_modules"), modules);
146
+ return prepared;
147
+ }
148
+ export async function preparedBundle(settings) {
129
149
  const { product, project } = settings;
130
150
  await initializeProject(settings);
131
151
  const folders = await loadWorkflowFolders(settings, {
@@ -133,6 +153,7 @@ export async function preparedProject(settings) {
133
153
  });
134
154
  const staged = await stageCandidates(project, folders.candidates);
135
155
  const compiled = await compile(settings);
156
+ await recordProject(settings);
136
157
  const combined = staged.map((candidate) => operatorDeclaration({ ...candidate, project }, compiled));
137
158
  const manifests = await writeManifests(project, workflowManifests(compiled, combined));
138
159
  await writeFile(join(project, "declarations.json"), JSON.stringify(combined));
@@ -145,9 +166,6 @@ export async function preparedProject(settings) {
145
166
  repository: folders.repository,
146
167
  directory: join(dirname(project), "workflow-bundles"),
147
168
  });
148
- const modules = join(dirname(project), "workflow-bundles/node_modules");
149
- await rm(modules, { force: true });
150
- await symlink(join(project, "node_modules"), modules);
151
169
  return {
152
170
  workflows: folders.workflows,
153
171
  repository: folders.repository,
@@ -1,10 +1,9 @@
1
1
  import { turnHarness, harnessDefaults, nativeHarness, } from "../src/harness-defaults.js";
2
2
  import { absoluteWorkflowFolder } from "../src/project-settings.js";
3
- import { projectConfiguration, projectRoot, topLevel, } from "./project-configuration.js";
4
- import { isProfilesFile } from "../src/turn-profile.js";
3
+ import { retentionSettings, } from "../src/record-retention.js";
4
+ import { configurationDigests, configuredTurnSettings, projectConfiguration, projectRoot, topLevel, } from "./project-configuration.js";
5
5
  import { parseCommands, parseCommandSeconds, } from "../src/command-settings.js";
6
- import { parseFallbackModels, parseTokenPrices, } from "../src/token-prices.js";
7
- import { readFile, writeFile } from "node:fs/promises";
6
+ import { writeFile } from "node:fs/promises";
8
7
  import { homedir } from "node:os";
9
8
  import { dirname, extname, join, resolve } from "node:path";
10
9
  import { pathToFileURL } from "node:url";
@@ -66,19 +65,12 @@ function checkedWorker(name) {
66
65
  });
67
66
  }
68
67
  }
69
- async function profilesFile() {
70
- const path = environment["CLOVING_PROFILES"];
71
- if (path === undefined)
72
- return configuration.profiles;
73
- try {
74
- const value = JSON.parse(await readFile(path, "utf8"));
75
- if (isProfilesFile(value))
76
- return value.profiles;
77
- }
78
- catch {
79
- throw new Error("The daemon did not start: invalid-profiles");
80
- }
81
- throw new Error("The daemon did not start: invalid-profiles");
68
+ function startRefusal(error) {
69
+ if (error instanceof Error && error.message === "invalid-profiles")
70
+ throw new Error("The daemon did not start: invalid-profiles", {
71
+ cause: error,
72
+ });
73
+ throw error;
82
74
  }
83
75
  function queueLanes() {
84
76
  const value = environment["CLOVING_QUEUE_LANES"];
@@ -153,6 +145,8 @@ if (opening.kind !== "started") {
153
145
  process.exit(1);
154
146
  }
155
147
  process.stdout.write(${JSON.stringify(guide(workflows))});
148
+ process.send?.("started");
149
+ process.channel?.unref();
156
150
  const stop = async () => { await opening.daemon.close(); process.exit(0); };
157
151
  process.once("SIGINT", stop);
158
152
  process.once("SIGTERM", stop);
@@ -193,11 +187,9 @@ function guide(workflows) {
193
187
  "",
194
188
  ].join("\n");
195
189
  }
196
- const tokenPrices = parseTokenPrices(environment["CLOVING_TOKEN_PRICES"]);
190
+ const { tokenPrices, fallbackModels, profiles } = await configuredTurnSettings(configuration).catch(startRefusal);
197
191
  const commands = parseCommands(environment["CLOVING_COMMANDS"]);
198
192
  const commandSeconds = parseCommandSeconds(environment["CLOVING_COMMAND_SECONDS"]);
199
- const fallbackModels = parseFallbackModels(environment["CLOVING_FALLBACK_MODELS"]);
200
- const profiles = await profilesFile();
201
193
  const schedules = parseSchedulesSetting(environment["CLOVING_SCHEDULES"]);
202
194
  const worker = selectedWorker();
203
195
  function nativeCommands() {
@@ -219,14 +211,18 @@ const ticketCommand = listed("CLOVING_TICKET_COMMAND");
219
211
  const queueDailySpendLimitUsd = queueSpendLimit();
220
212
  const lanes = queueLanes();
221
213
  const queueMilliseconds = queueInterval();
222
- const manifests = await preparedProject({
223
- product,
224
- project,
214
+ const retention = retentionSettings(environment);
215
+ const places = {
225
216
  repository,
226
217
  home: homedir(),
227
218
  xdgConfigHome: environment["XDG_CONFIG_HOME"],
219
+ };
220
+ const folders = {
221
+ ...places,
228
222
  userFolder: absoluteWorkflowFolder(environment["CLOVING_WORKFLOW_FOLDER"]),
229
- });
223
+ };
224
+ const profilesPath = environment["CLOVING_PROFILES"];
225
+ const manifests = await preparedProject({ product, project, ...folders });
230
226
  await launchers({
231
227
  commands,
232
228
  commandSeconds,
@@ -238,6 +234,7 @@ await launchers({
238
234
  harness,
239
235
  shell: environment["SHELL"] ?? "/bin/sh",
240
236
  profiles,
237
+ settingDigests: configurationDigests(configuration, profiles),
241
238
  command,
242
239
  ticketCommand,
243
240
  nextCommand,
@@ -245,11 +242,24 @@ await launchers({
245
242
  queueLanes: lanes,
246
243
  queueDailySpendLimitUsd,
247
244
  resetQueue: flags.includes("--reset-queue"),
245
+ retention,
248
246
  notifyCommand: listed("CLOVING_NOTIFY_COMMAND"),
249
247
  githubCommand: listed("CLOVING_GH_COMMAND"),
250
248
  requiredChecks: listed("CLOVING_REQUIRED_CHECKS") ?? null,
251
249
  checkCommand: listed("CLOVING_CHECK_COMMAND"),
252
250
  privateTexts: listed("CLOVING_PRIVATE_TEXTS"),
251
+ reloadCommand: [
252
+ process.execPath,
253
+ join(product, `tools/self-host-reload${extname(import.meta.filename)}`),
254
+ JSON.stringify({
255
+ directory,
256
+ ...places,
257
+ ...(profilesPath === undefined
258
+ ? {}
259
+ : { profilesFile: resolve(profilesPath) }),
260
+ fileVariables: Object.keys(environment).filter((name) => process.env[name] === undefined),
261
+ }),
262
+ ],
253
263
  }, manifests);
254
264
  process.exitCode = await hostedPrograms({
255
265
  environment,
@@ -5,9 +5,11 @@ export interface CandidatePackageOptions {
5
5
  readonly helpers?: Readonly<Record<string, string>>;
6
6
  }
7
7
  export declare function resolvedSymbol(checker: ts.TypeChecker, symbol: ts.Symbol | undefined): ts.Symbol | undefined;
8
- export declare function candidateProgram(text: string, installed?: CandidatePackageOptions): {
8
+ export interface CandidateProgram {
9
9
  readonly program: ts.Program;
10
10
  readonly source: ts.SourceFile;
11
11
  readonly sources: readonly ts.SourceFile[];
12
12
  readonly modules: ReadonlyMap<string, ts.SourceFile>;
13
- };
13
+ }
14
+ export declare function candidateProgram(text: string, installed?: CandidatePackageOptions): CandidateProgram;
15
+ export declare function candidateCompiler(installed?: CandidatePackageOptions): (text: string) => CandidateProgram;
@@ -10,9 +10,12 @@ export function resolvedSymbol(checker, symbol) {
10
10
  return checker.getAliasedSymbol(symbol);
11
11
  }
12
12
  export function candidateProgram(text, installed = {}) {
13
+ return candidateCompiler(installed)(text);
14
+ }
15
+ export function candidateCompiler(installed = {}) {
13
16
  const packageRoot = resolve(installed.packageRoot ?? candidatePackageRoot);
14
17
  const candidate = join(packageRoot, "candidate.ts");
15
- const sources = new Map([[candidate, text]]);
18
+ const sources = new Map([[candidate, ""]]);
16
19
  for (const [name, content] of Object.entries(installed.helpers ?? {})) {
17
20
  if (workflowHelperPath("candidate.ts", `./${name}`) !== name)
18
21
  throw new Error("invalid-workflow-helper");
@@ -32,12 +35,18 @@ export function candidateProgram(text, installed = {}) {
32
35
  noEmit: true,
33
36
  };
34
37
  const host = trustedHost({ sources, options, entries, packageRoot });
35
- const program = ts.createProgram([...sources.keys(), ...entries.values()], options, host);
36
- const source = program.getSourceFile(candidate);
38
+ return (text) => {
39
+ sources.set(candidate, text);
40
+ const program = ts.createProgram([...sources.keys(), ...entries.values()], options, host);
41
+ return compiledCandidate(program, { candidate, sources, entries });
42
+ };
43
+ }
44
+ function compiledCandidate(program, input) {
45
+ const source = program.getSourceFile(input.candidate);
37
46
  if (!source)
38
47
  throw new Error("candidate-analysis-unavailable");
39
48
  const modules = new Map();
40
- for (const [name, file] of entries) {
49
+ for (const [name, file] of input.entries) {
41
50
  const declaration = program.getSourceFile(file);
42
51
  if (!declaration)
43
52
  throw new Error("candidate-surface-unavailable");
@@ -47,7 +56,7 @@ export function candidateProgram(text, installed = {}) {
47
56
  program,
48
57
  source,
49
58
  modules,
50
- sources: [...sources.keys()].map((name) => {
59
+ sources: [...input.sources.keys()].map((name) => {
51
60
  const entry = program.getSourceFile(name);
52
61
  if (!entry)
53
62
  throw new Error("candidate-analysis-unavailable");
@@ -55,6 +64,21 @@ export function candidateProgram(text, installed = {}) {
55
64
  }),
56
65
  };
57
66
  }
67
+ function sourceReader(readFile) {
68
+ const declarations = new Map();
69
+ return (file, languageVersion) => {
70
+ const content = readFile(file);
71
+ if (content === undefined)
72
+ return undefined;
73
+ const cached = declarations.get(file);
74
+ if (cached?.text === content)
75
+ return cached;
76
+ const source = ts.createSourceFile(file, content, languageVersion, true);
77
+ if (file.endsWith(".d.ts"))
78
+ declarations.set(file, source);
79
+ return source;
80
+ };
81
+ }
58
82
  function trustedHost(input) {
59
83
  const { sources, options, entries, packageRoot } = input;
60
84
  const libraries = dirname(ts.getDefaultLibFilePath(options)) + sep;
@@ -72,12 +96,7 @@ function trustedHost(input) {
72
96
  const host = ts.createCompilerHost(options);
73
97
  host.readFile = readFile;
74
98
  host.fileExists = (file) => readFile(file) !== undefined;
75
- host.getSourceFile = (file, languageVersion) => {
76
- const content = readFile(file);
77
- return content === undefined
78
- ? undefined
79
- : ts.createSourceFile(file, content, languageVersion, true);
80
- };
99
+ host.getSourceFile = sourceReader(readFile);
81
100
  host.resolveModuleNameLiterals = (names, containingFile) => names.map(({ text: name }) => {
82
101
  const installed = entries.get(name);
83
102
  if (installed)
@@ -1,5 +1,6 @@
1
1
  import { type CandidatePackageOptions } from "./workflow-candidate-program.ts";
2
2
  import type { CandidateAnalysis, InstalledWorkflowExports } from "../src/workflow-candidate.ts";
3
+ export declare function createWorkflowCandidateAnalyzer(installed?: CandidatePackageOptions): (text: string) => ReturnType<typeof analyzeWorkflowCandidate>;
3
4
  export declare function analyzeWorkflowCandidate(text: string, installed?: CandidatePackageOptions): {
4
5
  readonly analysis: CandidateAnalysis;
5
6
  readonly installedExports: InstalledWorkflowExports;
@@ -1,12 +1,17 @@
1
1
  import { passiveCandidateModule, workflowShape, } from "./workflow-candidate-shape.js";
2
2
  import ts from "typescript";
3
3
  import { dirname, relative, sep } from "node:path";
4
- import { candidateProgram, resolvedSymbol, } from "./workflow-candidate-program.js";
4
+ import { candidateCompiler, candidateProgram, resolvedSymbol, } from "./workflow-candidate-program.js";
5
5
  import { protectedEffectFact, commandWaitCall, unsupportedCandidateNode, } from "./workflow-candidate-syntax.js";
6
6
  import { literalField } from "./workflow-candidate-fields.js";
7
7
  import { sourceEffectNodes } from "./workflow-source-effects.js";
8
8
  import { candidateEffectTotals } from "./workflow-candidate-counts.js";
9
9
  import { helperEffectTotals } from "./workflow-candidate-helper-counts.js";
10
+ export function createWorkflowCandidateAnalyzer(installed = {}) {
11
+ const compile = candidateCompiler(installed);
12
+ const localSources = Object.keys(installed.helpers ?? {});
13
+ return (text) => analyzeProgram(compile(text), localSources);
14
+ }
10
15
  function exportSnapshot(modules, checker) {
11
16
  return Object.fromEntries([...modules].map(([name, declaration]) => {
12
17
  const symbol = checker.getSymbolAtLocation(declaration);
@@ -90,7 +95,10 @@ function mergeHelpers(modules, checker) {
90
95
  });
91
96
  }
92
97
  export function analyzeWorkflowCandidate(text, installed = {}) {
93
- const { program, source, sources, modules } = candidateProgram(text, installed);
98
+ return analyzeProgram(candidateProgram(text, installed), Object.keys(installed.helpers ?? {}));
99
+ }
100
+ function analyzeProgram(compiled, localSources) {
101
+ const { program, source, sources, modules } = compiled;
94
102
  const checker = program.getTypeChecker();
95
103
  const symbols = {
96
104
  checker,
@@ -125,7 +133,7 @@ export function analyzeWorkflowCandidate(text, installed = {}) {
125
133
  const facts = sources.map((entry) => analyzedSource({ ...symbols, source: entry }, { program, workflow: source }));
126
134
  return {
127
135
  analysis: combineFacts({
128
- localSources: Object.keys(installed.helpers ?? {}),
136
+ localSources,
129
137
  facts,
130
138
  expanded: helperEffectTotals({
131
139
  symbols: { ...symbols, source },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloving",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Local durable development workflow service",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -128,7 +128,7 @@
128
128
  "check:format": "prettier --check src tools quality test eslint.config.mjs package.json tsconfig.json tsconfig.build.json",
129
129
  "test:quality": "node --test test/quality/*.test.ts",
130
130
  "record:test-durations": "node tools/record-test-durations.ts",
131
- "test:scale": "node --test --test-concurrency=1 test/authority-opening-scale.benchmark.ts",
131
+ "test:scale": "node --test --test-concurrency=1 test/authority-opening-scale.benchmark.ts test/status-scale.benchmark.ts",
132
132
  "check": "node tools/quality-pending.ts",
133
133
  "test:concurrency": "node tools/quality-pending.ts"
134
134
  },
@@ -1,6 +1,6 @@
1
1
  # Develop template decisions
2
2
 
3
- `template.json` describes the five decisions used by the develop template.
3
+ `template.json` describes the six decisions used by the develop template.
4
4
  The default workflow in `develop.ts` accepts the declared ticket input, selects
5
5
  from the queue, opens a pull request, waits for Greptile at 5/5, and calls
6
6
  `mergeWhenGreen` with `check: "beforeMerge"`. Its declaration and helper files
@@ -31,8 +31,9 @@ step bridge. The alternative configurations use `develop/option-tests.ts`, with
31
31
  point IDs in `develop/scenarios.ts`. For an edited copy, replace the default
32
32
  `flowTests` and `refusalTests` imports in `develop.test.ts` with
33
33
  `optionTests as flowTests`, then run every `flowTests` scenario. Keep the shared
34
- turn and partial-helper tests. Package tests apply each source edit, validate
35
- its requirements, and test seven complete configurations through the folder
34
+ turn, partial-helper and interruption tests. `develop/interruption-tests.ts`
35
+ follows the selected point IDs, so it tests each configuration. Package tests apply each source edit, validate
36
+ its requirements, and test ten complete configurations through the folder
36
37
  loader and the installed package, covering every option.
37
38
 
38
39
  `develop/turn.ts` implements the development turn for either a resolved
@@ -44,6 +45,22 @@ allow the initial turn and three fix turns; the workflow must stop after that.
44
45
  The package tests also load small compositions of this helper through folder
45
46
  admission for both input forms and test both forms in complete workflows.
46
47
 
48
+ `developReport` in `develop/report.ts` runs the turn again once when its result
49
+ has the reason `claude-turn-interrupted`. This occurs when the daemon cannot
50
+ resume an open turn after a restart. Before the daemon reports this reason, it
51
+ stops the processes of the earlier turn in the work tree. The new turn has the same target, so the
52
+ daemon gives it the same work tree. Its scope adds the key `continuation`, and
53
+ its prompt tells the worker that the earlier turn stopped before its report.
54
+ The worker checks the work tree, the commits since the start of the round, and
55
+ the ticket before it continues.
56
+
57
+ After a second interruption of the same turn, `developReport` returns a blocked
58
+ outcome with the reason `turn-interrupted`. `develop/round.ts` and
59
+ `develop/local.ts` return this outcome before they use the report. A
60
+ pull-request workflow then adds `cloving: blocked` to the GitHub issue of the
61
+ ticket. Other results that are not `completed` fail the run. The default effect
62
+ limit of 31 includes one more turn in each of the four rounds.
63
+
47
64
  `develop/partial.ts` implements the person's partial-work question.
48
65
  `develop/partial-choice.ts` connects its answer to a development report and the
49
66
  next round, or returns a blocked outcome without opening a review or merging. `reviewPartial`
@@ -68,10 +85,70 @@ Task text keeps its full contents, including any issue-looking lines, without
68
85
  selecting an issue to label, close or return to the queue.
69
86
 
70
87
  The first option of each point is the default: ticket input, queue selection,
71
- Greptile at 5/5, merge after checks, and return partial work to the queue.
88
+ no additional verification, Greptile at 5/5, merge after checks, and return
89
+ partial work to the queue.
72
90
  Selecting `person` at selection, acceptance and partial work, with `local` at
73
91
  merge, describes the existing reviewed local-branch process.
74
92
 
93
+ The `verify` point defaults to `none`, preserving the current round and
94
+ declaration. Select `red-green` with either `green` or `person` at `merge`.
95
+ Import `verifyRedGreen` from `develop/red-green.ts` in `develop/round.ts`.
96
+ Immediately after `openPull`, before constructing the review subject or
97
+ calling `acceptWork`, add:
98
+
99
+ ```typescript
100
+ const verification = await verifyRedGreen({
101
+ cycleId: input.cycleId,
102
+ head: pull.commit,
103
+ round,
104
+ });
105
+ if (verification !== null && "kind" in verification) return verification;
106
+ if (verification !== null)
107
+ return repair({ cycleId: input.cycleId, round, report, result: verification });
108
+ ```
109
+
110
+ Add `"command"` to `workflow.capabilities`, `"commands": ["red-green"]` to
111
+ `workflow`, and `"command": ["run:red-green"]` to `methods` in
112
+ `develop.declaration.json`. Increase `workflow.effectLimit` by 4, from 31 to
113
+ 35, allowing one verification command in each of the four development rounds.
114
+ Set `selection.verify` to `"red-green"` in `develop/scenarios.ts` and run the
115
+ option tests. The local merge option does not support this verification.
116
+
117
+ The helper calls the operator command on the pull request's immutable head with
118
+ scope `{ phase: "verify", operation: "red-green", round }`.
119
+ `accepted` and `no-test-change` proceed to review. `false-red`,
120
+ `red-not-assertion`, `skipped-test` and `green-failed` pass the verdict and full
121
+ tool output through `repair` to the next fix turn. These fixes share the
122
+ existing four-round limit with review and merge fixes. `prepare-failed`, a
123
+ failed command or an invalid result blocks immediately with
124
+ `red-green-unavailable`, without a fix turn.
125
+
126
+ Register `red-green` in `CLOVING_COMMANDS` as an argument array containing the
127
+ absolute path of the installed `cloving` executable followed by `"red-green"`.
128
+ Configure the project file `.cloving/config.json`, for example:
129
+
130
+ ```json
131
+ {
132
+ "version": 1,
133
+ "checks": {
134
+ "redGreen": {
135
+ "test": ["node", "--test"],
136
+ "testFiles": ["test/"],
137
+ "prepare": ["npm", "ci", "--ignore-scripts"]
138
+ }
139
+ }
140
+ }
141
+ ```
142
+
143
+ The tool appends changed test paths to `test`; optional `prepare` runs in each
144
+ temporary worktree. The daemon passes this configuration as
145
+ `CLOVING_RED_GREEN`. For direct tool use, set that variable to the `redGreen`
146
+ object. The repository needs `refs/remotes/origin/HEAD` to identify the base.
147
+ Increase `CLOVING_COMMAND_SECONDS`, for example to `600`, to cover both
148
+ preparation and test runs. Optional `assertionPattern` and `skipPattern`
149
+ override the defaults for non-terminal `node --test` output. See the package
150
+ README's `cloving red-green` documentation for the tool's complete contract.
151
+
75
152
  Each option has an `id`, `label`, `summary`, `change`, `requires` and `needs`.
76
153
  `change` describes the step call or declaration edit for the setup agent.
77
154
  It is guidance for editing source, rather than an executable patch.
@@ -127,7 +127,7 @@ flowTests.push({
127
127
  flowTests.push({
128
128
  name: "CLV-221 the default workflow merges only a reviewed pull after the checks",
129
129
  run: async () => {
130
- using bridge = fakeStepBridge({ effects: effects(), effectLimit: 27 });
130
+ using bridge = fakeStepBridge({ effects: effects(), effectLimit: 31 });
131
131
  assert.deepEqual(await develop(input), {
132
132
  kind: "handoff_requested",
133
133
  cycleId: input.cycleId,
@@ -0,0 +1,157 @@
1
+ import assert from "node:assert/strict";
2
+ import {
3
+ fakeStepBridge,
4
+ type ScenarioRequest,
5
+ } from "cloving/workflows/testing";
6
+ import { develop } from "../develop.ts";
7
+ import {
8
+ approved,
9
+ callName,
10
+ completeCalls,
11
+ requestInput,
12
+ scenarioEffects,
13
+ } from "./option-tests.ts";
14
+ import { developReport, ticketInput } from "./report.ts";
15
+ import { completed, input } from "./scenarios.ts";
16
+
17
+ const interrupted = {
18
+ kind: "failed" as const,
19
+ operationId: "interrupted-turn",
20
+ reason: "claude-turn-interrupted",
21
+ };
22
+
23
+ function turns(requests: readonly ScenarioRequest[]) {
24
+ return requests.flatMap((request) =>
25
+ request[0] === "effect" && request[1].method === "run-turn"
26
+ ? [request[1]]
27
+ : [],
28
+ );
29
+ }
30
+
31
+ export const interruptionTests = [
32
+ {
33
+ name: "CLV-272 an interrupted turn runs again once in the same work tree",
34
+ run: async () => {
35
+ const scenario = scenarioEffects();
36
+ using bridge = fakeStepBridge({
37
+ effects: {
38
+ ...scenario,
39
+ "claude run-turn": [interrupted, ...scenario["claude run-turn"]],
40
+ },
41
+ decisions: [approved, approved],
42
+ });
43
+ assert.equal((await develop(requestInput())).kind, "handoff_requested");
44
+ const expected = completeCalls();
45
+ expected.splice(expected.indexOf("run-turn"), 0, "run-turn");
46
+ assert.deepEqual(bridge.requests.map(callName), expected);
47
+ const [first, second] = turns(bridge.requests);
48
+ assert.ok(first && second);
49
+ assert.deepEqual(first.scope, {
50
+ phase: "implement",
51
+ operation: "claude-turn",
52
+ round: 1,
53
+ });
54
+ assert.ok(!first.payload.includes("stopped before its report"));
55
+ assert.deepEqual(second.scope, {
56
+ phase: "implement",
57
+ operation: "claude-turn",
58
+ round: 1,
59
+ key: "continuation",
60
+ });
61
+ assert.equal(second.target, first.target);
62
+ assert.ok(second.payload.endsWith(first.payload));
63
+ for (const sentence of [
64
+ "The earlier turn of round 1 stopped before its report.",
65
+ "The work tree holds the work of that turn.",
66
+ "Before you continue, check the work tree, the commits since the start of the round, and the ticket.",
67
+ ])
68
+ assert.ok(second.payload.includes(sentence), sentence);
69
+ },
70
+ },
71
+ {
72
+ name: "CLV-272 a second interruption of the same turn blocks the run",
73
+ run: async () => {
74
+ using bridge = fakeStepBridge({
75
+ effects: {
76
+ ...scenarioEffects(),
77
+ "claude run-turn": [interrupted, interrupted],
78
+ },
79
+ });
80
+ assert.deepEqual(await develop(requestInput()), {
81
+ kind: "blocked",
82
+ cycleId: requestInput().cycleId,
83
+ reason: "turn-interrupted",
84
+ evidenceId: "implement",
85
+ });
86
+ assert.deepEqual(bridge.requests.map(callName), stoppedCalls());
87
+ const labels = bridge.requests.flatMap((request) =>
88
+ request[0] === "effect" && request[1].method === "label"
89
+ ? [request[1].payload]
90
+ : [],
91
+ );
92
+ for (const payload of labels.slice(1))
93
+ assert.match(payload, /"add":\["cloving: blocked"\]/);
94
+ },
95
+ },
96
+ {
97
+ name: "CLV-272 an interrupted fix turn continues with its round and feedback",
98
+ run: async () => {
99
+ using bridge = fakeStepBridge({
100
+ effects: {
101
+ "claude run-turn": [
102
+ interrupted,
103
+ completed({ summary: "Fixed.\nTicket status: complete" }),
104
+ ],
105
+ },
106
+ });
107
+ const output = "Check output.\n";
108
+ const report = await developReport({
109
+ input: ticketInput(input),
110
+ round: 2,
111
+ feedback: { reason: "check-failed", output, remaining: ["Add it."] },
112
+ });
113
+ assert.ok(!("kind" in report));
114
+ assert.equal(report.summary, "Fixed.\nTicket status: complete");
115
+ const [first, second] = turns(bridge.requests);
116
+ assert.ok(first && second);
117
+ assert.equal(first.scope.round, 2);
118
+ assert.equal(second.scope.round, 2);
119
+ assert.equal(second.scope.key, "continuation");
120
+ assert.ok(
121
+ second.payload.startsWith(
122
+ "The earlier turn of round 2 stopped before its report.",
123
+ ),
124
+ );
125
+ assert.ok(second.payload.endsWith(first.payload));
126
+ assert.ok(second.payload.endsWith(output));
127
+ assert.match(
128
+ second.payload,
129
+ /This is fix round 2\. Address check-failed/,
130
+ );
131
+ },
132
+ },
133
+ ...[
134
+ { kind: "failed" as const, operationId: "turn", reason: "worker-failed" },
135
+ { kind: "refused" as const, reason: "budget-exhausted" },
136
+ ].map((failure) => ({
137
+ name: `CLV-272 a ${failure.reason} continuation fails the run`,
138
+ run: async () => {
139
+ using bridge = fakeStepBridge({
140
+ effects: {
141
+ ...scenarioEffects(),
142
+ "claude run-turn": [interrupted, failure],
143
+ },
144
+ });
145
+ await assert.rejects(develop(requestInput()), {
146
+ message: failure.reason,
147
+ });
148
+ assert.deepEqual(bridge.requests.map(callName), stoppedCalls());
149
+ },
150
+ })),
151
+ ];
152
+
153
+ function stoppedCalls(): string[] {
154
+ const complete = completeCalls();
155
+ const started = complete.slice(0, complete.indexOf("run-turn") + 1);
156
+ return started.concat("run-turn", started.includes("label") ? ["label"] : []);
157
+ }
@@ -13,6 +13,7 @@ export async function localRound(
13
13
  ): Promise<CycleOutcome | DevelopFeedback> {
14
14
  const { input, round } = state;
15
15
  const report = await developReport(state);
16
+ if ("kind" in report) return report;
16
17
  const partial = await partialChoice({ input, round, report });
17
18
  if (partial !== null) return partial;
18
19
  if (