@foldrun/core 2.4.0 → 2.5.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/dist/src/agent-activity.d.ts +5 -0
- package/dist/src/agent-activity.js +31 -0
- package/dist/src/agent-import.js +1 -1
- package/dist/src/agent-tools.js +1 -1
- package/dist/src/agents-md.js +1 -1
- package/dist/src/alerts.d.ts +60 -0
- package/dist/src/alerts.js +161 -0
- package/dist/src/browser-pod.js +4 -0
- package/dist/src/capture.d.ts +23 -0
- package/dist/src/capture.js +80 -0
- package/dist/src/compare.d.ts +57 -0
- package/dist/src/compare.js +116 -0
- package/dist/src/completions.js +1 -0
- package/dist/src/deploy.d.ts +36 -1
- package/dist/src/deploy.js +170 -11
- package/dist/src/egress.d.ts +16 -1
- package/dist/src/evals.d.ts +5 -1
- package/dist/src/evals.js +20 -6
- package/dist/src/frontmatter-fields.js +9 -0
- package/dist/src/frontmatter-schema.js +3 -0
- package/dist/src/history.d.ts +4 -0
- package/dist/src/history.js +15 -0
- package/dist/src/hooks.js +1 -1
- package/dist/src/insights.d.ts +30 -0
- package/dist/src/insights.js +152 -0
- package/dist/src/issues.d.ts +140 -0
- package/dist/src/issues.js +302 -0
- package/dist/src/library.js +1 -1
- package/dist/src/limits.d.ts +8 -0
- package/dist/src/limits.js +36 -5
- package/dist/src/linkable.js +1 -1
- package/dist/src/matter.d.ts +8 -0
- package/dist/src/matter.js +28 -0
- package/dist/src/model-credential.d.ts +14 -0
- package/dist/src/model-credential.js +40 -0
- package/dist/src/network.d.ts +12 -0
- package/dist/src/network.js +25 -1
- package/dist/src/notify.d.ts +16 -0
- package/dist/src/notify.js +17 -7
- package/dist/src/okf.js +1 -1
- package/dist/src/online-checks.d.ts +76 -0
- package/dist/src/online-checks.js +432 -0
- package/dist/src/operator.d.ts +277 -0
- package/dist/src/operator.js +176 -2
- package/dist/src/otel-config.d.ts +18 -0
- package/dist/src/otel-config.js +69 -0
- package/dist/src/otel.d.ts +38 -0
- package/dist/src/otel.js +85 -0
- package/dist/src/outcome.d.ts +40 -0
- package/dist/src/outcome.js +77 -0
- package/dist/src/package.js +1 -1
- package/dist/src/platform.d.ts +1 -1
- package/dist/src/prompt-layers.d.ts +30 -0
- package/dist/src/prompt-layers.js +77 -0
- package/dist/src/provider-health.js +1 -1
- package/dist/src/providers.d.ts +5 -0
- package/dist/src/providers.js +2 -2
- package/dist/src/run-container.d.ts +16 -2
- package/dist/src/run-container.js +57 -5
- package/dist/src/runner.d.ts +53 -2
- package/dist/src/runner.js +356 -70
- package/dist/src/runs-filter.d.ts +48 -0
- package/dist/src/runs-filter.js +73 -0
- package/dist/src/scan.d.ts +19 -1
- package/dist/src/scan.js +60 -7
- package/dist/src/starter.js +4 -2
- package/dist/src/step-exec.d.ts +29 -1
- package/dist/src/step-exec.js +106 -6
- package/dist/src/store.d.ts +130 -3
- package/dist/src/store.js +145 -6
- package/dist/src/tokens.d.ts +38 -0
- package/dist/src/tokens.js +57 -0
- package/dist/src/tool-names.js +1 -1
- package/dist/src/tool-programs.js +1 -1
- package/dist/src/trace-tree.d.ts +40 -0
- package/dist/src/trace-tree.js +156 -0
- package/dist/src/translator.d.ts +8 -0
- package/dist/src/translator.js +7 -2
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/workspace-check.d.ts +18 -0
- package/dist/src/workspace-check.js +34 -0
- package/package.json +2 -2
- package/src/agent-activity.ts +32 -0
- package/src/agent-import.ts +1 -1
- package/src/agent-tools.ts +1 -1
- package/src/agents-md.ts +1 -1
- package/src/alerts.ts +190 -0
- package/src/browser-pod.ts +3 -0
- package/src/capture.ts +88 -0
- package/src/compare.ts +146 -0
- package/src/completions.ts +1 -0
- package/src/deploy.ts +184 -10
- package/src/egress.ts +12 -2
- package/src/evals.ts +19 -6
- package/src/frontmatter-fields.ts +9 -0
- package/src/frontmatter-schema.ts +3 -0
- package/src/history.ts +13 -0
- package/src/hooks.ts +1 -1
- package/src/insights.ts +174 -0
- package/src/issues.ts +396 -0
- package/src/library.ts +1 -1
- package/src/limits.ts +38 -5
- package/src/linkable.ts +1 -1
- package/src/matter.ts +33 -0
- package/src/model-credential.ts +45 -0
- package/src/network.ts +25 -1
- package/src/notify.ts +22 -7
- package/src/okf.ts +1 -1
- package/src/online-checks.ts +450 -0
- package/src/operator.ts +213 -3
- package/src/otel-config.ts +67 -0
- package/src/otel.ts +100 -0
- package/src/outcome.ts +90 -0
- package/src/package.ts +1 -1
- package/src/platform.ts +1 -1
- package/src/prompt-layers.ts +90 -0
- package/src/provider-health.ts +1 -1
- package/src/providers.ts +7 -2
- package/src/run-container.ts +66 -7
- package/src/runner.ts +363 -46
- package/src/runs-filter.ts +88 -0
- package/src/scan.ts +68 -7
- package/src/starter.ts +4 -2
- package/src/step-exec.ts +147 -9
- package/src/store.ts +254 -8
- package/src/tokens.ts +68 -0
- package/src/tool-names.ts +1 -1
- package/src/tool-programs.ts +1 -1
- package/src/trace-tree.ts +187 -0
- package/src/translator.ts +13 -2
- package/src/trigger-log.ts +1 -0
- package/src/workspace-check.ts +41 -0
package/src/compare.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Two runs of one flow side by side (FOL-72 step 6): "why did this get
|
|
2
|
+
// worse" read off the records — which files changed between the revisions
|
|
3
|
+
// they ran, and step by step what differs: status, the instruction, the
|
|
4
|
+
// model, time, tokens, model calls, the tools it used and the errors it hit.
|
|
5
|
+
// Pure; the caller hands in the changed files (history.ts changedPathsBetween).
|
|
6
|
+
|
|
7
|
+
import type { RunRecord, StepRecord } from "./store.ts";
|
|
8
|
+
|
|
9
|
+
export interface StepSide {
|
|
10
|
+
status: string;
|
|
11
|
+
instruction: string;
|
|
12
|
+
model?: string;
|
|
13
|
+
secs: number | null;
|
|
14
|
+
tokensIn: number;
|
|
15
|
+
tokensOut: number;
|
|
16
|
+
modelCalls: number;
|
|
17
|
+
/** Tool name → how many calls. */
|
|
18
|
+
tools: Record<string, number>;
|
|
19
|
+
toolErrors: number;
|
|
20
|
+
/** The first errors it wrote, each one line. */
|
|
21
|
+
errors: string[];
|
|
22
|
+
/** The first line of what it concluded. */
|
|
23
|
+
said: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface StepDiff {
|
|
27
|
+
/** 1-based, as the trace numbers steps. */
|
|
28
|
+
step: number;
|
|
29
|
+
agent: string;
|
|
30
|
+
base: StepSide | null;
|
|
31
|
+
run: StepSide | null;
|
|
32
|
+
/** What differs, in words, most telling first; [] when nothing does. */
|
|
33
|
+
changes: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RunComparison {
|
|
37
|
+
base: { id: string; status: string; startedAt: string; revision: string | null };
|
|
38
|
+
run: { id: string; status: string; startedAt: string; revision: string | null };
|
|
39
|
+
revisionChanged: boolean;
|
|
40
|
+
/** Files changed between the two revisions; null when that cannot be said. */
|
|
41
|
+
changedFiles: string[] | null;
|
|
42
|
+
steps: StepDiff[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const firstLine = (s: string | null | undefined, n = 200) => {
|
|
46
|
+
const l = (s ?? "").split("\n").map((x) => x.trim()).find(Boolean);
|
|
47
|
+
return l ? l.slice(0, n) : null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function side(s: StepRecord): StepSide {
|
|
51
|
+
const tools: Record<string, number> = {};
|
|
52
|
+
let toolErrors = 0;
|
|
53
|
+
for (const e of s.events) {
|
|
54
|
+
if (e.type !== "tool") continue;
|
|
55
|
+
// A pair is one call: count the call, and its completion's error.
|
|
56
|
+
if (e.ms == null) tools[e.text] = (tools[e.text] ?? 0) + 1;
|
|
57
|
+
else if (e.err) toolErrors++;
|
|
58
|
+
}
|
|
59
|
+
const start = s.startedAt ? Date.parse(s.startedAt) : NaN;
|
|
60
|
+
const end = s.finishedAt ? Date.parse(s.finishedAt) : NaN;
|
|
61
|
+
return {
|
|
62
|
+
status: s.status,
|
|
63
|
+
instruction: s.instruction,
|
|
64
|
+
...(s.model ? { model: s.model } : {}),
|
|
65
|
+
secs: Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, Math.round((end - start) / 1000)) : null,
|
|
66
|
+
tokensIn: s.tokens?.input ?? 0,
|
|
67
|
+
tokensOut: s.tokens?.output ?? 0,
|
|
68
|
+
modelCalls: s.calls?.length ?? s.turns ?? 0,
|
|
69
|
+
tools,
|
|
70
|
+
toolErrors,
|
|
71
|
+
errors: s.events.filter((e) => e.type === "error").slice(0, 3).map((e) => firstLine(e.text) ?? ""),
|
|
72
|
+
said: firstLine(s.conclusion ?? s.result),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const k = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
|
|
77
|
+
|
|
78
|
+
/** Much more or much less: by a quarter and by a real amount. */
|
|
79
|
+
const moved = (a: number, b: number, floor: number) => Math.abs(b - a) >= floor && (a === 0 ? b > 0 : Math.abs(b - a) / a >= 0.25);
|
|
80
|
+
|
|
81
|
+
function changes(a: StepSide | null, b: StepSide | null): string[] {
|
|
82
|
+
if (!a && b) return ["new step: not in the base run"];
|
|
83
|
+
if (a && !b) return ["not in this run"];
|
|
84
|
+
if (!a || !b) return [];
|
|
85
|
+
const out: string[] = [];
|
|
86
|
+
if (a.status !== b.status) out.push(`status: ${a.status} → ${b.status}`);
|
|
87
|
+
const newErrors = b.errors.filter((e) => !a.errors.includes(e));
|
|
88
|
+
if (newErrors.length) out.push(`new error: ${newErrors[0]}`);
|
|
89
|
+
if (a.instruction.trim() !== b.instruction.trim()) out.push("instruction changed");
|
|
90
|
+
if ((a.model ?? "") !== (b.model ?? "")) out.push(`model: ${a.model ?? "default"} → ${b.model ?? "default"}`);
|
|
91
|
+
if (b.toolErrors > a.toolErrors) out.push(`tool errors: ${a.toolErrors} → ${b.toolErrors}`);
|
|
92
|
+
const names = new Set([...Object.keys(a.tools), ...Object.keys(b.tools)]);
|
|
93
|
+
const toolDiff = [...names]
|
|
94
|
+
.map((n) => ({ n, d: (b.tools[n] ?? 0) - (a.tools[n] ?? 0) }))
|
|
95
|
+
.filter((x) => x.d !== 0)
|
|
96
|
+
.sort((x, y) => Math.abs(y.d) - Math.abs(x.d))
|
|
97
|
+
.slice(0, 4)
|
|
98
|
+
.map((x) => `${x.d > 0 ? "+" : "−"}${x.n} ×${Math.abs(x.d)}`);
|
|
99
|
+
if (toolDiff.length) out.push(`tools: ${toolDiff.join(", ")}`);
|
|
100
|
+
if (a.secs !== null && b.secs !== null && moved(a.secs, b.secs, 10)) out.push(`took ${a.secs}s → ${b.secs}s`);
|
|
101
|
+
if (moved(a.tokensIn, b.tokensIn, 5000)) out.push(`tokens in ${k(a.tokensIn)} → ${k(b.tokensIn)}`);
|
|
102
|
+
if (moved(a.modelCalls, b.modelCalls, 3)) out.push(`model calls ${a.modelCalls} → ${b.modelCalls}`);
|
|
103
|
+
if (a.said !== b.said && b.said) out.push(`said: ${b.said}`);
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Steps lined up: the flow's own steps by position where the agents agree,
|
|
108
|
+
* fan-out instances by agent and order. */
|
|
109
|
+
function align(a: StepRecord[], b: StepRecord[]): [StepRecord | null, StepRecord | null][] {
|
|
110
|
+
const pairs: [StepRecord | null, StepRecord | null][] = [];
|
|
111
|
+
const used = new Set<number>();
|
|
112
|
+
b.forEach((s, i) => {
|
|
113
|
+
let j = a[i] && a[i]!.agent === s.agent && !used.has(i) ? i : -1;
|
|
114
|
+
if (j < 0) j = a.findIndex((x, m) => !used.has(m) && x.agent === s.agent);
|
|
115
|
+
if (j >= 0) used.add(j);
|
|
116
|
+
pairs.push([j >= 0 ? a[j]! : null, s]);
|
|
117
|
+
});
|
|
118
|
+
a.forEach((s, m) => !used.has(m) && pairs.push([s, null]));
|
|
119
|
+
return pairs;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function compareRuns(base: RunRecord, run: RunRecord, changedFiles: string[] | null = null): RunComparison {
|
|
123
|
+
const head = (r: RunRecord) => ({ id: r.id, status: r.status, startedAt: r.startedAt, revision: r.revision ?? null });
|
|
124
|
+
return {
|
|
125
|
+
base: head(base),
|
|
126
|
+
run: head(run),
|
|
127
|
+
revisionChanged: (base.revision ?? null) !== (run.revision ?? null),
|
|
128
|
+
changedFiles,
|
|
129
|
+
steps: align(base.steps, run.steps).map(([a, b], i) => {
|
|
130
|
+
const sa = a ? side(a) : null;
|
|
131
|
+
const sb = b ? side(b) : null;
|
|
132
|
+
return { step: b ? run.steps.indexOf(b) + 1 : i + 1, agent: (b ?? a)!.agent, base: sa, run: sb, changes: changes(sa, sb) };
|
|
133
|
+
}),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The run to compare a run with when none is named: the newest earlier run
|
|
138
|
+
* of the same flow that finished well (completed, not BLOCKED or BAD), or
|
|
139
|
+
* null. `outcomeOf` is the caller's, so this stays free of the verdict reader. */
|
|
140
|
+
export function lastGoodBefore<R extends { id: string; flow: string; startedAt: string }>(runs: R[], run: { id: string; flow: string; startedAt: string }, good: (r: R) => boolean): R | null {
|
|
141
|
+
return (
|
|
142
|
+
runs
|
|
143
|
+
.filter((r) => r.flow === run.flow && r.id !== run.id && r.startedAt < run.startedAt && good(r))
|
|
144
|
+
.sort((a, b) => b.startedAt.localeCompare(a.startedAt))[0] ?? null
|
|
145
|
+
);
|
|
146
|
+
}
|
package/src/completions.ts
CHANGED
|
@@ -77,6 +77,7 @@ const TOOL_GROUPS: Completion[] = [
|
|
|
77
77
|
{ label: "code", hint: "Bash — runs anything in the sandbox" },
|
|
78
78
|
{ label: "web", hint: "search, fetch, browse, crawl, map, extract, answer, monitor — providers under web:" },
|
|
79
79
|
{ label: "ask", hint: "ask_person — ask the person running the desk mid-step, and wait for the answer" },
|
|
80
|
+
{ label: "peers", hint: "peers_list + peers_message — the colleagues running beside it in its parallel group, and a message to one of them" },
|
|
80
81
|
{ label: "install", hint: "install apt, pip or npm packages mid-step — every agent has it; disallowedTools: [install] removes it" },
|
|
81
82
|
];
|
|
82
83
|
|
package/src/deploy.ts
CHANGED
|
@@ -33,20 +33,26 @@ import {
|
|
|
33
33
|
assertSafeName,
|
|
34
34
|
type DeployFile,
|
|
35
35
|
notifyWorkspaceChanged,
|
|
36
|
+
accountDir,
|
|
37
|
+
frontmatterErrorText,
|
|
36
38
|
} from "./store.ts";
|
|
39
|
+
import { readAgentsMdKeys } from "./agents-md.ts";
|
|
40
|
+
import { libraryTools } from "./library.ts";
|
|
37
41
|
import { lintFlow } from "./flow-lint.ts";
|
|
38
42
|
import { refNames } from "./refs.ts";
|
|
39
|
-
import matter from "
|
|
43
|
+
import matter from "./matter.ts";
|
|
40
44
|
import { conformanceIssues } from "./okf.ts";
|
|
41
45
|
import { timezoneProblem } from "./clock.ts";
|
|
42
46
|
import { webProblems, webWarnings, runnerEngines } from "./providers.ts";
|
|
43
47
|
import { readLimits } from "./limits.ts";
|
|
48
|
+
import { parseAlerts } from "./alerts.ts";
|
|
49
|
+
import { parseTelemetry } from "./otel-config.ts";
|
|
44
50
|
import { networkProblems } from "./network.ts";
|
|
45
51
|
import { actionProblems } from "./web-actions.ts";
|
|
46
52
|
import { languageProblem } from "./language.ts";
|
|
47
53
|
import { localeProblems } from "./locale.ts";
|
|
48
54
|
import { entriesNoFollow } from "./paths.ts";
|
|
49
|
-
import { filterIgnored, findingFingerprint, ruleForWhere, scanFiles, type ScanFinding, type ScanOptions } from "./scan.ts";
|
|
55
|
+
import { filterIgnored, findingFingerprint, ruleForWhere, scanFiles, scanRule, type ScanFinding, type ScanOptions } from "./scan.ts";
|
|
50
56
|
|
|
51
57
|
/** Directories a deploy never reads out of a source tree. */
|
|
52
58
|
// `.cache` is what a run's tools leave in an agent's folder (HOME is that
|
|
@@ -121,9 +127,60 @@ export interface DeployResult extends DeployPlan {
|
|
|
121
127
|
// agree on what counts as an agent.
|
|
122
128
|
export const SUBAGENT_IMPORT_DIRS = [".agents/agents", ".claude/agents"] as const;
|
|
123
129
|
|
|
124
|
-
|
|
130
|
+
/** What agents write as they run, not what anyone authored: state/ and
|
|
131
|
+
* memory/, the workspace's or one agent's. */
|
|
132
|
+
const DATA_PATH = /^(?:agents\/[^/]+\/)?(?:state|memory)\//;
|
|
133
|
+
|
|
134
|
+
/** Bounds for a reader that only looks at the text (the checks, the
|
|
135
|
+
* assistant): on 20 Oct 2026 the daily rescan read one desk's 1.2 GB of
|
|
136
|
+
* state/ into a 2 GB heap and crash-looped the worker. A deploy sets none. */
|
|
137
|
+
export interface ReadTreeOptions {
|
|
138
|
+
/** Data (DATA_PATH) larger than this is left out, unread. */
|
|
139
|
+
maxDataFileBytes?: number;
|
|
140
|
+
/** Data past this many bytes in all, or this many files, is left out. */
|
|
141
|
+
maxDataBytes?: number;
|
|
142
|
+
maxDataFiles?: number;
|
|
143
|
+
/** Everything else past this many bytes in all: readTree throws
|
|
144
|
+
* TreeTooLarge rather than return a part of the definitions, which a
|
|
145
|
+
* check would read as missing agents and flows. */
|
|
146
|
+
maxSourceBytes?: number;
|
|
147
|
+
/** Each data file left out, and its size. */
|
|
148
|
+
onSkip?: (path: string, bytes: number) => void;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export class TreeTooLarge extends Error {
|
|
152
|
+
/** What an HTTP route answers with it. */
|
|
153
|
+
readonly status = 413;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function readTree(dir: string, opts: ReadTreeOptions = {}): DeployFile[] {
|
|
125
157
|
const root = path.resolve(dir);
|
|
126
158
|
const out: DeployFile[] = [];
|
|
159
|
+
let dataBytes = 0;
|
|
160
|
+
let dataFiles = 0;
|
|
161
|
+
let sourceBytes = 0;
|
|
162
|
+
/** May this file be read? Data over its budget is left out; source over
|
|
163
|
+
* its budget ends the read. */
|
|
164
|
+
const admit = (rel: string, bytes: number): boolean => {
|
|
165
|
+
if (DATA_PATH.test(rel)) {
|
|
166
|
+
const over =
|
|
167
|
+
(opts.maxDataFileBytes !== undefined && bytes > opts.maxDataFileBytes) ||
|
|
168
|
+
(opts.maxDataBytes !== undefined && dataBytes + bytes > opts.maxDataBytes) ||
|
|
169
|
+
(opts.maxDataFiles !== undefined && dataFiles >= opts.maxDataFiles);
|
|
170
|
+
if (over) {
|
|
171
|
+
opts.onSkip?.(rel, bytes);
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
dataBytes += bytes;
|
|
175
|
+
dataFiles++;
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
sourceBytes += bytes;
|
|
179
|
+
if (opts.maxSourceBytes !== undefined && sourceBytes > opts.maxSourceBytes) {
|
|
180
|
+
throw new TreeTooLarge(`the workspace's files other than state/ and memory/ are over ${Math.round(opts.maxSourceBytes / 1048576)} MiB — not read`);
|
|
181
|
+
}
|
|
182
|
+
return true;
|
|
183
|
+
};
|
|
127
184
|
|
|
128
185
|
const walk = (abs: string, rel: string) => {
|
|
129
186
|
let entries: string[];
|
|
@@ -148,6 +205,7 @@ export function readTree(dir: string): DeployFile[] {
|
|
|
148
205
|
if (stat.isDirectory()) {
|
|
149
206
|
walk(full, next);
|
|
150
207
|
} else if (next === "AGENTS.md" || next === "project.md" || IN_WORKSPACE_DIR.test(next)) {
|
|
208
|
+
if (!admit(next, stat.size)) continue;
|
|
151
209
|
out.push({ path: next, content: fs.readFileSync(full, "utf8") });
|
|
152
210
|
}
|
|
153
211
|
}
|
|
@@ -188,9 +246,12 @@ export function readTree(dir: string): DeployFile[] {
|
|
|
188
246
|
if (nativeAgent.has(target) || out.some((fEntry) => fEntry.path === target)) continue;
|
|
189
247
|
const full = path.join(root, importDir, entry);
|
|
190
248
|
try {
|
|
191
|
-
|
|
249
|
+
const st = fs.lstatSync(full);
|
|
250
|
+
if (st.isSymbolicLink()) continue;
|
|
251
|
+
if (!admit(target, st.size)) continue;
|
|
192
252
|
out.push({ path: target, content: fs.readFileSync(full, "utf8") });
|
|
193
|
-
} catch {
|
|
253
|
+
} catch (err) {
|
|
254
|
+
if (err instanceof TreeTooLarge) throw err;
|
|
194
255
|
// an unreadable import is skipped, not fatal
|
|
195
256
|
}
|
|
196
257
|
}
|
|
@@ -238,6 +299,90 @@ export function unopenedFrontmatter(raw: string): string | null {
|
|
|
238
299
|
* advice (an agent that could leak, an unpinned package) is for `foldrun
|
|
239
300
|
* check` and the Checks page, not for every deploy's output.
|
|
240
301
|
*/
|
|
302
|
+
/**
|
|
303
|
+
* What `deploy_block:` in AGENTS.md refuses (FOL-58). `none`, the default:
|
|
304
|
+
* the scanner's findings are warnings and the deploy goes ahead, as it always
|
|
305
|
+
* did. `secrets`: a key or password written into a file is refused.
|
|
306
|
+
* `security`: every security finding is — secrets, hidden or injected text,
|
|
307
|
+
* the lethal trifecta, pipe-to-shell, an unpinned MCP server, a step that
|
|
308
|
+
* acts outside with no check or approval. A
|
|
309
|
+
* `foldrun-ignore` with a reason still accepts one.
|
|
310
|
+
*/
|
|
311
|
+
export const DEPLOY_BLOCK_MODES = ["none", "secrets", "security"] as const;
|
|
312
|
+
export type DeployBlockMode = (typeof DEPLOY_BLOCK_MODES)[number];
|
|
313
|
+
|
|
314
|
+
const blockMode = (v: unknown): DeployBlockMode | null =>
|
|
315
|
+
typeof v === "string" && (DEPLOY_BLOCK_MODES as readonly string[]).includes(v.trim()) ? (v.trim() as DeployBlockMode) : null;
|
|
316
|
+
|
|
317
|
+
/** The workspace's own AGENTS.md, else the account's, else none. */
|
|
318
|
+
export function deployBlockMode(files: DeployFile[], accountKeys: Record<string, unknown>): DeployBlockMode {
|
|
319
|
+
// project.md is the older name readAgentsMdKeys still reads (Codex review).
|
|
320
|
+
const own = files.find((f) => f.path === "AGENTS.md") ?? files.find((f) => f.path === "project.md");
|
|
321
|
+
let fromWorkspace: DeployBlockMode | null = null;
|
|
322
|
+
if (own) {
|
|
323
|
+
try {
|
|
324
|
+
fromWorkspace = blockMode((matter(own.content).data as Record<string, unknown>).deploy_block);
|
|
325
|
+
} catch {
|
|
326
|
+
// an AGENTS.md that does not parse is reported elsewhere
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return fromWorkspace ?? blockMode(accountKeys.deploy_block) ?? "none";
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function blockersFor(tenant: string, given: DeployFile[]): DeployIssue[] {
|
|
333
|
+
// The tree as it will be written: `./AGENTS.md` is saved as AGENTS.md, so
|
|
334
|
+
// it is read as that here too (Codex review).
|
|
335
|
+
// Two entries for one path: the save keeps the last, so this does too.
|
|
336
|
+
const byPath = new Map<string, DeployFile>();
|
|
337
|
+
for (const f of given) {
|
|
338
|
+
// path.normalize, as saveWorkspace writes it (a backslash is a filename
|
|
339
|
+
// character here, not a separator — Codex review).
|
|
340
|
+
const p = path.normalize(f.path);
|
|
341
|
+
byPath.set(p, { ...f, path: p });
|
|
342
|
+
}
|
|
343
|
+
const files = [...byPath.values()];
|
|
344
|
+
const account = readAgentsMdKeys(accountDir(tenant));
|
|
345
|
+
const mode = deployBlockMode(files, account);
|
|
346
|
+
if (mode === "none") return [];
|
|
347
|
+
// Nearest wins: a workspace tool of the same name replaces the library's,
|
|
348
|
+
// and the scanner adds the workspace's own outward tools itself. Named as
|
|
349
|
+
// the runtime names them — the definition's name:, else the file's.
|
|
350
|
+
const ownTools = new Set<string>();
|
|
351
|
+
for (const f of files) {
|
|
352
|
+
const m = /^tools\/([^/]+?)(?:\/tool)?\.md$/.exec(f.path);
|
|
353
|
+
if (!m) continue;
|
|
354
|
+
let data: Record<string, unknown> = {};
|
|
355
|
+
try {
|
|
356
|
+
data = matter(f.content).data as Record<string, unknown>;
|
|
357
|
+
} catch {
|
|
358
|
+
continue; // unreadable: the runtime skips it and keeps the library's
|
|
359
|
+
}
|
|
360
|
+
// Only a definition the runtime accepts replaces the library's; an
|
|
361
|
+
// invalid one is skipped there, and the library tool stays (Codex review).
|
|
362
|
+
const def = parseToolDef(data, m[1], f.content);
|
|
363
|
+
if (def) ownTools.add(def.name ?? m[1]);
|
|
364
|
+
}
|
|
365
|
+
const outwardTools = Object.entries(libraryTools(tenant))
|
|
366
|
+
.filter(([n, d]) => (d as { outward?: boolean }).outward === true && !ownTools.has(n))
|
|
367
|
+
.map(([n]) => n);
|
|
368
|
+
return deployBlockers(files, mode, { accountNetwork: account.network, outwardTools });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** The findings `mode` turns into refusals, as deploy issues. */
|
|
372
|
+
export function deployBlockers(files: DeployFile[], mode: DeployBlockMode, options: ScanOptions = {}): DeployIssue[] {
|
|
373
|
+
if (mode === "none") return [];
|
|
374
|
+
// Every finding checkFiles reports — the scanner's and the structural
|
|
375
|
+
// ones, flow/outward-ungated among them (Codex review) — with the same
|
|
376
|
+
// ignores honoured, kept by its rule's category.
|
|
377
|
+
const categories = new Set(mode === "secrets" ? ["secret"] : ["secret", "content", "security", "supply-chain"]);
|
|
378
|
+
return checkFiles(files, options)
|
|
379
|
+
.filter((f) => f.level !== "info" && categories.has(scanRule(f.rule)?.category ?? ""))
|
|
380
|
+
.map((f) => ({
|
|
381
|
+
where: `${f.file}${f.line ? `:${f.line}` : ""}`,
|
|
382
|
+
message: `${f.message} [${f.rule}] — refused: deploy_block: ${mode} in AGENTS.md`,
|
|
383
|
+
}));
|
|
384
|
+
}
|
|
385
|
+
|
|
241
386
|
export function deployWarnings(files: DeployFile[]): DeployIssue[] {
|
|
242
387
|
const scanned = scanFiles(files, { only: ["secret", "content"] })
|
|
243
388
|
.filter((f) => f.level !== "info")
|
|
@@ -261,19 +406,20 @@ export function checkFiles(files: DeployFile[], options: ScanOptions = {}): Scan
|
|
|
261
406
|
};
|
|
262
407
|
const own = [
|
|
263
408
|
...deployIssues(files).map((i) => finding(i, "error")),
|
|
264
|
-
...structuralWarnings(files).map((i) => finding(i, "warn", i.kind)),
|
|
409
|
+
...structuralWarnings(files, options.outwardTools).map((i) => finding(i, "warn", i.kind)),
|
|
265
410
|
];
|
|
266
411
|
return [...filterIgnored(own, files), ...scanFiles(files, options)].sort(
|
|
267
412
|
(a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0) || a.rule.localeCompare(b.rule),
|
|
268
413
|
);
|
|
269
414
|
}
|
|
270
415
|
|
|
271
|
-
function structuralWarnings(files: DeployFile[]): (DeployIssue & { kind?: string })[] {
|
|
416
|
+
function structuralWarnings(files: DeployFile[], extraOutward: Iterable<string> = []): (DeployIssue & { kind?: string })[] {
|
|
272
417
|
const front = (content: string): Record<string, unknown> => {
|
|
273
418
|
try { return matter(content).data as Record<string, unknown>; } catch { return {}; }
|
|
274
419
|
};
|
|
275
|
-
//
|
|
276
|
-
|
|
420
|
+
// Tools that change something outside the workspace: the workspace's own,
|
|
421
|
+
// and the library's the caller resolved (nearest wins already applied).
|
|
422
|
+
const outwardTools = new Set<string>(extraOutward);
|
|
277
423
|
for (const f of files) {
|
|
278
424
|
const m = f.path.match(/^tools\/([^/]+?)(?:\/tool)?\.md$/);
|
|
279
425
|
if (!m) continue;
|
|
@@ -282,6 +428,8 @@ function structuralWarnings(files: DeployFile[]): (DeployIssue & { kind?: string
|
|
|
282
428
|
// `outward: true` is read off the frontmatter, the same test the runtime
|
|
283
429
|
// and the CLI use; parseToolDef does not fold it onto the def.
|
|
284
430
|
if (def && data.outward === true) outwardTools.add(def.name ?? m[1]);
|
|
431
|
+
// A workspace tool that is not outward replaces a library one that was.
|
|
432
|
+
else if (def) outwardTools.delete(def.name ?? m[1]);
|
|
285
433
|
}
|
|
286
434
|
// Agents that grant one of them.
|
|
287
435
|
const outwardAgents: string[] = [];
|
|
@@ -342,6 +490,24 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
|
|
|
342
490
|
if (m) agents.add(m[1]);
|
|
343
491
|
}
|
|
344
492
|
|
|
493
|
+
// Frontmatter whose YAML does not parse is a file the runtime cannot read:
|
|
494
|
+
// an agent with no tools and the default model, a flow that never fires,
|
|
495
|
+
// a tool that is skipped. Refused where it was written. Until 20 Oct 2026
|
|
496
|
+
// nothing said so — gray-matter's cache let a second parse pass — and one
|
|
497
|
+
// such agent file kept the worker from booting for every account.
|
|
498
|
+
// Definition files only: a skill is read leniently (an unquoted colon is
|
|
499
|
+
// quoted and retried, runner.ts safeMatter), and the markdown beside a
|
|
500
|
+
// definition — references, a tool's README — is prose, not frontmatter.
|
|
501
|
+
for (const f of files) {
|
|
502
|
+
if (!/^(AGENTS\.md|agents\/[^/]+\/agent\.md|flows\/[^/]+\.md|tools\/[^/]+\.md|tools\/[^/]+\/tool\.md|evals\/[^/]+\.md|hooks\/[^/]+\/hook\.md|\.claude\/agents\/[^/]+\.md)$/.test(f.path)) continue;
|
|
503
|
+
if (!/^\uFEFF?---\r?\n/.test(f.content)) continue;
|
|
504
|
+
try {
|
|
505
|
+
matter(f.content);
|
|
506
|
+
} catch (err) {
|
|
507
|
+
at(f.path, frontmatterErrorText(err));
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
345
511
|
// `timezone:` is read at four levels and a value nothing can parse falls
|
|
346
512
|
// silently through to the level above — so it is refused where it was
|
|
347
513
|
// written, not discovered as a date that is a day out.
|
|
@@ -370,6 +536,11 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
|
|
|
370
536
|
// `network:` that cannot be read would leave the step on the level
|
|
371
537
|
// above it — wider or narrower than the author wrote.
|
|
372
538
|
for (const w of networkProblems(front.network)) at(f.path, w);
|
|
539
|
+
// `alerts:` that cannot be read would never alert (FOL-72).
|
|
540
|
+
for (const w of parseAlerts(front.alerts).problems) at(f.path, w);
|
|
541
|
+
// `telemetry:` that cannot be read would never send — and a token
|
|
542
|
+
// written into it would ship in the file.
|
|
543
|
+
for (const w of parseTelemetry(front.telemetry).problems) at(f.path, w);
|
|
373
544
|
const lang = languageProblem(front.language);
|
|
374
545
|
if (lang) at(f.path, lang);
|
|
375
546
|
for (const w of localeProblems(front)) at(f.path, w);
|
|
@@ -509,7 +680,10 @@ export function planDeploy(tenant: string, workspace: string, files: DeployFile[
|
|
|
509
680
|
added: added.sort(),
|
|
510
681
|
updated: updated.sort(),
|
|
511
682
|
removed: removed.sort(),
|
|
512
|
-
|
|
683
|
+
// deploy_block: (FOL-58) turns the scanner's findings into refusals —
|
|
684
|
+
// scanned with what the Checks page passes too: the account's network:
|
|
685
|
+
// and the library's outward tools (Codex review).
|
|
686
|
+
issues: [...deployIssues(files), ...blockersFor(tenant, files)],
|
|
513
687
|
warnings: deployWarnings(files),
|
|
514
688
|
blockedBy: runsInFlight(tenant, workspace),
|
|
515
689
|
};
|
package/src/egress.ts
CHANGED
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
// platform's (it needs a long-lived process and a Service); the pod side is
|
|
44
44
|
// here in core, because it is what the runner image runs.
|
|
45
45
|
|
|
46
|
+
import type { PeerInfo } from "./operator.ts";
|
|
46
47
|
import { trimSlashes } from "./paths.ts";
|
|
47
48
|
import type { TestEffect } from "./test-mode.ts";
|
|
48
49
|
import type { NetworkLevel } from "./network.ts";
|
|
@@ -150,13 +151,22 @@ export type EgressLogLine = string | { text: string; effect: TestEffect };
|
|
|
150
151
|
* means "no proxy here" and the runner falls back to materialising, exactly
|
|
151
152
|
* as it always did.
|
|
152
153
|
*/
|
|
154
|
+
|
|
153
155
|
export interface EgressHooks {
|
|
154
156
|
/** `test` — the run is a test run: the proxy applies the outward-write
|
|
155
157
|
* policy in test-mode.ts to every request on this lease. Carried on the
|
|
156
158
|
* lease, not the request, because the pod is the party not trusted to
|
|
157
159
|
* say so. */
|
|
158
|
-
/** `step` — the step's index in run.steps; keys that step's operator inbox.
|
|
159
|
-
|
|
160
|
+
/** `step` — the step's index in run.steps; keys that step's operator inbox.
|
|
161
|
+
* `agent` and `peers` — this step's agent and the other steps of its
|
|
162
|
+
* parallel group (tools: [peers]): the proxy serves the roster and
|
|
163
|
+
* addresses a colleague's message to the right inbox. */
|
|
164
|
+
lease(args: { tenant: string; runId: string; grant: EgressGrant; test?: boolean; step?: number; agent?: string; peers?: PeerInfo[] }): Promise<EgressLease | null>;
|
|
165
|
+
/** The step is over — every attempt, however it ended (tools: [peers]).
|
|
166
|
+
* A lease comes and goes per attempt, so it cannot say this: a sibling
|
|
167
|
+
* between retries, or waiting for a slot, holds none and is not done.
|
|
168
|
+
* Optional: a platform without it keeps every sibling reachable. */
|
|
169
|
+
stepEnded?(args: { tenant: string; runId: string; step: number }): Promise<void>;
|
|
160
170
|
}
|
|
161
171
|
|
|
162
172
|
/** Strip a header set of the proxy's own and hop-by-hop headers. */
|
package/src/evals.ts
CHANGED
|
@@ -38,12 +38,13 @@ import fs from "node:fs";
|
|
|
38
38
|
import path from "node:path";
|
|
39
39
|
import { deployedCommit } from "./deploy.ts";
|
|
40
40
|
import { latestRevisionId } from "./history.ts";
|
|
41
|
-
import matter from "
|
|
41
|
+
import matter from "./matter.ts";
|
|
42
42
|
import { spawn } from "node:child_process";
|
|
43
43
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
44
44
|
import {
|
|
45
45
|
workspaceDir,
|
|
46
46
|
readRun,
|
|
47
|
+
runVerdict,
|
|
47
48
|
writeWorkspaceFile,
|
|
48
49
|
resolveModel,
|
|
49
50
|
resolveEffort,
|
|
@@ -266,16 +267,26 @@ export function promoteRunToEval(
|
|
|
266
267
|
tenant: string,
|
|
267
268
|
workspace: string,
|
|
268
269
|
runId: string,
|
|
269
|
-
|
|
270
|
+
/** `should`: what a run that went wrong should have done, in a sentence —
|
|
271
|
+
* the judge's claim for the case (FOL-72 step 10). */
|
|
272
|
+
opts: { evalName?: string; caseName?: string; expect?: string[]; should?: string; by?: string } = {},
|
|
270
273
|
): { file: string; created: boolean; caseName: string } {
|
|
271
274
|
const run = readRun(tenant, workspace, runId);
|
|
272
275
|
if (!run) throw refuse(`run ${runId} not found`, 404);
|
|
273
|
-
if (run.status !== "completed") {
|
|
274
|
-
throw refuse(`only a
|
|
276
|
+
if (run.status !== "completed" && run.status !== "failed") {
|
|
277
|
+
throw refuse(`only a finished run can become an eval — this one is ${run.status}`, 409);
|
|
278
|
+
}
|
|
279
|
+
// A run that went wrong is the test worth having most — but its own answer
|
|
280
|
+
// is the wrong one to hold the next run to, so it needs to be told what
|
|
281
|
+
// was right (FOL-72 step 10).
|
|
282
|
+
const should = oneLine(opts.should ?? "");
|
|
283
|
+
const wentWrong = run.status === "failed" || ["BAD", "BLOCKED"].includes(runVerdict(run) ?? "");
|
|
284
|
+
if (wentWrong && !should && !(opts.expect && opts.expect.length)) {
|
|
285
|
+
throw refuse("this run went wrong, so its answer is not the one to keep — say what it should have done (`should`, a sentence for the judge) or give `expect` lines", 409);
|
|
275
286
|
}
|
|
276
287
|
const produced = run.steps.filter((s) => (s.result ?? "").trim());
|
|
277
288
|
const last = produced[produced.length - 1];
|
|
278
|
-
if (!last) throw refuse("this run produced no result to hold the next one to", 409);
|
|
289
|
+
if (!last && !should && !(opts.expect && opts.expect.length)) throw refuse("this run produced no result to hold the next one to", 409);
|
|
279
290
|
if (run.steps.length === 0 || !run.steps[0].instruction?.trim()) {
|
|
280
291
|
throw refuse("this run has no task on record", 409);
|
|
281
292
|
}
|
|
@@ -310,8 +321,10 @@ export function promoteRunToEval(
|
|
|
310
321
|
}
|
|
311
322
|
return `${m[1]}: ${m[2]}`;
|
|
312
323
|
});
|
|
324
|
+
} else if (should) {
|
|
325
|
+
expect = [`judge: ${should.slice(0, 400)}`];
|
|
313
326
|
} else {
|
|
314
|
-
const conclusion = oneLine(run.summary?.trim() || (last
|
|
327
|
+
const conclusion = oneLine(run.summary?.trim() || (last!.result ?? "")).slice(0, 200);
|
|
315
328
|
expect = [`judge: reaches the same conclusion as the recorded run: ${conclusion}`];
|
|
316
329
|
}
|
|
317
330
|
|
|
@@ -50,6 +50,7 @@ const LOCALE = { validated: true } as const;
|
|
|
50
50
|
const AGENT: FieldSpec[] = [
|
|
51
51
|
{ key: "name", type: "string", help: "kebab-case; the folder is the identity" },
|
|
52
52
|
{ key: "description", type: "string", help: "what it does and when — other agents and people read it" },
|
|
53
|
+
{ key: "owner", type: "string", help: "the person accountable for it — an email or a name; else the workspace's, else the account's" },
|
|
53
54
|
{ key: "model", type: "string", help: "fast | default | max, an alias, or a full model id" },
|
|
54
55
|
{ key: "effort", type: "string", values: EFFORT_LEVELS, help: "how long it thinks" },
|
|
55
56
|
{ key: "size", type: "string", values: ["small", "large", "heavy"], help: "the sandbox it rents; anything else becomes large" },
|
|
@@ -86,6 +87,7 @@ const AGENTS_MD: FieldSpec[] = [
|
|
|
86
87
|
{ key: "mdagent_version", type: ["string", "number"], legacy: true, help: "older spelling of foldrun_version" },
|
|
87
88
|
{ key: "name", type: "string", help: "the workspace's name" },
|
|
88
89
|
{ key: "description", type: "string", help: "shown in the workspace list" },
|
|
90
|
+
{ key: "owner", type: "string", help: "the person accountable for every agent and flow here that names none" },
|
|
89
91
|
{ key: "provider", type: "object", help: "the model endpoint every agent here uses unless it names its own" },
|
|
90
92
|
{ key: "runtime", type: "object", help: "packages for agents that declare none" },
|
|
91
93
|
{ key: "web", type: "object", validated: true, help: "web.browse defaults" },
|
|
@@ -102,18 +104,25 @@ const AGENTS_MD: FieldSpec[] = [
|
|
|
102
104
|
{ key: "budget", type: ["number", "string"], help: "USD per month, or N/day | N/week | N/month" },
|
|
103
105
|
{ key: "priority", type: "string", values: ["high", "normal", "low"], help: "account: where its runs stand in the queue" },
|
|
104
106
|
{ key: "concurrency", type: ["number", "string"], help: "account: runs at once" },
|
|
107
|
+
{ key: "alerts", type: "object", validated: true, help: "thresholds that tell notify: — failure_rate (30%), min_runs, credits_day, run_minutes, stale_days, cost_spike, runs_per_hour, new_host" },
|
|
108
|
+
{ key: "capture", type: "string", values: ["full", "metadata"], help: "what runs record of each tool call: full (its input and output, secrets masked) or metadata (name, size and time only)" },
|
|
109
|
+
{ key: "network_enforce", type: "boolean", help: "true: a site off a step's network: list is refused, not only named in the trace. The account's or the workspace's true is enough" },
|
|
110
|
+
{ key: "telemetry", type: "object", validated: true, help: "send each run as an OpenTelemetry trace — otlp: <https …/v1/traces>, headers: { authorization: ${SECRET} }" },
|
|
111
|
+
{ key: "deploy_block", type: "string", values: ["none", "secrets", "security"], help: "refuse a deploy on scanner findings: secrets (a key written in) or security (any security warning)" },
|
|
105
112
|
{ key: "schedule", type: ["string", "object"], validated: true, legacy: true, help: "refused: only a flow has a clock" },
|
|
106
113
|
];
|
|
107
114
|
|
|
108
115
|
const FLOW: FieldSpec[] = [
|
|
109
116
|
{ key: "name", type: "string", help: "the flow's name" },
|
|
110
117
|
{ key: "description", type: "string", help: "what it is for" },
|
|
118
|
+
{ key: "owner", type: "string", help: "the person accountable for it — an email or a name; else the workspace's, else the account's" },
|
|
111
119
|
{ key: "trigger", type: "string", values: FLOW_TRIGGERS, help: "what starts it; any other value never fires" },
|
|
112
120
|
{ key: "schedule", type: "string", help: "cron, when trigger: schedule" },
|
|
113
121
|
{ key: "timezone", type: "string", validated: true, help: "the zone its schedule reads in" },
|
|
114
122
|
{ key: "model", type: "string", help: "every step's model unless the step says" },
|
|
115
123
|
{ key: "effort", type: "string", values: EFFORT_LEVELS, help: "every step's effort unless the step says" },
|
|
116
124
|
{ key: "overlap", type: "string", validated: true, help: "skip | queue" },
|
|
125
|
+
{ key: "issues", type: "string", values: ["stay-open", "auto-close"], help: "what a good run does to this flow's open issues: stay-open (the default — a person resolves them) or auto-close" },
|
|
117
126
|
{ key: "priority", type: "string", validated: true, help: "high | normal | low" },
|
|
118
127
|
{ key: "catchup", type: "string", validated: true, help: "last | none" },
|
|
119
128
|
{ key: "on", type: "string", validated: true, help: "trigger: flow — completed | failed | blocked | any" },
|
|
@@ -6,6 +6,7 @@ import { FRONTMATTER, type FieldType, type SchemaKind } from "./frontmatter-fiel
|
|
|
6
6
|
import { resolveEffort, parseDuration, parseInstant, parseRate, STEP_OPTION_KEYS } from "./store.ts";
|
|
7
7
|
import { budgetProblem } from "./budget.ts";
|
|
8
8
|
import { cronProblem } from "./cron.ts";
|
|
9
|
+
import { modelSecretCollision } from "./model-credential.ts";
|
|
9
10
|
|
|
10
11
|
export { FRONTMATTER, schemaKindOf, type FieldSpec, type FieldType, type SchemaKind } from "./frontmatter-fields.ts";
|
|
11
12
|
|
|
@@ -31,6 +32,7 @@ const CHECKS: Partial<Record<SchemaKind, Record<string, (value: unknown) => stri
|
|
|
31
32
|
apis: apisProblem,
|
|
32
33
|
budget: (v) => budgetProblem(v, ["run"]),
|
|
33
34
|
ask: (v) => askProblem(v),
|
|
35
|
+
secrets: modelSecretCollision,
|
|
34
36
|
},
|
|
35
37
|
"agents-md": {
|
|
36
38
|
budget: (v) => budgetProblem(v, ["day", "week", "month"]),
|
|
@@ -54,6 +56,7 @@ const CHECKS: Partial<Record<SchemaKind, Record<string, (value: unknown) => stri
|
|
|
54
56
|
transport: (v) => (["http", "script", "mcp"].includes(String(v).toLowerCase()) ? null : `"${String(v)}" is not a transport — http, script or mcp`),
|
|
55
57
|
methods: methodsProblem,
|
|
56
58
|
rate: (v) => (parseRate(v) ? null : `"${shown(v)}" is not a rate — N/s, N/minute, N/hour, or a number per second, above 0`),
|
|
59
|
+
secrets: modelSecretCollision,
|
|
57
60
|
},
|
|
58
61
|
"eval": {
|
|
59
62
|
effort: (v) => (resolveEffort(v) ? null : `"${String(v)}" is not an effort — low, medium, high, xhigh or max; the judge runs at none`),
|
package/src/history.ts
CHANGED
|
@@ -402,6 +402,19 @@ export function readRevision(tenant: string, scope: string, id: string, forPath?
|
|
|
402
402
|
return forPath ? { ...rev, files: rev.files.filter((f) => f.path === forPath) } : rev;
|
|
403
403
|
}
|
|
404
404
|
|
|
405
|
+
/** The files changed after revision `from` up to and including `to`, in a
|
|
406
|
+
* scope — what differs between the versions two runs ran (FOL-72). Null
|
|
407
|
+
* when either is unknown or `to` is not after `from`. */
|
|
408
|
+
export function changedPathsBetween(tenant: string, scope: string, from: string | null, to: string | null): string[] | null {
|
|
409
|
+
if (!from || !to) return null;
|
|
410
|
+
if (from === to) return [];
|
|
411
|
+
const revs = listRevisions(tenant, scope, { limit: 500 }); // newest first
|
|
412
|
+
const ti = revs.findIndex((r) => r.id === to || r.commit === to);
|
|
413
|
+
const fi = revs.findIndex((r) => r.id === from || r.commit === from);
|
|
414
|
+
if (ti < 0 || fi < 0 || ti > fi) return null;
|
|
415
|
+
return [...new Set(revs.slice(ti, fi).flatMap((r) => r.paths))].sort();
|
|
416
|
+
}
|
|
417
|
+
|
|
405
418
|
/** The most recent revision id for a scope — what a change is attributed
|
|
406
419
|
* to when nothing more specific (a deployed commit) is on record. */
|
|
407
420
|
export function latestRevisionId(tenant: string, scope: string): string | null {
|
package/src/hooks.ts
CHANGED
|
@@ -31,7 +31,7 @@ import fs from "node:fs";
|
|
|
31
31
|
import path from "node:path";
|
|
32
32
|
import os from "node:os";
|
|
33
33
|
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
34
|
-
import matter from "
|
|
34
|
+
import matter from "./matter.ts";
|
|
35
35
|
|
|
36
36
|
export const HOOK_EVENTS = ["tool.call", "tool.result", "step.start", "step.end"] as const;
|
|
37
37
|
export type HookEvent = (typeof HOOK_EVENTS)[number];
|