@method-ai-hq/sdk 0.19.0 → 0.20.1

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