@foldrun/core 2.4.0 → 2.6.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 +2 -2
- package/dist/src/agent-tools.js +2 -2
- package/dist/src/agents-md.d.ts +5 -1
- package/dist/src/agents-md.js +42 -18
- 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 +26 -3
- package/dist/src/deploy.d.ts +36 -1
- package/dist/src/deploy.js +237 -13
- package/dist/src/egress.d.ts +16 -1
- package/dist/src/evals.d.ts +27 -2
- package/dist/src/evals.js +62 -19
- package/dist/src/flow-lint.d.ts +14 -0
- package/dist/src/flow-lint.js +35 -0
- package/dist/src/flow-patterns.js +29 -14
- package/dist/src/frontmatter-fields.d.ts +27 -0
- package/dist/src/frontmatter-fields.js +60 -3
- package/dist/src/frontmatter-guard.d.ts +19 -0
- package/dist/src/frontmatter-guard.js +67 -0
- package/dist/src/frontmatter-schema.d.ts +2 -2
- package/dist/src/frontmatter-schema.js +33 -2
- package/dist/src/frontmatter-text.d.ts +31 -0
- package/dist/src/frontmatter-text.js +102 -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/judge-verdict.d.ts +19 -0
- package/dist/src/judge-verdict.js +54 -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 +42 -0
- package/dist/src/matter.js +132 -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 -1
- package/dist/src/network.js +35 -8
- 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 +9 -2
- 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 +2 -2
- package/dist/src/providers.d.ts +5 -0
- package/dist/src/providers.js +2 -2
- package/dist/src/run-container.d.ts +25 -2
- package/dist/src/run-container.js +61 -6
- package/dist/src/runner.d.ts +53 -2
- package/dist/src/runner.js +471 -102
- package/dist/src/runs-filter.d.ts +48 -0
- package/dist/src/runs-filter.js +73 -0
- package/dist/src/scan.d.ts +30 -1
- package/dist/src/scan.js +145 -14
- package/dist/src/starter.js +27 -2
- package/dist/src/step-exec.d.ts +39 -3
- package/dist/src/step-exec.js +118 -15
- package/dist/src/store.d.ts +144 -3
- package/dist/src/store.js +150 -7
- 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 +2 -2
- package/src/agent-tools.ts +2 -2
- package/src/agents-md.ts +39 -14
- 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 +27 -3
- package/src/deploy.ts +238 -12
- package/src/egress.ts +12 -2
- package/src/evals.ts +64 -19
- package/src/flow-lint.ts +36 -0
- package/src/flow-patterns.ts +26 -13
- package/src/frontmatter-fields.ts +83 -3
- package/src/frontmatter-guard.ts +70 -0
- package/src/frontmatter-schema.ts +33 -4
- package/src/frontmatter-text.ts +109 -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/judge-verdict.ts +52 -0
- package/src/library.ts +1 -1
- package/src/limits.ts +38 -5
- package/src/linkable.ts +1 -1
- package/src/matter.ts +145 -0
- package/src/model-credential.ts +45 -0
- package/src/network.ts +35 -7
- 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 +8 -2
- package/src/platform.ts +1 -1
- package/src/prompt-layers.ts +90 -0
- package/src/provider-health.ts +2 -2
- package/src/providers.ts +7 -2
- package/src/run-container.ts +77 -8
- package/src/runner.ts +477 -79
- package/src/runs-filter.ts +88 -0
- package/src/scan.ts +153 -14
- package/src/starter.ts +27 -2
- package/src/step-exec.ts +168 -17
- package/src/store.ts +268 -9
- 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
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// Issues: what went wrong, kept open until a person closes it (FOL-54 step 3).
|
|
2
|
+
//
|
|
3
|
+
// The overview used to list "needs attention" from each flow's newest run in
|
|
4
|
+
// the last day, so a later success hid a failure and anything older than a
|
|
5
|
+
// day vanished on its own. An issue is the same thing going wrong again —
|
|
6
|
+
// one desk's flow failing at one agent, or ending BLOCKED or BAD, or one
|
|
7
|
+
// platform cause across every desk — with when it was first and last seen,
|
|
8
|
+
// how many times, which runs, and what to do. It stays open until someone
|
|
9
|
+
// resolves it; a later good run is shown ("fine since …") but closes nothing.
|
|
10
|
+
// (Closing on its own is opt-in, per flow — step 4.)
|
|
11
|
+
//
|
|
12
|
+
// Issues are derived from the runs; only what people did to them is stored
|
|
13
|
+
// (IssueState: acknowledged, snoozed, resolved, reopened, with a history).
|
|
14
|
+
// A run after the issue was resolved opens it again. Pure: the browser, the
|
|
15
|
+
// API and the CLI read the same function.
|
|
16
|
+
/** The model refusing the credential, as the runner sees it (runner.ts
|
|
17
|
+
* isAuthRefusal reads this one pattern, so a refusal the runner retries on
|
|
18
|
+
* is the same refusal an issue groups by — Codex review). */
|
|
19
|
+
export const AUTH_REFUSAL = /API Error: 401|access token has (been revoked|expired)|OAuth token (has )?(been )?(revoked|expired)|Failed to authenticate/i;
|
|
20
|
+
// On 2026-09-30 every browser step on every desk failed "login-checker: pod
|
|
21
|
+
// never became ready" — the nightly prune had deleted the runner image — and
|
|
22
|
+
// the overview listed it as a dozen separate desk failures, each looking like
|
|
23
|
+
// that desk's login-checker was broken; on 2026-09-29 the worker could not
|
|
24
|
+
// reach the Kubernetes API for eight minutes ("pod create failed … dial tcp
|
|
25
|
+
// 10.43.0.1:443: i/o timeout"). These are the failures no edit to a
|
|
26
|
+
// flow can cause or fix; they are grouped into one item that says so.
|
|
27
|
+
const PLATFORM_CAUSES = [
|
|
28
|
+
{
|
|
29
|
+
key: "sandbox",
|
|
30
|
+
label: "Run sandboxes cannot start",
|
|
31
|
+
fix: "the runner image or the node — not the desks. Rerun once it is fixed.",
|
|
32
|
+
test: /pod never became ready|pod create failed|ImagePullBackOff|ErrImagePull|failed to pull image|sandbox (never started|failed to start)/i,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
key: "model-auth",
|
|
36
|
+
label: "The model credential is refused",
|
|
37
|
+
fix: "the Claude sign-in expired or was revoked; sign in again, then rerun.",
|
|
38
|
+
test: new RegExp(`${AUTH_REFUSAL.source}|refresh token expired|authentication_error|invalid x-api-key|(claude|anthropic|model)[^\\n]{0,40}\\b401\\b|\\b401\\b[^\\n]{0,40}(claude|anthropic|model)`, "i"),
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
key: "model-cap",
|
|
42
|
+
label: "The model usage cap is reached",
|
|
43
|
+
fix: "runs resume when the cap resets; nothing to change in the desks.",
|
|
44
|
+
test: /weekly (usage )?limit|usage limit (reached|exceeded)|reached your (weekly|usage) limit/i,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
// The provider out of capacity is not the account's cap: it passes in
|
|
48
|
+
// minutes, not at a reset (Codex review).
|
|
49
|
+
key: "model-busy",
|
|
50
|
+
label: "The model provider is overloaded",
|
|
51
|
+
fix: "the provider's capacity, not the desks; rerun in a few minutes.",
|
|
52
|
+
test: /overloaded_error|\b529\b[^\n]{0,40}overloaded|overloaded[^\n]{0,40}\b529\b/i,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
key: "worker",
|
|
56
|
+
label: "The platform lost the run",
|
|
57
|
+
fix: "a worker restarted mid-run; rerun it.",
|
|
58
|
+
test: /worker (restarted|was restarted|died|lost)|run was orphaned|lost its worker/i,
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
/** Whether a failure reason is the platform's, and which kind. */
|
|
62
|
+
export function platformCause(reason) {
|
|
63
|
+
if (!reason)
|
|
64
|
+
return null;
|
|
65
|
+
const hit = PLATFORM_CAUSES.find((c) => c.test.test(reason));
|
|
66
|
+
return hit ? { key: hit.key, label: hit.label, fix: hit.fix } : null;
|
|
67
|
+
}
|
|
68
|
+
/** A short stable id from the issue's key (FNV-1a, 64 bits as two halves). */
|
|
69
|
+
function idOf(key) {
|
|
70
|
+
let a = 0x811c9dc5, b = 0x01000193 ^ key.length;
|
|
71
|
+
for (let i = 0; i < key.length; i++) {
|
|
72
|
+
const c = key.charCodeAt(i);
|
|
73
|
+
a = Math.imul(a ^ c, 0x01000193) >>> 0;
|
|
74
|
+
b = Math.imul(b ^ c, 0x5bd1e995) >>> 0;
|
|
75
|
+
}
|
|
76
|
+
return `iss_${a.toString(36)}${b.toString(36)}`;
|
|
77
|
+
}
|
|
78
|
+
/** Which issue a run belongs to, or null when it is not a problem. */
|
|
79
|
+
export function issueKeyOf(r) {
|
|
80
|
+
if (r.missed)
|
|
81
|
+
return { id: idOf(`missed\u0000${r.workspace}\u0000${r.what}`), kind: "missed", agent: null, cause: null };
|
|
82
|
+
if (r.outcome === "failed") {
|
|
83
|
+
const cause = platformCause(r.failure?.reason);
|
|
84
|
+
if (cause)
|
|
85
|
+
return { id: idOf(`platform\u0000${cause.key}`), kind: "platform", agent: null, cause };
|
|
86
|
+
const agent = r.failure?.agent ?? null;
|
|
87
|
+
return { id: idOf(`failed\u0000${r.workspace}\u0000${r.what}\u0000${agent ?? ""}`), kind: "failed", agent, cause: null };
|
|
88
|
+
}
|
|
89
|
+
if (r.outcome === "blocked" || r.outcome === "bad") {
|
|
90
|
+
return { id: idOf(`${r.outcome}\u0000${r.workspace}\u0000${r.what}`), kind: r.outcome, agent: null, cause: null };
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
const ACTION = {
|
|
95
|
+
failed: "Read why it failed, fix the flow or agent, then rerun",
|
|
96
|
+
blocked: "Clear what it is waiting on (a login, a key, an input), then rerun",
|
|
97
|
+
bad: "Read what it found and decide what to do",
|
|
98
|
+
missed: "Check why it did not start: the flow or the account paused, out of credit, or the scheduler down — then run it by hand",
|
|
99
|
+
};
|
|
100
|
+
const lastOf = (h, actions) => [...h].reverse().find((e) => actions.includes(e.action)) ?? null;
|
|
101
|
+
/**
|
|
102
|
+
* Every issue the runs show, with what people did to them folded in.
|
|
103
|
+
* `states` is keyed by issue id. Resolved issues are kept (with their last
|
|
104
|
+
* occurrences) while their runs are still in the window, so their history
|
|
105
|
+
* can be read; a status filter is the caller's.
|
|
106
|
+
*/
|
|
107
|
+
export function foldIssues(runs, states, now,
|
|
108
|
+
/** `autoClose(workspace, flow)`: the flow says `issues: auto-close`, so a
|
|
109
|
+
* run that goes fine after a failure closes it (FOL-72 step 7). Never a
|
|
110
|
+
* platform cause or a missed schedule — those stay until a person says. */
|
|
111
|
+
opts = {}) {
|
|
112
|
+
// When it went wrong (or right): its end, else its start. A run that
|
|
113
|
+
// started before a resolve and failed after it is news, and of two
|
|
114
|
+
// overlapping failures the one that ended last is the newest (Codex review).
|
|
115
|
+
const occurred = (r) => r.finishedAt ?? r.startedAt;
|
|
116
|
+
const all = [...runs].sort((a, b) => occurred(a).localeCompare(occurred(b)));
|
|
117
|
+
// The last run of each desk's flow to finish, to say "fine since" — only
|
|
118
|
+
// when it is itself fine, and finished after the issue's last occurrence
|
|
119
|
+
// (a success between two failures, or one that overlapped a later
|
|
120
|
+
// failure, is not a recovery — Codex review).
|
|
121
|
+
const newestDone = new Map();
|
|
122
|
+
for (const r of all) {
|
|
123
|
+
if (r.outcome === "running" || r.outcome === "queued" || r.outcome === "waiting")
|
|
124
|
+
continue;
|
|
125
|
+
const k = `${r.workspace}\u0000${r.what}`;
|
|
126
|
+
const cur = newestDone.get(k);
|
|
127
|
+
if (!cur || occurred(r) >= occurred(cur))
|
|
128
|
+
newestDone.set(k, r);
|
|
129
|
+
}
|
|
130
|
+
const fine = (o) => o === "good" || o === "done" || o === "quiet";
|
|
131
|
+
const groups = new Map();
|
|
132
|
+
for (const r of all) {
|
|
133
|
+
const key = issueKeyOf(r);
|
|
134
|
+
if (!key)
|
|
135
|
+
continue;
|
|
136
|
+
const g = groups.get(key.id) ?? { key, runs: [] };
|
|
137
|
+
g.runs.push(r);
|
|
138
|
+
groups.set(key.id, g);
|
|
139
|
+
}
|
|
140
|
+
const out = [];
|
|
141
|
+
for (const [id, { key, runs: hit }] of groups) {
|
|
142
|
+
const history = states[id]?.history ?? [];
|
|
143
|
+
const res = lastOf(history, ["resolve", "reopen"]);
|
|
144
|
+
// Replay the runs and the resolves/reopens in time order: a run going
|
|
145
|
+
// wrong after a resolve starts a new episode, a resolve closes the
|
|
146
|
+
// current one, a reopen carries it on. The issue shows its current (or
|
|
147
|
+
// last closed) episode — never every one ever closed. A run that ended
|
|
148
|
+
// at the moment of a resolve was closed by it. (Codex review, in rounds.)
|
|
149
|
+
// In a flow that says `issues: auto-close`, every run of it that went
|
|
150
|
+
// fine closes the episode as a resolve would, marked as the platform's.
|
|
151
|
+
const auto = key.kind !== "platform" && key.kind !== "missed" && hit[0] && opts.autoClose?.(hit[0].workspace, hit[0].what) === true;
|
|
152
|
+
const fineRuns = auto ? all.filter((r) => r.workspace === hit[0].workspace && r.what === hit[0].what && fine(r.outcome)) : [];
|
|
153
|
+
const timeline = [
|
|
154
|
+
...hit.map((r) => ({ t: occurred(r), order: 0, run: r, act: null })),
|
|
155
|
+
...history.filter((e) => e.action === "resolve" || e.action === "reopen").map((e) => ({ t: e.at, order: 1, run: null, act: e.action })),
|
|
156
|
+
...fineRuns.map((r) => ({ t: occurred(r), order: 1, run: null, act: "auto" })),
|
|
157
|
+
].sort((a, b) => a.t.localeCompare(b.t) || a.order - b.order);
|
|
158
|
+
let shown = [];
|
|
159
|
+
let closed = false;
|
|
160
|
+
let closedBy = null;
|
|
161
|
+
let autoAt = null;
|
|
162
|
+
// The last time the platform closed it: an acknowledge or a snooze from
|
|
163
|
+
// before then belongs to the episode it closed (Codex review).
|
|
164
|
+
let lastAutoClose = null;
|
|
165
|
+
let episodes = 0;
|
|
166
|
+
for (const ev of timeline) {
|
|
167
|
+
if (ev.run) {
|
|
168
|
+
if (closed || !shown.length) {
|
|
169
|
+
shown = [];
|
|
170
|
+
episodes++;
|
|
171
|
+
}
|
|
172
|
+
closed = false;
|
|
173
|
+
shown.push(ev.run);
|
|
174
|
+
}
|
|
175
|
+
else if (ev.act === "resolve" || (ev.act === "auto" && shown.length)) {
|
|
176
|
+
closed = true;
|
|
177
|
+
closedBy = ev.act === "auto" ? "auto" : "person";
|
|
178
|
+
if (ev.act === "auto") {
|
|
179
|
+
autoAt = ev.t;
|
|
180
|
+
lastAutoClose = ev.t;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
else if (ev.act === "reopen")
|
|
184
|
+
closed = false;
|
|
185
|
+
}
|
|
186
|
+
if (!shown.length)
|
|
187
|
+
continue;
|
|
188
|
+
const resolvedAt = closed ? (closedBy === "auto" ? autoAt : res?.action === "resolve" ? res.at : null) : null;
|
|
189
|
+
const newest = shown[shown.length - 1];
|
|
190
|
+
// A snooze holds while its time has not come and, for "until N more",
|
|
191
|
+
// while fewer than N have happened since it.
|
|
192
|
+
const current = (e) => (e && (!lastAutoClose || e.at > lastAutoClose) ? e : null);
|
|
193
|
+
const snoozeEv = current(lastOf(history, ["snooze", "resolve", "reopen"]));
|
|
194
|
+
const since = snoozeEv?.action === "snooze" ? shown.filter((r) => occurred(r) > snoozeEv.at).length : 0;
|
|
195
|
+
const snoozeLeft = snoozeEv?.action === "snooze" && snoozeEv.untilCount ? Math.max(0, snoozeEv.untilCount - since) : null;
|
|
196
|
+
const snoozeHolds = snoozeEv?.action === "snooze" &&
|
|
197
|
+
(!snoozeEv.until || Date.parse(snoozeEv.until) > now) &&
|
|
198
|
+
(snoozeLeft === null || snoozeLeft > 0) &&
|
|
199
|
+
Boolean(snoozeEv.until || snoozeEv.untilCount);
|
|
200
|
+
const status = (() => {
|
|
201
|
+
if (resolvedAt)
|
|
202
|
+
return "resolved";
|
|
203
|
+
if (snoozeHolds)
|
|
204
|
+
return "snoozed";
|
|
205
|
+
const ack = current(lastOf(history, ["acknowledge", "resolve", "reopen"]));
|
|
206
|
+
if (ack?.action === "acknowledge")
|
|
207
|
+
return "acknowledged";
|
|
208
|
+
return "open";
|
|
209
|
+
})();
|
|
210
|
+
const ack = current(lastOf(history, ["acknowledge", "resolve", "reopen"]));
|
|
211
|
+
const flowKey = `${newest.workspace}\u0000${newest.what}`;
|
|
212
|
+
const last = key.kind === "platform" ? null : newestDone.get(flowKey) ?? null;
|
|
213
|
+
const ok = last && fine(last.outcome) && occurred(last) > occurred(newest) ? occurred(last) : null;
|
|
214
|
+
const workspaces = [...new Set(shown.map((r) => r.workspace))].sort();
|
|
215
|
+
out.push({
|
|
216
|
+
id,
|
|
217
|
+
kind: key.kind,
|
|
218
|
+
status,
|
|
219
|
+
workspace: newest.workspace,
|
|
220
|
+
workspaces,
|
|
221
|
+
what: key.cause ? key.cause.label : newest.what,
|
|
222
|
+
agent: key.agent,
|
|
223
|
+
reason: key.kind === "missed"
|
|
224
|
+
? newest.headline ?? `due ${newest.startedAt}, no run started`
|
|
225
|
+
: key.cause
|
|
226
|
+
? newest.failure ? `${newest.failure.agent}: ${newest.failure.reason}` : key.cause.label
|
|
227
|
+
: newest.failure ? `${newest.failure.agent}: ${newest.failure.reason}` : newest.headline?.trim() || newest.outcome,
|
|
228
|
+
action: key.cause ? `Fix ${key.cause.fix}` : ACTION[key.kind],
|
|
229
|
+
firstSeen: occurred(shown[0]),
|
|
230
|
+
lastSeen: occurred(newest),
|
|
231
|
+
count: shown.length,
|
|
232
|
+
runs: shown.slice(-20).reverse().map((r) => ({ workspace: r.workspace, id: r.id, at: occurred(r), ...(r.missed ? { missed: true } : {}) })),
|
|
233
|
+
recoveredAt: ok,
|
|
234
|
+
acknowledged: ack?.action === "acknowledge" ? { at: ack.at, by: ack.by } : null,
|
|
235
|
+
snoozedUntil: status === "snoozed" && snoozeEv?.until ? snoozeEv.until : null,
|
|
236
|
+
snoozedFor: status === "snoozed" ? snoozeLeft : null,
|
|
237
|
+
regressed: episodes > 1,
|
|
238
|
+
sinceRevision: shown[0].revision ?? null,
|
|
239
|
+
// Only while it is resolved; once it happens again, the history says it was.
|
|
240
|
+
resolved: status === "resolved"
|
|
241
|
+
? closedBy === "auto"
|
|
242
|
+
? { at: resolvedAt, by: "auto: a later run went fine" }
|
|
243
|
+
: res
|
|
244
|
+
? { at: res.at, by: res.by }
|
|
245
|
+
: null
|
|
246
|
+
: null,
|
|
247
|
+
history,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
const rank = { open: 0, acknowledged: 1, snoozed: 2, resolved: 3 };
|
|
251
|
+
const kindRank = { platform: 0, missed: 1, failed: 2, blocked: 3, bad: 4 };
|
|
252
|
+
return out.sort((a, b) => rank[a.status] - rank[b.status] || kindRank[a.kind] - kindRank[b.kind] || b.lastSeen.localeCompare(a.lastSeen));
|
|
253
|
+
}
|
|
254
|
+
/** An issue still asking for someone: open or acknowledged, not snoozed or resolved. */
|
|
255
|
+
export const isActiveIssue = (i) => i.status === "open" || i.status === "acknowledged";
|
|
256
|
+
/**
|
|
257
|
+
* What the run doctor needs to look at an issue: its newest run, the
|
|
258
|
+
* workspace that run is in (the assistant works inside one), and the
|
|
259
|
+
* question the run page's Doctor button asks — so the issue's button, the
|
|
260
|
+
* CLI's `issues doctor` and the run page put it the same way. Null for a
|
|
261
|
+
* missed schedule, which has no run to read.
|
|
262
|
+
*/
|
|
263
|
+
export function issueDoctor(issue) {
|
|
264
|
+
const run = issue.runs.find((r) => !r.missed);
|
|
265
|
+
if (!run || issue.kind === "missed")
|
|
266
|
+
return null;
|
|
267
|
+
const ended = issue.kind === "blocked" ? "end BLOCKED" : issue.kind === "bad" ? "end BAD" : "fail";
|
|
268
|
+
return { workspace: run.workspace, runId: run.id, prompt: `Doctor: why did run ${run.id} ${ended}, and how do I fix it?` };
|
|
269
|
+
}
|
|
270
|
+
/** Parse "30m", "4h", "1d", "1w" into milliseconds; null if not one. */
|
|
271
|
+
export function snoozeMs(s) {
|
|
272
|
+
const m = /^(\d+)\s*(m|h|d|w)$/.exec(s.trim());
|
|
273
|
+
if (!m)
|
|
274
|
+
return null;
|
|
275
|
+
const n = Number(m[1]);
|
|
276
|
+
const unit = { m: 60_000, h: 3_600_000, d: 86_400_000, w: 7 * 86_400_000 }[m[2]];
|
|
277
|
+
return n > 0 ? n * unit : null;
|
|
278
|
+
}
|
|
279
|
+
/** One person's act on an issue, appended to its history. Refuses a snooze
|
|
280
|
+
* with no future `until`. Pure: the caller stores the result. */
|
|
281
|
+
export function applyIssueAction(state, ev, now) {
|
|
282
|
+
if (ev.action === "snooze" && !(ev.until && Date.parse(ev.until) > now) && !(typeof ev.untilCount === "number" && ev.untilCount > 0)) {
|
|
283
|
+
throw new Error("a snooze needs a time in the future, or a number of times it may happen again");
|
|
284
|
+
}
|
|
285
|
+
const history = [...(state?.history ?? []), ev];
|
|
286
|
+
// Bounded: an issue snoozed every morning for a year is still one row. The
|
|
287
|
+
// newest of each kind of act is always kept, whatever its age, because the
|
|
288
|
+
// issue's state is read from them — dropping the last resolve would bring
|
|
289
|
+
// back what it closed (Codex review).
|
|
290
|
+
if (history.length <= 100)
|
|
291
|
+
return { history };
|
|
292
|
+
const keep = new Set(history.map((_, i) => i).slice(-100));
|
|
293
|
+
for (const a of ["acknowledge", "snooze", "resolve", "reopen"]) {
|
|
294
|
+
const i = history.map((e) => e.action).lastIndexOf(a);
|
|
295
|
+
if (i >= 0)
|
|
296
|
+
keep.add(i);
|
|
297
|
+
}
|
|
298
|
+
// Every resolve and reopen: the episodes an issue shows are replayed from
|
|
299
|
+
// them, and each one is a person's act, so there are few (Codex review).
|
|
300
|
+
history.forEach((e, i) => (e.action === "resolve" || e.action === "reopen") && keep.add(i));
|
|
301
|
+
return { history: history.filter((_, i) => keep.has(i)) };
|
|
302
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** The line to end a judge prompt with. */
|
|
2
|
+
export declare const JUDGE_VERDICT_INSTRUCTION = "Check each part against the text in a few short sentences. Then, on the last line and alone, write exactly PASS or FAIL \u2014 PASS only if every part holds.";
|
|
3
|
+
/**
|
|
4
|
+
* The judge's verdict: the PASS or FAIL alone on its last line, with the
|
|
5
|
+
* reasoning before it as the detail. An answer in the old shape (the word
|
|
6
|
+
* first) still reads; one with no verdict at all is a FAIL that says so.
|
|
7
|
+
*/
|
|
8
|
+
export declare function judgeVerdict(text: string): {
|
|
9
|
+
passed: boolean;
|
|
10
|
+
detail: string;
|
|
11
|
+
};
|
|
12
|
+
/** `judge:` in AGENTS.md — the model and effort every judge in the workspace
|
|
13
|
+
* grades with, unless an eval names its own. `model` is a tier or alias as
|
|
14
|
+
* anywhere else; what is wrong with the block, in words, for check. */
|
|
15
|
+
export declare function parseJudge(raw: unknown): {
|
|
16
|
+
model?: string;
|
|
17
|
+
effort?: string;
|
|
18
|
+
problems: string[];
|
|
19
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// What a judge said: the PASS or FAIL alone on its last line, after its
|
|
2
|
+
// reasoning. Shared by the eval judge (evals.ts) and a flow step's
|
|
3
|
+
// `verify: judge:` (step-exec.ts), which ask for the same shape.
|
|
4
|
+
//
|
|
5
|
+
// The word comes last because, asked for it first, a judge commits before
|
|
6
|
+
// it has looked and then argues the other way — "FAIL … which meets the
|
|
7
|
+
// criterion" (vaza evals, 21 Oct 2026).
|
|
8
|
+
import { EFFORT_LEVELS } from "./frontmatter-fields.js";
|
|
9
|
+
/** The line to end a judge prompt with. */
|
|
10
|
+
export const JUDGE_VERDICT_INSTRUCTION = "Check each part against the text in a few short sentences. Then, on the last line and alone, write exactly PASS or FAIL — PASS only if every part holds.";
|
|
11
|
+
/**
|
|
12
|
+
* The judge's verdict: the PASS or FAIL alone on its last line, with the
|
|
13
|
+
* reasoning before it as the detail. An answer in the old shape (the word
|
|
14
|
+
* first) still reads; one with no verdict at all is a FAIL that says so.
|
|
15
|
+
*/
|
|
16
|
+
export function judgeVerdict(text) {
|
|
17
|
+
const lines = text.trim().split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
18
|
+
const word = (l) => /^[*_`#\s]*(PASS|FAIL)[*_`.\s]*$/i.exec(l ?? "")?.[1]?.toUpperCase();
|
|
19
|
+
let verdict = word(lines[lines.length - 1]);
|
|
20
|
+
let reason = lines.slice(0, -1).join(" ");
|
|
21
|
+
if (!verdict) {
|
|
22
|
+
const first = /^[*_`#\s]*(PASS|FAIL)\b[*_`:.\s-]*(.*)$/i.exec(lines[0] ?? "");
|
|
23
|
+
if (first) {
|
|
24
|
+
verdict = first[1].toUpperCase();
|
|
25
|
+
reason = [first[2], ...lines.slice(1)].join(" ").trim();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (!verdict)
|
|
29
|
+
return { passed: false, detail: `judge gave no PASS or FAIL: ${text.trim().slice(0, 300) || "nothing"}` };
|
|
30
|
+
// The end of the reasoning is the part that decided it.
|
|
31
|
+
const why = reason.length > 400 ? `…${reason.slice(-400)}` : reason;
|
|
32
|
+
return { passed: verdict === "PASS", detail: `${verdict}${why ? ` — ${why}` : ""}` };
|
|
33
|
+
}
|
|
34
|
+
/** `judge:` in AGENTS.md — the model and effort every judge in the workspace
|
|
35
|
+
* grades with, unless an eval names its own. `model` is a tier or alias as
|
|
36
|
+
* anywhere else; what is wrong with the block, in words, for check. */
|
|
37
|
+
export function parseJudge(raw) {
|
|
38
|
+
if (raw === undefined || raw === null)
|
|
39
|
+
return { problems: [] };
|
|
40
|
+
if (typeof raw !== "object" || Array.isArray(raw))
|
|
41
|
+
return { problems: ["judge: is a block — {model: fast | default | max, effort: low…max}"] };
|
|
42
|
+
const o = raw;
|
|
43
|
+
const problems = [];
|
|
44
|
+
for (const k of Object.keys(o))
|
|
45
|
+
if (k !== "model" && k !== "effort")
|
|
46
|
+
problems.push(`judge: ${k} — not a key; the keys are model and effort`);
|
|
47
|
+
const model = typeof o.model === "string" && o.model.trim() ? o.model.trim() : undefined;
|
|
48
|
+
if (o.model !== undefined && !model)
|
|
49
|
+
problems.push("judge: model — a tier (fast, default, max), an alias or a model id");
|
|
50
|
+
const effort = typeof o.effort === "string" && EFFORT_LEVELS.includes(o.effort.trim()) ? o.effort.trim() : undefined;
|
|
51
|
+
if (o.effort !== undefined && !effort)
|
|
52
|
+
problems.push(`judge: effort ${String(o.effort)} — low, medium, high, xhigh or max`);
|
|
53
|
+
return { ...(model ? { model } : {}), ...(effort ? { effort } : {}), problems };
|
|
54
|
+
}
|
package/dist/src/library.js
CHANGED
|
@@ -14,7 +14,7 @@ import { platform } from "./platform.js";
|
|
|
14
14
|
import fs from "node:fs";
|
|
15
15
|
import path from "node:path";
|
|
16
16
|
import { dataRoot, singleWorkspace, singleAccountRoot, MAX_EDITABLE_FILE } from "./paths.js";
|
|
17
|
-
import matter from "
|
|
17
|
+
import matter from "./matter.js";
|
|
18
18
|
import { KINDS } from "./kinds.js";
|
|
19
19
|
import { recordRevision, registerTreeReader } from "./history.js";
|
|
20
20
|
import { assertSafeName, assertCanonicalCase, buildMemoryIndex, parseToolDef, readToolDir, syncBundleFor, listWorkspaces, listAgents, workspaceDir, } from "./store.js";
|
package/dist/src/limits.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
export type Limits = Record<string, number>;
|
|
2
2
|
/** The key that caps every call in the step. */
|
|
3
3
|
export declare const TOTAL_KEY = "calls";
|
|
4
|
+
/** The key that caps one identical call: a model asking the same tool the
|
|
5
|
+
* same thing over and over is stuck, and every repeat is spend. Each call
|
|
6
|
+
* also counts under `repeat:<hash of tool and input>`; `repeat: N` caps
|
|
7
|
+
* every one of those. */
|
|
8
|
+
export declare const REPEAT_KEY = "repeat";
|
|
4
9
|
/** One spelling for comparing: `desk-email` and `desk_email` are the same
|
|
5
10
|
* tool (a script tool's SDK name has its dashes made underscores). */
|
|
6
11
|
export declare function normKey(key: string): string;
|
|
@@ -34,6 +39,9 @@ export declare function toolOwners(apiOwners: Record<string, string>, scripts: {
|
|
|
34
39
|
* name, its input, and the owners map.
|
|
35
40
|
*/
|
|
36
41
|
export declare function limitKeysFor(toolName: string, input: Record<string, unknown> | undefined, owners?: Record<string, string>): string[];
|
|
42
|
+
/** What step-exec counts one call under: limitKeysFor's keys and the
|
|
43
|
+
* call's own `repeat:` key, for `limits: repeat`. */
|
|
44
|
+
export declare function callKeysFor(toolName: string, input: Record<string, unknown> | undefined, owners?: Record<string, string>): string[];
|
|
37
45
|
export interface LimitRefusal {
|
|
38
46
|
key: string;
|
|
39
47
|
used: number;
|
package/dist/src/limits.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// web: 150 # every web action together
|
|
7
7
|
// crm: 20 # an API, script or MCP tool, by the name it is granted as
|
|
8
8
|
// calls: 300 # every tool call in the step, whatever the tool
|
|
9
|
+
// repeat: 5 # the same call — one tool, the same input — at most this often
|
|
9
10
|
//
|
|
10
11
|
// Counted per step and enforced in step-exec.ts's PreToolUse hook — the one
|
|
11
12
|
// place every call passes, the step's own and its sub-agents', on the
|
|
@@ -17,10 +18,27 @@
|
|
|
17
18
|
//
|
|
18
19
|
// Nearest wins per key: the account's AGENTS.md, then the workspace's, then
|
|
19
20
|
// the agent's own `limits:`, then a flow step's `limits:` option.
|
|
21
|
+
import crypto from "node:crypto";
|
|
20
22
|
import { WEB_ACTIONS } from "./providers.js";
|
|
21
23
|
import { DELEGATE_TOOLS } from "./subagents.js";
|
|
22
24
|
/** The key that caps every call in the step. */
|
|
23
25
|
export const TOTAL_KEY = "calls";
|
|
26
|
+
/** The key that caps one identical call: a model asking the same tool the
|
|
27
|
+
* same thing over and over is stuck, and every repeat is spend. Each call
|
|
28
|
+
* also counts under `repeat:<hash of tool and input>`; `repeat: N` caps
|
|
29
|
+
* every one of those. */
|
|
30
|
+
export const REPEAT_KEY = "repeat";
|
|
31
|
+
/** JSON with its keys sorted, so `{a,b}` and `{b,a}` are the same call. */
|
|
32
|
+
function stable(v) {
|
|
33
|
+
if (Array.isArray(v))
|
|
34
|
+
return `[${v.map(stable).join(",")}]`;
|
|
35
|
+
if (v && typeof v === "object")
|
|
36
|
+
return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stable(v[k])}`).join(",")}}`;
|
|
37
|
+
return JSON.stringify(v) ?? "null";
|
|
38
|
+
}
|
|
39
|
+
function repeatKey(toolName, input) {
|
|
40
|
+
return `${REPEAT_KEY}:${crypto.createHash("sha1").update(`${toolName}\0${stable(input ?? {})}`).digest("hex").slice(0, 16)}`;
|
|
41
|
+
}
|
|
24
42
|
/** SDK built-ins, counted under the foldrun group that grants them — and
|
|
25
43
|
* under their own SDK name, so `Bash: 5` works for a Claude Code file. */
|
|
26
44
|
const BUILTIN_GROUP = {
|
|
@@ -36,6 +54,7 @@ const PLATFORM_SERVERS = {
|
|
|
36
54
|
foldrun_history: "history",
|
|
37
55
|
foldrun_desks: "desks",
|
|
38
56
|
foldrun_ask: "ask",
|
|
57
|
+
foldrun_peers: "peers",
|
|
39
58
|
};
|
|
40
59
|
/** One spelling for comparing: `desk-email` and `desk_email` are the same
|
|
41
60
|
* tool (a script tool's SDK name has its dashes made underscores). */
|
|
@@ -72,7 +91,7 @@ export function readLimits(raw, where = "limits") {
|
|
|
72
91
|
for (const [key, value] of flat) {
|
|
73
92
|
const k = key.trim();
|
|
74
93
|
if (!KEY_RE.test(k)) {
|
|
75
|
-
problems.push(`${where}: "${k}" is not a key — a tool's name, web.<action>, or
|
|
94
|
+
problems.push(`${where}: "${k}" is not a key — a tool's name, web.<action>, calls, or repeat.`);
|
|
76
95
|
continue;
|
|
77
96
|
}
|
|
78
97
|
if (k.includes(".")) {
|
|
@@ -169,6 +188,11 @@ export function limitKeysFor(toolName, input, owners = {}) {
|
|
|
169
188
|
keys.push(toolName);
|
|
170
189
|
return keys;
|
|
171
190
|
}
|
|
191
|
+
/** What step-exec counts one call under: limitKeysFor's keys and the
|
|
192
|
+
* call's own `repeat:` key, for `limits: repeat`. */
|
|
193
|
+
export function callKeysFor(toolName, input, owners = {}) {
|
|
194
|
+
return [...limitKeysFor(toolName, input, owners), repeatKey(toolName, input)];
|
|
195
|
+
}
|
|
172
196
|
/**
|
|
173
197
|
* One step's counts. `take` checks every key the call counts under and
|
|
174
198
|
* either refuses — counting nothing — or counts it under all of them.
|
|
@@ -187,7 +211,8 @@ export class CallCounter {
|
|
|
187
211
|
take(keys) {
|
|
188
212
|
const norm = [...new Set(keys.map(normKey))];
|
|
189
213
|
for (const k of norm) {
|
|
190
|
-
const
|
|
214
|
+
const repeat = k.startsWith(`${REPEAT_KEY}:`);
|
|
215
|
+
const l = this.limits.get(repeat ? REPEAT_KEY : k);
|
|
191
216
|
if (!l)
|
|
192
217
|
continue;
|
|
193
218
|
const used = this.used.get(k) ?? 0;
|
|
@@ -195,7 +220,9 @@ export class CallCounter {
|
|
|
195
220
|
this.refused++;
|
|
196
221
|
return {
|
|
197
222
|
key: l.key, used, limit: l.limit,
|
|
198
|
-
message:
|
|
223
|
+
message: repeat
|
|
224
|
+
? `limit reached: this exact call (same tool, same input) ${used} of ${l.limit} times in this step — repeating it will not change the answer; try something different, or say what is stuck`
|
|
225
|
+
: `limit reached: ${l.key} ${used} of ${l.limit} in this step — work with what you have, or say what more you would need`,
|
|
199
226
|
};
|
|
200
227
|
}
|
|
201
228
|
}
|
|
@@ -218,7 +245,11 @@ export class CallCounter {
|
|
|
218
245
|
summary() {
|
|
219
246
|
if (!this.active)
|
|
220
247
|
return null;
|
|
221
|
-
|
|
248
|
+
// repeat: the most any one call was made, against its cap.
|
|
249
|
+
const most = (k) => k === REPEAT_KEY
|
|
250
|
+
? Math.max(0, ...[...this.used.entries()].filter(([u]) => u.startsWith(`${REPEAT_KEY}:`)).map(([, n]) => n))
|
|
251
|
+
: this.used.get(k) ?? 0;
|
|
252
|
+
const parts = [...this.limits.entries()].map(([k, l]) => `${l.key} ${most(k)}/${l.limit}`);
|
|
222
253
|
return `limits: ${parts.join(", ")}${this.refused ? ` — ${this.refused} call${this.refused === 1 ? "" : "s"} refused` : ""}`;
|
|
223
254
|
}
|
|
224
255
|
}
|
|
@@ -248,7 +279,7 @@ export function limitKeyProblems(limits, ctx) {
|
|
|
248
279
|
return [n];
|
|
249
280
|
}).map(normKey));
|
|
250
281
|
for (const key of Object.keys(limits)) {
|
|
251
|
-
if (key === TOTAL_KEY)
|
|
282
|
+
if (key === TOTAL_KEY || key === REPEAT_KEY)
|
|
252
283
|
continue;
|
|
253
284
|
const base = key.startsWith("web.") ? "web" : key;
|
|
254
285
|
const n = normKey(base);
|
package/dist/src/linkable.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// real workspace, so a future rename fails a test instead of a run.
|
|
14
14
|
import fs from "node:fs";
|
|
15
15
|
import path from "node:path";
|
|
16
|
-
import matter from "
|
|
16
|
+
import matter from "./matter.js";
|
|
17
17
|
import { STORAGE_DIR } from "./store.js";
|
|
18
18
|
/** The directories a bare [[name]] may point at, matching the resolver. */
|
|
19
19
|
export const LINKABLE_DIRS = [STORAGE_DIR, "state", "knowledge", "memory", "skills", "outputs"];
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import grayMatter from "gray-matter";
|
|
2
|
+
import { type Job } from "./frontmatter-fields.ts";
|
|
3
|
+
type Options = NonNullable<Parameters<typeof grayMatter>[1]>;
|
|
4
|
+
export interface JobFold {
|
|
5
|
+
/** A key lifted out of a block, and the block it was in. */
|
|
6
|
+
folded: Map<string, Job>;
|
|
7
|
+
/** A key set both at the top and inside a block: the top one is kept, and
|
|
8
|
+
* check refuses the file (config/conflict). */
|
|
9
|
+
conflicts: Map<string, Job>;
|
|
10
|
+
}
|
|
11
|
+
/** What the fold did to this data, or null when it had no blocks. */
|
|
12
|
+
export declare function jobFold(data: unknown): JobFold | null;
|
|
13
|
+
declare function matter(input: string, options?: Options): grayMatter.GrayMatterFile<string>;
|
|
14
|
+
declare namespace matter {
|
|
15
|
+
var stringify: typeof grayMatter.stringify;
|
|
16
|
+
var test: typeof grayMatter.test;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* matter(), for the three files that hold configuration — an agent, a flow,
|
|
20
|
+
* AGENTS.md — with the four job blocks read as flat keys. Every reader of
|
|
21
|
+
* those files goes through this (tests/jobs.test.ts holds them to it);
|
|
22
|
+
* knowledge, memory, skills and every other document keep matter(), so a
|
|
23
|
+
* note's own `governance:` is its own (Codex debate, 21 Oct 2026).
|
|
24
|
+
*/
|
|
25
|
+
export declare function configMatter(input: string, options?: Options): grayMatter.GrayMatterFile<string>;
|
|
26
|
+
export default matter;
|
|
27
|
+
/** The first line of a YAML parse error, with where it is: what a person
|
|
28
|
+
* needs to fix the file, without the parser's excerpt. */
|
|
29
|
+
export declare function frontmatterErrorText(err: unknown): string;
|
|
30
|
+
/** The 1-based file line a YAML parse error points at, when it says. The
|
|
31
|
+
* text gray-matter hands the parser starts with the newline after the
|
|
32
|
+
* opening `---`, so the parser's 0-based line is the file's 1-based one. */
|
|
33
|
+
export declare function frontmatterErrorLine(err: unknown): number | undefined;
|
|
34
|
+
/** Frontmatter as the runtime reads a skill: strictly first, then once more
|
|
35
|
+
* with every unquoted value that holds a colon quoted (the commonest way a
|
|
36
|
+
* hand-written SKILL.md breaks, and one other skills clients forgive), and
|
|
37
|
+
* empty when even that fails. `lenient` says the second read was needed. */
|
|
38
|
+
export declare function parseLenient(raw: string): {
|
|
39
|
+
data: Record<string, unknown>;
|
|
40
|
+
lenient: boolean;
|
|
41
|
+
failed: boolean;
|
|
42
|
+
};
|