@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.
- package/README.md +1 -1
- package/dist/packages/sdk/package.json +7 -2
- package/dist/packages/sdk/src/authoring-instructions.d.ts +1 -1
- package/dist/packages/sdk/src/authoring-instructions.js +1 -1
- package/dist/packages/sdk/src/current-inspection.js +3 -0
- package/dist/packages/sdk/src/current-runtime.d.ts +6 -1
- package/dist/packages/sdk/src/current-runtime.js +47 -2
- package/dist/packages/sdk/src/fix-lines.d.ts +82 -0
- package/dist/packages/sdk/src/fix-lines.js +63 -0
- package/dist/packages/sdk/src/fix-outcomes.d.ts +72 -0
- package/dist/packages/sdk/src/fix-outcomes.js +201 -0
- package/dist/packages/sdk/src/home.d.ts +30 -26
- package/dist/packages/sdk/src/home.js +164 -60
- package/dist/packages/sdk/src/improve.d.ts +146 -24
- package/dist/packages/sdk/src/improve.js +665 -105
- package/dist/packages/sdk/src/judge.d.ts +23 -10
- package/dist/packages/sdk/src/judge.js +110 -26
- package/dist/packages/sdk/src/learned.d.ts +43 -0
- package/dist/packages/sdk/src/learned.js +97 -0
- package/dist/packages/sdk/src/local-cli.js +8 -3
- package/dist/packages/sdk/src/local-judgments.d.ts +2 -0
- package/dist/packages/sdk/src/merge.d.ts +38 -0
- package/dist/packages/sdk/src/merge.js +65 -0
- package/dist/packages/sdk/src/method-client.js +1 -1
- package/dist/packages/sdk/src/method-files.d.ts +3 -2
- package/dist/packages/sdk/src/method-files.js +7 -3
- package/dist/packages/sdk/src/method-help.d.ts +1 -1
- package/dist/packages/sdk/src/method-help.js +25 -16
- package/dist/packages/sdk/src/method-sync.d.ts +7 -0
- package/dist/packages/sdk/src/method-sync.js +33 -0
- package/dist/packages/sdk/src/method.d.ts +5 -0
- package/dist/packages/sdk/src/method.js +169 -15
- package/dist/packages/sdk/src/outbox.d.ts +28 -6
- package/dist/packages/sdk/src/outbox.js +13 -6
- package/dist/packages/sdk/src/proposals.d.ts +0 -28
- package/dist/packages/sdk/src/proposals.js +2 -129
- package/dist/packages/sdk/src/quality.d.ts +65 -5
- package/dist/packages/sdk/src/quality.js +82 -17
- package/dist/packages/sdk/src/run-saved.d.ts +4 -0
- package/dist/packages/sdk/src/run-saved.js +33 -2
- package/dist/packages/sdk/src/undo.d.ts +50 -0
- package/dist/packages/sdk/src/undo.js +108 -0
- package/dist/packages/sdk/src/versions.js +5 -3
- package/dist/packages/sdk/src/watch.d.ts +20 -0
- package/dist/packages/sdk/src/watch.js +86 -0
- package/dist/packages/workflow-language/src/inspection.d.ts +30 -0
- package/dist/packages/workflow-language/src/inspection.js +3 -1
- 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
|
-
|
|
40
|
-
|
|
41
|
-
writePrivateJson(methodFile(
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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}
|
|
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
|
-
|
|
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
|
|
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 ??
|
|
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
|
|
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>;
|