@method-ai-hq/sdk 0.18.2 → 0.20.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 (48) hide show
  1. package/README.md +1 -1
  2. package/dist/packages/sdk/package.json +7 -2
  3. package/dist/packages/sdk/src/authoring-instructions.d.ts +1 -1
  4. package/dist/packages/sdk/src/authoring-instructions.js +1 -1
  5. package/dist/packages/sdk/src/current-inspection.js +3 -0
  6. package/dist/packages/sdk/src/current-runtime.d.ts +6 -1
  7. package/dist/packages/sdk/src/current-runtime.js +47 -2
  8. package/dist/packages/sdk/src/fix-lines.d.ts +82 -0
  9. package/dist/packages/sdk/src/fix-lines.js +63 -0
  10. package/dist/packages/sdk/src/fix-outcomes.d.ts +72 -0
  11. package/dist/packages/sdk/src/fix-outcomes.js +201 -0
  12. package/dist/packages/sdk/src/home.d.ts +30 -26
  13. package/dist/packages/sdk/src/home.js +164 -60
  14. package/dist/packages/sdk/src/improve.d.ts +146 -24
  15. package/dist/packages/sdk/src/improve.js +665 -105
  16. package/dist/packages/sdk/src/judge.d.ts +23 -10
  17. package/dist/packages/sdk/src/judge.js +110 -26
  18. package/dist/packages/sdk/src/learned.d.ts +43 -0
  19. package/dist/packages/sdk/src/learned.js +97 -0
  20. package/dist/packages/sdk/src/local-cli.js +8 -3
  21. package/dist/packages/sdk/src/local-judgments.d.ts +2 -0
  22. package/dist/packages/sdk/src/merge.d.ts +38 -0
  23. package/dist/packages/sdk/src/merge.js +65 -0
  24. package/dist/packages/sdk/src/method-client.js +1 -1
  25. package/dist/packages/sdk/src/method-files.d.ts +3 -2
  26. package/dist/packages/sdk/src/method-files.js +7 -3
  27. package/dist/packages/sdk/src/method-help.d.ts +1 -1
  28. package/dist/packages/sdk/src/method-help.js +25 -16
  29. package/dist/packages/sdk/src/method-sync.d.ts +7 -0
  30. package/dist/packages/sdk/src/method-sync.js +33 -0
  31. package/dist/packages/sdk/src/method.d.ts +5 -0
  32. package/dist/packages/sdk/src/method.js +169 -15
  33. package/dist/packages/sdk/src/outbox.d.ts +28 -6
  34. package/dist/packages/sdk/src/outbox.js +13 -6
  35. package/dist/packages/sdk/src/proposals.d.ts +0 -28
  36. package/dist/packages/sdk/src/proposals.js +2 -129
  37. package/dist/packages/sdk/src/quality.d.ts +65 -5
  38. package/dist/packages/sdk/src/quality.js +82 -17
  39. package/dist/packages/sdk/src/run-saved.d.ts +4 -0
  40. package/dist/packages/sdk/src/run-saved.js +33 -2
  41. package/dist/packages/sdk/src/undo.d.ts +50 -0
  42. package/dist/packages/sdk/src/undo.js +108 -0
  43. package/dist/packages/sdk/src/versions.js +5 -3
  44. package/dist/packages/sdk/src/watch.d.ts +20 -0
  45. package/dist/packages/sdk/src/watch.js +86 -0
  46. package/dist/packages/workflow-language/src/inspection.d.ts +30 -0
  47. package/dist/packages/workflow-language/src/inspection.js +3 -1
  48. package/package.json +7 -2
@@ -1,5 +1,5 @@
1
- import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
- import { basename, dirname, join, resolve, sep } from 'node:path';
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, resolve, sep } from 'node:path';
3
3
  import { randomBytes, randomUUID } from 'node:crypto';
4
4
  import { isDeepStrictEqual } from 'node:util';
5
5
  import { MethodPackageSchema, packageDigest, packagePath } from '../../contracts/src/method-package.js';
@@ -9,13 +9,62 @@ import { fileHash } from './method-files.js';
9
9
  import { methodCache } from './prepare.js';
10
10
  import { writePrivateJson } from './files.js';
11
11
  import { currentVersion, methodContent, methodIdPattern } from './versions.js';
12
- import { changedSteps, localRuns, saveLocalProposal, writeMethodFile } from './proposals.js';
12
+ import { localRuns, saveLocalProposal, writeMethodFile } from './proposals.js';
13
+ import { changedSteps } from './merge.js';
13
14
  import { localJudgments, runFolderIndex } from './local-judgments.js';
15
+ import { fixFolder, readFixState, saveError, saveNotice, saveOutcome, setWorker, storedOutcome, writeFixState } from './fix-outcomes.js';
16
+ import { recordChange, saveVersion, snapshot, undoLast } from './undo.js';
17
+ import { fixUndoneLine, ruleBackLine } from './fix-lines.js';
18
+ const outcomeKinds = ['fixed', 'no_fix_found', 'could_not_test', 'question', 'held', 'not_reproduced', 'not_fixable_here'];
19
+ const reasons = ['missing_file', 'can_send', 'too_large', 'no_live_step'];
20
+ const plainText = (value) => typeof value === 'string' && value.trim() ? value.trim() : undefined;
14
21
  /**
15
- * Read the result of improve.method: {proposal: <SubmitProposal as JSON text>, kind, proposal_id, capped}. A change has
16
- * the tested workflow (and a changed package file for a script change); none has no workflow.
22
+ * One outcome of improve.method in the form the lines read: {id, outcome, detail, reason?, question?, held_with?,
23
+ * learned_rule?, again?, tests?: {case, base, candidate: {passes, runs}, guards: []}, tried?}. A fixed outcome's detail
24
+ * says what is different now; a held outcome's detail is about the loop, so the line names only the other person.
17
25
  */
18
- export function readImproveResult(result) {
26
+ export function loopOutcome(o) {
27
+ if (typeof o?.id !== 'string' || !outcomeKinds.includes(o.outcome))
28
+ throw Error('The improvement run has no outcome for each correction.');
29
+ const out = { id: o.id, outcome: o.outcome };
30
+ const detail = plainText(o.detail);
31
+ if (o.outcome === 'fixed') {
32
+ if (detail)
33
+ out.summary = detail;
34
+ }
35
+ else if (o.outcome !== 'held' && detail)
36
+ out.detail = detail;
37
+ if (o.outcome === 'could_not_test')
38
+ out.reason = reasons.includes(o.reason) ? o.reason : 'missing_file';
39
+ if (out.reason === 'missing_file' && typeof o.path === 'string' && o.path)
40
+ out.path = o.path;
41
+ if (o.outcome === 'question')
42
+ out.question = plainText(o.question) ?? 'What should the result have said instead?';
43
+ if (o.outcome === 'held' && plainText(o.held_with))
44
+ out.by = plainText(o.held_with);
45
+ if (typeof o.learned_rule === 'string' && o.learned_rule)
46
+ out.rule_added = true;
47
+ if (Number.isSafeInteger(o.tried) && o.tried > 0)
48
+ out.tried = o.tried;
49
+ const passes = o.tests?.candidate?.passes;
50
+ if (o.outcome === 'fixed' && Number.isSafeInteger(passes) && passes > 0)
51
+ out.tested = { times: passes, earlier: Array.isArray(o.tests?.guards) ? o.tests.guards.length : 0 };
52
+ if (o.again === true)
53
+ out.again = true;
54
+ // Optional words for the lines, when the loop gives them: the plain noun of what the Method works on, and the tested output.
55
+ if (plainText(o.subject))
56
+ out.subject = plainText(o.subject);
57
+ if (o.tested_output && typeof o.tested_output === 'object')
58
+ out.tested_output = o.tested_output;
59
+ return out;
60
+ }
61
+ /**
62
+ * Read the result of improve.method: {proposal: <SubmitProposal as JSON text>, kind, proposal_id, outcomes: <JSON text,
63
+ * one outcome per new correction>, background_cases: [case ids], summary: one plain line}. A change has the tested
64
+ * workflow and the changed package files (a script, learned.json). A pruning sweep (mode prune) has no outcomes; its
65
+ * summary says what it removed, and what it left because nothing checks it.
66
+ */
67
+ export function readImproveResult(result, options = {}) {
19
68
  const value = result;
20
69
  if (!value || typeof value.proposal !== 'string')
21
70
  throw Error('The improvement run gave no proposal.');
@@ -35,8 +84,20 @@ export function readImproveResult(result) {
35
84
  throw Error(`The improvement run says kind ${String(value.kind)}, but its proposal is kind ${evidence.kind}.`);
36
85
  if (evidence.kind === 'change' && (!proposal.workflow || typeof proposal.workflow !== 'object'))
37
86
  throw Error('The improvement proposal is a change without a workflow.');
87
+ let outcomes = [];
88
+ if (!options.prune) {
89
+ try {
90
+ outcomes = typeof value.outcomes === 'string' ? JSON.parse(value.outcomes) : undefined;
91
+ }
92
+ catch {
93
+ outcomes = undefined;
94
+ }
95
+ if (!Array.isArray(outcomes))
96
+ throw Error('The improvement run has no outcome for each correction.');
97
+ }
98
+ const background = Array.isArray(value.background_cases) ? value.background_cases.filter((c) => typeof c === 'string' && !!c) : [];
38
99
  return { cause: proposal.cause.trim(), workflow: evidence.kind === 'change' ? proposal.workflow : null, files: Array.isArray(proposal.files) ? proposal.files : [], evidence,
39
- capped: typeof value.capped === 'string' ? value.capped : '' };
100
+ capped: typeof value.capped === 'string' ? value.capped : '', outcomes: outcomes.map(loopOutcome), background_cases: [...new Set(background)], summary: plainText(value.summary) ?? '' };
40
101
  }
41
102
  /**
42
103
  * Download the published improve.method and its files into the Method cache, once per version. For developers of
@@ -76,11 +137,11 @@ export async function cachedImproveMethod(client) {
76
137
  return { file, root, version_id: saved.version_id };
77
138
  }
78
139
  /**
79
- * Run improve.method on this computer. Its model steps and its scripts' model requests follow the rule of every run:
140
+ * Run improve.method on this computer (mode prune: the pruning pass). Its model steps and its scripts' model requests follow the rule of every run:
80
141
  * the user's own Claude Code or Codex first, hosted models only without one. The run gets the signed-in key and server
81
142
  * as its IMPROVE_API_KEY and IMPROVE_SERVER secrets, for this run only. Its output goes to a log file.
82
143
  */
83
- export async function runImprovement(client, file, judgments, runFolders, run) {
144
+ export async function runImprovement(client, file, judgments, runFolders, run, mode) {
84
145
  const token = client.token();
85
146
  if (!token)
86
147
  throw Error('Sign in with method login, then run method judge again.');
@@ -90,7 +151,7 @@ export async function runImprovement(client, file, judgments, runFolders, run) {
90
151
  const judgmentsFile = join(runDir, 'judgments.json'), inputs = join(runDir, 'improve-inputs.json'), log = join(runDir, 'improve.log');
91
152
  // The judgments with their notes go in a private file of this run; they stay on this computer.
92
153
  writePrivateJson(judgmentsFile, judgments);
93
- writePrivateJson(inputs, { method_file: file, grant_id: '', judgment_id: '', judgments_file: judgmentsFile, run_folders: runFolders });
154
+ writePrivateJson(inputs, { method_file: file, grant_id: '', judgment_id: '', judgments_file: judgmentsFile, run_folders: runFolders, ...(mode ? { mode } : {}) });
94
155
  const runFile = run ?? (await import('./current-runtime.js')).runCurrentFile;
95
156
  // The run prints its own result and progress; they go to the log, so the person reads one line.
96
157
  const out = process.stdout.write, err = process.stderr.write;
@@ -110,52 +171,141 @@ export async function runImprovement(client, file, judgments, runFolders, run) {
110
171
  }
111
172
  if (result.status !== 'completed')
112
173
  throw Object.assign(Error(result.failure?.headline ?? `The improvement stopped with status ${result.status}.`), { log });
113
- const summary = (() => { try {
114
- return JSON.parse(readFileSync(join(runDir, 'run', 'summary.json'), 'utf8'));
115
- }
116
- catch {
117
- return {};
118
- } })();
119
- return { ...readImproveResult(result.result), log, summary };
174
+ return { ...readImproveResult(result.result, { prune: mode === 'prune' }), log };
120
175
  }
121
176
  /** The ID of the regression case that a correction makes: one per judgment, so a later loop finds it. */
122
177
  export const correctionCaseId = (judgmentId) => `correction-${judgmentId.replace(/^jdg_/, '').slice(0, 12)}`;
123
- const caseSchema = { type: 'object', properties: { criteria: { type: 'array', items: { type: 'string', description: 'One plain sentence that a right result meets.' } } } };
178
+ const caseSchema = { type: 'object', properties: {
179
+ criteria: { type: 'array', items: { type: 'object', properties: {
180
+ text: { type: 'string', description: 'One plain sentence that a right result meets.' },
181
+ ref: { type: 'string', description: 'The name, from "What the person saw", of the thing the sentence is about.' }
182
+ } } },
183
+ check_script: { type: 'string', description: 'A Node.js script that checks the form or the action, or an empty text.' },
184
+ check_says: { type: 'string', description: 'One plain sentence: what the script checks. Empty when there is no script.' },
185
+ } };
186
+ const textOf = (path) => {
187
+ const bytes = readFileSync(path);
188
+ return bytes.subarray(0, 8192).includes(0) ? `[a binary file of ${bytes.length} bytes]` : bytes.toString('utf8');
189
+ };
190
+ /** What the person saw of a run, with the ref that a case uses for each thing (runtime 0.16.0 refs). */
191
+ export function seenOfRun(outcome) {
192
+ const seen = [];
193
+ if (outcome.result !== undefined && outcome.result !== null)
194
+ seen.push({ ref: 'result', label: 'The result', value: outcome.result });
195
+ for (const [name, value] of Object.entries(outcome.outputs ?? {}))
196
+ seen.push({ ref: `outputs.${name}`, label: `The output ${name}`, value });
197
+ const files = [];
198
+ const walk = (dir, prefix) => {
199
+ let entries = [];
200
+ try {
201
+ entries = readdirSync(dir, { withFileTypes: true });
202
+ }
203
+ catch {
204
+ return;
205
+ }
206
+ for (const entry of entries) {
207
+ if (files.length >= 20 || entry.name.startsWith('.'))
208
+ continue;
209
+ const path = join(dir, entry.name), name = prefix ? `${prefix}/${entry.name}` : entry.name;
210
+ if (entry.isDirectory())
211
+ walk(path, name);
212
+ else if (entry.isFile() && statSync(path).size <= 2_000_000)
213
+ files.push({ ref: `file:${name}`, label: `The file ${name}, which the run wrote`, value: textOf(path) });
214
+ }
215
+ };
216
+ if (typeof outcome.artifacts === 'string')
217
+ walk(outcome.artifacts, '');
218
+ for (const [path, copy] of Object.entries(outcome.files ?? {})) {
219
+ if (files.length >= 20)
220
+ break;
221
+ try {
222
+ if (statSync(copy).isFile())
223
+ files.push({ ref: `file:${path}`, label: `The file ${path}, which the run wrote`, value: textOf(copy) });
224
+ }
225
+ catch { /* the copy is gone */ }
226
+ }
227
+ seen.push(...files);
228
+ const calls = new Map();
229
+ for (const call of outcome.tool_calls ?? [])
230
+ calls.set(call.tool, [...(calls.get(call.tool) ?? []), call.arguments]);
231
+ for (const [tool, args] of calls)
232
+ seen.push({ ref: `effect:${tool}`, label: `The calls of ${tool} (their arguments)`, value: args });
233
+ for (const effect of outcome.effect_inputs ?? [])
234
+ seen.push({ ref: `effect:${effect.effect}`, label: `What ${effect.effect} sent or changed`, value: effect.inputs });
235
+ return seen;
236
+ }
124
237
  /**
125
- * Make the regression case of a correction, even from vague words such as "it's not even redlined": a model writes 1 to
126
- * 3 plain sentences from the note and the run's result, and method case new keeps them only when they fail on the run.
127
- * A rule that fails nothing gets one more try with the reason. The person's words decide a conflict with an instruction.
238
+ * Make the regression case of a correction, even from vague words such as "it's not even redlined". A model reads what
239
+ * the person saw (the result, the files the run wrote, and the calls and effects it made) and writes 1 to 3 plain
240
+ * sentences, each about one of those things; for a note about form or a missing action, also a small check script. A
241
+ * sentence is kept only when it fails on the corrected run and passes on every approved run of the same part, with two
242
+ * judges when both Claude Code and Codex are signed in. Sentences that do not fail get one more try with the reason.
128
243
  */
129
- export async function caseFromCorrection(file, judgment, runDir) {
130
- const id = correctionCaseId(judgment.id);
131
- if (existsSync(join(dirname(file), 'cases', id, 'case.json')))
244
+ export async function caseFromCorrection(file, judgment, runDir, options = {}) {
245
+ const id = correctionCaseId(judgment.id), folder = dirname(resolve(file));
246
+ if (existsSync(join(folder, 'cases', id, 'case.json')))
132
247
  return { id, made: false };
133
248
  const { outcomeOfRun, createCase } = await import('@withmethod/runtime/cases.js');
134
249
  const outcome = await outcomeOfRun(runDir);
250
+ const approved = [];
251
+ for (const dir of options.approvedRuns ?? []) {
252
+ try {
253
+ approved.push(await outcomeOfRun(dir));
254
+ }
255
+ catch { /* not a finished run */ }
256
+ }
257
+ const seen = seenOfRun(outcome), refs = new Set(seen.map(s => s.ref));
258
+ const fallback = refs.has('result') ? 'result' : seen[0]?.ref ?? 'result';
135
259
  const method = readDocument(file);
136
- const ref = typeof method.result === 'string' ? method.result.split('.')[0] : undefined;
137
- if (!ref)
138
- throw Error('The Method has a result with several values, so the case needs an output to judge.');
139
- const value = outcome.outputs?.[ref] ?? outcome.result;
140
- const note = judgment.note?.trim() || 'The person marked this result wrong.';
260
+ const note = [judgment.note?.trim() || 'The person marked this result wrong.', judgment.answer?.trim() ? `Asked what they meant, the person answered: ${judgment.answer.trim()}` : ''].filter(Boolean).join('\n');
141
261
  const { callModel } = await import('./model-call.js');
142
- const { preparedConfig } = await import('./quality.js');
262
+ const { preparedConfig, judgeProfiles, evaluateWithJudges } = await import('./quality.js');
143
263
  const prepared = await preparedConfig(file, {}, { judge: true });
264
+ const profiles = await judgeProfiles(prepared.config);
265
+ const runOptions = { processPath: prepared.processPath, ...(prepared.hostedModels ? { hostedModels: prepared.hostedModels } : {}) };
266
+ let budget = 60_000;
267
+ const shown = seen.map(s => { const text = clip(typeof s.value === 'string' ? s.value : JSON.stringify(s.value, null, 2), Math.max(500, Math.min(20_000, budget))); budget -= text.length; return `[${s.ref}] ${s.label}:\n${text}`; });
144
268
  const user = [`The person's correction, in their words:\n${note}`,
145
269
  judgment.corrected !== undefined && judgment.corrected !== null ? `The result the person says is right:\n${clip(typeof judgment.corrected === 'string' ? judgment.corrected : JSON.stringify(judgment.corrected, null, 2), 20_000)}` : '',
146
- `The Method's goal: ${String(method.goal ?? '')}`, `The result of the run (${ref}):\n${clip(typeof value === 'string' ? value : JSON.stringify(value, null, 2), 40_000)}`].filter(Boolean).join('\n\n');
270
+ `The Method's goal: ${String(method.goal ?? '')}`, `What the person saw (each with its name in brackets):\n\n${shown.join('\n\n')}`].filter(Boolean).join('\n\n');
271
+ const system = [
272
+ 'A person corrected the result of a run of a Method. Write 1 to 3 plain sentences that a right result of this run meets and that this result does not meet, so a later version can be checked against the correction.',
273
+ 'The person\'s words may be vague, such as "the Ashby one\'s wrong" or "it\'s not even redlined": use what the person saw to find what they mean.',
274
+ 'Each sentence is about one thing the person saw: give its name in brackets as ref (result, outputs.NAME, file:PATH or effect:NAME). Judge a file or a message by the file or the message itself, not by a summary of it.',
275
+ 'Each sentence names one checkable thing, without names or numbers that only this run has unless the correction is about them. Do not ask for anything that the correction does not ask for.',
276
+ 'When the correction is about form or a missing action (a document with no tracked changes, a message with no attachment, a message that was not sent, a missing field), also write check_script: a Node.js ES module that reads JSON from standard input with result, outputs, tool_calls (a list of {tool, arguments, standin}), effect_inputs (a list of {effect, inputs}) and files (an object: each file the run wrote, by the name in brackets after file:, mapped to an absolute path the script can read, for example a .docx to unzip), and prints {"pass": true or false, "reason": "one sentence"}. It must fail on this result, pass on a right one, and fail when the thing it checks is missing. In check_says, say in one plain sentence what it checks. Otherwise leave both empty.',
277
+ ].join(' ');
147
278
  let reason = '';
148
279
  for (let attempt = 0; attempt < 2; attempt++) {
149
- const answer = await callModel({ schema: caseSchema, user: reason ? `${user}\n\nYour last sentences could not be used: ${reason}` : user,
150
- system: 'A person corrected the result of a run of a Method. Write 1 to 3 plain sentences that a right result of this run meets and that this result does not meet, so a later version can be checked against the correction. The person\'s words may be vague, such as "the Ashby one\'s wrong" or "it\'s not even redlined": use the result to find what they mean. Each sentence names one checkable thing in the result, without names or numbers that only this run has unless the correction is about them. Do not ask for anything that the correction does not ask for.' });
151
- const criteria = (Array.isArray(answer.value?.criteria) ? answer.value.criteria : []).map((c) => String(c).trim()).filter(Boolean).slice(0, 3);
152
- if (!criteria.length) {
280
+ const answer = await callModel({ schema: caseSchema, system, user: reason ? `${user}\n\nYour last answer could not be used: ${reason}` : user });
281
+ const written = (Array.isArray(answer.value?.criteria) ? answer.value.criteria : [])
282
+ .map((c) => typeof c === 'string' ? { text: c.trim(), ref: fallback } : { text: String(c?.text ?? '').trim(), ref: refs.has(String(c?.ref ?? '').trim()) ? String(c.ref).trim() : fallback })
283
+ .filter((c) => c.text).slice(0, 3);
284
+ const script = typeof answer.value?.check_script === 'string' ? answer.value.check_script.trim() : '';
285
+ if (!written.length && !script) {
153
286
  reason = 'There were no sentences.';
154
287
  continue;
155
288
  }
289
+ // The sentences grouped by what they judge, with IDs that stay the same when the case is saved.
290
+ const expect = [];
291
+ written.forEach((c, i) => {
292
+ const item = expect.find(e => e.ref === c.ref) ?? (expect.push({ kind: 'rubric', ref: c.ref, criteria: [] }), expect.at(-1));
293
+ item.criteria.push({ id: `c${i + 1}`, text: c.text });
294
+ });
295
+ // A check script is written next to the Method file for createCase to copy into the case, then removed.
296
+ const entrypoint = `check-${id}.mjs`, scriptPath = join(folder, entrypoint);
297
+ if (script) {
298
+ writeFileSync(scriptPath, script + '\n', { mode: 0o600 });
299
+ expect.push({ kind: 'predicate', runtime: 'node', entrypoint, ...(answer.value?.check_says ? { text: String(answer.value.check_says).trim() } : {}) });
300
+ }
156
301
  try {
157
- await createCase({ methodFile: file, runDir, id, note, rubric: criteria, ref: `outputs.${ref}`, acceptConflict: note, config: prepared.config,
158
- options: { runOptions: { processPath: prepared.processPath, ...(prepared.hostedModels ? { hostedModels: prepared.hostedModels } : {}) } } });
302
+ const context = { caseDir: folder, config: prepared.config, options: { runOptions } };
303
+ const kept = await keptExpectations(expect, outcome, approved, context, profiles, evaluateWithJudges);
304
+ if (!kept.expect.length) {
305
+ reason = kept.reason;
306
+ continue;
307
+ }
308
+ await createCase({ methodFile: file, runDir, id, note, author: judgment.noted_by ?? null, expect: kept.expect, acceptConflict: note, config: prepared.config, options: { runOptions } });
159
309
  return { id, made: true };
160
310
  }
161
311
  catch (error) {
@@ -163,26 +313,74 @@ export async function caseFromCorrection(file, judgment, runDir) {
163
313
  throw error;
164
314
  reason = error.message;
165
315
  }
316
+ finally {
317
+ rmSync(scriptPath, { force: true });
318
+ }
166
319
  }
167
320
  throw Object.assign(Error(reason), { code: 'case_not_made' });
168
321
  }
169
322
  const clip = (text, max) => text.length > max ? `${text.slice(0, max)}…` : text;
170
- /** The Method's judgments: the account's, with the notes that stay on this computer for a device Method. */
323
+ /**
324
+ * The expectations that capture the correction: each sentence that fails on the corrected run and passes on every
325
+ * approved run, and the check script when it does the same. A sentence the judges disagree on does not fail and is dropped.
326
+ */
327
+ async function keptExpectations(expect, bad, approved, context, profiles, judge) {
328
+ const onBad = await judge(expect, bad, context, profiles);
329
+ const onGood = await Promise.all(approved.map(outcome => judge(expect, outcome, context, profiles)));
330
+ const reasons = [];
331
+ const kept = expect.flatMap((item, i) => {
332
+ const badResult = onBad.results[i], goodResults = onGood.map(r => r.results[i]);
333
+ if (item.kind === 'predicate') {
334
+ if (badResult?.status !== 'fail') {
335
+ reasons.push('The check script passes on the corrected run.');
336
+ return [];
337
+ }
338
+ if (goodResults.some(r => r?.status !== 'pass')) {
339
+ reasons.push('The check script fails on a run the person approved.');
340
+ return [];
341
+ }
342
+ return [item];
343
+ }
344
+ if (badResult?.status === 'unverifiable') {
345
+ reasons.push(`${item.ref} is not in the corrected run.`);
346
+ return [];
347
+ }
348
+ const criteria = item.criteria.filter((c) => {
349
+ const failsBad = badResult?.criteria?.find((x) => x.id === c.id)?.pass === false && !badResult.criteria.find((x) => x.id === c.id)?.unsure;
350
+ if (!failsBad) {
351
+ reasons.push(`"${c.text}" does not fail on the corrected run.`);
352
+ return false;
353
+ }
354
+ if (goodResults.some(r => r?.status === 'unverifiable' || r?.criteria?.find((x) => x.id === c.id)?.pass !== true)) {
355
+ reasons.push(`"${c.text}" fails on a run the person approved.`);
356
+ return false;
357
+ }
358
+ return true;
359
+ });
360
+ return criteria.length ? [{ ...item, criteria }] : [];
361
+ });
362
+ return { expect: kept, reason: reasons.join(' ') || 'Nothing could be checked.' };
363
+ }
364
+ /** The Method's judgments: the account's, with the notes and answers that stay on this computer for a device Method. */
171
365
  async function methodJudgments(client, file, methodId, waitMs) {
172
366
  const { judgments } = await client.request(`/api/cli/methods/${encodeURIComponent(methodId)}/judgments`, 'GET', undefined, true, waitMs ? { signal: AbortSignal.timeout(waitMs), stallMs: waitMs } : {});
173
367
  const local = new Map(localJudgments(file).map(j => [j.id, j]));
174
368
  const folders = runFolderIndex();
175
369
  return judgments.map(j => {
176
370
  const kept = local.get(j.id);
177
- return { ...j, note: j.note ?? kept?.note ?? null, corrected: j.corrected ?? kept?.corrected ?? null, run_dir: kept?.run_dir && existsSync(kept.run_dir) ? kept.run_dir : folders.get(j.run_id) };
371
+ return { ...j, note: j.note ?? kept?.note ?? null, corrected: j.corrected ?? kept?.corrected ?? null, answer: j.answer ?? kept?.answer ?? null,
372
+ run_dir: kept?.run_dir && existsSync(kept.run_dir) ? kept.run_dir : folders.get(j.run_id) };
178
373
  });
179
374
  }
180
- const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
375
+ /** The corrections of a Method that wait for a fix. */
376
+ export async function waitingCorrections(client, file, methodId, waitMs) {
377
+ return (await methodJudgments(client, file, methodId, waitMs)).filter(j => j.pending && j.verdict === 'wrong');
378
+ }
181
379
  /**
182
- * Fix the pending corrections of a Method file on this computer: the loop. Returns undefined when nothing waits. It
183
- * takes the pending corrections in the account first, so two computers never fix one correction twice, and gives them
184
- * back when the loop stops before it decides. A subscription limit or another failure of the model service is a plain
185
- * error.
380
+ * Fix the pending corrections of a Method file on this computer: one pass of the loop. Returns undefined when nothing
381
+ * waits. It takes the pending corrections in the account first, so two computers never fix one correction twice, and
382
+ * gives them back when the loop stops before it decides. Each correction gets its own outcome. A subscription limit or
383
+ * another failure of the model service is a plain error.
186
384
  */
187
385
  export async function fixCorrections(client, file, options = {}) {
188
386
  const document = readDocument(file);
@@ -190,28 +388,40 @@ export async function fixCorrections(client, file, options = {}) {
190
388
  if (!methodId || !client.token())
191
389
  return undefined;
192
390
  const all = await methodJudgments(client, file, methodId, options.waitMs);
193
- const waiting = all.filter(j => j.pending && j.verdict === 'wrong');
391
+ // skip: corrections that this process opened again after a later check undid their fix; the next method run takes them.
392
+ const waiting = all.filter(j => j.pending && j.verdict === 'wrong' && !options.skip?.has(j.id));
194
393
  if (!waiting.length)
195
394
  return undefined;
196
395
  const handled = (ids, value) => client.request(`/api/cli/methods/${encodeURIComponent(methodId)}/judgments/handled`, 'POST', { ids, handled: value });
197
396
  const taken = new Set((await handled(waiting.map(j => j.id), true)).changed);
198
- const fresh = waiting.filter(j => taken.has(j.id));
199
- if (!fresh.length)
397
+ const claimed = waiting.filter(j => taken.has(j.id));
398
+ if (!claimed.length)
200
399
  return undefined;
201
- const fromDashboard = fresh.filter(j => j.source === 'dashboard').length;
202
- process.stderr.write(`Fixing ${basename(file)} from ${plural(fresh.length, 'correction')} on this computer. This takes a few minutes.\n`);
203
400
  const base = methodContent(document), baseVersion = await currentVersion(file).catch(() => undefined);
204
401
  let decided = false;
402
+ const outcomes = [];
403
+ const empty = { file, judgments: claimed.map(j => j.id), cause: '', steps: [], cases: { made: [], not_made: [] }, usage: null, capped: '', log: '', outcomes, background_cases: [], fixed: [] };
205
404
  try {
206
- // A regression case per correction, so every later change is checked against it.
405
+ // A correction of a step whose text changed since its run is not fixed: the change may already answer it.
406
+ outcomes.push(...await changedSinceRun(client, methodId, claimed, base));
407
+ const fresh = claimed.filter(j => !outcomes.some(c => c.id === j.id));
408
+ if (!fresh.length) {
409
+ decided = true;
410
+ await reportOutcomes(client, methodId, outcomes);
411
+ return { ...empty, kind: 'none', applied: false };
412
+ }
413
+ process.stderr.write(`Testing fixes for ${fresh.length} correction${fresh.length === 1 ? '' : 's'} of ${file}.\n`);
414
+ // A regression case per correction, so every later change is checked against it; approved runs of the same step must still pass it.
207
415
  const cases = { made: [], not_made: [] };
416
+ const approved = all.filter(j => j.verdict === 'right' && j.run_dir);
208
417
  for (const j of fresh) {
209
418
  if (!j.run_dir) {
210
419
  cases.not_made.push({ id: j.id, reason: 'The run is not on this computer.' });
211
420
  continue;
212
421
  }
422
+ const approvedRuns = approved.filter(a => !j.step_id || !a.step_id || a.step_id === j.step_id).map(a => a.run_dir);
213
423
  try {
214
- const made = await caseFromCorrection(file, j, j.run_dir);
424
+ const made = await caseFromCorrection(file, j, j.run_dir, { approvedRuns });
215
425
  if (made.made)
216
426
  cases.made.push(made.id);
217
427
  }
@@ -229,52 +439,426 @@ export async function fixCorrections(client, file, options = {}) {
229
439
  const run = !j.run_dir && (freshIds.has(j.id) || j.verdict === 'wrong') ? await client.request(`/api/workspace/runs/${encodeURIComponent(j.run_id)}`).then(r => r.run).catch(() => undefined) : undefined;
230
440
  judgments.push({ id: j.id, run_id: j.run_id, ...(j.run_dir ? { run_dir: j.run_dir } : {}), step_id: j.step_id, verdict: j.verdict, note: j.note, ...(j.corrected !== null ? { corrected: j.corrected } : {}),
231
441
  ...(existsSync(join(dirname(file), 'cases', caseId, 'case.json')) ? { case_id: caseId } : {}), source: j.source, created_at: j.created_at, ...(freshIds.has(j.id) ? { new: true } : {}),
442
+ ...(j.noted_by ? { noted_by: j.noted_by } : {}), ...(j.question ? { question: j.question } : {}), ...(j.answer ? { answer: j.answer } : {}),
232
443
  ...(run ? { run: { id: run.id, status: run.status, created_at: run.created_at, version_id: run.workflow_version_id, inspection: run.inspection } } : {}) });
233
444
  }
234
445
  const runFolders = (await localRuns(file)).map(r => r.dir);
235
446
  const improved = await runImprovement(client, file, judgments, runFolders, options.run);
236
447
  decided = true;
237
448
  const evidence = improved.evidence;
238
- const earlier = (evidence.cases ?? []).filter((c) => !c.target && c.after === 'pass').length;
239
- const usage = evidence.budget ?? null;
240
- const used = usage ? ` (${plural(usage.model_calls, 'model call')}, ${usage.minutes < 1 ? 'under a minute' : `${Math.round(usage.minutes)} min`}${typeof usage.usd === 'number' ? `, $${usage.usd.toFixed(2)}` : ''})` : '';
241
- const outcome = { file, corrections: fresh.length, from_dashboard: fromDashboard, cause: improved.cause, cases, earlier_passed: earlier, usage, capped: improved.capped, log: improved.log };
449
+ const fromLoop = new Map(improved.outcomes.map(o => [o.id, o]));
450
+ // again: another correction's fix changed the same part; this one waits for the next pass, on the changed file.
451
+ const decidedNow = fresh.map(j => { const o = fromLoop.get(j.id); return o?.again ? { id: j.id, outcome: 'retry', again: true } : { ...(o ?? { outcome: 'no_fix_found' }), id: j.id }; });
452
+ const changes = (o) => o.outcome === 'fixed' || (o.outcome === 'could_not_test' && !!o.rule_added);
453
+ const result = { ...empty, cause: improved.cause, cases, usage: evidence.budget ?? null, capped: improved.capped, log: improved.log, steps: evidence.steps ?? [] };
242
454
  const record = (kind, extra = {}) => saveLocalProposal(file, { id: `lp_${randomBytes(8).toString('hex')}`, method_id: methodId, base_version_id: baseVersion?.version_id ?? null,
243
- status: kind, cause: improved.cause, evidence, created_at: new Date().toISOString(), base_workflow: base, proposed_workflow: improved.workflow, judgments: [...freshIds], ...extra });
244
- if (evidence.kind !== 'change') {
455
+ status: kind, cause: improved.cause, evidence, created_at: new Date().toISOString(), base_workflow: base, proposed_workflow: improved.workflow, judgments: [...freshIds], outcomes: decidedNow, ...extra });
456
+ // Nothing is applied: the corrections that the change would have answered wait for the next pass.
457
+ const notApplied = (why) => decidedNow.map(o => why.find(h => h.id === o.id) ?? (changes(o) ? { id: o.id, outcome: 'retry' } : o));
458
+ // A change that passes only by breaking another person's case is held: two people want different results.
459
+ const held = heldBy(file, fresh.filter(j => changes(fromLoop.get(j.id) ?? { id: j.id, outcome: 'no_fix_found' })), evidence);
460
+ let final, applied = false, version, undoId;
461
+ if (held.length) {
462
+ record('none', { reason: 'held' });
463
+ final = notApplied(held);
464
+ }
465
+ else if (evidence.kind !== 'change') {
245
466
  record('none');
246
- const why = improved.cause.replace(/^No safe change was found\.\s*/, '').split('\n')[0].slice(0, 300);
247
- return { ...outcome, kind: 'none', steps: evidence.steps ?? [], line: `No safe change found: ${why}${used}` };
467
+ final = decidedNow.map(o => changes(o) ? { ...o, rule_added: false } : o);
248
468
  }
249
- // The file must be the one the loop started from; a change made meanwhile is not overwritten.
250
- if (!isDeepStrictEqual(methodContent(readDocument(file)), base)) {
469
+ else if (!isDeepStrictEqual(methodContent(readDocument(file)), base)) {
470
+ // The file must be the one the loop started from; a change made meanwhile is not overwritten.
251
471
  record('none', { reason: 'The file changed while Method fixed it.' });
252
- return { ...outcome, kind: 'none', steps: evidence.steps ?? [], line: `No change applied: ${basename(file)} changed while Method fixed it. Run method judge again to fix it on the new file.` };
253
- }
254
- // The version before the change is saved too, so the Method's history has it to restore.
255
- const { Outbox } = await import('./outbox.js');
256
- const outbox = new Outbox(client);
257
- const before = await (await import('./versions.js')).prepareVersion(client, outbox, file).catch(() => undefined);
258
- const steps = applyChange(file, methodId, improved);
259
- const version = await saveFixedVersion(client, outbox, file, methodId, improved.cause, before?.version_id);
260
- record('applied', { applied_at: new Date().toISOString(), ...(version ? { applied_version_id: version.version_id } : {}) });
261
- const changed = `Changed ${steps.length ? `step ${steps.join(', ')}` : 'its files'} of ${basename(file)}: ${improved.cause}`, undo = version ? ` ${version.undo}` : '';
262
- return { ...outcome, kind: 'change', steps, version, changed: `${changed}${undo}${used}`,
263
- line: `${changed} It fixes the corrected run and passes ${plural(earlier, 'earlier correction')}.${undo}${used}` };
472
+ final = notApplied([]);
473
+ }
474
+ else {
475
+ const before = snapshot(file, improved.files.map(f => f.path));
476
+ applyChange(file, methodId, improved);
477
+ const notes = fresh.filter(j => changes(decidedNow.find(o => o.id === j.id))).map(j => j.note?.trim()).filter(Boolean);
478
+ undoId = recordChange(file, 'fix', improved.cause, notes.length ? `the fix for "${notes.join('" and "').slice(0, 300)}"` : 'the last fix', before).id;
479
+ version = await saveVersion(client, file, `Method fixed it from a correction: ${improved.cause}`.slice(0, 500));
480
+ record('applied', { applied_at: new Date().toISOString(), ...(version ? { applied_version_id: version.version_id } : {}) });
481
+ final = decidedNow;
482
+ applied = true;
483
+ }
484
+ outcomes.push(...final);
485
+ const retry = outcomes.filter(o => o.outcome === 'retry').map(o => o.id);
486
+ if (retry.length)
487
+ await handled(retry, false).catch(() => undefined);
488
+ await reportOutcomes(client, methodId, outcomes.filter(o => o.outcome !== 'retry' && o.outcome !== 'elsewhere'));
489
+ const fixed = applied ? fresh.filter(j => changes(final.find(o => o.id === j.id) ?? { id: j.id, outcome: 'no_fix_found' })).map(j => ({ id: j.id, note: j.note })) : [];
490
+ return { ...result, kind: applied ? 'change' : 'none', applied, ...(version ? { version } : {}), background_cases: applied ? improved.background_cases : [], fixed, ...(undoId ? { undo_id: undoId } : {}) };
264
491
  }
265
492
  catch (error) {
266
493
  // Stopped before it decided: the corrections wait for the next loop.
267
494
  if (!decided)
268
- await handled([...taken], false).catch(() => undefined);
269
- throw error;
495
+ await handled([...taken].filter(id => !outcomes.some(o => o.id === id)), false).catch(() => undefined);
496
+ if (!decided && outcomes.length)
497
+ await reportOutcomes(client, methodId, outcomes);
498
+ throw Object.assign(error instanceof Error ? error : Error(String(error)), { judgments: [...taken] });
270
499
  }
271
500
  }
272
- /** Write the tested change: the Method content (its id: line kept) and a changed package file of a script change. */
501
+ // One fix at a time per Method on this computer. A correction that arrives while a fix runs joins it: the running fix
502
+ // takes every waiting correction in one more pass before it finishes, and writes the outcome of each correction for
503
+ // method wait and the next method run.
504
+ const fileMethodId = (file) => { const id = readDocument(file).id; return typeof id === 'string' && methodIdPattern.test(id) ? id : undefined; };
505
+ /** Take the Method's lock: a file with this process ID. A lock whose process has stopped is taken over. */
506
+ function takeLock(path) {
507
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
508
+ for (let attempt = 0; attempt < 2; attempt++) {
509
+ try {
510
+ writeFileSync(path, JSON.stringify({ pid: process.pid }), { flag: 'wx', mode: 0o600 });
511
+ return true;
512
+ }
513
+ catch (error) {
514
+ if (error.code !== 'EEXIST')
515
+ throw error;
516
+ }
517
+ let pid;
518
+ try {
519
+ pid = JSON.parse(readFileSync(path, 'utf8')).pid;
520
+ }
521
+ catch { /* written now, or gone */ }
522
+ if (pid === undefined) {
523
+ try {
524
+ if (Date.now() - statSync(path).mtimeMs < 10_000)
525
+ return false;
526
+ }
527
+ catch {
528
+ continue;
529
+ }
530
+ }
531
+ else {
532
+ try {
533
+ process.kill(pid, 0);
534
+ return false;
535
+ }
536
+ catch (error) {
537
+ if (error.code !== 'ESRCH')
538
+ return false;
539
+ }
540
+ }
541
+ rmSync(path, { force: true });
542
+ }
543
+ return false;
544
+ }
545
+ /** Whether a fix of the Method runs on this computer now. */
546
+ export function fixRunning(methodId) {
547
+ try {
548
+ const pid = JSON.parse(readFileSync(join(fixFolder(methodId), 'lock'), 'utf8')).pid;
549
+ process.kill(pid, 0);
550
+ return true;
551
+ }
552
+ catch {
553
+ return false;
554
+ }
555
+ }
556
+ /** After 5 applied fixes, or when the agent that runs the fixes changed, the loop prunes the Method. */
557
+ const pruneEvery = 5;
558
+ /**
559
+ * Fix the waiting corrections of a Method file while holding its lock, pass after pass until none waits, so the
560
+ * corrections that arrive during a pass join the next. Each correction's outcome is saved for method wait. After a pass
561
+ * that changed the file, the other cases run (improve.method's background_cases): a failure undoes that change. Then a
562
+ * pruning pass runs when it is due. Returns undefined when another fix of the Method holds the lock.
563
+ */
564
+ export async function fixWaiting(client, file, options = {}) {
565
+ const methodId = fileMethodId(file);
566
+ if (!methodId || !client.token())
567
+ return [];
568
+ const lock = join(fixFolder(methodId), 'lock');
569
+ if (!takeLock(lock))
570
+ return undefined;
571
+ const fixes = [], skip = new Set();
572
+ try {
573
+ for (;;) {
574
+ let fix;
575
+ try {
576
+ fix = await fixCorrections(client, file, { ...options, skip });
577
+ }
578
+ catch (error) {
579
+ for (const id of error.judgments ?? [])
580
+ saveError(methodId, file, id, error.message, error.log);
581
+ throw error;
582
+ }
583
+ if (!fix)
584
+ break;
585
+ // A correction given back with again stays "working": the next pass fixes it on the changed file.
586
+ for (const outcome of fix.outcomes)
587
+ if (!outcome.again)
588
+ saveOutcome(methodId, file, outcome, fix.log);
589
+ fixes.push(fix);
590
+ if (fix.applied && fix.background_cases.length) {
591
+ const checked = await checkBackground(client, file, fix, options).catch(error => { process.stderr.write(`The later check stopped: ${error.message}\n`); return undefined; });
592
+ for (const id of checked?.reopened ?? [])
593
+ skip.add(id);
594
+ }
595
+ }
596
+ const { localAgent } = await import('@withmethod/runtime/agents.js');
597
+ const state = readFixState(methodId), agent = (await localAgent().catch(() => undefined)) ?? null;
598
+ // Every 5 corrections that a change answered, the loop prunes the Method.
599
+ const count = state.fixes_since_prune + fixes.filter(f => f.applied).reduce((n, f) => n + f.fixed.length, 0);
600
+ const due = count >= pruneEvery || (!!fixes.length && !!state.agent && !!agent && agent !== state.agent);
601
+ writeFixState(methodId, { fixes_since_prune: due ? 0 : count, agent: fixes.length ? agent : state.agent });
602
+ if (due)
603
+ await pruneMethod(client, file, options).catch(error => process.stderr.write(`The pruning pass stopped: ${error.message}\n`));
604
+ return fixes;
605
+ }
606
+ finally {
607
+ rmSync(lock, { force: true });
608
+ }
609
+ }
610
+ /**
611
+ * The later check of an applied change: the Method's other cases (improve.method's background_cases) run on the changed
612
+ * file. A failing case that belongs to a retired learned rule (its from.case) brings that rule back. Any other failure
613
+ * undoes the change, saves the restored file as a new version, and opens the corrections it answered again, so the next
614
+ * method run fixes them anew. Each of these says one line on the next method run or method wait.
615
+ */
616
+ export async function checkBackground(client, file, fix, options = {}) {
617
+ const methodId = fileMethodId(file);
618
+ const { listCases } = await import('@withmethod/runtime/cases.js');
619
+ const active = new Set((await listCases(file)).filter((c) => c.status === 'active').map((c) => c.id));
620
+ const ids = fix.background_cases.filter(id => active.has(id));
621
+ const done = { failed: [], restored: [], undone: false, reopened: [] };
622
+ if (!ids.length)
623
+ return done;
624
+ const run = options.runCases ?? (await import('./quality.js')).runCases;
625
+ const report = await run(file, {}, { ids });
626
+ done.failed = report.cases.filter(c => c.verdict === 'fail' || c.verdict === 'regression').map(c => c.id);
627
+ if (!done.failed.length)
628
+ return done;
629
+ const { readLearned, learnedFile, ruleText } = await import('./learned.js');
630
+ const ruleCases = new Set();
631
+ try {
632
+ for (const rules of Object.values(readLearned(file).steps))
633
+ for (const r of rules)
634
+ if (r.status === 'retired' && r.from?.case)
635
+ ruleCases.add(r.from.case);
636
+ }
637
+ catch { /* no readable learned rules */ }
638
+ const broken = done.failed.filter(id => !ruleCases.has(id));
639
+ if (broken.length && fix.undo_id) {
640
+ try {
641
+ undoLast(file, fix.undo_id);
642
+ done.undone = true;
643
+ }
644
+ catch (error) {
645
+ process.stderr.write(`The fix broke an earlier result, but it could not be undone: ${error.message}\n`);
646
+ }
647
+ if (done.undone) {
648
+ await saveVersion(client, file, `Undid a fix that broke an earlier result: ${fix.cause}`.slice(0, 500));
649
+ const answered = fix.fixed.map(f => f.id);
650
+ if (answered.length) {
651
+ try {
652
+ done.reopened = (await client.request(`/api/cli/methods/${encodeURIComponent(methodId)}/judgments/reopen`, 'POST', { ids: answered, detail: 'The fix changed a result that was right before, so it was undone. Method will fix it again.' })).changed;
653
+ }
654
+ catch (error) {
655
+ process.stderr.write(`The account did not open the corrections again: ${error.message}\n`);
656
+ }
657
+ }
658
+ for (const f of fix.fixed)
659
+ saveNotice(methodId, file, 'came_back', fixUndoneLine(f.note ?? undefined));
660
+ }
661
+ }
662
+ // A retired rule whose case fails again comes back (after the undo, which may have restored learned.json).
663
+ const restoredRules = [];
664
+ const learned = (() => { try {
665
+ return readLearned(file);
666
+ }
667
+ catch {
668
+ return undefined;
669
+ } })();
670
+ if (learned) {
671
+ const before = snapshot(file, ['learned.json']);
672
+ for (const rules of Object.values(learned.steps))
673
+ for (const rule of rules) {
674
+ if (rule.status !== 'retired' || !rule.from?.case || !done.failed.includes(rule.from.case))
675
+ continue;
676
+ Object.assign(rule, { status: 'active', retired_at: null, retired_reason: null });
677
+ done.restored.push(rule.id);
678
+ restoredRules.push(ruleText(rule));
679
+ }
680
+ if (done.restored.length) {
681
+ writeFileSync(learnedFile(file), JSON.stringify(learned, null, 2) + '\n');
682
+ recordChange(file, 'learned', `Put back ${done.restored.length} learned rule${done.restored.length === 1 ? '' : 's'}`, 'the rules that came back', before);
683
+ await saveVersion(client, file, `Put back learned rules whose problem came back: ${restoredRules.join(' ')}`.slice(0, 500));
684
+ for (const rule of restoredRules)
685
+ saveNotice(methodId, file, 'came_back', ruleBackLine(rule));
686
+ }
687
+ }
688
+ return done;
689
+ }
690
+ /**
691
+ * The pruning pass (improve.method with mode prune): it retires learned rules and deletes prompt text that tests show
692
+ * no longer change any result, and reports text that no test covers without deleting it. The change applies at once,
693
+ * with undo; the next method run or method wait shows the sweep's summary line.
694
+ */
695
+ export async function pruneMethod(client, file, options = {}) {
696
+ const methodId = fileMethodId(file);
697
+ if (!methodId || !client.token())
698
+ return undefined;
699
+ const base = methodContent(readDocument(file));
700
+ const all = await methodJudgments(client, file, methodId);
701
+ const judgments = all.map(j => ({ id: j.id, run_id: j.run_id, ...(j.run_dir ? { run_dir: j.run_dir } : {}), step_id: j.step_id, verdict: j.verdict, note: j.note,
702
+ ...(existsSync(join(dirname(file), 'cases', correctionCaseId(j.id), 'case.json')) ? { case_id: correctionCaseId(j.id) } : {}), source: j.source, created_at: j.created_at,
703
+ ...(j.noted_by ? { noted_by: j.noted_by } : {}) }));
704
+ const improved = await runImprovement(client, file, judgments, (await localRuns(file)).map(r => r.dir), options.run, 'prune');
705
+ const { prunedLine } = await import('./fix-lines.js');
706
+ if (improved.evidence.kind !== 'change') {
707
+ // Nothing removed; the summary can still say which instructions nothing checks.
708
+ if (improved.summary)
709
+ saveNotice(methodId, file, 'prune', improved.summary);
710
+ return { changed: false };
711
+ }
712
+ if (!isDeepStrictEqual(methodContent(readDocument(file)), base))
713
+ return { changed: false };
714
+ const before = snapshot(file, improved.files.map(f => f.path));
715
+ applyChange(file, methodId, improved);
716
+ recordChange(file, 'prune', improved.cause, 'the cleanup of rules and instructions that no longer changed any result', before);
717
+ await saveVersion(client, file, `Method removed what no longer changed any result: ${improved.cause}`.slice(0, 500));
718
+ if (improved.summary)
719
+ saveNotice(methodId, file, 'prune', prunedLine(improved.summary));
720
+ return { changed: true };
721
+ }
722
+ /**
723
+ * Fix the Method for one correction: run the fix, or, when a fix of the Method already runs on this computer, wait for
724
+ * the fix that takes this correction. Returns the stored outcome, or undefined when no fix here took it.
725
+ */
726
+ export async function fixCorrection(client, file, judgmentId, options = {}) {
727
+ const methodId = fileMethodId(file);
728
+ if (!methodId || !client.token())
729
+ return undefined;
730
+ const done = () => { const record = storedOutcome(methodId, judgmentId); return record && record.status !== 'working' ? record : undefined; };
731
+ for (;;) {
732
+ const saved = done();
733
+ if (saved)
734
+ return saved;
735
+ const ran = await fixWaiting(client, file, options);
736
+ if (ran)
737
+ return done();
738
+ await new Promise(wait => setTimeout(wait, options.pollMs ?? 500));
739
+ }
740
+ }
741
+ /**
742
+ * `method __fix FILE --judgment ID... [--prune]`: the fix of method judge, method answer and method run, in its own
743
+ * process. Every correction it was started for ends with a stored outcome or error.
744
+ */
745
+ export async function backgroundFix(client, file, ids, options = {}) {
746
+ const methodId = fileMethodId(file);
747
+ if (!methodId)
748
+ return;
749
+ setWorker(methodId, ids, process.pid);
750
+ if (options.prune) {
751
+ const lock = join(fixFolder(methodId), 'lock');
752
+ while (!takeLock(lock))
753
+ await new Promise(wait => setTimeout(wait, options.pollMs ?? 500));
754
+ try {
755
+ await pruneMethod(client, file, options);
756
+ }
757
+ finally {
758
+ rmSync(lock, { force: true });
759
+ }
760
+ }
761
+ for (const id of ids) {
762
+ try {
763
+ if (!await fixCorrection(client, file, id, options))
764
+ saveOutcome(methodId, file, { id, outcome: 'elsewhere' });
765
+ }
766
+ catch (error) {
767
+ if (storedOutcome(methodId, id)?.status === 'working')
768
+ saveError(methodId, file, id, error.message, error.log);
769
+ }
770
+ }
771
+ }
772
+ /** Send what the loop did with each correction, with the reason or the question. */
773
+ async function reportOutcomes(client, methodId, outcomes) {
774
+ if (!outcomes.length)
775
+ return;
776
+ try {
777
+ await client.request(`/api/cli/methods/${encodeURIComponent(methodId)}/judgments/outcomes`, 'POST', { outcomes: outcomes.map(o => {
778
+ const detail = o.outcome === 'held' ? [o.by ?? 'Someone', o.detail].filter(Boolean).join(': ') : o.outcome === 'already_changed' ? (o.by ? `${o.by} changed it after this run.` : undefined) : o.summary ?? o.detail;
779
+ return { id: o.id, outcome: o.outcome, ...(o.outcome === 'could_not_test' && o.reason ? { reason: o.reason } : {}), ...(o.outcome === 'question' && o.question ? { question: o.question.slice(0, 1000) } : {}),
780
+ ...(detail?.trim() ? { detail: detail.trim().slice(0, 500) } : {}) };
781
+ }) });
782
+ }
783
+ catch (error) {
784
+ process.stderr.write(`The account did not record what happened to the corrections: ${error.message}\n`);
785
+ }
786
+ }
787
+ /** The version that a judged run ran: from its run folder here, else from its account record. */
788
+ async function runVersion(client, j) {
789
+ if (j.run_dir) {
790
+ try {
791
+ const sync = JSON.parse(readFileSync(join(j.run_dir, 'method-sync.json'), 'utf8'));
792
+ if (typeof sync.version_id === 'string')
793
+ return sync.version_id;
794
+ }
795
+ catch { /* no account link */ }
796
+ }
797
+ try {
798
+ return (await client.request(`/api/workspace/runs/${encodeURIComponent(j.run_id)}`)).run?.workflow_version_id;
799
+ }
800
+ catch {
801
+ return undefined;
802
+ }
803
+ }
804
+ /**
805
+ * The corrections of a step whose text in the run's version differs from the file: outcome already_changed, with the
806
+ * person who saved the newest version when that version has the file's text of the step.
807
+ */
808
+ async function changedSinceRun(client, methodId, judgments, current) {
809
+ const changed = [], versions = new Map();
810
+ let file;
811
+ try {
812
+ file = loadWorkflow(current);
813
+ }
814
+ catch {
815
+ return changed;
816
+ }
817
+ const saved = (version) => {
818
+ const key = version ?? '';
819
+ if (!versions.has(key))
820
+ versions.set(key, client.request(`/api/cli/methods/${encodeURIComponent(methodId)}${version ? `?version=${encodeURIComponent(version)}` : ''}`).catch(() => undefined));
821
+ return versions.get(key);
822
+ };
823
+ for (const j of judgments) {
824
+ if (!j.step_id || !Object.hasOwn(file.steps ?? {}, j.step_id))
825
+ continue;
826
+ const version = await runVersion(client, j), ran = version ? await saved(version) : undefined;
827
+ if (!ran?.workflow?.steps || isDeepStrictEqual(ran.workflow.steps[j.step_id], file.steps[j.step_id]))
828
+ continue;
829
+ const newest = await saved();
830
+ const by = newest?.saved_by && isDeepStrictEqual(newest.workflow?.steps?.[j.step_id], file.steps[j.step_id]) ? newest.saved_by : undefined;
831
+ changed.push({ id: j.id, outcome: 'already_changed', ...(by ? { by } : {}) });
832
+ }
833
+ return changed;
834
+ }
835
+ /**
836
+ * The corrections whose tested change breaks a case that another person made (a case that passed before and fails
837
+ * after), with the other person and what they asked for. A case or a correction with no name is not held.
838
+ */
839
+ function heldBy(file, fresh, evidence) {
840
+ const broken = (Array.isArray(evidence?.cases) ? evidence.cases : []).filter((c) => !c.target && c.before === 'pass' && c.after !== 'pass').map((c) => {
841
+ try {
842
+ const made = JSON.parse(readFileSync(join(dirname(file), 'cases', c.id, 'case.json'), 'utf8'));
843
+ return { author: made.author, note: String(made.note ?? '') };
844
+ }
845
+ catch {
846
+ return undefined;
847
+ }
848
+ }).filter((c) => c?.author);
849
+ return fresh.flatMap(j => {
850
+ const other = j.noted_by ? broken.find((c) => c.author !== j.noted_by) : undefined;
851
+ return other ? [{ id: j.id, outcome: 'held', by: other.author, detail: other.note.slice(0, 200) }] : [];
852
+ });
853
+ }
854
+ /** Write the tested change: the Method content (its id: line kept) and the changed package files (a script, learned.json). */
273
855
  function applyChange(file, methodId, improved) {
274
856
  const before = methodContent(readDocument(file));
275
- loadWorkflow(improved.workflow);
276
- if (!isDeepStrictEqual(improved.workflow, before))
277
- writeMethodFile(file, methodId, improved.workflow);
857
+ if (improved.workflow) {
858
+ loadWorkflow(improved.workflow);
859
+ if (!isDeepStrictEqual(improved.workflow, before))
860
+ writeMethodFile(file, methodId, improved.workflow);
861
+ }
278
862
  const folder = dirname(resolve(file));
279
863
  for (const changed of improved.files) {
280
864
  const path = resolve(folder, packagePath(changed.path));
@@ -283,32 +867,8 @@ function applyChange(file, methodId, improved) {
283
867
  const bytes = Buffer.from(changed.content_base64, 'base64');
284
868
  if (fileHash(bytes) !== changed.sha256)
285
869
  throw Error(`The changed file ${changed.path} does not match its sha256.`);
870
+ mkdirSync(dirname(path), { recursive: true });
286
871
  writeFileSync(path, bytes);
287
872
  }
288
- return changedSteps(before, improved.workflow);
289
- }
290
- /**
291
- * Save the fixed file as a new version of the Method, with the cause as its reason. The version before stays in the
292
- * Method's history: restoring it on the Method page undoes the change.
293
- */
294
- async function saveFixedVersion(client, outbox, file, methodId, cause, previous) {
295
- try {
296
- const { savedVersions } = await import('./outbox.js');
297
- const { prepareVersion } = await import('./versions.js');
298
- const version = await prepareVersion(client, outbox, file, `Method fixed it from a correction: ${cause}`.slice(0, 500));
299
- await outbox.drain();
300
- if (!await outbox.whenSaved(version.version_id))
301
- return undefined;
302
- const number = previous && await outbox.whenSaved(previous) ? savedVersions(client.server)[previous]?.version_number : undefined;
303
- const undo = previous && number ? `To undo it, restore version ${number}: ${client.server}/methods/${methodId}?version=${previous}` : 'The version before it is in its history on the Method page.';
304
- return { version_id: version.version_id, url: version.url, undo };
305
- }
306
- catch {
307
- return undefined;
308
- }
309
- }
310
- /** The one line for corrections that waited, printed before method run or method judge goes on. */
311
- export function waitingLine(outcome) {
312
- const what = outcome.from_dashboard === outcome.corrections ? `${plural(outcome.corrections, 'correction')} from the dashboard` : plural(outcome.corrections, 'correction');
313
- return outcome.kind === 'change' ? `${what}: fixed, passes ${plural(outcome.earlier_passed, 'earlier correction')}. ${outcome.changed}` : `${what}: ${outcome.line}`;
873
+ return improved.workflow ? changedSteps(before, improved.workflow) : [];
314
874
  }