@method-ai-hq/sdk 0.18.1 → 0.19.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.
@@ -1,4 +1,4 @@
1
- import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { basename, dirname, join, resolve, sep } from 'node:path';
3
3
  import { randomBytes, randomUUID } from 'node:crypto';
4
4
  import { isDeepStrictEqual } from 'node:util';
@@ -9,7 +9,8 @@ import { fileHash } from './method-files.js';
9
9
  import { methodCache } from './prepare.js';
10
10
  import { writePrivateJson } from './files.js';
11
11
  import { currentVersion, methodContent, methodIdPattern } from './versions.js';
12
- import { changedSteps, localRuns, saveLocalProposal, writeMethodFile } from './proposals.js';
12
+ import { localRuns, saveLocalProposal, writeMethodFile } from './proposals.js';
13
+ import { changedSteps } from './merge.js';
13
14
  import { localJudgments, runFolderIndex } from './local-judgments.js';
14
15
  /**
15
16
  * Read the result of improve.method: {proposal: <SubmitProposal as JSON text>, kind, proposal_id, capped}. A change has
@@ -154,7 +155,7 @@ export async function caseFromCorrection(file, judgment, runDir) {
154
155
  continue;
155
156
  }
156
157
  try {
157
- await createCase({ methodFile: file, runDir, id, note, rubric: criteria, ref: `outputs.${ref}`, acceptConflict: note, config: prepared.config,
158
+ await createCase({ methodFile: file, runDir, id, note, author: judgment.noted_by ?? null, rubric: criteria, ref: `outputs.${ref}`, acceptConflict: note, config: prepared.config,
158
159
  options: { runOptions: { processPath: prepared.processPath, ...(prepared.hostedModels ? { hostedModels: prepared.hostedModels } : {}) } } });
159
160
  return { id, made: true };
160
161
  }
@@ -195,14 +196,26 @@ export async function fixCorrections(client, file, options = {}) {
195
196
  return undefined;
196
197
  const handled = (ids, value) => client.request(`/api/cli/methods/${encodeURIComponent(methodId)}/judgments/handled`, 'POST', { ids, handled: value });
197
198
  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)
199
+ const claimed = waiting.filter(j => taken.has(j.id));
200
+ if (!claimed.length)
200
201
  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
202
  const base = methodContent(document), baseVersion = await currentVersion(file).catch(() => undefined);
204
203
  let decided = false;
204
+ const outcomes = [];
205
+ const report = () => reportOutcomes(client, methodId, outcomes);
205
206
  try {
207
+ // 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;
212
+ if (!fresh.length) {
213
+ 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(' ')}` };
217
+ }
218
+ process.stderr.write(`Fixing ${basename(file)} from ${plural(fresh.length, 'correction')} on this computer. This takes a few minutes.\n`);
206
219
  // A regression case per correction, so every later change is checked against it.
207
220
  const cases = { made: [], not_made: [] };
208
221
  for (const j of fresh) {
@@ -238,11 +251,21 @@ export async function fixCorrections(client, file, options = {}) {
238
251
  const earlier = (evidence.cases ?? []).filter((c) => !c.target && c.after === 'pass').length;
239
252
  const usage = evidence.budget ?? null;
240
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)}` : ''})` : '';
241
- const outcome = { file, corrections: fresh.length, from_dashboard: fromDashboard, cause: improved.cause, cases, earlier_passed: earlier, usage, capped: improved.capped, log: improved.log };
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 };
242
255
  const record = (kind, extra = {}) => saveLocalProposal(file, { id: `lp_${randomBytes(8).toString('hex')}`, method_id: methodId, base_version_id: baseVersion?.version_id ?? null,
243
256
  status: kind, cause: improved.cause, evidence, created_at: new Date().toISOString(), base_workflow: base, proposed_workflow: improved.workflow, judgments: [...freshIds], ...extra });
257
+ // 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);
259
+ if (held.length) {
260
+ 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.` };
264
+ }
244
265
  if (evidence.kind !== 'change') {
245
266
  record('none');
267
+ outcomes.push(...fresh.map(j => ({ id: j.id, outcome: 'no_change' })));
268
+ await report();
246
269
  const why = improved.cause.replace(/^No safe change was found\.\s*/, '').split('\n')[0].slice(0, 300);
247
270
  return { ...outcome, kind: 'none', steps: evidence.steps ?? [], line: `No safe change found: ${why}${used}` };
248
271
  }
@@ -258,6 +281,8 @@ export async function fixCorrections(client, file, options = {}) {
258
281
  const steps = applyChange(file, methodId, improved);
259
282
  const version = await saveFixedVersion(client, outbox, file, methodId, improved.cause, before?.version_id);
260
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();
261
286
  const changed = `Changed ${steps.length ? `step ${steps.join(', ')}` : 'its files'} of ${basename(file)}: ${improved.cause}`, undo = version ? ` ${version.undo}` : '';
262
287
  return { ...outcome, kind: 'change', steps, version, changed: `${changed}${undo}${used}`,
263
288
  line: `${changed} It fixes the corrected run and passes ${plural(earlier, 'earlier correction')}.${undo}${used}` };
@@ -265,10 +290,231 @@ export async function fixCorrections(client, file, options = {}) {
265
290
  catch (error) {
266
291
  // Stopped before it decided: the corrections wait for the next loop.
267
292
  if (!decided)
268
- await handled([...taken], false).catch(() => undefined);
269
- throw error;
293
+ await handled([...taken].filter(id => !outcomes.some(o => o.id === id)), false).catch(() => undefined);
294
+ if (!decided && outcomes.length)
295
+ await report();
296
+ throw Object.assign(error instanceof Error ? error : Error(String(error)), { judgments: [...taken] });
270
297
  }
271
298
  }
299
+ // 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);
303
+ const fileMethodId = (file) => { const id = readDocument(file).id; return typeof id === 'string' && methodIdPattern.test(id) ? id : undefined; };
304
+ /** Take the Method's lock: a file with this process ID. A lock whose process has stopped is taken over. */
305
+ function takeLock(path) {
306
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
307
+ for (let attempt = 0; attempt < 2; attempt++) {
308
+ try {
309
+ writeFileSync(path, JSON.stringify({ pid: process.pid }), { flag: 'wx', mode: 0o600 });
310
+ return true;
311
+ }
312
+ catch (error) {
313
+ if (error.code !== 'EEXIST')
314
+ throw error;
315
+ }
316
+ let pid;
317
+ try {
318
+ pid = JSON.parse(readFileSync(path, 'utf8')).pid;
319
+ }
320
+ catch { /* written now, or gone */ }
321
+ if (pid === undefined) {
322
+ try {
323
+ if (Date.now() - statSync(path).mtimeMs < 10_000)
324
+ return false;
325
+ }
326
+ catch {
327
+ continue;
328
+ }
329
+ }
330
+ else {
331
+ try {
332
+ process.kill(pid, 0);
333
+ return false;
334
+ }
335
+ catch (error) {
336
+ if (error.code !== 'ESRCH')
337
+ return false;
338
+ }
339
+ }
340
+ rmSync(path, { force: true });
341
+ }
342
+ return false;
343
+ }
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;
349
+ try {
350
+ saved = JSON.parse(readFileSync(path, 'utf8'));
351
+ }
352
+ catch {
353
+ return undefined;
354
+ }
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
+ }
359
+ /**
360
+ * 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.
362
+ */
363
+ export async function fixWaiting(client, file, options = {}) {
364
+ const methodId = fileMethodId(file);
365
+ if (!methodId || !client.token())
366
+ return [];
367
+ const folder = fixFolder(methodId), lock = join(folder, 'lock');
368
+ if (!takeLock(lock))
369
+ return undefined;
370
+ const fixes = [];
371
+ 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
+ for (;;) {
382
+ let fix;
383
+ try {
384
+ fix = await fixCorrections(client, file, options);
385
+ }
386
+ catch (error) {
387
+ for (const id of error.judgments ?? [])
388
+ saveOutcome(folder, id, { error: error.message, ...(error.log ? { log: error.log } : {}) });
389
+ throw error;
390
+ }
391
+ if (!fix)
392
+ return fixes;
393
+ for (const id of fix.judgments)
394
+ saveOutcome(folder, id, { fix });
395
+ fixes.push(fix);
396
+ }
397
+ }
398
+ finally {
399
+ rmSync(lock, { force: true });
400
+ }
401
+ }
402
+ /**
403
+ * 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.
405
+ */
406
+ export async function fixCorrection(client, file, judgmentId, options = {}) {
407
+ const methodId = fileMethodId(file);
408
+ if (!methodId || !client.token())
409
+ return undefined;
410
+ const folder = fixFolder(methodId);
411
+ let joined = false;
412
+ for (;;) {
413
+ const saved = takeOutcome(folder, judgmentId);
414
+ if (saved)
415
+ return saved();
416
+ let ran, failure;
417
+ try {
418
+ ran = await fixWaiting(client, file, options);
419
+ }
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;
431
+ }
432
+ if (!joined) {
433
+ process.stderr.write(`Method is fixing ${basename(file)} on this computer; this correction joins that fix.\n`);
434
+ joined = true;
435
+ }
436
+ await new Promise(done => setTimeout(done, options.pollMs ?? 500));
437
+ }
438
+ }
439
+ /** Send what the loop did with each correction. An older server without the route keeps today's record. */
440
+ async function reportOutcomes(client, methodId, outcomes) {
441
+ if (!outcomes.length)
442
+ return;
443
+ 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) } : {}) })) });
445
+ }
446
+ 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`);
449
+ }
450
+ }
451
+ /** The version that a judged run ran: from its run folder here, else from its account record. */
452
+ async function runVersion(client, j) {
453
+ if (j.run_dir) {
454
+ try {
455
+ const sync = JSON.parse(readFileSync(join(j.run_dir, 'method-sync.json'), 'utf8'));
456
+ if (typeof sync.version_id === 'string')
457
+ return sync.version_id;
458
+ }
459
+ catch { /* no account link */ }
460
+ }
461
+ try {
462
+ return (await client.request(`/api/workspace/runs/${encodeURIComponent(j.run_id)}`)).run?.workflow_version_id;
463
+ }
464
+ catch {
465
+ return undefined;
466
+ }
467
+ }
468
+ /**
469
+ * The corrections of a step whose text in the run's version differs from the file: outcome already_changed, with the
470
+ * person who saved the newest version when that version has the file's text of the step.
471
+ */
472
+ async function changedSinceRun(client, methodId, judgments, current) {
473
+ const changed = [], versions = new Map();
474
+ let file;
475
+ try {
476
+ file = loadWorkflow(current);
477
+ }
478
+ catch {
479
+ return changed;
480
+ }
481
+ const saved = (version) => {
482
+ const key = version ?? '';
483
+ if (!versions.has(key))
484
+ versions.set(key, client.request(`/api/cli/methods/${encodeURIComponent(methodId)}${version ? `?version=${encodeURIComponent(version)}` : ''}`).catch(() => undefined));
485
+ return versions.get(key);
486
+ };
487
+ for (const j of judgments) {
488
+ if (!j.step_id || !Object.hasOwn(file.steps ?? {}, j.step_id))
489
+ continue;
490
+ const version = await runVersion(client, j), ran = version ? await saved(version) : undefined;
491
+ if (!ran?.workflow?.steps || isDeepStrictEqual(ran.workflow.steps[j.step_id], file.steps[j.step_id]))
492
+ continue;
493
+ const newest = await saved();
494
+ 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.` });
496
+ }
497
+ return changed;
498
+ }
499
+ /**
500
+ * 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.
502
+ */
503
+ function heldBy(file, fresh, evidence) {
504
+ const broken = (Array.isArray(evidence?.cases) ? evidence.cases : []).filter((c) => !c.target && c.before === 'pass' && c.after !== 'pass').map((c) => {
505
+ try {
506
+ 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 ?? '') };
508
+ }
509
+ catch {
510
+ return undefined;
511
+ }
512
+ }).filter((c) => c?.author);
513
+ return fresh.flatMap(j => {
514
+ 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]*$/, '.') }] : [];
516
+ });
517
+ }
272
518
  /** Write the tested change: the Method content (its id: line kept) and a changed package file of a script change. */
273
519
  function applyChange(file, methodId, improved) {
274
520
  const before = methodContent(readDocument(file));
@@ -20,9 +20,21 @@ export declare function judgmentLine(judgment: Pick<Judgment, 'verdict' | 'step_
20
20
  * content stays on this computer (run_data: device), the account gets the verdict and step only, and the judgment with
21
21
  * its note and corrected result goes in .method/judgments.jsonl next to the Method file. A wrong judgment then fixes the
22
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.
23
+ * own Claude Code or Codex, and applies it only when it fixes the run and breaks no earlier correction. When a fix of
24
+ * the same Method already runs on this computer, the correction joins it: that fix takes it in one more pass, and this
25
+ * command waits for it. It prints one line: what changed, or why nothing did.
25
26
  */
26
27
  export declare function judgeCommand(args: string[], clientFactory: (server: string) => MethodClient, options?: {
27
28
  run?: RunFile;
28
29
  }): Promise<void>;
30
+ /** Two judgments of the same run and step by two people with opposite verdicts: neither is fixed until one is kept. */
31
+ export type HeldPair = {
32
+ run_id: string;
33
+ step_id: string | null;
34
+ judgments: Array<{
35
+ id: string;
36
+ verdict: string;
37
+ noted_by: string | null;
38
+ note: string | null;
39
+ }>;
40
+ };
@@ -3,8 +3,8 @@ import { basename } from 'node:path';
3
3
  import { parseArgs } from 'node:util';
4
4
  import { DEFAULT_SERVER } from './method-client.js';
5
5
  import { deviceRun, findRunFolder, methodFileOfRun, saveLocalJudgment } from './local-judgments.js';
6
- import { fixCorrections, waitingLine } from './improve.js';
7
- import { readHome } from './home.js';
6
+ import { fixCorrection, waitingLine } from './improve.js';
7
+ import { catchUp, readHome } from './home.js';
8
8
  export const maxNoteLength = 4000;
9
9
  export const maxCorrectedBytes = 1024 * 1024;
10
10
  /** The corrected result in a file: its JSON value when it parses as JSON, or else its text. */
@@ -36,15 +36,20 @@ export function judgmentLine(judgment) {
36
36
  * content stays on this computer (run_data: device), the account gets the verdict and step only, and the judgment with
37
37
  * its note and corrected result goes in .method/judgments.jsonl next to the Method file. A wrong judgment then fixes the
38
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.
39
+ * own Claude Code or Codex, and applies it only when it fixes the run and breaks no earlier correction. When a fix of
40
+ * the same Method already runs on this computer, the correction joins it: that fix takes it in one more pass, and this
41
+ * command waits for it. It prints one line: what changed, or why nothing did.
41
42
  */
42
43
  export async function judgeCommand(args, clientFactory, options = {}) {
43
- const usage = 'Use method judge RUN_ID right|wrong [--step STEP_ID] [--note TEXT] [--corrected FILE].';
44
+ // The output stream at the start: a fix that runs in this process sends its run's own output to a log meanwhile.
45
+ const print = process.stdout.write.bind(process.stdout);
46
+ const usage = 'Use method judge RUN_ID right|wrong [--step STEP_ID] [--note TEXT] [--corrected FILE], or method judge RUN_ID keep JUDGMENT_ID.';
44
47
  const { values, positionals } = parseArgs({ args, allowPositionals: true, options: {
45
48
  step: { type: 'string' }, note: { type: 'string' }, corrected: { type: 'string' }, json: { type: 'boolean' }, server: { type: 'string' }
46
49
  } });
47
50
  const [runId, verdict] = positionals;
51
+ if (runId && verdict === 'keep' && positionals.length === 3)
52
+ return keepCommand(runId, positionals[2], values, clientFactory, options);
48
53
  if (!runId || !verdict || positionals.length !== 2)
49
54
  throw Error(usage);
50
55
  if (verdict !== 'right' && verdict !== 'wrong')
@@ -73,23 +78,78 @@ export async function judgeCommand(args, clientFactory, options = {}) {
73
78
  file: basename(runFile), step_id: step ?? null, verdict, note: note ?? null, ...(corrected !== undefined ? { corrected } : {}), created_at: response.judgment.created_at,
74
79
  });
75
80
  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');
81
+ // A judgment that contradicts another person's judgment of the same step is held: show both, with the two choices.
82
+ const pairs = response.held ? await heldPairs(client, response.judgment.method_id, response.judgment.id) : [];
83
+ if (verdict === 'right' || response.held) {
84
+ print(values.json ? JSON.stringify({ judgment: response.judgment, ...(pairs.length ? { held: pairs } : {}) }, null, 2) + '\n' : [recorded, ...pairs.map(heldLine)].join('\n') + '\n');
78
85
  return;
79
86
  }
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;
87
+ return fixAfter(client, response.judgment, recorded, runFile, values, options, pairs, print);
88
+ }
89
+ /** The run's Method file fixes the correction: the file that ran the run, else the Method's home file on this computer. */
90
+ async function fixAfter(client, judgment, recorded, runFile, values, options, pairs, print = process.stdout.write.bind(process.stdout)) {
91
+ const home = judgment.method_id ? readHome(client.server, judgment.method_id)?.path : undefined;
82
92
  const file = runFile ?? (home && existsSync(home) ? home : undefined);
93
+ const held = pairs.map(heldLine), extra = pairs.length ? { held: pairs } : {};
83
94
  if (!file) {
84
95
  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');
96
+ print(values.json ? JSON.stringify({ judgment, fix: null, ...extra }, null, 2) + '\n' : [line, ...held].join('\n') + '\n');
97
+ return;
98
+ }
99
+ // The fix starts from the newest version; a teammate's change of the same step stops it until the person combines them.
100
+ const caught = await catchUp(client, file, 'fixing');
101
+ if (caught.status === 'conflict') {
102
+ print(values.json ? JSON.stringify({ judgment, fix: null, stopped: caught.result, ...extra }, null, 2) + '\n' : [`${recorded} ${caught.result.failure.headline}`, ...held].join('\n') + '\n');
103
+ process.exitCode = 2;
86
104
  return;
87
105
  }
88
- const fix = await fixCorrections(client, file, options);
106
+ if ('line' in caught)
107
+ process.stderr.write(caught.line + '\n');
108
+ const fix = await fixCorrection(client, file, judgment.id, options);
89
109
  if (values.json) {
90
- process.stdout.write(JSON.stringify({ judgment: response.judgment, fix: fix ?? null }, null, 2) + '\n');
110
+ print(JSON.stringify({ judgment, fix: fix ?? null, ...extra }, null, 2) + '\n');
91
111
  return;
92
112
  }
93
113
  const line = !fix ? `${recorded} Another loop is fixing it.` : fix.corrections > 1 ? `${recorded} ${waitingLine(fix)}` : `${recorded} ${fix.line}`;
94
- process.stdout.write(line + '\n');
114
+ print([line, ...held].join('\n') + '\n');
115
+ }
116
+ /** The held pairs of a Method, from its judgments in the account. Nothing when they cannot be read. */
117
+ async function heldPairs(client, methodId, mine) {
118
+ if (!methodId)
119
+ return [];
120
+ let judgments;
121
+ try {
122
+ judgments = (await client.request(`/api/cli/methods/${encodeURIComponent(methodId)}/judgments`)).judgments ?? [];
123
+ }
124
+ catch {
125
+ return [];
126
+ }
127
+ const byId = new Map(judgments.map(j => [j.id, j])), pairs = [], seen = new Set();
128
+ for (const j of judgments) {
129
+ const other = j.outcome === 'held' && j.held_with ? byId.get(j.held_with) : undefined;
130
+ if (!other || seen.has(j.id) || seen.has(other.id))
131
+ continue;
132
+ seen.add(j.id);
133
+ seen.add(other.id);
134
+ const [first, second] = mine === other.id ? [other, j] : [j, other];
135
+ pairs.push({ run_id: j.run_id, step_id: j.step_id ?? null, judgments: [first, second].map(x => ({ id: x.id, verdict: x.verdict, noted_by: x.noted_by ?? null, note: x.note ?? null })) });
136
+ }
137
+ return pairs;
138
+ }
139
+ const heldLine = (pair) => {
140
+ const said = (j) => `${j.noted_by ?? 'Someone'} says it is ${j.verdict}${j.note ? ` ("${j.note.slice(0, 200)}")` : ''}`;
141
+ return `Held: ${pair.step_id ? `step ${pair.step_id} of run ${pair.run_id}` : `run ${pair.run_id}`}. ${pair.judgments.map(said).join('; ')}. Nothing is fixed until one is kept: ${pair.judgments.map(j => `method judge ${pair.run_id} keep ${j.id} (${j.noted_by ?? 'this one'}'s)`).join(' or ')}.`;
142
+ };
143
+ /** `method judge RUN keep JUDGMENT_ID`: keep this judgment of a held pair; the other one is set aside. A kept wrong judgment is fixed. */
144
+ async function keepCommand(runId, judgmentId, values, clientFactory, options) {
145
+ const client = clientFactory(values.server ?? DEFAULT_SERVER);
146
+ const response = await client.request(`/api/judgments/${encodeURIComponent(judgmentId)}/settle`, 'POST', { keep: true });
147
+ const kept = response.judgment;
148
+ const recorded = `Kept ${kept?.noted_by ? `${kept.noted_by}'s judgment` : 'this judgment'}${kept?.verdict ? ` (${kept.verdict})` : ''}; the other one is set aside.`;
149
+ if (kept?.verdict !== 'wrong' || !kept.method_id) {
150
+ process.stdout.write(values.json ? JSON.stringify({ judgment: kept ?? { id: judgmentId } }, null, 2) + '\n' : recorded + '\n');
151
+ return;
152
+ }
153
+ const runDir = findRunFolder(runId);
154
+ return fixAfter(client, kept, recorded, runDir ? methodFileOfRun(runDir) : undefined, values, options, []);
95
155
  }
@@ -33,8 +33,6 @@ export function parse(args) {
33
33
  export async function run(target, flags, syncFactory) {
34
34
  const path = isWorkflowLink(target) ? (await saveWorkflowLink(target, process.cwd())).path : resolve(target);
35
35
  (await import("./legacy.js")).convertLegacyOrThrow(path);
36
- if (!syncFactory)
37
- await (await import("./home.js")).followAccount(new MethodClient(flags.server), path);
38
36
  loadWorkflow(methodContent(readDocument(path)));
39
37
  const notice = await (await import("./quality.js")).casesNotice(path);
40
38
  if (notice)
@@ -85,12 +83,12 @@ async function accountSync(path, flags) {
85
83
  return { client, outbox, version, sync: () => new Sync(client, runDir, state.workflow_id, state.version_id, state.id, readDocument(path).run_data === "device", outbox) };
86
84
  }
87
85
  const method = readDocument(path);
88
- const setup = await localSetup(path, flags);
89
- const needs = accountNeeds(method, setup.config);
90
- if (needs.classification && !client.token())
86
+ // A setup that fails here fails the run too, and the run sends its failed record.
87
+ const needs = await localSetup(path, flags).then(setup => accountNeeds(method, setup.config), () => undefined);
88
+ if (needs?.classification && !client.token())
91
89
  await client.login();
92
90
  if (!client.token()) {
93
- process.stderr.write(`Sign in to keep versions and runs in your account${needs.models ? " and to use hosted models" : ""}: method login\n`);
91
+ process.stderr.write(`Sign in to keep versions and runs in your account${needs?.models ? " and to use hosted models" : ""}: method login\n`);
94
92
  return undefined;
95
93
  }
96
94
  const version = await prepareVersion(client, outbox, path);
@@ -0,0 +1,38 @@
1
+ export type MergeConflict = {
2
+ path: string;
3
+ base: unknown;
4
+ ours: unknown;
5
+ theirs: unknown;
6
+ };
7
+ /** Not clean: workflow keeps ours at each conflict. */
8
+ export type MergeResult = {
9
+ clean: true;
10
+ workflow: any;
11
+ } | {
12
+ clean: false;
13
+ conflicts: MergeConflict[];
14
+ workflow: any;
15
+ };
16
+ /**
17
+ * Three-way merge of Method documents, per step and per top-level key. base is the version both sides started from
18
+ * (an empty base when there is none), ours is the local file, and theirs is the other version.
19
+ */
20
+ export declare function mergeMethod(base: any, ours: any, theirs: any): MergeResult;
21
+ export type PackageFile = {
22
+ path: string;
23
+ sha256: string;
24
+ };
25
+ export type FilesMergeResult<T extends PackageFile = PackageFile> = {
26
+ clean: boolean;
27
+ files: T[];
28
+ conflicts: {
29
+ path: string;
30
+ }[];
31
+ };
32
+ /**
33
+ * Three-way merge of a Method's package files, by path and sha256. A file that only one side added, changed or removed
34
+ * takes that change; a file that both sides changed in different ways is a conflict (ours stays in files).
35
+ */
36
+ export declare function mergeFiles<T extends PackageFile>(base: T[], ours: T[], theirs: T[]): FilesMergeResult<T>;
37
+ /** The steps whose definition differs between two documents. */
38
+ export declare function changedSteps(before: any, after: any): string[];
@@ -0,0 +1,65 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ const isObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
3
+ /** Keys of ours in their order; a key that only theirs has goes after the key before it in theirs. */
4
+ function keyOrder(ours, theirs) {
5
+ const keys = Object.keys(ours), theirKeys = Object.keys(theirs);
6
+ theirKeys.forEach((key, index) => {
7
+ if (keys.includes(key))
8
+ return;
9
+ const before = theirKeys.slice(0, index).reverse().find(other => keys.includes(other));
10
+ keys.splice(before === undefined ? 0 : keys.indexOf(before) + 1, 0, key);
11
+ });
12
+ return keys;
13
+ }
14
+ /**
15
+ * Three-way merge of Method documents, per step and per top-level key. base is the version both sides started from
16
+ * (an empty base when there is none), ours is the local file, and theirs is the other version.
17
+ */
18
+ export function mergeMethod(base, ours, theirs) {
19
+ if (isDeepStrictEqual(ours, base))
20
+ return { clean: true, workflow: theirs };
21
+ if (isDeepStrictEqual(theirs, base) || isDeepStrictEqual(ours, theirs))
22
+ return { clean: true, workflow: ours };
23
+ const conflicts = [];
24
+ const pick = (path, b, o, t) => {
25
+ if (isDeepStrictEqual(o, t) || isDeepStrictEqual(b, t))
26
+ return o;
27
+ if (isDeepStrictEqual(b, o))
28
+ return t;
29
+ conflicts.push({ path, base: b, ours: o, theirs: t });
30
+ return o;
31
+ };
32
+ const merge = (path, b, o, t, nested) => {
33
+ const result = {};
34
+ for (const key of keyOrder(o, t)) {
35
+ const at = path ? `${path}.${key}` : key;
36
+ const value = nested(key) && isObject(o[key]) && isObject(t[key]) && isObject(b?.[key] ?? {})
37
+ ? merge(at, b?.[key] ?? {}, o[key], t[key], () => false) : pick(at, b?.[key], o[key], t[key]);
38
+ if (value !== undefined)
39
+ result[key] = value;
40
+ }
41
+ return result;
42
+ };
43
+ const workflow = merge('', isObject(base) ? base : {}, ours, theirs, key => key === 'steps');
44
+ return conflicts.length ? { clean: false, conflicts, workflow } : { clean: true, workflow };
45
+ }
46
+ /**
47
+ * Three-way merge of a Method's package files, by path and sha256. A file that only one side added, changed or removed
48
+ * takes that change; a file that both sides changed in different ways is a conflict (ours stays in files).
49
+ */
50
+ export function mergeFiles(base, ours, theirs) {
51
+ const byPath = (list) => new Map(list.map(f => [f.path, f]));
52
+ const b = byPath(base), o = byPath(ours), t = byPath(theirs);
53
+ const files = [], conflicts = [];
54
+ for (const path of [...new Set([...o.keys(), ...t.keys(), ...b.keys()])].sort()) {
55
+ const [bs, os, ts] = [b.get(path)?.sha256, o.get(path)?.sha256, t.get(path)?.sha256];
56
+ const kept = os === ts || bs === ts ? o.get(path) : bs === os ? t.get(path) : (conflicts.push({ path }), o.get(path));
57
+ if (kept)
58
+ files.push(kept);
59
+ }
60
+ return { clean: !conflicts.length, files, conflicts };
61
+ }
62
+ /** The steps whose definition differs between two documents. */
63
+ export function changedSteps(before, after) {
64
+ return [...new Set([...Object.keys(before?.steps ?? {}), ...Object.keys(after?.steps ?? {})])].filter(id => !isDeepStrictEqual(before?.steps?.[id], after?.steps?.[id]));
65
+ }
@@ -90,7 +90,7 @@ export class MethodClient {
90
90
  throw Object.assign(Error(`${status}: ${title || statusText || "Method returned a non-JSON response"} (${method} ${path}). Local run files are preserved.`), { status });
91
91
  }
92
92
  if (!ok)
93
- throw Object.assign(Error(`${status}: ${String(result.message ?? result.error ?? "Method request failed").slice(0, 1000)}`), { status, code: typeof result.code === 'string' ? result.code.slice(0, 100) : 'method_request_failed' });
93
+ throw Object.assign(Error(`${status}: ${String(result.message ?? result.error ?? "Method request failed").slice(0, 1000)}`), { status, code: typeof result.code === 'string' ? result.code.slice(0, 100) : 'method_request_failed', body: result });
94
94
  return result;
95
95
  }
96
96
  /** Upload (data) or download bytes. Retries a lost connection or a server error twice; stops only when no bytes move. */