simt-dev 0.7.0 → 0.8.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/CHANGELOG.md +191 -870
- package/README.md +188 -213
- package/bin/simt.mjs +19 -55
- package/docs/decisions/ADR-014-workspace-is-configuration.md +2 -2
- package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
- package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
- package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
- package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
- package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
- package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
- package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
- package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
- package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
- package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
- package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
- package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
- package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
- package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
- package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
- package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
- package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
- package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
- package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
- package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
- package/ecosystems/bun.json +6 -0
- package/ecosystems/cargo.json +25 -0
- package/ecosystems/composer.json +6 -0
- package/ecosystems/dart.json +24 -0
- package/ecosystems/deno.json +21 -0
- package/ecosystems/devcontainers.json +6 -0
- package/ecosystems/docker-compose.json +6 -0
- package/ecosystems/docker.json +6 -0
- package/ecosystems/dotnet.json +25 -0
- package/ecosystems/elixir.json +23 -0
- package/ecosystems/gitsubmodule.json +6 -0
- package/ecosystems/go.json +25 -0
- package/ecosystems/gradle.json +13 -0
- package/ecosystems/maven.json +23 -0
- package/ecosystems/node.json +25 -0
- package/ecosystems/python.json +20 -0
- package/ecosystems/ruby.json +23 -0
- package/ecosystems/swift.json +25 -0
- package/ecosystems/terraform.json +6 -0
- package/ecosystems/uv.json +6 -0
- package/harnesses/antigravity.json +162 -0
- package/harnesses/claude.json +206 -0
- package/harnesses/codex.json +136 -0
- package/harnesses/copilot.json +88 -0
- package/harnesses/cursor.json +140 -0
- package/harnesses/deepseek.json +99 -0
- package/harnesses/gemini.json +122 -0
- package/harnesses/opencode.json +173 -0
- package/package.json +12 -2
- package/rules/core.md +7 -0
- package/rules/mdc/intake-questions.mdc +8 -0
- package/rules/mdc/slice-commit.mdc +57 -4
- package/src/advice/advice.mjs +3 -2
- package/src/agenthooks/prompt.mjs +5 -2
- package/src/agenthooks/render.mjs +86 -180
- package/src/agenthooks/shim.mjs +1 -1
- package/src/agents/render.mjs +15 -18
- package/src/api.mjs +31 -0
- package/src/argv.mjs +48 -3
- package/src/bench/ablate.mjs +3 -4
- package/src/bench/adapters.json +163 -105
- package/src/bench/adapters.mjs +466 -0
- package/src/bench/arms.mjs +333 -0
- package/src/bench/grade.mjs +33 -73
- package/src/bench/graders.mjs +42 -0
- package/src/bench/mine.mjs +121 -475
- package/src/bench/probe-ledger.mjs +149 -0
- package/src/bench/probes.catalog.mjs +918 -0
- package/src/bench/probes.mjs +79 -1064
- package/src/bench/render.mjs +111 -0
- package/src/bench/report.mjs +120 -962
- package/src/bench/run.mjs +185 -802
- package/src/bench/variants.mjs +4 -5
- package/src/bench/workspace.mjs +160 -0
- package/src/brief/assemble.mjs +48 -15
- package/src/ci/dependabot.mjs +25 -55
- package/src/ci/workflow.mjs +30 -7
- package/src/cli.mjs +45 -0
- package/src/commands/agent.mjs +4 -13
- package/src/commands/agenthook.mjs +70 -40
- package/src/commands/bench.mjs +191 -18
- package/src/commands/brief.mjs +5 -4
- package/src/commands/ci.mjs +13 -14
- package/src/commands/compare.mjs +42 -42
- package/src/commands/completions.mjs +2 -6
- package/src/commands/decide.mjs +4 -4
- package/src/commands/distill.mjs +4 -3
- package/src/commands/docs.mjs +88 -0
- package/src/commands/doctor.mjs +11 -7
- package/src/commands/evidence.mjs +6 -9
- package/src/commands/explain.mjs +0 -1
- package/src/commands/gate.mjs +26 -25
- package/src/commands/graph.mjs +2 -3
- package/src/commands/harness.mjs +6 -5
- package/src/commands/help.mjs +168 -19
- package/src/commands/hooks.mjs +119 -49
- package/src/commands/ignore.mjs +13 -8
- package/src/commands/impact.mjs +8 -15
- package/src/commands/index.mjs +318 -193
- package/src/commands/init-scan.mjs +90 -0
- package/src/commands/init.mjs +124 -105
- package/src/commands/lease.mjs +73 -25
- package/src/commands/lessons.mjs +10 -9
- package/src/commands/local.mjs +3 -3
- package/src/commands/map.mjs +24 -14
- package/src/commands/mcp.mjs +2 -18
- package/src/commands/pack.mjs +9 -6
- package/src/commands/plan.mjs +6 -5
- package/src/commands/plugin.mjs +2 -2
- package/src/commands/prbody.mjs +3 -3
- package/src/commands/repoindex.mjs +12 -11
- package/src/commands/review.mjs +2 -2
- package/src/commands/rules.mjs +37 -16
- package/src/commands/shared.mjs +14 -19
- package/src/commands/skills.mjs +33 -32
- package/src/commands/skillscompile.mjs +7 -8
- package/src/commands/skillsinstall.mjs +7 -6
- package/src/commands/slice.mjs +220 -74
- package/src/commands/spec.mjs +167 -0
- package/src/commands/status.mjs +27 -18
- package/src/commands/sync.mjs +27 -25
- package/src/commands/tests.mjs +18 -21
- package/src/commands/tokens.mjs +21 -26
- package/src/commands/undo.mjs +5 -15
- package/src/commands/upgrade.mjs +15 -7
- package/src/commands/why.mjs +15 -21
- package/src/compare/hooksstate.mjs +2 -6
- package/src/compare/learn.mjs +3 -1
- package/src/compare/row.mjs +0 -2
- package/src/context.mjs +64 -0
- package/src/decisions/index-doc.mjs +46 -0
- package/src/decisions/index.mjs +3 -2
- package/src/diff.mjs +1 -1
- package/src/distill/distill.mjs +14 -1
- package/src/distill/engine.mjs +5 -1
- package/src/docs/commands.mjs +65 -0
- package/src/ecosystem/records.mjs +219 -0
- package/src/explain.mjs +31 -0
- package/src/gates/index.mjs +385 -0
- package/src/graph/compile.mjs +8 -4
- package/src/graph/load.mjs +6 -2
- package/src/graph/spec.mjs +10 -8
- package/src/harness/records.mjs +143 -0
- package/src/hooks/capability.mjs +7 -5
- package/src/hooks/dispatcher.mjs +209 -60
- package/src/hooks/gates.mjs +10 -43
- package/src/hooks/resolve.mjs +4 -14
- package/src/hooks/shim.mjs +5 -2
- package/src/hooks/where.mjs +2 -7
- package/src/identity.mjs +31 -12
- package/src/index/coverage.mjs +58 -0
- package/src/index/extract.mjs +6 -16
- package/src/index/gate.mjs +1 -1
- package/src/index/govern.mjs +26 -9
- package/src/index/impact.mjs +4 -8
- package/src/index/map.mjs +14 -15
- package/src/index/open.mjs +173 -0
- package/src/index/persist.mjs +224 -57
- package/src/index/store.mjs +82 -39
- package/src/index/tests.mjs +2 -6
- package/src/journal.mjs +94 -20
- package/src/kernel/card.mjs +37 -63
- package/src/kernel/checks.mjs +4 -10
- package/src/lease.mjs +102 -7
- package/src/ledger/index.mjs +150 -14
- package/src/local/client.mjs +4 -6
- package/src/locate.mjs +33 -9
- package/src/manifest/schema.mjs +213 -0
- package/src/mcp/paginate.mjs +33 -0
- package/src/mcp/resources.mjs +7 -10
- package/src/mcp/schemas.mjs +512 -0
- package/src/mcp/server.mjs +119 -25
- package/src/mcp/tools-facts.mjs +326 -0
- package/src/mcp/tools-read.mjs +221 -0
- package/src/mcp/tools.mjs +4 -967
- package/src/measure/corpus.mjs +150 -0
- package/src/measure/metrics.mjs +236 -0
- package/src/measure/records.mjs +211 -0
- package/src/measure/session.mjs +3 -2
- package/src/measure/stats.mjs +538 -0
- package/src/measure/task-schema.mjs +125 -0
- package/src/measure/tasks.mjs +69 -0
- package/src/memory/decide.mjs +6 -15
- package/src/memory/lesson-text.mjs +61 -0
- package/src/memory/lessons.mjs +29 -82
- package/src/name.mjs +7 -0
- package/src/node-version.mjs +37 -0
- package/src/output/index.mjs +209 -10
- package/src/packs/manage.mjs +32 -12
- package/src/packs.mjs +20 -2
- package/src/plan/index.mjs +1 -1
- package/src/plugins.mjs +22 -16
- package/src/prbody.mjs +7 -11
- package/src/projection/loaders.mjs +7 -692
- package/src/projection/lock.mjs +11 -1
- package/src/projection/manifest.mjs +29 -468
- package/src/projection/mdc.mjs +7 -8
- package/src/projection/project.mjs +126 -221
- package/src/projection/region.mjs +561 -0
- package/src/projection/rules.mjs +20 -550
- package/src/projection/surfaces.mjs +43 -210
- package/src/projection/targets.mjs +5 -5
- package/src/repo-root.mjs +31 -0
- package/src/resolve/eval.mjs +2 -1
- package/src/resolve/fingerprint.mjs +19 -22
- package/src/resolve/resolve.mjs +1 -41
- package/src/review/critic.mjs +10 -13
- package/src/run.mjs +98 -0
- package/src/secrets.mjs +14 -22
- package/src/state/events.mjs +129 -0
- package/src/status-snapshot.mjs +233 -0
- package/src/status.mjs +2 -6
- package/src/sys/git.mjs +82 -0
- package/src/sys/proc.mjs +151 -0
- package/src/testing/parsers.mjs +265 -0
- package/src/testing/patches.mjs +23 -0
- package/src/testing/paths.mjs +49 -0
- package/src/testing/runners.mjs +186 -0
- package/src/testing/vault.mjs +68 -0
- package/src/text/frontmatter.mjs +49 -0
- package/src/text/tokens.mjs +52 -0
- package/src/toolwords.mjs +3 -0
- package/src/util/fs.mjs +20 -0
- package/src/util/sort.mjs +12 -0
- package/src/workspace.mjs +58 -11
- package/src/write/plan.mjs +82 -0
- package/templates/plan/tracker.json +1 -0
- package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
- package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
- package/docs/decisions/README.md +0 -600
- package/src/bench/tasks.mjs +0 -36
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Sealed export / import of the mined benchmark corpus (review 0003 Q48).
|
|
2
|
+
//
|
|
3
|
+
// The vault is keyed on the repository's root commit, outside every checkout, so
|
|
4
|
+
// a clone of the repository cannot read the hidden test patches the miner wrote:
|
|
5
|
+
// the committed corpus was unrunnable from any machine that was not the one that
|
|
6
|
+
// mined it. A sealed document carries the task descriptors and both patches for
|
|
7
|
+
// every task in a single file, so `bench export` on one machine and `bench import`
|
|
8
|
+
// on another reproduce the corpus exactly.
|
|
9
|
+
//
|
|
10
|
+
// The document is deterministic: no timestamp, tasks sorted by id, canonical
|
|
11
|
+
// (key-sorted) JSON, and a sha256 seal over everything but the seal itself. Two
|
|
12
|
+
// exports of the same corpus are byte-identical, and a reader can verify the
|
|
13
|
+
// seal before trusting a single byte of it.
|
|
14
|
+
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { chmod, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
17
|
+
import { dirname, join, resolve } from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { STATE_DIR } from '../name.mjs';
|
|
20
|
+
import { packageVersion } from '../package-version.mjs';
|
|
21
|
+
import { resolveVaultDir } from '../testing/vault.mjs';
|
|
22
|
+
|
|
23
|
+
/** The document kind stamp, so a reader can refuse a file that is not a corpus. */
|
|
24
|
+
export const CORPUS_KIND = 'simt-bench-corpus';
|
|
25
|
+
|
|
26
|
+
/** The document schema version. Bump when the shape changes, not when content does. */
|
|
27
|
+
export const CORPUS_SCHEMA_VERSION = 1;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Canonical serialisation: object keys sorted, no whitespace. Arrays keep their
|
|
31
|
+
* order (the caller sorts them). This is what the seal hashes, so both the
|
|
32
|
+
* exporter and the importer must reduce the payload to exactly this form.
|
|
33
|
+
*
|
|
34
|
+
* @param {*} value a JSON value
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
function canonical(value) {
|
|
38
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
39
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
40
|
+
const keys = Object.keys(value).sort();
|
|
41
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {{tasks: object[]}} payload the document minus its seal
|
|
46
|
+
* @returns {string} hex sha256 of the canonical payload
|
|
47
|
+
*/
|
|
48
|
+
function sealOf(payload) {
|
|
49
|
+
return createHash('sha256').update(canonical(payload)).digest('hex');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function payloadOf(doc) {
|
|
53
|
+
const { seal, ...payload } = doc;
|
|
54
|
+
void seal;
|
|
55
|
+
return payload;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read every mined task and its hidden patches from `root` and assemble the
|
|
60
|
+
* sealed corpus document (no file written; the command decides where it goes).
|
|
61
|
+
*
|
|
62
|
+
* @param {string} root
|
|
63
|
+
* @param {{env?: NodeJS.ProcessEnv}} [options]
|
|
64
|
+
* @returns {Promise<object>} the sealed document
|
|
65
|
+
*/
|
|
66
|
+
export async function buildCorpusDocument(root, { env = process.env } = {}) {
|
|
67
|
+
const absRoot = resolve(root);
|
|
68
|
+
const tasksDir = join(absRoot, STATE_DIR, 'bench', 'tasks');
|
|
69
|
+
const vaultRoot = resolveVaultDir(absRoot, { env });
|
|
70
|
+
|
|
71
|
+
const names = (await readdir(tasksDir).catch(() => [])).filter((f) => f.endsWith('.json')).sort();
|
|
72
|
+
const tasks = [];
|
|
73
|
+
for (const name of names) {
|
|
74
|
+
const task = JSON.parse(await readFile(join(tasksDir, name), 'utf8'));
|
|
75
|
+
const testPatch = await readFile(join(vaultRoot, task.id, 'test.patch'))
|
|
76
|
+
.then((b) => b.toString('base64'))
|
|
77
|
+
.catch(() => '');
|
|
78
|
+
const goldPatch = await readFile(join(vaultRoot, task.id, 'gold.patch'))
|
|
79
|
+
.then((b) => b.toString('base64'))
|
|
80
|
+
.catch(() => '');
|
|
81
|
+
tasks.push({ id: task.id, task, testPatch, goldPatch });
|
|
82
|
+
}
|
|
83
|
+
tasks.sort((a, b) => a.id.localeCompare(b.id));
|
|
84
|
+
|
|
85
|
+
const corpus = await readFile(join(dirname(tasksDir), 'corpus.json'), 'utf8')
|
|
86
|
+
.then((t) => JSON.parse(t))
|
|
87
|
+
.catch(() => null);
|
|
88
|
+
|
|
89
|
+
const payload = {
|
|
90
|
+
schemaVersion: CORPUS_SCHEMA_VERSION,
|
|
91
|
+
kind: CORPUS_KIND,
|
|
92
|
+
minedFrom: corpus?.minedFrom ?? null,
|
|
93
|
+
simtVersion: corpus?.simtVersion ?? packageVersion(),
|
|
94
|
+
taskCount: tasks.length,
|
|
95
|
+
tasks,
|
|
96
|
+
};
|
|
97
|
+
return { ...payload, seal: sealOf(payload) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Verify a corpus document's seal. Returns a reason string when the document is
|
|
102
|
+
* not a trusted corpus; `null` when it is.
|
|
103
|
+
*
|
|
104
|
+
* @param {object} doc
|
|
105
|
+
* @returns {string|null}
|
|
106
|
+
*/
|
|
107
|
+
export function verifyCorpusDocument(doc) {
|
|
108
|
+
if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) return 'not a corpus document';
|
|
109
|
+
if (doc.kind !== CORPUS_KIND) return `unexpected kind \`${doc.kind}\` (want \`${CORPUS_KIND}\`)`;
|
|
110
|
+
if (doc.schemaVersion !== CORPUS_SCHEMA_VERSION) {
|
|
111
|
+
return `unsupported schemaVersion \`${doc.schemaVersion}\` (want ${CORPUS_SCHEMA_VERSION})`;
|
|
112
|
+
}
|
|
113
|
+
const payload = payloadOf(doc);
|
|
114
|
+
if (sealOf(payload) !== doc.seal) return 'seal mismatch: the document was modified after it was sealed';
|
|
115
|
+
if (!Array.isArray(payload.tasks)) return 'missing task list';
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Write a verified corpus document into `root`: task descriptors to
|
|
121
|
+
* `.simt/bench/tasks/` and the hidden patches to the vault. Idempotent — a task
|
|
122
|
+
* already present is overwritten with the document's copy, which is the point of
|
|
123
|
+
* importing a known corpus.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} root
|
|
126
|
+
* @param {object} doc a document that passed `verifyCorpusDocument`
|
|
127
|
+
* @param {{env?: NodeJS.ProcessEnv, apply?: boolean}} [options]
|
|
128
|
+
* @returns {Promise<{applied: boolean, tasks: number, tasksDir: string, vaultRoot: string}>}
|
|
129
|
+
*/
|
|
130
|
+
export async function writeCorpusDocument(root, doc, { env = process.env, apply = true } = {}) {
|
|
131
|
+
const absRoot = resolve(root);
|
|
132
|
+
const tasksDir = join(absRoot, STATE_DIR, 'bench', 'tasks');
|
|
133
|
+
const vaultRoot = resolveVaultDir(absRoot, { env });
|
|
134
|
+
|
|
135
|
+
if (apply) await mkdir(tasksDir, { recursive: true });
|
|
136
|
+
for (const entry of doc.tasks) {
|
|
137
|
+
if (apply) {
|
|
138
|
+
await writeFile(join(tasksDir, `${entry.id}.json`), `${JSON.stringify(entry.task, null, 2)}\n`, 'utf8');
|
|
139
|
+
const taskVault = join(vaultRoot, entry.id);
|
|
140
|
+
// An imported corpus restores an answer key, so it gets the same 0700 the miner
|
|
141
|
+
// gives one (review 0003 Q49): the import must not be the cheaper way in.
|
|
142
|
+
await mkdir(taskVault, { recursive: true, mode: 0o700 });
|
|
143
|
+
await chmod(taskVault, 0o700).catch(() => {});
|
|
144
|
+
await writeFile(join(taskVault, 'test.patch'), Buffer.from(entry.testPatch, 'base64'));
|
|
145
|
+
await writeFile(join(taskVault, 'gold.patch'), Buffer.from(entry.goldPatch, 'base64'));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { applied: apply, tasks: doc.tasks.length, tasksDir, vaultRoot };
|
|
150
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Derived per-run metrics: what a record costs, and how good it is.
|
|
2
|
+
//
|
|
3
|
+
// Split from the statistics beside it (review 0003 Q78): a metric is a function of one
|
|
4
|
+
// record, a statistical test is a function of a population of them, and a reader looking
|
|
5
|
+
// for the cost model should not have to open the bootstrap.
|
|
6
|
+
//
|
|
7
|
+
// The cost model distinguishes three answers because a missing cost must never read as a
|
|
8
|
+
// zero cost: `priced` from the token table, `reported` by the harness, `missing` when
|
|
9
|
+
// neither exists (review 0003 Q46).
|
|
10
|
+
import { readFile } from 'node:fs/promises';
|
|
11
|
+
import { join, resolve } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { LOCAL_DIR } from '../state.mjs';
|
|
14
|
+
|
|
15
|
+
function normalizePriceEntry(raw, label) {
|
|
16
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
17
|
+
throw new Error(`${label} must be a JSON object with {in, cache_write, cache_read, out}`);
|
|
18
|
+
}
|
|
19
|
+
const pIn = Number(raw.in ?? raw.input ?? 0);
|
|
20
|
+
const pCw = Number(raw.cache_write ?? raw.cacheWrite ?? 0);
|
|
21
|
+
const pCr = Number(raw.cache_read ?? raw.cacheRead ?? 0);
|
|
22
|
+
const pOut = Number(raw.out ?? raw.output ?? 0);
|
|
23
|
+
for (const [key, value] of [
|
|
24
|
+
['in', pIn],
|
|
25
|
+
['cache_write', pCw],
|
|
26
|
+
['cache_read', pCr],
|
|
27
|
+
['out', pOut],
|
|
28
|
+
]) {
|
|
29
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
30
|
+
throw new Error(`${label}.${key} must be a non-negative number`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { in: pIn, cache_write: pCw, cache_read: pCr, out: pOut };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isFlatPriceObject(obj) {
|
|
37
|
+
return (
|
|
38
|
+
'in' in obj ||
|
|
39
|
+
'input' in obj ||
|
|
40
|
+
'cache_write' in obj ||
|
|
41
|
+
'cacheWrite' in obj ||
|
|
42
|
+
'cache_read' in obj ||
|
|
43
|
+
'cacheRead' in obj ||
|
|
44
|
+
'out' in obj ||
|
|
45
|
+
'output' in obj
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Load `.simt/local/bench/prices.json` if present.
|
|
51
|
+
*
|
|
52
|
+
* Supports either a flat `{in, cache_write, cache_read, out}` object (prices per 1M
|
|
53
|
+
* tokens) or a map keyed by model/harness name with an optional `"default"` entry.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} root
|
|
56
|
+
* @returns {Promise<null|{kind: 'flat'|'map', entry?: object, entries?: object}>}
|
|
57
|
+
*/
|
|
58
|
+
export async function loadPrices(root) {
|
|
59
|
+
// The pre-move path is a fallback for one release (review 0003 Q19).
|
|
60
|
+
const pricesPath = join(resolve(root), LOCAL_DIR, 'bench', 'prices.json');
|
|
61
|
+
const legacyPath = join(resolve(root), '.simt', 'bench', 'prices.json');
|
|
62
|
+
let rawText;
|
|
63
|
+
try {
|
|
64
|
+
rawText = await readFile(pricesPath, 'utf8');
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error && error.code === 'ENOENT') {
|
|
67
|
+
try {
|
|
68
|
+
rawText = await readFile(legacyPath, 'utf8');
|
|
69
|
+
} catch (legacyError) {
|
|
70
|
+
if (legacyError && legacyError.code === 'ENOENT') return null;
|
|
71
|
+
throw legacyError;
|
|
72
|
+
}
|
|
73
|
+
} else throw error;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let parsed;
|
|
77
|
+
try {
|
|
78
|
+
parsed = JSON.parse(rawText);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
throw new Error(`invalid JSON in ${pricesPath}: ${error.message}`);
|
|
81
|
+
}
|
|
82
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
83
|
+
throw new Error(`${pricesPath} must be a JSON object`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (isFlatPriceObject(parsed)) {
|
|
87
|
+
return { kind: 'flat', entry: normalizePriceEntry(parsed, pricesPath) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const entries = {};
|
|
91
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
92
|
+
entries[key] = normalizePriceEntry(value, `${pricesPath}[${JSON.stringify(key)}]`);
|
|
93
|
+
}
|
|
94
|
+
return { kind: 'map', entries };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function resolvePriceEntry(prices, run) {
|
|
98
|
+
if (!prices) return null;
|
|
99
|
+
if (prices.kind === 'flat') return normalizePriceEntry(prices.entry, 'prices');
|
|
100
|
+
if (prices.kind === 'map') {
|
|
101
|
+
const entry =
|
|
102
|
+
(run?.model && prices.entries[run.model]) ||
|
|
103
|
+
(run?.harness && prices.entries[run.harness]) ||
|
|
104
|
+
prices.entries.default ||
|
|
105
|
+
null;
|
|
106
|
+
return entry ? normalizePriceEntry(entry, 'prices') : null;
|
|
107
|
+
}
|
|
108
|
+
if (isFlatPriceObject(prices)) {
|
|
109
|
+
return normalizePriceEntry(prices, 'prices');
|
|
110
|
+
}
|
|
111
|
+
const entry =
|
|
112
|
+
(run?.model && prices[run.model]) || (run?.harness && prices[run.harness]) || prices.default || null;
|
|
113
|
+
return entry ? normalizePriceEntry(entry, 'prices') : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Compute billed cost in USD for a single run record.
|
|
118
|
+
*
|
|
119
|
+
* Returns `{usd, source}` where `usd` is null when the cost is unknown: an arm whose
|
|
120
|
+
* prices are absent must not report a free run.
|
|
121
|
+
*
|
|
122
|
+
* @param {object} run
|
|
123
|
+
* @param {object|null} [prices]
|
|
124
|
+
* @returns {{usd: number|null, source: 'priced'|'reported'|'missing'}}
|
|
125
|
+
*/
|
|
126
|
+
export function computeRunCost(run, prices = null) {
|
|
127
|
+
const usage = run?.usage ?? {};
|
|
128
|
+
const input = Number(usage.input ?? 0);
|
|
129
|
+
const cacheWrite = Number(usage.cacheWrite ?? usage.cache_write ?? 0);
|
|
130
|
+
const cacheRead = Number(usage.cacheRead ?? usage.cache_read ?? 0);
|
|
131
|
+
const output = Number(usage.output ?? 0);
|
|
132
|
+
const totalTokens = input + cacheWrite + cacheRead + output;
|
|
133
|
+
|
|
134
|
+
const entry = resolvePriceEntry(prices, run);
|
|
135
|
+
if (entry) {
|
|
136
|
+
if (totalTokens === 0 && Number(run?.costUsd ?? 0) > 0) {
|
|
137
|
+
return { usd: Number(run.costUsd), source: 'reported' };
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
usd:
|
|
141
|
+
(input * entry.in + cacheWrite * entry.cache_write + cacheRead * entry.cache_read + output * entry.out) /
|
|
142
|
+
1_000_000,
|
|
143
|
+
source: 'priced',
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const reported = run?.costUsd;
|
|
147
|
+
if (typeof reported === 'number' && Number.isFinite(reported)) {
|
|
148
|
+
return { usd: reported, source: 'reported' };
|
|
149
|
+
}
|
|
150
|
+
return { usd: null, source: 'missing' };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Whether a run attempt counts as resolved: hidden tests pass AND declared gates pass. */
|
|
154
|
+
export function isRunResolved(run) {
|
|
155
|
+
return Boolean(run?.resolved && (run?.gatesPassed ?? true));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Quality score Q in [0, 1]: 0.5 hidden tests + 0.2 gates + 0.2 probe + 0.1 diff hygiene.
|
|
160
|
+
*
|
|
161
|
+
* @param {object} run
|
|
162
|
+
* @returns {number}
|
|
163
|
+
*/
|
|
164
|
+
export function computeRunQuality(run) {
|
|
165
|
+
const testsScore = run?.resolved ? 1 : 0;
|
|
166
|
+
const gatesScore = (run?.gatesPassed ?? true) ? 1 : 0;
|
|
167
|
+
|
|
168
|
+
let probeScore = 1;
|
|
169
|
+
if (run?.probe !== null && run?.probe !== undefined) {
|
|
170
|
+
if (typeof run.probe === 'boolean') probeScore = run.probe ? 1 : 0;
|
|
171
|
+
else if (typeof run.probe === 'number' && Number.isFinite(run.probe)) {
|
|
172
|
+
probeScore = Math.max(0, Math.min(1, run.probe));
|
|
173
|
+
} else if (typeof run.probe === 'object') probeScore = run.probe.passed ? 1 : 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const files = Number(run?.diff?.files ?? 0);
|
|
177
|
+
const outside = Number(run?.diff?.outsideBlastRadius ?? 0);
|
|
178
|
+
let hygieneScore = 1;
|
|
179
|
+
if (outside > 0) {
|
|
180
|
+
hygieneScore = files > 0 ? Math.max(0, Math.min(1, (files - outside) / files)) : 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return (5 * testsScore + 2 * gatesScore + 2 * probeScore + hygieneScore) / 10;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Evaluate one record into the row an arm aggregation and a paired test consume.
|
|
188
|
+
*
|
|
189
|
+
* @param {object} run
|
|
190
|
+
* @param {object|null} [prices]
|
|
191
|
+
* @returns {object}
|
|
192
|
+
*/
|
|
193
|
+
export function evaluateRunRecord(run, prices = null) {
|
|
194
|
+
const usage = run?.usage ?? {};
|
|
195
|
+
const input = Number(usage.input ?? 0);
|
|
196
|
+
const cacheWrite = Number(usage.cacheWrite ?? usage.cache_write ?? 0);
|
|
197
|
+
const cacheRead = Number(usage.cacheRead ?? usage.cache_read ?? 0);
|
|
198
|
+
const output = Number(usage.output ?? 0);
|
|
199
|
+
const turns = Number(usage.turns ?? 0);
|
|
200
|
+
const toolCalls = Number(usage.toolCalls ?? usage.tool_calls ?? 0);
|
|
201
|
+
const R = isRunResolved(run) ? 1 : 0;
|
|
202
|
+
const Q = computeRunQuality(run);
|
|
203
|
+
const cost = computeRunCost(run, prices);
|
|
204
|
+
const cacheDenom = input + cacheRead;
|
|
205
|
+
const cacheHitRatio = cacheDenom > 0 ? cacheRead / cacheDenom : 0;
|
|
206
|
+
// A v2 experiment is the conditions, a v1 experiment was a label. Both reduce to one
|
|
207
|
+
// string, so grouping by experiment keeps working across a stored corpus.
|
|
208
|
+
const experiment = run?.experiment;
|
|
209
|
+
const experimentKey =
|
|
210
|
+
typeof experiment === 'string'
|
|
211
|
+
? experiment
|
|
212
|
+
: experiment && typeof experiment === 'object'
|
|
213
|
+
? [experiment.simtVersion ?? '-', experiment.rulesDigest ?? '-', experiment.corpusDigest ?? '-'].join('/')
|
|
214
|
+
: '';
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
task: String(run?.task ?? ''),
|
|
218
|
+
stratum: String(run?.stratum ?? 'unknown'),
|
|
219
|
+
arm: String(run?.arm ?? 'default'),
|
|
220
|
+
harness: String(run?.harness ?? ''),
|
|
221
|
+
model: String(run?.model ?? ''),
|
|
222
|
+
experiment: experimentKey,
|
|
223
|
+
rep: Number(run?.rep ?? 1),
|
|
224
|
+
R,
|
|
225
|
+
Q,
|
|
226
|
+
C: cost.usd,
|
|
227
|
+
costSource: cost.source,
|
|
228
|
+
turns,
|
|
229
|
+
toolCalls,
|
|
230
|
+
input,
|
|
231
|
+
cacheWrite,
|
|
232
|
+
cacheRead,
|
|
233
|
+
output,
|
|
234
|
+
cacheHitRatio,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Stored benchmark run records, schema v2.
|
|
2
|
+
//
|
|
3
|
+
// A v1 record said what an arm scored and nothing about the conditions it scored under.
|
|
4
|
+
// Two records from different rule text, different corpora or different harness versions
|
|
5
|
+
// were indistinguishable, so a report could compare them and call the difference an
|
|
6
|
+
// effect (review 0003 Q78). The record now carries the experiment that produced it, and
|
|
7
|
+
// the run id names the model whenever a model other than the harness default was used:
|
|
8
|
+
// two models driven through one harness used to overwrite each other's run directory.
|
|
9
|
+
//
|
|
10
|
+
// Loading is tolerant in one direction only: a record that does not parse is skipped and
|
|
11
|
+
// reported as a warning, because one corrupt file is not an empty corpus. A reader that
|
|
12
|
+
// answers "no runs" for a directory of unreadable files hides the corruption entirely.
|
|
13
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { arch, cpus, platform } from 'node:os';
|
|
16
|
+
import { join, resolve } from 'node:path';
|
|
17
|
+
|
|
18
|
+
import { LOCAL_DIR } from '../state.mjs';
|
|
19
|
+
import { packageVersion } from '../package-version.mjs';
|
|
20
|
+
|
|
21
|
+
export const RECORD_SCHEMA_VERSION = 2;
|
|
22
|
+
|
|
23
|
+
/** Directory a run's records are stored under, with the pre-move path as a fallback. */
|
|
24
|
+
export function runsDir(root) {
|
|
25
|
+
return join(resolve(root), LOCAL_DIR, 'bench', 'runs');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function legacyRunsDir(root) {
|
|
29
|
+
return join(resolve(root), '.simt', 'bench', 'runs');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The run directory name for one arm and repetition.
|
|
34
|
+
*
|
|
35
|
+
* The model is part of the name when it is a real choice. A `default` model (or a
|
|
36
|
+
* harness whose model is itself) adds nothing to the name, and a name that changes for
|
|
37
|
+
* every run would make the stored path useless to a reader.
|
|
38
|
+
*
|
|
39
|
+
* @param {{harness: string, model?: string|null, arm: string, rep: number}} spec
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
export function runIdFor({ harness, model, arm, rep }) {
|
|
43
|
+
const named =
|
|
44
|
+
typeof model === 'string' && model.length > 0 && model !== 'default' && model !== harness;
|
|
45
|
+
return `${harness}-${named ? `${model}-` : ''}${arm}-r${rep}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The conditions a run was produced under, recorded so a report can refuse to pool
|
|
50
|
+
* records that were not comparable.
|
|
51
|
+
*
|
|
52
|
+
* @param {{simtVersion?: string, rulesDigest?: string|null, adapterVersion?: string|null,
|
|
53
|
+
* corpusDigest?: string|null, seed?: number|null, isolation?: string}} input
|
|
54
|
+
* @returns {object}
|
|
55
|
+
*/
|
|
56
|
+
export function experimentOf({
|
|
57
|
+
simtVersion = packageVersion(),
|
|
58
|
+
rulesDigest = null,
|
|
59
|
+
adapterVersion = null,
|
|
60
|
+
corpusDigest = null,
|
|
61
|
+
seed = null,
|
|
62
|
+
isolation = 'none',
|
|
63
|
+
} = {}) {
|
|
64
|
+
return {
|
|
65
|
+
simtVersion,
|
|
66
|
+
rulesDigest,
|
|
67
|
+
adapterVersion,
|
|
68
|
+
corpusDigest,
|
|
69
|
+
seed,
|
|
70
|
+
isolation,
|
|
71
|
+
hardware: `${platform()}/${arch()}/${cpus().length}cpu`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A digest of the corpus a run was measured against.
|
|
77
|
+
*
|
|
78
|
+
* It is the sorted task ids and their base commits, hashed: a re-mine that changes which
|
|
79
|
+
* tasks exist, or where they start, changes the digest, and a report can say the two
|
|
80
|
+
* records were not measured on the same corpus.
|
|
81
|
+
*
|
|
82
|
+
* @param {object[]} tasks normalised task descriptors
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
export function corpusDigestOf(tasks = []) {
|
|
86
|
+
const lines = [...tasks]
|
|
87
|
+
.map((task) => `${task.id}\0${task.base}`)
|
|
88
|
+
.sort()
|
|
89
|
+
.join('\n');
|
|
90
|
+
return createHash('sha256').update(lines).digest('hex').slice(0, 16);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Build one record.
|
|
95
|
+
*
|
|
96
|
+
* `status` is the record's own claim: `ok` measured something, `error` did not. An error
|
|
97
|
+
* record keeps the run's identity so the report can exclude it by name rather than by
|
|
98
|
+
* looking for a missing field.
|
|
99
|
+
*
|
|
100
|
+
* @param {object} input
|
|
101
|
+
* @returns {object}
|
|
102
|
+
*/
|
|
103
|
+
export function runRecord({
|
|
104
|
+
task,
|
|
105
|
+
arm,
|
|
106
|
+
harness,
|
|
107
|
+
model,
|
|
108
|
+
rep,
|
|
109
|
+
measured = true,
|
|
110
|
+
isolation = 'none',
|
|
111
|
+
experiment = null,
|
|
112
|
+
status = 'ok',
|
|
113
|
+
error = null,
|
|
114
|
+
agent = null,
|
|
115
|
+
resolved = false,
|
|
116
|
+
gatesPassed = false,
|
|
117
|
+
f2pPassed = [],
|
|
118
|
+
usage = null,
|
|
119
|
+
costUsd = null,
|
|
120
|
+
diff = { files: 0, outsideBlastRadius: 0 },
|
|
121
|
+
skills = null,
|
|
122
|
+
probe = null,
|
|
123
|
+
}) {
|
|
124
|
+
const record = {
|
|
125
|
+
schemaVersion: RECORD_SCHEMA_VERSION,
|
|
126
|
+
task: String(task?.id ?? task ?? ''),
|
|
127
|
+
stratum: String(task?.stratum ?? 'unknown'),
|
|
128
|
+
arm,
|
|
129
|
+
harness,
|
|
130
|
+
model,
|
|
131
|
+
rep,
|
|
132
|
+
measured: Boolean(measured),
|
|
133
|
+
isolation,
|
|
134
|
+
experiment,
|
|
135
|
+
status,
|
|
136
|
+
agent,
|
|
137
|
+
resolved: Boolean(resolved),
|
|
138
|
+
gatesPassed: Boolean(gatesPassed),
|
|
139
|
+
f2pPassed: [...f2pPassed],
|
|
140
|
+
usage: usage ?? { input: 0, cacheWrite: 0, cacheRead: 0, output: 0, turns: 0, toolCalls: 0 },
|
|
141
|
+
costUsd: typeof costUsd === 'number' && Number.isFinite(costUsd) ? costUsd : null,
|
|
142
|
+
diff,
|
|
143
|
+
skills,
|
|
144
|
+
probe,
|
|
145
|
+
};
|
|
146
|
+
if (error !== null) record.error = String(error);
|
|
147
|
+
return record;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Read every stored record, and report the files it could not read.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} root
|
|
154
|
+
* @returns {Promise<{records: object[], warnings: string[]}>}
|
|
155
|
+
*/
|
|
156
|
+
export async function readRunRecords(root) {
|
|
157
|
+
let base = runsDir(root);
|
|
158
|
+
let entries = await readdir(base, { withFileTypes: true }).catch(() => null);
|
|
159
|
+
if (entries === null) {
|
|
160
|
+
base = legacyRunsDir(root);
|
|
161
|
+
entries = await readdir(base, { withFileTypes: true }).catch(() => []);
|
|
162
|
+
}
|
|
163
|
+
const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name));
|
|
164
|
+
|
|
165
|
+
const records = [];
|
|
166
|
+
const warnings = [];
|
|
167
|
+
const readOne = async (file) => {
|
|
168
|
+
let text;
|
|
169
|
+
try {
|
|
170
|
+
text = await readFile(file, 'utf8');
|
|
171
|
+
} catch (error) {
|
|
172
|
+
warnings.push(`could not read ${file}: ${error.message ?? error}`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
const record = JSON.parse(text);
|
|
177
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
|
178
|
+
warnings.push(`skipped ${file}: not a record object`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
records.push(record);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
warnings.push(`skipped ${file}: ${error.message ?? error}`);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
for (const entry of sorted) {
|
|
188
|
+
if (entry.name.startsWith('.')) continue;
|
|
189
|
+
const full = join(base, entry.name);
|
|
190
|
+
if (entry.isDirectory()) {
|
|
191
|
+
const files = (await readdir(full).catch(() => []))
|
|
192
|
+
.filter((file) => file.endsWith('.json') && !file.startsWith('.'))
|
|
193
|
+
.sort();
|
|
194
|
+
for (const file of files) await readOne(join(full, file));
|
|
195
|
+
} else if (entry.isFile() && entry.name.endsWith('.json')) {
|
|
196
|
+
await readOne(full);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { records, warnings };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Every stored record. A caller that has nowhere to put a warning gets the records and
|
|
204
|
+
* no warnings; the report is the caller that shows them.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} root
|
|
207
|
+
* @returns {Promise<object[]>}
|
|
208
|
+
*/
|
|
209
|
+
export async function loadRunRecords(root) {
|
|
210
|
+
return (await readRunRecords(root)).records;
|
|
211
|
+
}
|
package/src/measure/session.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { readFile } from 'node:fs/promises';
|
|
9
9
|
import { resolve } from 'node:path';
|
|
10
10
|
|
|
11
|
+
import { CLI } from '../name.mjs';
|
|
11
12
|
import { tokens } from './context.mjs';
|
|
12
13
|
|
|
13
14
|
const RULE_PATH_RE =
|
|
@@ -53,14 +54,14 @@ export async function measureSession(transcriptPath) {
|
|
|
53
54
|
return {
|
|
54
55
|
ok: false,
|
|
55
56
|
error: '--session requires a local transcript path',
|
|
56
|
-
remedy:
|
|
57
|
+
remedy: `pass a local JSONL or JSON transcript path: ${CLI} tokens --session <path>`,
|
|
57
58
|
};
|
|
58
59
|
}
|
|
59
60
|
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(transcriptPath.trim())) {
|
|
60
61
|
return {
|
|
61
62
|
ok: false,
|
|
62
63
|
error: `${transcriptPath}: remote URLs are refused; session transcripts are read locally only (ADR-075)`,
|
|
63
|
-
remedy:
|
|
64
|
+
remedy: `pass a local file path on this machine: ${CLI} tokens --session ./session.jsonl`,
|
|
64
65
|
};
|
|
65
66
|
}
|
|
66
67
|
|