@method-ai-hq/sdk 0.19.0 → 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 (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 +131 -34
  11. package/dist/packages/sdk/src/improve.js +489 -175
  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,5 @@
1
1
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
- import { basename, dirname, join, resolve, sep } from 'node:path';
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';
@@ -12,11 +12,59 @@ import { currentVersion, methodContent, methodIdPattern } from './versions.js';
12
12
  import { localRuns, saveLocalProposal, writeMethodFile } from './proposals.js';
13
13
  import { changedSteps } from './merge.js';
14
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;
15
21
  /**
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.
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.
18
25
  */
19
- 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 = {}) {
20
68
  const value = result;
21
69
  if (!value || typeof value.proposal !== 'string')
22
70
  throw Error('The improvement run gave no proposal.');
@@ -36,8 +84,20 @@ export function readImproveResult(result) {
36
84
  throw Error(`The improvement run says kind ${String(value.kind)}, but its proposal is kind ${evidence.kind}.`);
37
85
  if (evidence.kind === 'change' && (!proposal.workflow || typeof proposal.workflow !== 'object'))
38
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) : [];
39
99
  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 : '' };
100
+ capped: typeof value.capped === 'string' ? value.capped : '', outcomes: outcomes.map(loopOutcome), background_cases: [...new Set(background)], summary: plainText(value.summary) ?? '' };
41
101
  }
42
102
  /**
43
103
  * Download the published improve.method and its files into the Method cache, once per version. For developers of
@@ -77,11 +137,11 @@ export async function cachedImproveMethod(client) {
77
137
  return { file, root, version_id: saved.version_id };
78
138
  }
79
139
  /**
80
- * 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:
81
141
  * the user's own Claude Code or Codex first, hosted models only without one. The run gets the signed-in key and server
82
142
  * as its IMPROVE_API_KEY and IMPROVE_SERVER secrets, for this run only. Its output goes to a log file.
83
143
  */
84
- export async function runImprovement(client, file, judgments, runFolders, run) {
144
+ export async function runImprovement(client, file, judgments, runFolders, run, mode) {
85
145
  const token = client.token();
86
146
  if (!token)
87
147
  throw Error('Sign in with method login, then run method judge again.');
@@ -91,7 +151,7 @@ export async function runImprovement(client, file, judgments, runFolders, run) {
91
151
  const judgmentsFile = join(runDir, 'judgments.json'), inputs = join(runDir, 'improve-inputs.json'), log = join(runDir, 'improve.log');
92
152
  // The judgments with their notes go in a private file of this run; they stay on this computer.
93
153
  writePrivateJson(judgmentsFile, judgments);
94
- 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 } : {}) });
95
155
  const runFile = run ?? (await import('./current-runtime.js')).runCurrentFile;
96
156
  // The run prints its own result and progress; they go to the log, so the person reads one line.
97
157
  const out = process.stdout.write, err = process.stderr.write;
@@ -111,52 +171,141 @@ export async function runImprovement(client, file, judgments, runFolders, run) {
111
171
  }
112
172
  if (result.status !== 'completed')
113
173
  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 };
174
+ return { ...readImproveResult(result.result, { prune: mode === 'prune' }), log };
121
175
  }
122
176
  /** The ID of the regression case that a correction makes: one per judgment, so a later loop finds it. */
123
177
  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.' } } } };
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
+ }
125
237
  /**
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.
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.
129
243
  */
130
- export async function caseFromCorrection(file, judgment, runDir) {
131
- const id = correctionCaseId(judgment.id);
132
- 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')))
133
247
  return { id, made: false };
134
248
  const { outcomeOfRun, createCase } = await import('@withmethod/runtime/cases.js');
135
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';
136
259
  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.';
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');
142
261
  const { callModel } = await import('./model-call.js');
143
- const { preparedConfig } = await import('./quality.js');
262
+ const { preparedConfig, judgeProfiles, evaluateWithJudges } = await import('./quality.js');
144
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}`; });
145
268
  const user = [`The person's correction, in their words:\n${note}`,
146
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)}` : '',
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');
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(' ');
148
278
  let reason = '';
149
279
  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) {
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) {
154
286
  reason = 'There were no sentences.';
155
287
  continue;
156
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
+ }
157
301
  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 } : {}) } } });
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 } });
160
309
  return { id, made: true };
161
310
  }
162
311
  catch (error) {
@@ -164,26 +313,74 @@ export async function caseFromCorrection(file, judgment, runDir) {
164
313
  throw error;
165
314
  reason = error.message;
166
315
  }
316
+ finally {
317
+ rmSync(scriptPath, { force: true });
318
+ }
167
319
  }
168
320
  throw Object.assign(Error(reason), { code: 'case_not_made' });
169
321
  }
170
322
  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. */
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. */
172
365
  async function methodJudgments(client, file, methodId, waitMs) {
173
366
  const { judgments } = await client.request(`/api/cli/methods/${encodeURIComponent(methodId)}/judgments`, 'GET', undefined, true, waitMs ? { signal: AbortSignal.timeout(waitMs), stallMs: waitMs } : {});
174
367
  const local = new Map(localJudgments(file).map(j => [j.id, j]));
175
368
  const folders = runFolderIndex();
176
369
  return judgments.map(j => {
177
370
  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) };
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) };
179
373
  });
180
374
  }
181
- 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
+ }
182
379
  /**
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.
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.
187
384
  */
188
385
  export async function fixCorrections(client, file, options = {}) {
189
386
  const document = readDocument(file);
@@ -191,7 +388,8 @@ export async function fixCorrections(client, file, options = {}) {
191
388
  if (!methodId || !client.token())
192
389
  return undefined;
193
390
  const all = await methodJudgments(client, file, methodId, options.waitMs);
194
- 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));
195
393
  if (!waiting.length)
196
394
  return undefined;
197
395
  const handled = (ids, value) => client.request(`/api/cli/methods/${encodeURIComponent(methodId)}/judgments/handled`, 'POST', { ids, handled: value });
@@ -202,29 +400,28 @@ export async function fixCorrections(client, file, options = {}) {
202
400
  const base = methodContent(document), baseVersion = await currentVersion(file).catch(() => undefined);
203
401
  let decided = false;
204
402
  const outcomes = [];
205
- const report = () => reportOutcomes(client, methodId, 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: [] };
206
404
  try {
207
405
  // 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;
406
+ outcomes.push(...await changedSinceRun(client, methodId, claimed, base));
407
+ const fresh = claimed.filter(j => !outcomes.some(c => c.id === j.id));
212
408
  if (!fresh.length) {
213
409
  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(' ')}` };
410
+ await reportOutcomes(client, methodId, outcomes);
411
+ return { ...empty, kind: 'none', applied: false };
217
412
  }
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.
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.
220
415
  const cases = { made: [], not_made: [] };
416
+ const approved = all.filter(j => j.verdict === 'right' && j.run_dir);
221
417
  for (const j of fresh) {
222
418
  if (!j.run_dir) {
223
419
  cases.not_made.push({ id: j.id, reason: 'The run is not on this computer.' });
224
420
  continue;
225
421
  }
422
+ const approvedRuns = approved.filter(a => !j.step_id || !a.step_id || a.step_id === j.step_id).map(a => a.run_dir);
226
423
  try {
227
- const made = await caseFromCorrection(file, j, j.run_dir);
424
+ const made = await caseFromCorrection(file, j, j.run_dir, { approvedRuns });
228
425
  if (made.made)
229
426
  cases.made.push(made.id);
230
427
  }
@@ -242,64 +439,68 @@ export async function fixCorrections(client, file, options = {}) {
242
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;
243
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 } : {}),
244
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 } : {}),
245
443
  ...(run ? { run: { id: run.id, status: run.status, created_at: run.created_at, version_id: run.workflow_version_id, inspection: run.inspection } } : {}) });
246
444
  }
247
445
  const runFolders = (await localRuns(file)).map(r => r.dir);
248
446
  const improved = await runImprovement(client, file, judgments, runFolders, options.run);
249
447
  decided = true;
250
448
  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 };
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 ?? [] };
255
454
  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 });
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));
257
458
  // 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);
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;
259
461
  if (held.length) {
260
462
  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.` };
463
+ final = notApplied(held);
264
464
  }
265
- if (evidence.kind !== 'change') {
465
+ else if (evidence.kind !== 'change') {
266
466
  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}` };
467
+ final = decidedNow.map(o => changes(o) ? { ...o, rule_added: false } : o);
271
468
  }
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)) {
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.
274
471
  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}` };
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 } : {}) };
289
491
  }
290
492
  catch (error) {
291
493
  // Stopped before it decided: the corrections wait for the next loop.
292
494
  if (!decided)
293
495
  await handled([...taken].filter(id => !outcomes.some(o => o.id === id)), false).catch(() => undefined);
294
496
  if (!decided && outcomes.length)
295
- await report();
497
+ await reportOutcomes(client, methodId, outcomes);
296
498
  throw Object.assign(error instanceof Error ? error : Error(String(error)), { judgments: [...taken] });
297
499
  }
298
500
  }
299
501
  // 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);
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.
303
504
  const fileMethodId = (file) => { const id = readDocument(file).id; return typeof id === 'string' && methodIdPattern.test(id) ? id : undefined; };
304
505
  /** Take the Method's lock: a file with this process ID. A lock whose process has stopped is taken over. */
305
506
  function takeLock(path) {
@@ -341,111 +542,246 @@ function takeLock(path) {
341
542
  }
342
543
  return false;
343
544
  }
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;
545
+ /** Whether a fix of the Method runs on this computer now. */
546
+ export function fixRunning(methodId) {
349
547
  try {
350
- saved = JSON.parse(readFileSync(path, 'utf8'));
548
+ const pid = JSON.parse(readFileSync(join(fixFolder(methodId), 'lock'), 'utf8')).pid;
549
+ process.kill(pid, 0);
550
+ return true;
351
551
  }
352
552
  catch {
353
- return undefined;
553
+ return false;
354
554
  }
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
555
  }
556
+ /** After 5 applied fixes, or when the agent that runs the fixes changed, the loop prunes the Method. */
557
+ const pruneEvery = 5;
359
558
  /**
360
559
  * 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.
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.
362
563
  */
363
564
  export async function fixWaiting(client, file, options = {}) {
364
565
  const methodId = fileMethodId(file);
365
566
  if (!methodId || !client.token())
366
567
  return [];
367
- const folder = fixFolder(methodId), lock = join(folder, 'lock');
568
+ const lock = join(fixFolder(methodId), 'lock');
368
569
  if (!takeLock(lock))
369
570
  return undefined;
370
- const fixes = [];
571
+ const fixes = [], skip = new Set();
371
572
  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
573
  for (;;) {
382
574
  let fix;
383
575
  try {
384
- fix = await fixCorrections(client, file, options);
576
+ fix = await fixCorrections(client, file, { ...options, skip });
385
577
  }
386
578
  catch (error) {
387
579
  for (const id of error.judgments ?? [])
388
- saveOutcome(folder, id, { error: error.message, ...(error.log ? { log: error.log } : {}) });
580
+ saveError(methodId, file, id, error.message, error.log);
389
581
  throw error;
390
582
  }
391
583
  if (!fix)
392
- return fixes;
393
- for (const id of fix.judgments)
394
- saveOutcome(folder, id, { 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);
395
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
+ }
396
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;
397
605
  }
398
606
  finally {
399
607
  rmSync(lock, { force: true });
400
608
  }
401
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
+ }
402
722
  /**
403
723
  * 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.
724
+ * the fix that takes this correction. Returns the stored outcome, or undefined when no fix here took it.
405
725
  */
406
726
  export async function fixCorrection(client, file, judgmentId, options = {}) {
407
727
  const methodId = fileMethodId(file);
408
728
  if (!methodId || !client.token())
409
729
  return undefined;
410
- const folder = fixFolder(methodId);
411
- let joined = false;
730
+ const done = () => { const record = storedOutcome(methodId, judgmentId); return record && record.status !== 'working' ? record : undefined; };
412
731
  for (;;) {
413
- const saved = takeOutcome(folder, judgmentId);
732
+ const saved = done();
414
733
  if (saved)
415
- return saved();
416
- let ran, failure;
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));
417
754
  try {
418
- ran = await fixWaiting(client, file, options);
755
+ await pruneMethod(client, file, options);
419
756
  }
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;
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' });
431
765
  }
432
- if (!joined) {
433
- process.stderr.write(`Method is fixing ${basename(file)} on this computer; this correction joins that fix.\n`);
434
- joined = true;
766
+ catch (error) {
767
+ if (storedOutcome(methodId, id)?.status === 'working')
768
+ saveError(methodId, file, id, error.message, error.log);
435
769
  }
436
- await new Promise(done => setTimeout(done, options.pollMs ?? 500));
437
770
  }
438
771
  }
439
- /** Send what the loop did with each correction. An older server without the route keeps today's record. */
772
+ /** Send what the loop did with each correction, with the reason or the question. */
440
773
  async function reportOutcomes(client, methodId, outcomes) {
441
774
  if (!outcomes.length)
442
775
  return;
443
776
  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) } : {}) })) });
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
+ }) });
445
782
  }
446
783
  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`);
784
+ process.stderr.write(`The account did not record what happened to the corrections: ${error.message}\n`);
449
785
  }
450
786
  }
451
787
  /** The version that a judged run ran: from its run folder here, else from its account record. */
@@ -492,19 +828,19 @@ async function changedSinceRun(client, methodId, judgments, current) {
492
828
  continue;
493
829
  const newest = await saved();
494
830
  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.` });
831
+ changed.push({ id: j.id, outcome: 'already_changed', ...(by ? { by } : {}) });
496
832
  }
497
833
  return changed;
498
834
  }
499
835
  /**
500
836
  * 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.
837
+ * after), with the other person and what they asked for. A case or a correction with no name is not held.
502
838
  */
503
839
  function heldBy(file, fresh, evidence) {
504
840
  const broken = (Array.isArray(evidence?.cases) ? evidence.cases : []).filter((c) => !c.target && c.before === 'pass' && c.after !== 'pass').map((c) => {
505
841
  try {
506
842
  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 ?? '') };
843
+ return { author: made.author, note: String(made.note ?? '') };
508
844
  }
509
845
  catch {
510
846
  return undefined;
@@ -512,15 +848,17 @@ function heldBy(file, fresh, evidence) {
512
848
  }).filter((c) => c?.author);
513
849
  return fresh.flatMap(j => {
514
850
  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]*$/, '.') }] : [];
851
+ return other ? [{ id: j.id, outcome: 'held', by: other.author, detail: other.note.slice(0, 200) }] : [];
516
852
  });
517
853
  }
518
- /** Write the tested change: the Method content (its id: line kept) and a changed package file of a script change. */
854
+ /** Write the tested change: the Method content (its id: line kept) and the changed package files (a script, learned.json). */
519
855
  function applyChange(file, methodId, improved) {
520
856
  const before = methodContent(readDocument(file));
521
- loadWorkflow(improved.workflow);
522
- if (!isDeepStrictEqual(improved.workflow, before))
523
- 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
+ }
524
862
  const folder = dirname(resolve(file));
525
863
  for (const changed of improved.files) {
526
864
  const path = resolve(folder, packagePath(changed.path));
@@ -529,32 +867,8 @@ function applyChange(file, methodId, improved) {
529
867
  const bytes = Buffer.from(changed.content_base64, 'base64');
530
868
  if (fileHash(bytes) !== changed.sha256)
531
869
  throw Error(`The changed file ${changed.path} does not match its sha256.`);
870
+ mkdirSync(dirname(path), { recursive: true });
532
871
  writeFileSync(path, bytes);
533
872
  }
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}`;
873
+ return improved.workflow ? changedSteps(before, improved.workflow) : [];
560
874
  }