@method-ai-hq/sdk 0.17.3 → 0.18.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 (36) hide show
  1. package/README.md +2 -2
  2. package/dist/packages/sdk/package.json +2 -2
  3. package/dist/packages/sdk/src/authoring-instructions.d.ts +4 -4
  4. package/dist/packages/sdk/src/authoring-instructions.js +5 -5
  5. package/dist/packages/sdk/src/authoring.js +8 -6
  6. package/dist/packages/sdk/src/capabilities.d.ts +9 -6
  7. package/dist/packages/sdk/src/capabilities.js +15 -32
  8. package/dist/packages/sdk/src/computer-settings.d.ts +2 -5
  9. package/dist/packages/sdk/src/computer-settings.js +4 -11
  10. package/dist/packages/sdk/src/current-runtime.js +19 -8
  11. package/dist/packages/sdk/src/improve.d.ts +104 -29
  12. package/dist/packages/sdk/src/improve.js +240 -70
  13. package/dist/packages/sdk/src/judge.d.ts +12 -4
  14. package/dist/packages/sdk/src/judge.js +46 -10
  15. package/dist/packages/sdk/src/local-cli.d.ts +0 -1
  16. package/dist/packages/sdk/src/local-cli.js +16 -15
  17. package/dist/packages/sdk/src/local-judgments.d.ts +40 -0
  18. package/dist/packages/sdk/src/local-judgments.js +144 -0
  19. package/dist/packages/sdk/src/local-setup.d.ts +0 -2
  20. package/dist/packages/sdk/src/local-setup.js +1 -1
  21. package/dist/packages/sdk/src/method-client.d.ts +2 -0
  22. package/dist/packages/sdk/src/method-client.js +6 -2
  23. package/dist/packages/sdk/src/method-help.d.ts +1 -1
  24. package/dist/packages/sdk/src/method-help.js +20 -23
  25. package/dist/packages/sdk/src/method.js +35 -2
  26. package/dist/packages/sdk/src/model-call.d.ts +73 -0
  27. package/dist/packages/sdk/src/model-call.js +74 -0
  28. package/dist/packages/sdk/src/outbox.d.ts +1 -1
  29. package/dist/packages/sdk/src/outbox.js +6 -5
  30. package/dist/packages/sdk/src/own-model-key.d.ts +1 -1
  31. package/dist/packages/sdk/src/own-model-key.js +1 -1
  32. package/dist/packages/sdk/src/proposals.d.ts +10 -26
  33. package/dist/packages/sdk/src/proposals.js +32 -64
  34. package/dist/packages/sdk/src/quality.d.ts +1 -2
  35. package/dist/packages/sdk/src/quality.js +25 -16
  36. package/package.json +2 -2
@@ -1,57 +1,132 @@
1
- import { type MethodClient } from './method-client.js';
2
- /** run_id: the run folder on this computer that the note is about. */
3
- export type ImproveRequest = {
4
- step_id?: string;
5
- note?: string;
6
- run_id?: string;
1
+ import type { MethodClient } from './method-client.js';
2
+ /** A judgment as improve.method reads it. new marks the corrections to fix now; run is the account record of a run with no folder here. */
3
+ export type ImproveJudgment = {
4
+ id: string;
5
+ run_id: string;
6
+ run_dir?: string;
7
+ step_id: string | null;
8
+ verdict: 'right' | 'wrong';
9
+ note: string | null;
10
+ corrected?: unknown;
11
+ case_id?: string;
12
+ source: string;
13
+ created_at: string;
14
+ new?: boolean;
15
+ run?: unknown;
7
16
  };
8
17
  /** Runs a local Method file; the default is the runtime that `method run FILE` uses. */
9
18
  export type RunFile = (file: string, flags: Record<string, unknown>, syncFactory: undefined, onEvent: undefined, client: MethodClient, baseConfig: undefined, secrets: Record<string, string>) => Promise<{
10
19
  status: string;
11
20
  result?: unknown;
21
+ failure?: any;
12
22
  }>;
13
23
  /** The SubmitProposal that improve.method returns, read from its result. */
14
24
  export type ImproveProposal = {
15
25
  cause: string;
16
26
  workflow: unknown | null;
27
+ files: {
28
+ path: string;
29
+ sha256: string;
30
+ content_base64: string;
31
+ }[];
17
32
  evidence: {
18
- kind: ProposalKind;
33
+ kind: 'change' | 'none';
19
34
  steps?: string[];
20
35
  [key: string]: unknown;
21
36
  };
22
37
  };
23
- type ProposalKind = 'change' | 'none';
38
+ export type FixOutcome = {
39
+ file: string;
40
+ corrections: number;
41
+ from_dashboard: number;
42
+ kind: 'change' | 'none';
43
+ line: string;
44
+ cause: string;
45
+ steps: string[];
46
+ cases: {
47
+ made: string[];
48
+ not_made: {
49
+ id: string;
50
+ reason: string;
51
+ }[];
52
+ };
53
+ earlier_passed: number;
54
+ version?: {
55
+ version_id: string;
56
+ url: string;
57
+ undo: string;
58
+ } | undefined;
59
+ usage: {
60
+ model_calls: number;
61
+ usd: number | null;
62
+ minutes: number;
63
+ } | null;
64
+ capped: string;
65
+ log: string;
66
+ changed?: string;
67
+ };
24
68
  /**
25
- * Read the result of improve.method: {proposal: <SubmitProposal as JSON text>, kind, proposal_id}. A change has the
26
- * tested workflow; none has no workflow.
69
+ * Read the result of improve.method: {proposal: <SubmitProposal as JSON text>, kind, proposal_id, capped}. A change has
70
+ * the tested workflow (and a changed package file for a script change); none has no workflow.
27
71
  */
28
- export declare function readImproveResult(result: unknown): ImproveProposal;
29
- export declare const judgmentsLine = "Method fixes this Method from your judgments. Record the person's reaction: method judge RUN_ID wrong --note \"...\".";
72
+ export declare function readImproveResult(result: unknown): ImproveProposal & {
73
+ capped: string;
74
+ };
30
75
  /**
31
- * `method improve FILE`. A Method that keeps run content in the account is fixed from the person's judgments
32
- * (method judge), so this prints that and starts nothing. A Method with device run data (run_data: device, or an
33
- * organization that keeps run content on devices) runs improve.method on this computer, so its run content stays here,
34
- * and saves a local proposal.
76
+ * Download the published improve.method and its files into the Method cache, once per version. For developers of
77
+ * improve.method: METHOD_IMPROVE_METHOD=PATH runs that local improve.method file instead, from its own folder.
35
78
  */
36
- export declare function improveCommand(args: string[], clientFactory: (server: string) => MethodClient, run?: RunFile): Promise<boolean | undefined>;
37
- /** Download the published improve.method and its files into the Method cache, once per version. */
38
79
  export declare function cachedImproveMethod(client: MethodClient): Promise<{
39
80
  file: string;
40
81
  root: string;
41
82
  version_id: string;
42
83
  }>;
43
84
  /**
44
- * Run improve.method on this computer and save its result as a local proposal of the file. The run gets the signed-in
45
- * key and server as its IMPROVE_API_KEY and IMPROVE_SERVER secrets, for this run only: they stay in memory.
85
+ * Run improve.method on this computer. Its model steps and its scripts' model requests follow the rule of every run:
86
+ * the user's own Claude Code or Codex first, hosted models only without one. The run gets the signed-in key and server
87
+ * as its IMPROVE_API_KEY and IMPROVE_SERVER secrets, for this run only. Its output goes to a log file.
46
88
  */
47
- export declare function improveOnDevice(client: MethodClient, file: string, request: ImproveRequest, run?: RunFile): Promise<{
48
- proposal_id: string;
49
- source: string;
50
- file: string;
51
- kind: ProposalKind;
89
+ export declare function runImprovement(client: MethodClient, file: string, judgments: ImproveJudgment[], runFolders: string[], run?: RunFile): Promise<{
90
+ log: string;
91
+ summary: any;
52
92
  cause: string;
53
- steps: string[];
54
- proposal_file: string;
55
- next: string;
93
+ workflow: unknown | null;
94
+ files: {
95
+ path: string;
96
+ sha256: string;
97
+ content_base64: string;
98
+ }[];
99
+ evidence: {
100
+ kind: "change" | "none";
101
+ steps?: string[];
102
+ [key: string]: unknown;
103
+ };
104
+ capped: string;
56
105
  }>;
57
- export {};
106
+ /** The ID of the regression case that a correction makes: one per judgment, so a later loop finds it. */
107
+ export declare const correctionCaseId: (judgmentId: string) => string;
108
+ /**
109
+ * Make the regression case of a correction, even from vague words such as "it's not even redlined": a model writes 1 to
110
+ * 3 plain sentences from the note and the run's result, and method case new keeps them only when they fail on the run.
111
+ * A rule that fails nothing gets one more try with the reason. The person's words decide a conflict with an instruction.
112
+ */
113
+ export declare function caseFromCorrection(file: string, judgment: {
114
+ id: string;
115
+ note: string | null;
116
+ corrected?: unknown;
117
+ }, runDir: string): Promise<{
118
+ id: string;
119
+ made: boolean;
120
+ }>;
121
+ /**
122
+ * Fix the pending corrections of a Method file on this computer: the loop. Returns undefined when nothing waits. It
123
+ * takes the pending corrections in the account first, so two computers never fix one correction twice, and gives them
124
+ * back when the loop stops before it decides. A subscription limit or another failure of the model service is a plain
125
+ * error.
126
+ */
127
+ export declare function fixCorrections(client: MethodClient, file: string, options?: {
128
+ run?: RunFile;
129
+ waitMs?: number;
130
+ }): Promise<FixOutcome | undefined>;
131
+ /** The one line for corrections that waited, printed before method run or method judge goes on. */
132
+ export declare function waitingLine(outcome: FixOutcome): string;
@@ -1,20 +1,19 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
- import { dirname, join, resolve } from 'node:path';
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { basename, dirname, join, resolve, sep } from 'node:path';
3
3
  import { randomBytes, randomUUID } from 'node:crypto';
4
- import { parseArgs } from 'node:util';
4
+ import { isDeepStrictEqual } from 'node:util';
5
5
  import { MethodPackageSchema, packageDigest, packagePath } from '../../contracts/src/method-package.js';
6
6
  import { loadWorkflow } from '../../workflow-language/src/validate.js';
7
- import { authoringPath, readDocument } from './authoring.js';
8
- import { DEFAULT_SERVER } from './method-client.js';
7
+ import { readDocument } from './authoring.js';
9
8
  import { fileHash } from './method-files.js';
10
9
  import { methodCache } from './prepare.js';
11
10
  import { writePrivateJson } from './files.js';
12
- import { currentVersion, methodContent } from './versions.js';
13
- import { localRuns, saveLocalProposal } from './proposals.js';
14
- const kinds = ['change', 'none'];
11
+ import { currentVersion, methodContent, methodIdPattern } from './versions.js';
12
+ import { changedSteps, localRuns, saveLocalProposal, writeMethodFile } from './proposals.js';
13
+ import { localJudgments, runFolderIndex } from './local-judgments.js';
15
14
  /**
16
- * Read the result of improve.method: {proposal: <SubmitProposal as JSON text>, kind, proposal_id}. A change has the
17
- * tested workflow; none has no workflow.
15
+ * Read the result of improve.method: {proposal: <SubmitProposal as JSON text>, kind, proposal_id, capped}. A change has
16
+ * the tested workflow (and a changed package file for a script change); none has no workflow.
18
17
  */
19
18
  export function readImproveResult(result) {
20
19
  const value = result;
@@ -30,49 +29,27 @@ export function readImproveResult(result) {
30
29
  if (typeof proposal?.cause !== 'string' || !proposal.cause.trim())
31
30
  throw Error('The improvement proposal has no cause.');
32
31
  const evidence = proposal.evidence;
33
- if (!evidence || typeof evidence !== 'object' || !kinds.includes(evidence.kind))
32
+ if (!evidence || typeof evidence !== 'object' || !['change', 'none'].includes(evidence.kind))
34
33
  throw Error('The improvement proposal has no known kind.');
35
34
  if (value.kind !== undefined && value.kind !== evidence.kind)
36
35
  throw Error(`The improvement run says kind ${String(value.kind)}, but its proposal is kind ${evidence.kind}.`);
37
36
  if (evidence.kind === 'change' && (!proposal.workflow || typeof proposal.workflow !== 'object'))
38
37
  throw Error('The improvement proposal is a change without a workflow.');
39
- return { cause: proposal.cause.trim(), workflow: evidence.kind === 'change' ? proposal.workflow : null, evidence };
38
+ return { cause: proposal.cause.trim(), workflow: evidence.kind === 'change' ? proposal.workflow : null, files: Array.isArray(proposal.files) ? proposal.files : [], evidence,
39
+ capped: typeof value.capped === 'string' ? value.capped : '' };
40
40
  }
41
- const nextStep = (file, id, kind) => ({
42
- change: `Read the change, then run method apply ${file} ${id}. It does not publish.`,
43
- none: 'No change passed the tests.',
44
- })[kind];
45
- const print = (value) => process.stdout.write(JSON.stringify(value, null, 2) + '\n');
46
- export const judgmentsLine = 'Method fixes this Method from your judgments. Record the person\'s reaction: method judge RUN_ID wrong --note "...".';
47
41
  /**
48
- * `method improve FILE`. A Method that keeps run content in the account is fixed from the person's judgments
49
- * (method judge), so this prints that and starts nothing. A Method with device run data (run_data: device, or an
50
- * organization that keeps run content on devices) runs improve.method on this computer, so its run content stays here,
51
- * and saves a local proposal.
42
+ * Download the published improve.method and its files into the Method cache, once per version. For developers of
43
+ * improve.method: METHOD_IMPROVE_METHOD=PATH runs that local improve.method file instead, from its own folder.
52
44
  */
53
- export async function improveCommand(args, clientFactory, run) {
54
- const { values, positionals } = parseArgs({ args, allowPositionals: true, options: { note: { type: 'string' }, step: { type: 'string' }, server: { type: 'string' } } });
55
- if (positionals.length !== 1)
56
- throw Error('Use method improve FILE [--note TEXT] [--step ID].');
57
- const file = authoringPath(positionals[0]), document = readDocument(file), workflow = loadWorkflow(methodContent(document));
58
- if (values.step && !Object.hasOwn(workflow.steps, values.step))
59
- throw Error(`Step ${values.step} is not in ${positionals[0]}.`);
60
- if (values.note !== undefined && !values.note.trim())
61
- throw Error('Write the correction after --note.');
62
- const request = { ...(values.step ? { step_id: values.step } : {}), ...(values.note ? { note: values.note.trim() } : {}) };
63
- const client = clientFactory(values.server ?? DEFAULT_SERVER);
64
- if (!client.token())
65
- await client.login();
66
- if (document.run_data !== 'device' && !await (await import('./method-sync.js')).organizationKeepsContent(client)) {
67
- process.stdout.write(judgmentsLine + '\n');
68
- return;
69
- }
70
- // A note is about the newest run of the Method on this computer.
71
- const newest = request.note ? (await localRuns(file))[0] : undefined;
72
- return print(await improveOnDevice(client, file, { ...request, ...(newest ? { run_id: newest.dir } : {}) }, run));
73
- }
74
- /** Download the published improve.method and its files into the Method cache, once per version. */
75
45
  export async function cachedImproveMethod(client) {
46
+ const local = process.env.METHOD_IMPROVE_METHOD;
47
+ if (local) {
48
+ const file = resolve(local);
49
+ if (!existsSync(file))
50
+ throw Error(`METHOD_IMPROVE_METHOD names ${file}, which does not exist.`);
51
+ return { file, root: dirname(file), version_id: 'local' };
52
+ }
76
53
  const saved = await client.request('/api/cli/improve/method');
77
54
  if (!/^v_[a-f0-9]{32}$/.test(saved.version_id))
78
55
  throw Error('Method sent an improvement Method without a version ID.');
@@ -99,46 +76,239 @@ export async function cachedImproveMethod(client) {
99
76
  return { file, root, version_id: saved.version_id };
100
77
  }
101
78
  /**
102
- * Run improve.method on this computer and save its result as a local proposal of the file. The run gets the signed-in
103
- * key and server as its IMPROVE_API_KEY and IMPROVE_SERVER secrets, for this run only: they stay in memory.
79
+ * Run improve.method on this computer. Its model steps and its scripts' model requests follow the rule of every run:
80
+ * the user's own Claude Code or Codex first, hosted models only without one. The run gets the signed-in key and server
81
+ * as its IMPROVE_API_KEY and IMPROVE_SERVER secrets, for this run only. Its output goes to a log file.
104
82
  */
105
- export async function improveOnDevice(client, file, request, run) {
83
+ export async function runImprovement(client, file, judgments, runFolders, run) {
106
84
  const token = client.token();
107
85
  if (!token)
108
- throw Error('Sign in with method login, then run method improve again.');
109
- const secrets = { IMPROVE_API_KEY: token, IMPROVE_SERVER: client.server };
86
+ throw Error('Sign in with method login, then run method judge again.');
110
87
  const improve = await cachedImproveMethod(client);
111
88
  const runDir = join(methodCache(), 'runs', randomUUID());
112
- const inputs = join(runDir, 'improve-inputs.json');
113
- writePrivateJson(inputs, { method_file: file, note: request.note ?? '', step_id: request.step_id ?? '', grant_id: '', judgment_id: '', run_id: request.run_id ?? '' });
89
+ mkdirSync(runDir, { recursive: true, mode: 0o700 });
90
+ const judgmentsFile = join(runDir, 'judgments.json'), inputs = join(runDir, 'improve-inputs.json'), log = join(runDir, 'improve.log');
91
+ // The judgments with their notes go in a private file of this run; they stay on this computer.
92
+ writePrivateJson(judgmentsFile, judgments);
93
+ writePrivateJson(inputs, { method_file: file, grant_id: '', judgment_id: '', judgments_file: judgmentsFile, run_folders: runFolders });
114
94
  const runFile = run ?? (await import('./current-runtime.js')).runCurrentFile;
115
- process.stderr.write(`Improving ${file} on this computer. Its run content stays here.\n`);
116
- // The run prints its own result; send it to stderr so that stdout holds only the proposal.
117
- const write = process.stdout.write;
118
- process.stdout.write = ((chunk, ...rest) => process.stderr.write(chunk, ...rest));
95
+ // The run prints its own result and progress; they go to the log, so the person reads one line.
96
+ const out = process.stdout.write, err = process.stderr.write;
97
+ const toLog = ((chunk, ...rest) => { try {
98
+ appendFileSync(log, typeof chunk === 'string' ? chunk : Buffer.from(chunk));
99
+ }
100
+ catch { /* the log is best effort */ } const done = rest.find(r => typeof r === 'function'); done?.(); return true; });
101
+ process.stdout.write = toLog;
102
+ process.stderr.write = toLog;
119
103
  let result;
120
104
  try {
121
- result = await runFile(improve.file, { inputs, 'run-dir': runDir, workspace: improve.root, server: client.server }, undefined, undefined, client, undefined, secrets);
105
+ result = await runFile(improve.file, { inputs, 'run-dir': join(runDir, 'run'), workspace: improve.root, server: client.server }, undefined, undefined, client, undefined, { IMPROVE_API_KEY: token, IMPROVE_SERVER: client.server });
122
106
  }
123
107
  finally {
124
- process.stdout.write = write;
108
+ process.stdout.write = out;
109
+ process.stderr.write = err;
125
110
  }
126
111
  if (result.status !== 'completed')
127
- throw Error(`The improvement run stopped with status ${result.status}. Its records are in ${runDir}.`);
128
- let submitted;
112
+ throw Object.assign(Error(result.failure?.headline ?? `The improvement stopped with status ${result.status}.`), { log });
113
+ const summary = (() => { try {
114
+ return JSON.parse(readFileSync(join(runDir, 'run', 'summary.json'), 'utf8'));
115
+ }
116
+ catch {
117
+ return {};
118
+ } })();
119
+ return { ...readImproveResult(result.result), log, summary };
120
+ }
121
+ /** The ID of the regression case that a correction makes: one per judgment, so a later loop finds it. */
122
+ export const correctionCaseId = (judgmentId) => `correction-${judgmentId.replace(/^jdg_/, '').slice(0, 12)}`;
123
+ const caseSchema = { type: 'object', properties: { criteria: { type: 'array', items: { type: 'string', description: 'One plain sentence that a right result meets.' } } } };
124
+ /**
125
+ * Make the regression case of a correction, even from vague words such as "it's not even redlined": a model writes 1 to
126
+ * 3 plain sentences from the note and the run's result, and method case new keeps them only when they fail on the run.
127
+ * A rule that fails nothing gets one more try with the reason. The person's words decide a conflict with an instruction.
128
+ */
129
+ export async function caseFromCorrection(file, judgment, runDir) {
130
+ const id = correctionCaseId(judgment.id);
131
+ if (existsSync(join(dirname(file), 'cases', id, 'case.json')))
132
+ return { id, made: false };
133
+ const { outcomeOfRun, createCase } = await import('@withmethod/runtime/cases.js');
134
+ const outcome = await outcomeOfRun(runDir);
135
+ const method = readDocument(file);
136
+ const ref = typeof method.result === 'string' ? method.result.split('.')[0] : undefined;
137
+ if (!ref)
138
+ throw Error('The Method has a result with several values, so the case needs an output to judge.');
139
+ const value = outcome.outputs?.[ref] ?? outcome.result;
140
+ const note = judgment.note?.trim() || 'The person marked this result wrong.';
141
+ const { callModel } = await import('./model-call.js');
142
+ const { preparedConfig } = await import('./quality.js');
143
+ const prepared = await preparedConfig(file, {}, { judge: true });
144
+ const user = [`The person's correction, in their words:\n${note}`,
145
+ judgment.corrected !== undefined && judgment.corrected !== null ? `The result the person says is right:\n${clip(typeof judgment.corrected === 'string' ? judgment.corrected : JSON.stringify(judgment.corrected, null, 2), 20_000)}` : '',
146
+ `The Method's goal: ${String(method.goal ?? '')}`, `The result of the run (${ref}):\n${clip(typeof value === 'string' ? value : JSON.stringify(value, null, 2), 40_000)}`].filter(Boolean).join('\n\n');
147
+ let reason = '';
148
+ for (let attempt = 0; attempt < 2; attempt++) {
149
+ const answer = await callModel({ schema: caseSchema, user: reason ? `${user}\n\nYour last sentences could not be used: ${reason}` : user,
150
+ system: 'A person corrected the result of a run of a Method. Write 1 to 3 plain sentences that a right result of this run meets and that this result does not meet, so a later version can be checked against the correction. The person\'s words may be vague, such as "the Ashby one\'s wrong" or "it\'s not even redlined": use the result to find what they mean. Each sentence names one checkable thing in the result, without names or numbers that only this run has unless the correction is about them. Do not ask for anything that the correction does not ask for.' });
151
+ const criteria = (Array.isArray(answer.value?.criteria) ? answer.value.criteria : []).map((c) => String(c).trim()).filter(Boolean).slice(0, 3);
152
+ if (!criteria.length) {
153
+ reason = 'There were no sentences.';
154
+ continue;
155
+ }
156
+ try {
157
+ await createCase({ methodFile: file, runDir, id, note, rubric: criteria, ref: `outputs.${ref}`, acceptConflict: note, config: prepared.config,
158
+ options: { runOptions: { processPath: prepared.processPath, ...(prepared.hostedModels ? { hostedModels: prepared.hostedModels } : {}) } } });
159
+ return { id, made: true };
160
+ }
161
+ catch (error) {
162
+ if (!['case_not_red', 'case_vacuous', 'case_invalid'].includes(error.code))
163
+ throw error;
164
+ reason = error.message;
165
+ }
166
+ }
167
+ throw Object.assign(Error(reason), { code: 'case_not_made' });
168
+ }
169
+ const clip = (text, max) => text.length > max ? `${text.slice(0, max)}…` : text;
170
+ /** The Method's judgments: the account's, with the notes that stay on this computer for a device Method. */
171
+ async function methodJudgments(client, file, methodId, waitMs) {
172
+ const { judgments } = await client.request(`/api/cli/methods/${encodeURIComponent(methodId)}/judgments`, 'GET', undefined, true, waitMs ? { signal: AbortSignal.timeout(waitMs), stallMs: waitMs } : {});
173
+ const local = new Map(localJudgments(file).map(j => [j.id, j]));
174
+ const folders = runFolderIndex();
175
+ return judgments.map(j => {
176
+ const kept = local.get(j.id);
177
+ return { ...j, note: j.note ?? kept?.note ?? null, corrected: j.corrected ?? kept?.corrected ?? null, run_dir: kept?.run_dir && existsSync(kept.run_dir) ? kept.run_dir : folders.get(j.run_id) };
178
+ });
179
+ }
180
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
181
+ /**
182
+ * Fix the pending corrections of a Method file on this computer: the loop. Returns undefined when nothing waits. It
183
+ * takes the pending corrections in the account first, so two computers never fix one correction twice, and gives them
184
+ * back when the loop stops before it decides. A subscription limit or another failure of the model service is a plain
185
+ * error.
186
+ */
187
+ export async function fixCorrections(client, file, options = {}) {
188
+ const document = readDocument(file);
189
+ const methodId = typeof document.id === 'string' && methodIdPattern.test(document.id) ? document.id : undefined;
190
+ if (!methodId || !client.token())
191
+ return undefined;
192
+ const all = await methodJudgments(client, file, methodId, options.waitMs);
193
+ const waiting = all.filter(j => j.pending && j.verdict === 'wrong');
194
+ if (!waiting.length)
195
+ return undefined;
196
+ const handled = (ids, value) => client.request(`/api/cli/methods/${encodeURIComponent(methodId)}/judgments/handled`, 'POST', { ids, handled: value });
197
+ const taken = new Set((await handled(waiting.map(j => j.id), true)).changed);
198
+ const fresh = waiting.filter(j => taken.has(j.id));
199
+ if (!fresh.length)
200
+ return undefined;
201
+ const fromDashboard = fresh.filter(j => j.source === 'dashboard').length;
202
+ process.stderr.write(`Fixing ${basename(file)} from ${plural(fresh.length, 'correction')} on this computer. This takes a few minutes.\n`);
203
+ const base = methodContent(document), baseVersion = await currentVersion(file).catch(() => undefined);
204
+ let decided = false;
129
205
  try {
130
- submitted = readImproveResult(result.result);
206
+ // A regression case per correction, so every later change is checked against it.
207
+ const cases = { made: [], not_made: [] };
208
+ for (const j of fresh) {
209
+ if (!j.run_dir) {
210
+ cases.not_made.push({ id: j.id, reason: 'The run is not on this computer.' });
211
+ continue;
212
+ }
213
+ try {
214
+ const made = await caseFromCorrection(file, j, j.run_dir);
215
+ if (made.made)
216
+ cases.made.push(made.id);
217
+ }
218
+ catch (error) {
219
+ if (error.code !== 'case_not_made' && error.owner !== 'agent_work')
220
+ throw error;
221
+ cases.not_made.push({ id: j.id, reason: error.message });
222
+ }
223
+ }
224
+ // A judged run with no folder here (a cloud run) comes as its account record.
225
+ const freshIds = new Set(fresh.map(j => j.id));
226
+ const judgments = [];
227
+ for (const j of all) {
228
+ const caseId = correctionCaseId(j.id);
229
+ const run = !j.run_dir && (freshIds.has(j.id) || j.verdict === 'wrong') ? await client.request(`/api/workspace/runs/${encodeURIComponent(j.run_id)}`).then(r => r.run).catch(() => undefined) : undefined;
230
+ judgments.push({ id: j.id, run_id: j.run_id, ...(j.run_dir ? { run_dir: j.run_dir } : {}), step_id: j.step_id, verdict: j.verdict, note: j.note, ...(j.corrected !== null ? { corrected: j.corrected } : {}),
231
+ ...(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 } : {}),
232
+ ...(run ? { run: { id: run.id, status: run.status, created_at: run.created_at, version_id: run.workflow_version_id, inspection: run.inspection } } : {}) });
233
+ }
234
+ const runFolders = (await localRuns(file)).map(r => r.dir);
235
+ const improved = await runImprovement(client, file, judgments, runFolders, options.run);
236
+ decided = true;
237
+ const evidence = improved.evidence;
238
+ const earlier = (evidence.cases ?? []).filter((c) => !c.target && c.after === 'pass').length;
239
+ const usage = evidence.budget ?? null;
240
+ const used = usage ? ` (${plural(usage.model_calls, 'model call')}, ${usage.minutes < 1 ? 'under a minute' : `${Math.round(usage.minutes)} min`}${typeof usage.usd === 'number' ? `, $${usage.usd.toFixed(2)}` : ''})` : '';
241
+ const outcome = { file, corrections: fresh.length, from_dashboard: fromDashboard, cause: improved.cause, cases, earlier_passed: earlier, usage, capped: improved.capped, log: improved.log };
242
+ const record = (kind, extra = {}) => saveLocalProposal(file, { id: `lp_${randomBytes(8).toString('hex')}`, method_id: methodId, base_version_id: baseVersion?.version_id ?? null,
243
+ status: kind, cause: improved.cause, evidence, created_at: new Date().toISOString(), base_workflow: base, proposed_workflow: improved.workflow, judgments: [...freshIds], ...extra });
244
+ if (evidence.kind !== 'change') {
245
+ record('none');
246
+ const why = improved.cause.replace(/^No safe change was found\.\s*/, '').split('\n')[0].slice(0, 300);
247
+ return { ...outcome, kind: 'none', steps: evidence.steps ?? [], line: `No safe change found: ${why}${used}` };
248
+ }
249
+ // The file must be the one the loop started from; a change made meanwhile is not overwritten.
250
+ if (!isDeepStrictEqual(methodContent(readDocument(file)), base)) {
251
+ record('none', { reason: 'The file changed while Method fixed it.' });
252
+ return { ...outcome, kind: 'none', steps: evidence.steps ?? [], line: `No change applied: ${basename(file)} changed while Method fixed it. Run method judge again to fix it on the new file.` };
253
+ }
254
+ // The version before the change is saved too, so the Method's history has it to restore.
255
+ const { Outbox } = await import('./outbox.js');
256
+ const outbox = new Outbox(client);
257
+ const before = await (await import('./versions.js')).prepareVersion(client, outbox, file).catch(() => undefined);
258
+ const steps = applyChange(file, methodId, improved);
259
+ const version = await saveFixedVersion(client, outbox, file, methodId, improved.cause, before?.version_id);
260
+ record('applied', { applied_at: new Date().toISOString(), ...(version ? { applied_version_id: version.version_id } : {}) });
261
+ const changed = `Changed ${steps.length ? `step ${steps.join(', ')}` : 'its files'} of ${basename(file)}: ${improved.cause}`, undo = version ? ` ${version.undo}` : '';
262
+ return { ...outcome, kind: 'change', steps, version, changed: `${changed}${undo}${used}`,
263
+ line: `${changed} It fixes the corrected run and passes ${plural(earlier, 'earlier correction')}.${undo}${used}` };
131
264
  }
132
265
  catch (error) {
133
- throw Error(`${error.message} Its records are in ${runDir}.`);
266
+ // Stopped before it decided: the corrections wait for the next loop.
267
+ if (!decided)
268
+ await handled([...taken], false).catch(() => undefined);
269
+ throw error;
134
270
  }
135
- const id = `lp_${randomBytes(8).toString('hex')}`, document = readDocument(file);
136
- const path = saveLocalProposal(file, {
137
- id, method_id: typeof document.id === 'string' ? document.id : null, base_version_id: (await currentVersion(file))?.version_id ?? null,
138
- status: 'open', cause: submitted.cause, evidence: submitted.evidence, created_at: new Date().toISOString(),
139
- base_workflow: methodContent(document), proposed_workflow: submitted.workflow,
140
- improvement: { version_id: improve.version_id, run_dir: runDir },
141
- });
142
- const kind = submitted.evidence.kind;
143
- return { proposal_id: id, source: 'local', file, kind, cause: submitted.cause, steps: submitted.evidence.steps ?? [], proposal_file: path, next: nextStep(file, id, kind) };
271
+ }
272
+ /** Write the tested change: the Method content (its id: line kept) and a changed package file of a script change. */
273
+ function applyChange(file, methodId, improved) {
274
+ const before = methodContent(readDocument(file));
275
+ loadWorkflow(improved.workflow);
276
+ if (!isDeepStrictEqual(improved.workflow, before))
277
+ writeMethodFile(file, methodId, improved.workflow);
278
+ const folder = dirname(resolve(file));
279
+ for (const changed of improved.files) {
280
+ const path = resolve(folder, packagePath(changed.path));
281
+ if (!path.startsWith(folder + sep))
282
+ throw Error(`The change names a file outside the Method folder: ${changed.path}`);
283
+ const bytes = Buffer.from(changed.content_base64, 'base64');
284
+ if (fileHash(bytes) !== changed.sha256)
285
+ throw Error(`The changed file ${changed.path} does not match its sha256.`);
286
+ writeFileSync(path, bytes);
287
+ }
288
+ return changedSteps(before, improved.workflow);
289
+ }
290
+ /**
291
+ * Save the fixed file as a new version of the Method, with the cause as its reason. The version before stays in the
292
+ * Method's history: restoring it on the Method page undoes the change.
293
+ */
294
+ async function saveFixedVersion(client, outbox, file, methodId, cause, previous) {
295
+ try {
296
+ const { savedVersions } = await import('./outbox.js');
297
+ const { prepareVersion } = await import('./versions.js');
298
+ const version = await prepareVersion(client, outbox, file, `Method fixed it from a correction: ${cause}`.slice(0, 500));
299
+ await outbox.drain();
300
+ if (!await outbox.whenSaved(version.version_id))
301
+ return undefined;
302
+ const number = previous && await outbox.whenSaved(previous) ? savedVersions(client.server)[previous]?.version_number : undefined;
303
+ const undo = previous && number ? `To undo it, restore version ${number}: ${client.server}/methods/${methodId}?version=${previous}` : 'The version before it is in its history on the Method page.';
304
+ return { version_id: version.version_id, url: version.url, undo };
305
+ }
306
+ catch {
307
+ return undefined;
308
+ }
309
+ }
310
+ /** The one line for corrections that waited, printed before method run or method judge goes on. */
311
+ export function waitingLine(outcome) {
312
+ const what = outcome.from_dashboard === outcome.corrections ? `${plural(outcome.corrections, 'correction')} from the dashboard` : plural(outcome.corrections, 'correction');
313
+ return outcome.kind === 'change' ? `${what}: fixed, passes ${plural(outcome.earlier_passed, 'earlier correction')}. ${outcome.changed}` : `${what}: ${outcome.line}`;
144
314
  }
@@ -1,4 +1,5 @@
1
1
  import { type MethodClient } from './method-client.js';
2
+ import { type RunFile } from './improve.js';
2
3
  export declare const maxNoteLength = 4000;
3
4
  export declare const maxCorrectedBytes: number;
4
5
  export type Judgment = {
@@ -11,10 +12,17 @@ export type Judgment = {
11
12
  };
12
13
  /** The corrected result in a file: its JSON value when it parses as JSON, or else its text. */
13
14
  export declare function correctedValue(path: string): unknown;
14
- /** The one line that says what was recorded. */
15
- export declare function judgmentLine(judgment: Pick<Judgment, 'verdict' | 'step_id'>, fixing: boolean): string;
15
+ /** The line that says what was recorded. */
16
+ export declare function judgmentLine(judgment: Pick<Judgment, 'verdict' | 'step_id'>): string;
16
17
  /**
17
18
  * `method judge RUN_ID right|wrong [--step ID] [--note TEXT] [--corrected FILE] [--json]`: record the person's reaction
18
- * to a run's result. RUN_ID is the dashboard run ID or this computer's run ID; the server resolves both.
19
+ * to a run's result. RUN_ID is the dashboard run ID or this computer's run ID; the server resolves both. When the run's
20
+ * content stays on this computer (run_data: device), the account gets the verdict and step only, and the judgment with
21
+ * its note and corrected result goes in .method/judgments.jsonl next to the Method file. A wrong judgment then fixes the
22
+ * Method here, in the foreground: method judge makes a regression case from the note, finds a change with the user's
23
+ * own Claude Code or Codex, and applies it only when it fixes the run and breaks no earlier correction. It prints one
24
+ * line: what changed, or why nothing did.
19
25
  */
20
- export declare function judgeCommand(args: string[], clientFactory: (server: string) => MethodClient): Promise<void>;
26
+ export declare function judgeCommand(args: string[], clientFactory: (server: string) => MethodClient, options?: {
27
+ run?: RunFile;
28
+ }): Promise<void>;
@@ -1,6 +1,10 @@
1
- import { readFileSync, statSync } from 'node:fs';
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { basename } from 'node:path';
2
3
  import { parseArgs } from 'node:util';
3
4
  import { DEFAULT_SERVER } from './method-client.js';
5
+ import { deviceRun, findRunFolder, methodFileOfRun, saveLocalJudgment } from './local-judgments.js';
6
+ import { fixCorrections, waitingLine } from './improve.js';
7
+ import { readHome } from './home.js';
4
8
  export const maxNoteLength = 4000;
5
9
  export const maxCorrectedBytes = 1024 * 1024;
6
10
  /** The corrected result in a file: its JSON value when it parses as JSON, or else its text. */
@@ -22,15 +26,20 @@ export function correctedValue(path) {
22
26
  return text;
23
27
  }
24
28
  }
25
- /** The one line that says what was recorded. */
26
- export function judgmentLine(judgment, fixing) {
27
- return `Recorded: ${judgment.verdict}${judgment.step_id ? ` on step ${judgment.step_id}` : ''}.${fixing ? ' Method is fixing it in the background.' : ''}`;
29
+ /** The line that says what was recorded. */
30
+ export function judgmentLine(judgment) {
31
+ return `Recorded: ${judgment.verdict}${judgment.step_id ? ` on step ${judgment.step_id}` : ''}.`;
28
32
  }
29
33
  /**
30
34
  * `method judge RUN_ID right|wrong [--step ID] [--note TEXT] [--corrected FILE] [--json]`: record the person's reaction
31
- * to a run's result. RUN_ID is the dashboard run ID or this computer's run ID; the server resolves both.
35
+ * to a run's result. RUN_ID is the dashboard run ID or this computer's run ID; the server resolves both. When the run's
36
+ * content stays on this computer (run_data: device), the account gets the verdict and step only, and the judgment with
37
+ * its note and corrected result goes in .method/judgments.jsonl next to the Method file. A wrong judgment then fixes the
38
+ * Method here, in the foreground: method judge makes a regression case from the note, finds a change with the user's
39
+ * own Claude Code or Codex, and applies it only when it fixes the run and breaks no earlier correction. It prints one
40
+ * line: what changed, or why nothing did.
32
41
  */
33
- export async function judgeCommand(args, clientFactory) {
42
+ export async function judgeCommand(args, clientFactory, options = {}) {
34
43
  const usage = 'Use method judge RUN_ID right|wrong [--step STEP_ID] [--note TEXT] [--corrected FILE].';
35
44
  const { values, positionals } = parseArgs({ args, allowPositionals: true, options: {
36
45
  step: { type: 'string' }, note: { type: 'string' }, corrected: { type: 'string' }, json: { type: 'boolean' }, server: { type: 'string' }
@@ -47,13 +56,40 @@ export async function judgeCommand(args, clientFactory) {
47
56
  throw Error(`The note has ${note.length} characters; the most is ${maxNoteLength}.`);
48
57
  if (values.step !== undefined && !values.step.trim())
49
58
  throw Error('Write the step ID after --step.');
50
- const body = { verdict, ...(values.step ? { step_id: values.step.trim() } : {}), ...(note ? { note } : {}),
51
- ...(values.corrected !== undefined ? { corrected: correctedValue(values.corrected) } : {}) };
59
+ const step = values.step?.trim();
60
+ const corrected = values.corrected !== undefined ? correctedValue(values.corrected) : undefined;
61
+ // The run on this computer, and the Method file that ran it.
62
+ const runDir = findRunFolder(runId), device = !!runDir && deviceRun(runDir);
63
+ const runFile = runDir ? methodFileOfRun(runDir) : undefined;
64
+ if (device && !runFile && (note || corrected !== undefined))
65
+ throw Error(`This run's content stays on this computer, so its note stays next to the Method file. Run method judge in the folder of the Method file.`);
66
+ const content = device ? {} : { ...(note ? { note } : {}), ...(corrected !== undefined ? { corrected } : {}) };
67
+ const body = { verdict, ...(step ? { step_id: step } : {}), ...content };
52
68
  const client = clientFactory(values.server ?? DEFAULT_SERVER);
53
69
  const response = await client.request(`/api/cli/runs/${encodeURIComponent(runId)}/judgments`, 'POST', body);
70
+ if (device && runFile)
71
+ saveLocalJudgment(runFile, {
72
+ id: response.judgment.id, run_id: runId, account_run_id: response.judgment.run_id ?? null, run_dir: runDir, method_id: response.judgment.method_id ?? null,
73
+ file: basename(runFile), step_id: step ?? null, verdict, note: note ?? null, ...(corrected !== undefined ? { corrected } : {}), created_at: response.judgment.created_at,
74
+ });
75
+ const recorded = judgmentLine(response.judgment);
76
+ if (verdict === 'right') {
77
+ process.stdout.write(values.json ? JSON.stringify({ judgment: response.judgment }, null, 2) + '\n' : recorded + '\n');
78
+ return;
79
+ }
80
+ // The Method file: the one that ran the run, else the Method's home file on this computer.
81
+ const home = response.judgment.method_id ? readHome(client.server, response.judgment.method_id)?.path : undefined;
82
+ const file = runFile ?? (home && existsSync(home) ? home : undefined);
83
+ if (!file) {
84
+ const line = `${recorded} The Method file is not on this computer, so the next method run of it on a computer fixes it.`;
85
+ process.stdout.write(values.json ? JSON.stringify({ judgment: response.judgment, fix: null }, null, 2) + '\n' : line + '\n');
86
+ return;
87
+ }
88
+ const fix = await fixCorrections(client, file, options);
54
89
  if (values.json) {
55
- process.stdout.write(JSON.stringify(response, null, 2) + '\n');
90
+ process.stdout.write(JSON.stringify({ judgment: response.judgment, fix: fix ?? null }, null, 2) + '\n');
56
91
  return;
57
92
  }
58
- process.stdout.write(judgmentLine(response.judgment, !!response.fixing) + '\n');
93
+ const line = !fix ? `${recorded} Another loop is fixing it.` : fix.corrections > 1 ? `${recorded} ${waitingLine(fix)}` : `${recorded} ${fix.line}`;
94
+ process.stdout.write(line + '\n');
59
95
  }