@method-ai-hq/sdk 0.18.2 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +1 -1
  2. package/dist/packages/sdk/package.json +7 -2
  3. package/dist/packages/sdk/src/authoring-instructions.d.ts +1 -1
  4. package/dist/packages/sdk/src/authoring-instructions.js +1 -1
  5. package/dist/packages/sdk/src/current-inspection.js +3 -0
  6. package/dist/packages/sdk/src/current-runtime.d.ts +6 -1
  7. package/dist/packages/sdk/src/current-runtime.js +47 -2
  8. package/dist/packages/sdk/src/fix-lines.d.ts +82 -0
  9. package/dist/packages/sdk/src/fix-lines.js +63 -0
  10. package/dist/packages/sdk/src/fix-outcomes.d.ts +72 -0
  11. package/dist/packages/sdk/src/fix-outcomes.js +201 -0
  12. package/dist/packages/sdk/src/home.d.ts +30 -26
  13. package/dist/packages/sdk/src/home.js +164 -60
  14. package/dist/packages/sdk/src/improve.d.ts +146 -24
  15. package/dist/packages/sdk/src/improve.js +665 -105
  16. package/dist/packages/sdk/src/judge.d.ts +23 -10
  17. package/dist/packages/sdk/src/judge.js +110 -26
  18. package/dist/packages/sdk/src/learned.d.ts +43 -0
  19. package/dist/packages/sdk/src/learned.js +97 -0
  20. package/dist/packages/sdk/src/local-cli.js +8 -3
  21. package/dist/packages/sdk/src/local-judgments.d.ts +2 -0
  22. package/dist/packages/sdk/src/merge.d.ts +38 -0
  23. package/dist/packages/sdk/src/merge.js +65 -0
  24. package/dist/packages/sdk/src/method-client.js +1 -1
  25. package/dist/packages/sdk/src/method-files.d.ts +3 -2
  26. package/dist/packages/sdk/src/method-files.js +7 -3
  27. package/dist/packages/sdk/src/method-help.d.ts +1 -1
  28. package/dist/packages/sdk/src/method-help.js +25 -16
  29. package/dist/packages/sdk/src/method-sync.d.ts +7 -0
  30. package/dist/packages/sdk/src/method-sync.js +33 -0
  31. package/dist/packages/sdk/src/method.d.ts +5 -0
  32. package/dist/packages/sdk/src/method.js +169 -15
  33. package/dist/packages/sdk/src/outbox.d.ts +28 -6
  34. package/dist/packages/sdk/src/outbox.js +13 -6
  35. package/dist/packages/sdk/src/proposals.d.ts +0 -28
  36. package/dist/packages/sdk/src/proposals.js +2 -129
  37. package/dist/packages/sdk/src/quality.d.ts +65 -5
  38. package/dist/packages/sdk/src/quality.js +82 -17
  39. package/dist/packages/sdk/src/run-saved.d.ts +4 -0
  40. package/dist/packages/sdk/src/run-saved.js +33 -2
  41. package/dist/packages/sdk/src/undo.d.ts +50 -0
  42. package/dist/packages/sdk/src/undo.js +108 -0
  43. package/dist/packages/sdk/src/versions.js +5 -3
  44. package/dist/packages/sdk/src/watch.d.ts +20 -0
  45. package/dist/packages/sdk/src/watch.js +86 -0
  46. package/dist/packages/workflow-language/src/inspection.d.ts +30 -0
  47. package/dist/packages/workflow-language/src/inspection.js +3 -1
  48. package/package.json +7 -2
@@ -32,13 +32,20 @@ export function recordKnownVersion(server, methodId, versionId, versionNumber) {
32
32
  writePrivateJson(savedFile(server), { ...savedVersions(server), [versionId]: { method_id: methodId, version_number: versionNumber } });
33
33
  }
34
34
  /** A checkout of a saved version: the next save of this Method on this computer has it as its parent. */
35
- export function recordCheckout(server, methodId, versionId, versionNumber, workflow) {
35
+ export function recordCheckout(server, methodId, versionId, versionNumber, workflow, extra = {}) {
36
36
  recordKnownVersion(server, methodId, versionId, versionNumber);
37
- writePrivateJson(methodFile(server, methodId), { version_id: versionId, version_number: versionNumber, workflow });
37
+ writePrivateJson(methodFile(server, methodId), { version_id: versionId, version_number: versionNumber, workflow, ...extra });
38
38
  }
39
- function recordSaved(item, versionNumber) {
40
- writePrivateJson(savedFile(item.server), { ...savedVersions(item.server), [item.version_id]: { method_id: item.method_id, version_number: versionNumber } });
41
- writePrivateJson(methodFile(item.server, item.method_id), { version_id: item.version_id, version_number: versionNumber, workflow: item.body.workflow });
39
+ /** A combine that stopped: the parts that both sides changed, as they were on this computer. */
40
+ export function recordCombineStop(server, methodId, conflict) {
41
+ writePrivateJson(methodFile(server, methodId), { ...lastSaved(server, methodId), conflict });
42
+ }
43
+ /** A save that the server kept beside a newer version stays off the base: the next run combines it with the newer one. */
44
+ function recordSaved(item, saved) {
45
+ const beside = saved.newest === false;
46
+ writePrivateJson(savedFile(item.server), { ...savedVersions(item.server), [item.version_id]: { method_id: item.method_id, version_number: saved.version_number, ...(beside ? { beside: saved.newer?.saved_by ?? null } : {}) } });
47
+ writePrivateJson(methodFile(item.server, item.method_id), beside ? { ...lastSaved(item.server, item.method_id), side_version_id: item.version_id }
48
+ : { version_id: item.version_id, version_number: saved.version_number, workflow: item.body.workflow });
42
49
  }
43
50
  /** Errors that a retry can fix: no connection, a stalled transfer, a server error, or too many requests. */
44
51
  export function retryable(error) {
@@ -274,7 +281,7 @@ export class Outbox {
274
281
  const saved = await this.client.request(`/api/cli/methods/${encodeURIComponent(item.method_id)}/versions/${encodeURIComponent(item.version_id)}`, 'PUT', item.body, true, { stallMs, onProgress });
275
282
  if (saved.version_id !== item.version_id)
276
283
  throw Object.assign(Error('The server saved another version ID than this content has.'), { status: 409 });
277
- recordSaved(item, saved.version_number);
284
+ recordSaved(item, saved);
278
285
  return;
279
286
  }
280
287
  if (!existsSync(join(item.run_dir, 'method-sync.json')))
@@ -12,27 +12,6 @@ export type ProposalRecord = {
12
12
  base_workflow: unknown;
13
13
  proposed_workflow: unknown | null;
14
14
  };
15
- export type MergeConflict = {
16
- path: string;
17
- base: unknown;
18
- ours: unknown;
19
- theirs: unknown;
20
- };
21
- export type MergeResult = {
22
- clean: true;
23
- workflow: any;
24
- } | {
25
- clean: false;
26
- conflicts: MergeConflict[];
27
- };
28
- /**
29
- * Three-way merge of Method documents, per step and per top-level key. base is the version the proposal started from,
30
- * ours is the local file, and theirs is the proposed version. A part that only one side changed takes that change; a part
31
- * that both sides changed in different ways is a conflict.
32
- */
33
- export declare function mergeMethod(base: any, ours: any, theirs: any): MergeResult;
34
- /** The steps whose definition differs between two documents. */
35
- export declare function changedSteps(before: any, after: any): string[];
36
15
  /** Write Method content to a file and keep its id: line. A JSON file stays JSON. */
37
16
  export declare function writeMethodFile(file: string, id: string | undefined, content: any): void;
38
17
  export declare const localProposalFolder: (file: string) => string;
@@ -41,13 +20,6 @@ export declare function saveLocalProposal(file: string, record: Omit<ProposalRec
41
20
  export declare function localProposals(file: string): (ProposalRecord & {
42
21
  path: string;
43
22
  })[];
44
- /**
45
- * Before a command that takes a .method FILE: when this computer is signed in and Method changed the Method in the
46
- * account (keeping run data in the account, or a fix of a Method that had not run on a computer), merge the change into
47
- * the file and print one line. A conflict leaves the file unchanged and writes both versions of each conflicting part
48
- * to a file for the coding agent to merge; the command goes on. Offline, signed out, or on any error, it does nothing.
49
- */
50
- export declare function autoApply(args: string[], clientFactory: (server: string) => MethodClient): Promise<void>;
51
23
  /** Run folders of this Method on this computer, newest first, with the account run ID of each run that was saved to the account. */
52
24
  export declare function localRuns(file: string): Promise<{
53
25
  dir: string;
@@ -1,61 +1,11 @@
1
1
  import { existsSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
2
  import { basename, dirname, join } from 'node:path';
3
- import { isDeepStrictEqual, parseArgs } from 'node:util';
4
- import { loadWorkflow } from '../../workflow-language/src/validate.js';
3
+ import { parseArgs } from 'node:util';
5
4
  import { authoringPath, readDocument, writeDocument } from './authoring.js';
6
5
  import { DEFAULT_SERVER } from './method-client.js';
7
6
  import { writePrivateJson } from './files.js';
8
- import { currentVersion, methodContent, methodIdPattern } from './versions.js';
7
+ import { methodIdPattern } from './versions.js';
9
8
  import { localMethodFolder } from './local-judgments.js';
10
- const isObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
11
- /** Keys of ours in their order; a key that only theirs has goes after the key before it in theirs. */
12
- function keyOrder(ours, theirs) {
13
- const keys = Object.keys(ours), theirKeys = Object.keys(theirs);
14
- theirKeys.forEach((key, index) => {
15
- if (keys.includes(key))
16
- return;
17
- const before = theirKeys.slice(0, index).reverse().find(other => keys.includes(other));
18
- keys.splice(before === undefined ? 0 : keys.indexOf(before) + 1, 0, key);
19
- });
20
- return keys;
21
- }
22
- /**
23
- * Three-way merge of Method documents, per step and per top-level key. base is the version the proposal started from,
24
- * ours is the local file, and theirs is the proposed version. A part that only one side changed takes that change; a part
25
- * that both sides changed in different ways is a conflict.
26
- */
27
- export function mergeMethod(base, ours, theirs) {
28
- if (isDeepStrictEqual(ours, base))
29
- return { clean: true, workflow: theirs };
30
- if (isDeepStrictEqual(theirs, base) || isDeepStrictEqual(ours, theirs))
31
- return { clean: true, workflow: ours };
32
- const conflicts = [];
33
- const pick = (path, b, o, t) => {
34
- if (isDeepStrictEqual(o, t) || isDeepStrictEqual(b, t))
35
- return o;
36
- if (isDeepStrictEqual(b, o))
37
- return t;
38
- conflicts.push({ path, base: b, ours: o, theirs: t });
39
- return o;
40
- };
41
- const merge = (path, b, o, t, nested) => {
42
- const result = {};
43
- for (const key of keyOrder(o, t)) {
44
- const at = path ? `${path}.${key}` : key;
45
- const value = nested(key) && isObject(o[key]) && isObject(t[key]) && isObject(b?.[key] ?? {})
46
- ? merge(at, b?.[key] ?? {}, o[key], t[key], () => false) : pick(at, b?.[key], o[key], t[key]);
47
- if (value !== undefined)
48
- result[key] = value;
49
- }
50
- return result;
51
- };
52
- const workflow = merge('', isObject(base) ? base : {}, ours, theirs, key => key === 'steps');
53
- return conflicts.length ? { clean: false, conflicts } : { clean: true, workflow };
54
- }
55
- /** The steps whose definition differs between two documents. */
56
- export function changedSteps(before, after) {
57
- return [...new Set([...Object.keys(before?.steps ?? {}), ...Object.keys(after?.steps ?? {})])].filter(id => !isDeepStrictEqual(before?.steps?.[id], after?.steps?.[id]));
58
- }
59
9
  /** Write Method content to a file and keep its id: line. A JSON file stays JSON. */
60
10
  export function writeMethodFile(file, id, content) {
61
11
  const { format, ...rest } = content;
@@ -91,83 +41,6 @@ export function localProposals(file) {
91
41
  return { ...record, proposed_workflow: record.proposed_workflow ?? null, path };
92
42
  }).filter(record => record.file === basename(file)).sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)));
93
43
  }
94
- async function accountProposal(client, id, options = {}) {
95
- const { proposal } = await client.request(`/api/proposals/${encodeURIComponent(id)}`, 'GET', undefined, true, options);
96
- return { ...proposal, source: 'account', proposed_workflow: proposal.proposed_workflow ?? null };
97
- }
98
- async function newestAccepted(client, methodId, options = {}) {
99
- const { proposals } = await client.request(`/api/methods/${encodeURIComponent(methodId)}/proposals?status=accepted`, 'GET', undefined, true, options);
100
- const accepted = (proposals ?? []).filter(p => p?.status === 'accepted').sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)));
101
- return accepted[0] ? accountProposal(client, accepted[0].id, options) : undefined;
102
- }
103
- /** Merge a change Method made in the account into the file. A clean merge writes the file and records the change as applied. Never publishes. */
104
- async function applyProposal(client, file, proposal, options = {}) {
105
- const document = readDocument(file), ours = methodContent(document);
106
- const merged = mergeMethod(proposal.base_workflow, ours, proposal.proposed_workflow);
107
- if (!merged.clean)
108
- return { status: 'conflict', conflicts: merged.conflicts };
109
- try {
110
- loadWorkflow(merged.workflow);
111
- }
112
- catch (error) {
113
- return { status: 'invalid', error: error.message };
114
- }
115
- if (!isDeepStrictEqual(merged.workflow, ours))
116
- writeMethodFile(file, typeof document.id === 'string' ? document.id : undefined, merged.workflow);
117
- return { status: 'applied', steps: changedSteps(ours, merged.workflow), ...await markApplied(client, file, proposal, options) };
118
- }
119
- async function markApplied(client, file, proposal, options = {}) {
120
- const version_id = (await currentVersion(file).catch(() => undefined))?.version_id;
121
- try {
122
- await client.request(`/api/proposals/${encodeURIComponent(proposal.id)}/applied`, 'POST', version_id ? { version_id } : {}, true, options);
123
- return { version_id };
124
- }
125
- catch (error) {
126
- return { version_id, warning: `The file is changed, but Method did not record the proposal as applied: ${error.message}` };
127
- }
128
- }
129
- const serverFlag = (args) => { const at = args.findIndex(arg => arg === '--server' || arg.startsWith('--server=')); return at < 0 ? undefined : args[at].startsWith('--server=') ? args[at].slice(9) : args[at + 1]; };
130
- const skipAutoApply = new Set(['changes', 'judge', 'init', 'new-id', 'diff', 'help', 'authoring', 'schema', '__worker', '__checks', '__model', 'worker', 'keys', 'secret', 'models', 'login', 'logout', 'sync', 'progress']);
131
- /**
132
- * Before a command that takes a .method FILE: when this computer is signed in and Method changed the Method in the
133
- * account (keeping run data in the account, or a fix of a Method that had not run on a computer), merge the change into
134
- * the file and print one line. A conflict leaves the file unchanged and writes both versions of each conflicting part
135
- * to a file for the coding agent to merge; the command goes on. Offline, signed out, or on any error, it does nothing.
136
- */
137
- export async function autoApply(args, clientFactory) {
138
- if (!args[0] || skipAutoApply.has(args[0]) || args.includes('--help') || process.env.METHOD_RUN_WORKER === '1')
139
- return;
140
- const target = args.slice(1).find(arg => !arg.startsWith('-') && /\.method$/i.test(arg));
141
- if (!target || !existsSync(target))
142
- return;
143
- try {
144
- const file = authoringPath(target), id = readDocument(file).id;
145
- if (typeof id !== 'string' || !methodIdPattern.test(id))
146
- return;
147
- const client = clientFactory(serverFlag(args) ?? DEFAULT_SERVER);
148
- if (!client.token())
149
- return;
150
- const options = { signal: AbortSignal.timeout(3000), stallMs: 3000 };
151
- const proposal = await newestAccepted(client, id, options);
152
- if (!proposal?.proposed_workflow)
153
- return;
154
- const result = await applyProposal(client, file, proposal, options);
155
- if (result.status === 'applied') {
156
- if (result.steps.length || result.warning)
157
- process.stderr.write(`Method changed ${target} (${proposal.id}): ${proposal.cause}${result.steps.length ? ` Changed steps: ${result.steps.join(', ')}.` : ''}${result.warning ? ` ${result.warning}` : ''}\n`);
158
- return;
159
- }
160
- // The agent merges by hand from the saved versions; the change is recorded as applied, so it is not merged again.
161
- localMethodFolder(file);
162
- const path = join(dirname(file), '.method', `conflict-${proposal.id}.json`);
163
- writePrivateJson(path, { proposal_id: proposal.id, cause: proposal.cause, ...(result.status === 'conflict' ? { conflicts: result.conflicts } : { error: result.error }), proposed_workflow: proposal.proposed_workflow });
164
- await markApplied(client, file, proposal, options).catch(() => undefined);
165
- process.stderr.write(result.status === 'conflict'
166
- ? `Method's change ${proposal.id} (${proposal.cause}) conflicts with your edits of ${target} in ${result.conflicts.map(c => c.path).join(', ')}. The file is not changed. Both versions of each part are in ${path}: edit ${target} so each part keeps both changes.\n`
167
- : `Method's change ${proposal.id} does not merge into a valid Method: ${result.error} The file is not changed. The changed version is in ${path}.\n`);
168
- }
169
- catch { /* offline or signed out: the command goes on without the change. */ }
170
- }
171
44
  /** Run folders of this Method on this computer, newest first, with the account run ID of each run that was saved to the account. */
172
45
  export async function localRuns(file) {
173
46
  const { recentRuns } = await import('./current-runtime.js');
@@ -1,3 +1,4 @@
1
+ import { MethodClient } from "./method-client.js";
1
2
  /** The same runtime preparation as method run, without starting a run. Hosted models are used when signed in. */
2
3
  export declare function preparedConfig(file: string, flags: {
3
4
  workspace?: string;
@@ -16,18 +17,77 @@ export declare const defaultRunRoots: () => string[];
16
17
  export declare function observeCommand(args: string[]): Promise<void>;
17
18
  /** A fingerprint of a Method and the files it runs, to tell whether its cases were checked since the last change. */
18
19
  export declare function methodFingerprint(file: string): string;
19
- /** One line for method run when the Method changed since its cases last passed. */
20
+ /** One line for method run when the Method changed since its earlier corrections last passed. Their authors are named. */
20
21
  export declare function casesNotice(file: string): Promise<string | undefined>;
22
+ /**
23
+ * The judge profiles for a rubric: the configured judge, and when it is the user's own Claude Code or Codex and the
24
+ * other one is installed and signed in too, that one as well. A judge that the Method names is the only judge.
25
+ */
26
+ export declare function judgeProfiles(config: any): Promise<any[]>;
27
+ /**
28
+ * Evaluate a case's expectations with every judge profile (the runtime's judges option). A criterion passes only when
29
+ * every judge passes it; when the judges disagree it fails as unsure.
30
+ */
31
+ export declare function evaluateWithJudges(expect: any[], outcome: any, context: {
32
+ caseDir: string;
33
+ config: any;
34
+ options?: any;
35
+ }, profiles: any[]): Promise<{
36
+ status: string;
37
+ results: {
38
+ criteria?: ({
39
+ id: string;
40
+ text: string;
41
+ judge: string;
42
+ probability: any;
43
+ pass: boolean;
44
+ reason: string;
45
+ votes?: undefined;
46
+ unsure?: undefined;
47
+ } | {
48
+ id: string;
49
+ text: string;
50
+ judge: string;
51
+ votes: any[];
52
+ pass: boolean;
53
+ unsure: boolean;
54
+ reason: string;
55
+ probability?: undefined;
56
+ } | {
57
+ id: string;
58
+ text: string;
59
+ judge: string;
60
+ votes: any[];
61
+ pass: boolean;
62
+ reason: any;
63
+ probability?: undefined;
64
+ unsure?: undefined;
65
+ })[];
66
+ judge_calls?: number;
67
+ kind: any;
68
+ status: string;
69
+ reason: string;
70
+ text?: any;
71
+ }[];
72
+ }>;
73
+ /** The name of the signed-in person, for the author of a new case; null when signed out or offline. Asked once per sign-in in a process. */
74
+ export declare function signedInName(client?: Pick<MethodClient, "server" | "request" | "token">): Promise<string | null>;
21
75
  /** Run the cases of a Method with the same preparation as method run. */
22
- export declare function runCases(file: string, flags: {
23
- workspace?: string;
24
- }, options?: {
76
+ export type CaseRunOptions = {
25
77
  casesDir?: string | undefined;
26
78
  ids?: string[] | undefined;
27
79
  baseline?: string | undefined;
28
80
  newIds?: string[];
29
81
  once?: boolean;
30
- }): Promise<{
82
+ runs?: number | undefined;
83
+ minPass?: number | undefined;
84
+ live?: string[] | undefined;
85
+ judgeCache?: boolean | undefined;
86
+ keepDir?: string | undefined;
87
+ };
88
+ export declare function runCases(file: string, flags: {
89
+ workspace?: string;
90
+ }, options?: CaseRunOptions): Promise<{
31
91
  counts: {
32
92
  [k: string]: number;
33
93
  };
@@ -1,16 +1,17 @@
1
1
  /** Effect observation, recorded cases, the save gate, and library observers. */
2
2
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
3
- import { dirname, join, relative, resolve } from "node:path";
3
+ import { basename, dirname, join, relative, resolve } from "node:path";
4
4
  import { execFileSync } from "node:child_process";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { createHash } from "node:crypto";
7
7
  import { parseArgs } from "node:util";
8
8
  import { observeRun, pendingRuns } from "@withmethod/runtime/observe.js";
9
- import { createCase, retireCase, listCases, testSuite } from "@withmethod/runtime/cases.js";
9
+ import { createCase, retireCase, listCases, testSuite, evaluate } from "@withmethod/runtime/cases.js";
10
10
  import { authoringPath, editDocument, readDocument } from "./authoring.js";
11
11
  import { localSetup } from "./local-setup.js";
12
12
  import { resolveAgentProfiles, checkAgents, accountNeeds } from "./capabilities.js";
13
- import { localAgent, resolveModels } from "@withmethod/runtime/agents.js";
13
+ import { agentSignedIn, localAgent, resolveModels } from "@withmethod/runtime/agents.js";
14
+ import { executable } from "@withmethod/runtime/io.js";
14
15
  import { hostedModelsFor, managedModels } from "./hosted-models.js";
15
16
  import { managedClassification } from "./classification-client.js";
16
17
  import { MethodClient } from "./method-client.js";
@@ -58,6 +59,8 @@ export async function preparedConfig(file, flags, options = {}) {
58
59
  config.classification = await classification.resolve(new AbortController().signal);
59
60
  await checkAgents(config.models);
60
61
  const prepared = await prepareRuntime(setup.sourceRoot, config, method);
62
+ // A case's check script runs on Node, which the CLI itself runs on.
63
+ prepared.config.runtimes = { node: { command: process.execPath, version: process.version }, ...prepared.config.runtimes };
61
64
  const hostedModels = Object.values(prepared.config.models ?? {}).some((profile) => profile.backend === 'method') ? await hostedModelsFor(client) : undefined;
62
65
  return { config: prepared.config, sourceRoot: setup.sourceRoot, processPath: prepared.processPath, prepareBundle: prepared.prepareBundle, ...(hostedModels ? { hostedModels } : {}), ...(classification ? { classification } : {}) };
63
66
  }
@@ -100,7 +103,7 @@ export async function observeCommand(args) {
100
103
  export function methodFingerprint(file) {
101
104
  const folder = dirname(file), method = readDocument(file);
102
105
  const parts = [readFileSync(file, "utf8")];
103
- const paths = new Set(method.files ?? []);
106
+ const paths = new Set([...(method.files ?? []), ...(existsSync(join(folder, "learned.json")) ? ["learned.json"] : [])]);
104
107
  for (const step of Object.values(method.steps ?? {})) {
105
108
  for (const exec of [step.do, step.check])
106
109
  if (exec?.kind === "run")
@@ -125,7 +128,7 @@ function recordChecked(file, report) {
125
128
  mkdirSync(dirname(checkedFile(file)), { recursive: true, mode: 0o700 });
126
129
  writePrivateJson(checkedFile(file), { file, fingerprint: methodFingerprint(file), passed: report.passed, cases: report.cases.length, at: new Date().toISOString() });
127
130
  }
128
- /** One line for method run when the Method changed since its cases last passed. */
131
+ /** One line for method run when the Method changed since its earlier corrections last passed. Their authors are named. */
129
132
  export async function casesNotice(file) {
130
133
  const cases = (await listCases(file)).filter((c) => c.status === "active");
131
134
  if (!cases.length)
@@ -137,29 +140,89 @@ export async function casesNotice(file) {
137
140
  catch { /* never checked */ }
138
141
  if (checked?.passed && checked.fingerprint === methodFingerprint(file))
139
142
  return undefined;
140
- return `${cases.length} case${cases.length === 1 ? "" : "s"} not checked since this Method changed: method test ${file}`;
143
+ const { uncheckedLine, correctionName } = await import("./fix-lines.js");
144
+ return uncheckedLine(cases.length, cases.filter((c) => c.author).map(correctionName), `method test ${basename(file)}`);
145
+ }
146
+ /**
147
+ * The judge profiles for a rubric: the configured judge, and when it is the user's own Claude Code or Codex and the
148
+ * other one is installed and signed in too, that one as well. A judge that the Method names is the only judge.
149
+ */
150
+ export async function judgeProfiles(config) {
151
+ const first = config?.models?.judge;
152
+ if (!first)
153
+ return [];
154
+ if (!["claude", "codex"].includes(first.backend) || first.command || first.model)
155
+ return [first];
156
+ const other = first.backend === "claude" ? "codex" : "claude";
157
+ try {
158
+ await executable(other);
159
+ }
160
+ catch {
161
+ return [first];
162
+ }
163
+ return await agentSignedIn(other) ? [first, { backend: other }] : [first];
164
+ }
165
+ /**
166
+ * Evaluate a case's expectations with every judge profile (the runtime's judges option). A criterion passes only when
167
+ * every judge passes it; when the judges disagree it fails as unsure.
168
+ */
169
+ export async function evaluateWithJudges(expect, outcome, context, profiles) {
170
+ return evaluate(expect, outcome, profiles.length < 2 ? context : { ...context, options: { ...context.options, judges: profiles } });
171
+ }
172
+ const signedIn = new Map();
173
+ /** The name of the signed-in person, for the author of a new case; null when signed out or offline. Asked once per sign-in in a process. */
174
+ export function signedInName(client = new MethodClient()) {
175
+ let token;
176
+ try {
177
+ token = client.token();
178
+ }
179
+ catch {
180
+ return Promise.resolve(null);
181
+ }
182
+ if (!token)
183
+ return Promise.resolve(null);
184
+ const key = `${client.server}\n${token}`;
185
+ if (!signedIn.has(key))
186
+ signedIn.set(key, (async () => {
187
+ try {
188
+ const me = await client.request("/api/me", "GET", undefined, true, { signal: AbortSignal.timeout(3000), stallMs: 3000 });
189
+ return typeof me?.user?.name === "string" && me.user.name.trim() ? me.user.name.trim() : null;
190
+ }
191
+ catch {
192
+ return null;
193
+ }
194
+ })());
195
+ return signedIn.get(key);
141
196
  }
142
197
  /** The Method's declared secrets from this computer or the environment, as method run gives them. */
143
198
  async function declaredSecrets(file) {
144
199
  const { resolveSecrets } = await import("./secrets.js");
145
200
  return resolveSecrets(Object.keys(readDocument(file)?.secrets ?? {}));
146
201
  }
147
- /** Run the cases of a Method with the same preparation as method run. */
148
202
  export async function runCases(file, flags, options = {}) {
149
203
  const prepared = await preparedConfig(file, flags, { judge: true });
150
- const report = await testSuite(file, prepared.config, { ...options,
204
+ // Claude Code and Codex both judge when both are signed in; a disagreement fails the criterion as unsure.
205
+ const judges = await judgeProfiles(prepared.config);
206
+ const report = await testSuite(file, prepared.config, { ...options, ...(judges.length > 1 ? { judges } : {}),
151
207
  runOptions: { processPath: prepared.processPath, prepareBundle: prepared.prepareBundle, secrets: await declaredSecrets(file), ...(prepared.classification ? { classification: prepared.classification } : {}), ...(prepared.hostedModels ? { hostedModels: prepared.hostedModels } : {}) } });
152
- if (!options.ids && !options.casesDir && !options.once)
208
+ if (!options.ids && !options.casesDir && !options.once && !options.runs && !options.live?.length)
153
209
  recordChecked(file, report);
154
210
  return report;
155
211
  }
156
212
  export async function testCommand(args) {
157
- const { values, positionals } = parseArgs({ args, allowPositionals: true, options: { case: { type: "string", multiple: true }, new: { type: "string", multiple: true }, baseline: { type: "string" }, cases: { type: "string" }, workspace: { type: "string" }, once: { type: "boolean" } } });
213
+ const { values, positionals } = parseArgs({ args, allowPositionals: true, options: { case: { type: "string", multiple: true }, new: { type: "string", multiple: true }, baseline: { type: "string" }, cases: { type: "string" }, workspace: { type: "string" }, once: { type: "boolean" },
214
+ runs: { type: "string" }, "min-pass": { type: "string" }, live: { type: "string", multiple: true }, "no-judge-cache": { type: "boolean" }, "keep-dir": { type: "string" } } });
158
215
  const file = positionals[0];
216
+ const usage = "Use method test FILE [--case ID]... [--baseline OLD_FILE] [--new ID]... [--once | --runs N [--min-pass N]] [--live STEP]... [--no-judge-cache] [--keep-dir DIR]";
159
217
  if (!file || positionals.length !== 1)
160
- throw Error("Use method test FILE [--case ID]... [--baseline OLD_FILE] [--new ID]... [--once]");
218
+ throw Error(usage);
219
+ const count = (value, name) => { if (value === undefined)
220
+ return undefined; const n = Number(value); if (!Number.isSafeInteger(n) || n < 1 || n > 20)
221
+ throw Error(`${name} must be a number from 1 to 20. ${usage}`); return n; };
161
222
  const report = await runCases(authoringPath(file), values, { casesDir: values.cases ? authoringPath(values.cases) : undefined, ids: values.case,
162
- baseline: values.baseline ? authoringPath(values.baseline) : undefined, newIds: values.new ?? [], once: !!values.once });
223
+ baseline: values.baseline ? authoringPath(values.baseline) : undefined, newIds: values.new ?? [], once: !!values.once,
224
+ runs: count(values.runs, "--runs"), minPass: count(values["min-pass"], "--min-pass"), live: values.live?.flatMap(step => step.split(",")).map(step => step.trim()).filter(Boolean),
225
+ ...(values["no-judge-cache"] ? { judgeCache: false } : {}), keepDir: values["keep-dir"] ? authoringPath(values["keep-dir"]) : undefined });
163
226
  print(report);
164
227
  process.exitCode = report.passed ? 0 : 1;
165
228
  }
@@ -172,14 +235,16 @@ export async function caseGate(file, accept = [], reason) {
172
235
  if (!cases.length)
173
236
  return { line: undefined, report: undefined };
174
237
  // A save checks every case; say so first, because live model steps and judges take time and cost money.
175
- process.stderr.write(`Checking ${cases.length} case${cases.length === 1 ? "" : "s"} before saving. Cases whose model steps changed run them live, up to three times each.\n`);
238
+ process.stderr.write(`Checking ${cases.length} earlier correction${cases.length === 1 ? "" : "s"} before saving. Parts of the agent that changed run again, which can take a few minutes.\n`);
176
239
  const report = await runCases(file, {});
177
- const t = report.totals;
178
- process.stderr.write(`Cases checked in ${Math.round(t.duration_ms / 1000)} s: ${t.live_model_steps} live model step${t.live_model_steps === 1 ? "" : "s"}, ${t.judge_calls} judge call${t.judge_calls === 1 ? "" : "s"}.\n`);
240
+ process.stderr.write(`Checked in ${Math.round(report.totals.duration_ms / 1000)} s.\n`);
179
241
  const failing = report.cases.filter((c) => !["pass", "fixed"].includes(c.verdict));
180
242
  const unaccepted = failing.filter((c) => !accept.includes(c.id));
243
+ const byId = new Map(cases.map((c) => [c.id, c]));
244
+ const { correctionName } = await import("./fix-lines.js");
245
+ const shown = (c) => correctionName(byId.get(c.id) ?? c);
181
246
  if (unaccepted.length)
182
- throw Object.assign(Error(`This version breaks ${unaccepted.length} approved case${unaccepted.length === 1 ? "" : "s"}: ${unaccepted.map((c) => `${c.id} (${c.verdict}: ${c.note})`).join("; ")}. Fix the Method and run method test, or retire a case whose rule no longer applies with method case retire. To publish anyway, add --accept-failing-case ${unaccepted.map((c) => c.id).join(",")} with --reason.`), { code: "cases_failed" });
247
+ throw Object.assign(Error(`This change breaks ${unaccepted.length} earlier correction${unaccepted.length === 1 ? "" : "s"}: ${unaccepted.map(shown).join("; ")}. Fix the Method and run method test, or retire a correction whose rule no longer applies with method case retire. To publish anyway, add --accept-failing-case ${unaccepted.map((c) => c.id).join(",")} with --reason.`), { code: "cases_failed" });
183
248
  if (failing.length && !reason)
184
249
  throw Error("Give --reason for accepting a failing case.");
185
250
  return { report, line: failing.length ? `Cases: ${report.cases.length - failing.length} of ${report.cases.length} passed; accepted failing ${failing.map((c) => c.id).join(", ")}.` : `Cases: ${report.cases.length} of ${report.cases.length} passed.` };
@@ -214,7 +279,7 @@ export async function caseCommand(args) {
214
279
  if (privacy)
215
280
  process.stderr.write(privacy + "\n");
216
281
  print(await createCase({ methodFile, runDir: values.run ? authoringPath(values.run) : undefined, passingRun: values["passing-run"] ? authoringPath(values["passing-run"]) : undefined,
217
- id: values.id, note: values.note, author: values.author ?? null, rubric: values.rubric ?? [], ref: values.ref, context: values.context ?? [], expect: json(values.expect) ?? [],
282
+ id: values.id, note: values.note, author: values.author ?? await signedInName(), rubric: values.rubric ?? [], ref: values.ref, context: values.context ?? [], expect: json(values.expect) ?? [],
218
283
  observations: json(values.observations), redact: json(values.redact), runs: integer(values.runs, "--runs"), minPass: integer(values["min-pass"], "--min-pass"),
219
284
  retentionDays: integer(values["retention-days"], "--retention-days"), supersedes: values.supersedes ?? [], casesDir, acceptConflict: values["accept-conflict"], config: prepared.config,
220
285
  options: { runOptions: { processPath: prepared.processPath, secrets: await declaredSecrets(methodFile), ...(prepared.classification ? { classification: prepared.classification } : {}), ...(prepared.hostedModels ? { hostedModels: prepared.hostedModels } : {}) } } }));
@@ -1,5 +1,9 @@
1
1
  import { MethodClient } from './method-client.js';
2
2
  import type { parse } from './local-cli.js';
3
+ /** How often a run that waits for the shared state checks again. Tests make it short. */
4
+ export declare const stateWait: {
5
+ pollMs: number;
6
+ };
3
7
  /** A run whose steps all finished: completed, or unconfirmed (an external change could not be confirmed). */
4
8
  export declare const finishedRun: (status: string) => status is "unconfirmed" | "completed";
5
9
  export declare function runSaved(saved: any, flags: ReturnType<typeof parse>['values'], client: MethodClient): Promise<any>;
@@ -13,6 +13,37 @@ import { workerRunId } from './run-worker.js';
13
13
  import { readDocument } from './authoring.js';
14
14
  import { withInputsFrom } from './inputs-from.js';
15
15
  import { setupFailure } from './failure.js';
16
+ /** How often a run that waits for the shared state checks again. Tests make it short. */
17
+ export const stateWait = { pollMs: 10_000 };
18
+ const clock = (at) => { const date = at ? new Date(at) : undefined; return date && !isNaN(+date) ? `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}` : undefined; };
19
+ /**
20
+ * Take the shared state. While another run that still runs (or lost its connection) holds it, say so once and check
21
+ * again until it is free: there is no time limit, and method cancel stops the wait. A run that stopped while it held
22
+ * the state stops this one: a person must check that run's actions before releasing it.
23
+ */
24
+ async function acquireState(client, statePath, runId, methodId) {
25
+ let told = false;
26
+ for (;;) {
27
+ try {
28
+ return await client.request(statePath, 'POST', { action: 'acquire', run_id: runId });
29
+ }
30
+ catch (error) {
31
+ const owner = error?.body?.owner;
32
+ if (error?.code !== 'state_owned')
33
+ throw error;
34
+ if (owner && (owner.status === 'finished' || owner.status === 'failed')) {
35
+ const name = owner.started_by ?? 'Another person';
36
+ throw Object.assign(Error(`${name}'s run stopped while it held the shared state. ${name} must check its actions, then run \`method state ${methodId} --release ${owner.run_id}\`.`), { code: 'state_held', owner: 'setup' });
37
+ }
38
+ if (!told) {
39
+ const started = clock(owner?.started_at);
40
+ process.stderr.write(`Waiting for ${owner?.started_by ? `${owner.started_by}'s run` : 'another run'}${started ? ` (started ${started})` : ''} to finish with the shared state${owner?.link ? `: ${owner.link}` : '.'}\n`);
41
+ told = true;
42
+ }
43
+ await new Promise(resolve => setTimeout(resolve, stateWait.pollMs));
44
+ }
45
+ }
46
+ }
16
47
  // Older versions are listed only after their saved-package tests pass.
17
48
  const supportedPackageRuntimes = new Set([runtimeVersion, '0.14.0', '0.12.0', '0.11.1', '0.11.0', '0.10.0', '0.9.5', '0.9.4', '0.9.3', '0.7.0', '0.7.1', '0.7.2', '0.8.0', '0.8.1', '0.8.2', '0.8.3']);
18
49
  /** A run whose steps all finished: completed, or unconfirmed (an external change could not be confirmed). */
@@ -100,7 +131,7 @@ export async function runSaved(saved, flags, client) {
100
131
  if (shared.enabled) {
101
132
  if (flags.state)
102
133
  throw Error('This Method uses account state. Do not replace it with --state.');
103
- const state = await client.request(statePath, 'POST', { action: 'acquire', run_id: request.run_id });
134
+ const state = await acquireState(client, statePath, request.run_id, saved.workflow_id);
104
135
  acquired = true;
105
136
  revision = state.revision;
106
137
  const pendingFile = join(directory, 'state-pending.json');
@@ -125,7 +156,7 @@ export async function runSaved(saved, flags, client) {
125
156
  const result = await client.request(statePath, 'POST', commit);
126
157
  revision = result.revision;
127
158
  }
128
- }, client, config);
159
+ }, client, config, {}, saved.saved_by ?? null);
129
160
  // Every step finished (an unconfirmed run too), so the run no longer owns the shared state.
130
161
  if (acquired && finishedRun(result.status)) {
131
162
  await client.request(statePath, 'POST', { action: 'release', run_id: request.run_id });
@@ -0,0 +1,50 @@
1
+ import { type MethodClient } from './method-client.js';
2
+ export type UndoRecord = {
3
+ id: string;
4
+ file: string;
5
+ kind: 'fix' | 'prune' | 'learned';
6
+ created_at: string;
7
+ cause: string;
8
+ /** What the person reads after undo: what is no longer in effect. */
9
+ what: string;
10
+ before: {
11
+ text: string;
12
+ files: Record<string, string | null>;
13
+ };
14
+ after: {
15
+ sha256: string;
16
+ files: Record<string, string | null>;
17
+ };
18
+ undone_at?: string;
19
+ };
20
+ /** Take what the file and the named files are now, before a change is written. */
21
+ export declare function snapshot(file: string, paths: string[]): {
22
+ text: string;
23
+ files: {
24
+ [k: string]: string | null;
25
+ };
26
+ };
27
+ /** Record a change after it was written, so method undo can restore the state before it. */
28
+ export declare function recordChange(file: string, kind: UndoRecord['kind'], cause: string, what: string, before: UndoRecord['before']): UndoRecord;
29
+ /** The changes of this file that can be undone, newest first. */
30
+ export declare function undoRecords(file: string): (UndoRecord & {
31
+ path: string;
32
+ })[];
33
+ /**
34
+ * Restore the Method file, and the files the change wrote, as they were before the last change Method made (or before
35
+ * the change `id`, which must be the last one). A file that changed after that change is not overwritten. Returns the
36
+ * undone record; the caller saves the restored file as a new version.
37
+ */
38
+ export declare function undoLast(file: string, id?: string): UndoRecord & {
39
+ path: string;
40
+ };
41
+ /**
42
+ * `method undo [FILE] [--json]`: restore the Method file, and the files the change wrote, as they were before the last
43
+ * fix, pruning change, or removed learned rule. The restored file is saved as a new version.
44
+ */
45
+ export declare function undoCommand(args: string[], clientFactory: (server: string) => MethodClient): Promise<void>;
46
+ /** Save a changed file as a new version when this computer is signed in. Undefined when it cannot be saved now. */
47
+ export declare function saveVersion(client: MethodClient, file: string, reason: string): Promise<{
48
+ version_id: string;
49
+ url: string;
50
+ } | undefined>;