@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.
Files changed (149) hide show
  1. package/dist/src/agent-activity.d.ts +5 -0
  2. package/dist/src/agent-activity.js +31 -0
  3. package/dist/src/agent-import.js +2 -2
  4. package/dist/src/agent-tools.js +2 -2
  5. package/dist/src/agents-md.d.ts +5 -1
  6. package/dist/src/agents-md.js +42 -18
  7. package/dist/src/alerts.d.ts +60 -0
  8. package/dist/src/alerts.js +161 -0
  9. package/dist/src/browser-pod.js +4 -0
  10. package/dist/src/capture.d.ts +23 -0
  11. package/dist/src/capture.js +80 -0
  12. package/dist/src/compare.d.ts +57 -0
  13. package/dist/src/compare.js +116 -0
  14. package/dist/src/completions.js +26 -3
  15. package/dist/src/deploy.d.ts +36 -1
  16. package/dist/src/deploy.js +237 -13
  17. package/dist/src/egress.d.ts +16 -1
  18. package/dist/src/evals.d.ts +27 -2
  19. package/dist/src/evals.js +62 -19
  20. package/dist/src/flow-lint.d.ts +14 -0
  21. package/dist/src/flow-lint.js +35 -0
  22. package/dist/src/flow-patterns.js +29 -14
  23. package/dist/src/frontmatter-fields.d.ts +27 -0
  24. package/dist/src/frontmatter-fields.js +60 -3
  25. package/dist/src/frontmatter-guard.d.ts +19 -0
  26. package/dist/src/frontmatter-guard.js +67 -0
  27. package/dist/src/frontmatter-schema.d.ts +2 -2
  28. package/dist/src/frontmatter-schema.js +33 -2
  29. package/dist/src/frontmatter-text.d.ts +31 -0
  30. package/dist/src/frontmatter-text.js +102 -0
  31. package/dist/src/history.d.ts +4 -0
  32. package/dist/src/history.js +15 -0
  33. package/dist/src/hooks.js +1 -1
  34. package/dist/src/insights.d.ts +30 -0
  35. package/dist/src/insights.js +152 -0
  36. package/dist/src/issues.d.ts +140 -0
  37. package/dist/src/issues.js +302 -0
  38. package/dist/src/judge-verdict.d.ts +19 -0
  39. package/dist/src/judge-verdict.js +54 -0
  40. package/dist/src/library.js +1 -1
  41. package/dist/src/limits.d.ts +8 -0
  42. package/dist/src/limits.js +36 -5
  43. package/dist/src/linkable.js +1 -1
  44. package/dist/src/matter.d.ts +42 -0
  45. package/dist/src/matter.js +132 -0
  46. package/dist/src/model-credential.d.ts +14 -0
  47. package/dist/src/model-credential.js +40 -0
  48. package/dist/src/network.d.ts +12 -1
  49. package/dist/src/network.js +35 -8
  50. package/dist/src/notify.d.ts +16 -0
  51. package/dist/src/notify.js +17 -7
  52. package/dist/src/okf.js +1 -1
  53. package/dist/src/online-checks.d.ts +76 -0
  54. package/dist/src/online-checks.js +432 -0
  55. package/dist/src/operator.d.ts +277 -0
  56. package/dist/src/operator.js +176 -2
  57. package/dist/src/otel-config.d.ts +18 -0
  58. package/dist/src/otel-config.js +69 -0
  59. package/dist/src/otel.d.ts +38 -0
  60. package/dist/src/otel.js +85 -0
  61. package/dist/src/outcome.d.ts +40 -0
  62. package/dist/src/outcome.js +77 -0
  63. package/dist/src/package.js +9 -2
  64. package/dist/src/platform.d.ts +1 -1
  65. package/dist/src/prompt-layers.d.ts +30 -0
  66. package/dist/src/prompt-layers.js +77 -0
  67. package/dist/src/provider-health.js +2 -2
  68. package/dist/src/providers.d.ts +5 -0
  69. package/dist/src/providers.js +2 -2
  70. package/dist/src/run-container.d.ts +25 -2
  71. package/dist/src/run-container.js +61 -6
  72. package/dist/src/runner.d.ts +53 -2
  73. package/dist/src/runner.js +471 -102
  74. package/dist/src/runs-filter.d.ts +48 -0
  75. package/dist/src/runs-filter.js +73 -0
  76. package/dist/src/scan.d.ts +30 -1
  77. package/dist/src/scan.js +145 -14
  78. package/dist/src/starter.js +27 -2
  79. package/dist/src/step-exec.d.ts +39 -3
  80. package/dist/src/step-exec.js +118 -15
  81. package/dist/src/store.d.ts +144 -3
  82. package/dist/src/store.js +150 -7
  83. package/dist/src/tokens.d.ts +38 -0
  84. package/dist/src/tokens.js +57 -0
  85. package/dist/src/tool-names.js +1 -1
  86. package/dist/src/tool-programs.js +1 -1
  87. package/dist/src/trace-tree.d.ts +40 -0
  88. package/dist/src/trace-tree.js +156 -0
  89. package/dist/src/translator.d.ts +8 -0
  90. package/dist/src/translator.js +7 -2
  91. package/dist/src/trigger-log.d.ts +1 -1
  92. package/dist/src/workspace-check.d.ts +18 -0
  93. package/dist/src/workspace-check.js +34 -0
  94. package/package.json +2 -2
  95. package/src/agent-activity.ts +32 -0
  96. package/src/agent-import.ts +2 -2
  97. package/src/agent-tools.ts +2 -2
  98. package/src/agents-md.ts +39 -14
  99. package/src/alerts.ts +190 -0
  100. package/src/browser-pod.ts +3 -0
  101. package/src/capture.ts +88 -0
  102. package/src/compare.ts +146 -0
  103. package/src/completions.ts +27 -3
  104. package/src/deploy.ts +238 -12
  105. package/src/egress.ts +12 -2
  106. package/src/evals.ts +64 -19
  107. package/src/flow-lint.ts +36 -0
  108. package/src/flow-patterns.ts +26 -13
  109. package/src/frontmatter-fields.ts +83 -3
  110. package/src/frontmatter-guard.ts +70 -0
  111. package/src/frontmatter-schema.ts +33 -4
  112. package/src/frontmatter-text.ts +109 -0
  113. package/src/history.ts +13 -0
  114. package/src/hooks.ts +1 -1
  115. package/src/insights.ts +174 -0
  116. package/src/issues.ts +396 -0
  117. package/src/judge-verdict.ts +52 -0
  118. package/src/library.ts +1 -1
  119. package/src/limits.ts +38 -5
  120. package/src/linkable.ts +1 -1
  121. package/src/matter.ts +145 -0
  122. package/src/model-credential.ts +45 -0
  123. package/src/network.ts +35 -7
  124. package/src/notify.ts +22 -7
  125. package/src/okf.ts +1 -1
  126. package/src/online-checks.ts +450 -0
  127. package/src/operator.ts +213 -3
  128. package/src/otel-config.ts +67 -0
  129. package/src/otel.ts +100 -0
  130. package/src/outcome.ts +90 -0
  131. package/src/package.ts +8 -2
  132. package/src/platform.ts +1 -1
  133. package/src/prompt-layers.ts +90 -0
  134. package/src/provider-health.ts +2 -2
  135. package/src/providers.ts +7 -2
  136. package/src/run-container.ts +77 -8
  137. package/src/runner.ts +477 -79
  138. package/src/runs-filter.ts +88 -0
  139. package/src/scan.ts +153 -14
  140. package/src/starter.ts +27 -2
  141. package/src/step-exec.ts +168 -17
  142. package/src/store.ts +268 -9
  143. package/src/tokens.ts +68 -0
  144. package/src/tool-names.ts +1 -1
  145. package/src/tool-programs.ts +1 -1
  146. package/src/trace-tree.ts +187 -0
  147. package/src/translator.ts +13 -2
  148. package/src/trigger-log.ts +1 -0
  149. 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
+ }
@@ -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 "gray-matter";
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";
@@ -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;
@@ -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 calls.`);
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 l = this.limits.get(k);
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: `limit reached: ${l.key} ${used} of ${l.limit} in this step — work with what you have, or say what more you would need`,
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
- const parts = [...this.limits.entries()].map(([k, l]) => `${l.key} ${this.used.get(k) ?? 0}/${l.limit}`);
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);
@@ -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 "gray-matter";
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
+ };