tldr-experts 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1957 -0
- package/README.md +99 -12
- package/dist/hooks/answer-capture.js +21 -12
- package/dist/hooks/budget-gate.js +95 -11
- package/dist/hooks/{chunk-0bt6yb2q.js → chunk-39zh2e44.js} +10 -1
- package/dist/hooks/chunk-4cp363kv.js +1766 -0
- package/dist/hooks/{chunk-t8tdv11p.js → chunk-7y2dq0pj.js} +1 -1
- package/dist/hooks/chunk-9zsqxr6y.js +213 -0
- package/dist/hooks/{chunk-a8p2rc94.js → chunk-b8kxzna2.js} +1 -1
- package/dist/hooks/{chunk-j234zf0t.js → chunk-c6t5nx0r.js} +37 -166
- package/dist/hooks/{chunk-g395gk7e.js → chunk-m3mewgnw.js} +36 -17
- package/dist/hooks/chunk-phmdk72a.js +164 -0
- package/dist/hooks/chunk-rpcxsqh3.js +199 -0
- package/dist/hooks/{chunk-azctppjh.js → chunk-rz541e2b.js} +7 -1
- package/dist/hooks/{chunk-y0jdr3et.js → chunk-s1c5h7yx.js} +71 -29
- package/dist/hooks/{chunk-p274ckxv.js → chunk-sq44k6g2.js} +155 -12
- package/dist/hooks/{chunk-x98qs959.js → chunk-t1ywrfr4.js} +35 -42
- package/dist/hooks/{chunk-1zwcxd3f.js → chunk-tzzwddct.js} +1 -1
- package/dist/hooks/claim-sources.js +31 -23
- package/dist/hooks/dod-gate.js +12 -37
- package/dist/hooks/no-reask.js +9 -9
- package/dist/hooks/session-start.js +176 -422
- package/dist/hooks/statusline.js +14 -11
- package/dist/tldrx.js +22798 -12309
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/tldrx/SKILL.md +16 -3
- package/stages/build/stage.md +5 -0
- package/stages/build/stage.yml +2 -1
- package/stages/plan/stage.md +11 -0
- package/templates/budget.yml +5 -0
- package/templates/epic.md +5 -3
- package/dist/hooks/chunk-ae6bkfs5.js +0 -0
- package/dist/hooks/chunk-kw4tffzf.js +0 -139
- package/dist/hooks/chunk-sdjnnmzz.js +0 -497
- package/dist/hooks/chunk-t56k6146.js +0 -14
|
@@ -0,0 +1,1766 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseHandoff
|
|
3
|
+
} from "./chunk-9zsqxr6y.js";
|
|
4
|
+
import {
|
|
5
|
+
MAX_PLAN_STORIES,
|
|
6
|
+
MAX_STORIES_PER_WAVE,
|
|
7
|
+
MAX_WAVES,
|
|
8
|
+
STORY_ID_RE,
|
|
9
|
+
WAVE_ID_RE,
|
|
10
|
+
parseFrontMatter,
|
|
11
|
+
requirePattern,
|
|
12
|
+
requireStringList,
|
|
13
|
+
requireVersion1
|
|
14
|
+
} from "./chunk-phmdk72a.js";
|
|
15
|
+
import {
|
|
16
|
+
classifySrc
|
|
17
|
+
} from "./chunk-m3mewgnw.js";
|
|
18
|
+
import {
|
|
19
|
+
asDocument,
|
|
20
|
+
isRecord,
|
|
21
|
+
requireArray,
|
|
22
|
+
requireEnum,
|
|
23
|
+
requireKeys,
|
|
24
|
+
requireNumber,
|
|
25
|
+
requireRecord,
|
|
26
|
+
requireString,
|
|
27
|
+
requireVersion,
|
|
28
|
+
result
|
|
29
|
+
} from "./chunk-39zh2e44.js";
|
|
30
|
+
import {
|
|
31
|
+
PROJECT_FRAMEWORK_DIR,
|
|
32
|
+
listRunDirs,
|
|
33
|
+
parseYaml
|
|
34
|
+
} from "./chunk-sq44k6g2.js";
|
|
35
|
+
|
|
36
|
+
// src/hooks/lib/runFile.ts
|
|
37
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
38
|
+
import { join } from "node:path";
|
|
39
|
+
var TERMINAL_STATUSES = ["done", "failed", "skipped", "cancelled"];
|
|
40
|
+
function loadRunView(runDir) {
|
|
41
|
+
const path = join(runDir, "run.yml");
|
|
42
|
+
if (!existsSync(path))
|
|
43
|
+
return null;
|
|
44
|
+
let doc;
|
|
45
|
+
try {
|
|
46
|
+
const parsed = parseYaml(readFileSync(path, "utf8"));
|
|
47
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
48
|
+
return null;
|
|
49
|
+
doc = parsed;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const rawCursor = doc.cursor;
|
|
54
|
+
const cursor = rawCursor === undefined || rawCursor === null ? null : {
|
|
55
|
+
phase: str(rawCursor.phase),
|
|
56
|
+
stage: str(rawCursor.stage),
|
|
57
|
+
task: typeof rawCursor.task === "string" ? rawCursor.task : null
|
|
58
|
+
};
|
|
59
|
+
const phases = [];
|
|
60
|
+
if (Array.isArray(doc.phases)) {
|
|
61
|
+
for (const entry of doc.phases) {
|
|
62
|
+
const stages = [];
|
|
63
|
+
if (Array.isArray(entry?.stages)) {
|
|
64
|
+
for (const s of entry.stages) {
|
|
65
|
+
stages.push({
|
|
66
|
+
id: str(s?.id),
|
|
67
|
+
status: str(s?.status),
|
|
68
|
+
expert: typeof s?.expert === "string" ? s.expert : null,
|
|
69
|
+
budget_usd: typeof s?.budget_usd === "number" ? s.budget_usd : null,
|
|
70
|
+
cost_usd: typeof s?.cost_usd === "number" ? s.cost_usd : null,
|
|
71
|
+
tasks: tasksOf(s?.tasks)
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
phases.push({ id: str(entry?.id), status: str(entry?.status), stages });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
dir: runDir,
|
|
80
|
+
run: str(doc.run) || str(doc.run_id),
|
|
81
|
+
title: str(doc.title),
|
|
82
|
+
scope: str(doc.scope),
|
|
83
|
+
status: str(doc.status),
|
|
84
|
+
updated_at: str(doc.updated_at),
|
|
85
|
+
attended_by: typeof doc.attended_by === "string" && doc.attended_by !== "" ? doc.attended_by : null,
|
|
86
|
+
cursor,
|
|
87
|
+
phases
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function tasksOf(value) {
|
|
91
|
+
if (!Array.isArray(value))
|
|
92
|
+
return [];
|
|
93
|
+
return value.map((task) => ({
|
|
94
|
+
cost_usd: typeof task?.cost_usd === "number" ? task.cost_usd : null,
|
|
95
|
+
metered: task?.metered !== false,
|
|
96
|
+
tokens: typeof task?.tokens === "number" ? task.tokens : null
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
function isAttendedByHostView(view) {
|
|
100
|
+
return view.attended_by === "host";
|
|
101
|
+
}
|
|
102
|
+
function runSpend(view) {
|
|
103
|
+
let meteredUsd = 0;
|
|
104
|
+
let hostTokens = 0;
|
|
105
|
+
let unmeteredTasks = 0;
|
|
106
|
+
for (const phase of view.phases) {
|
|
107
|
+
for (const stage of phase.stages) {
|
|
108
|
+
for (const task of stage.tasks) {
|
|
109
|
+
if (task.cost_usd !== null)
|
|
110
|
+
meteredUsd += task.cost_usd;
|
|
111
|
+
if (!task.metered || task.cost_usd === null)
|
|
112
|
+
unmeteredTasks += 1;
|
|
113
|
+
if (task.tokens !== null)
|
|
114
|
+
hostTokens += task.tokens;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { meteredUsd: Math.round(meteredUsd * 100) / 100, hostTokens, unmeteredTasks };
|
|
119
|
+
}
|
|
120
|
+
function hostTokensIn(view, phaseId) {
|
|
121
|
+
let tokens = 0;
|
|
122
|
+
for (const phase of view.phases) {
|
|
123
|
+
if (phaseId !== null && phase.id !== phaseId)
|
|
124
|
+
continue;
|
|
125
|
+
for (const stage of phase.stages) {
|
|
126
|
+
for (const task of stage.tasks) {
|
|
127
|
+
if (task.tokens !== null)
|
|
128
|
+
tokens += task.tokens;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return tokens;
|
|
133
|
+
}
|
|
134
|
+
function renderRunEconomies(view) {
|
|
135
|
+
const spend = runSpend(view);
|
|
136
|
+
const attended = isAttendedByHostView(view);
|
|
137
|
+
if (spend.hostTokens === 0 && spend.unmeteredTasks === 0 && !attended)
|
|
138
|
+
return null;
|
|
139
|
+
const parts = [`$${spend.meteredUsd.toFixed(2)} metered`];
|
|
140
|
+
if (spend.hostTokens > 0)
|
|
141
|
+
parts.push(`${String(spend.hostTokens)} host tokens`);
|
|
142
|
+
if (spend.unmeteredTasks > 0) {
|
|
143
|
+
parts.push(`${String(spend.unmeteredTasks)} unmetered turn${spend.unmeteredTasks === 1 ? "" : "s"}`);
|
|
144
|
+
}
|
|
145
|
+
const who = attended ? " (attended_by: host — the framework does not spawn on this run)" : "";
|
|
146
|
+
return `spend so far: ${parts.join(" + ")}${who}. ` + "The dollar figure is METERED spend only; host tokens are a different currency and are never converted.";
|
|
147
|
+
}
|
|
148
|
+
function isTerminal(status) {
|
|
149
|
+
return TERMINAL_STATUSES.includes(status);
|
|
150
|
+
}
|
|
151
|
+
function cursorStage(view) {
|
|
152
|
+
if (view.cursor === null)
|
|
153
|
+
return null;
|
|
154
|
+
for (const phase of view.phases) {
|
|
155
|
+
if (phase.id !== view.cursor.phase)
|
|
156
|
+
continue;
|
|
157
|
+
return phase.stages.find((s) => s.id === view.cursor?.stage) ?? null;
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
function openRunViews(root) {
|
|
162
|
+
const open = [];
|
|
163
|
+
for (const dir of listRunDirs(root)) {
|
|
164
|
+
const view = loadRunView(dir);
|
|
165
|
+
if (view === null || isTerminal(view.status))
|
|
166
|
+
continue;
|
|
167
|
+
open.push(view);
|
|
168
|
+
}
|
|
169
|
+
return open.sort((a, b) => a.updated_at > b.updated_at ? -1 : a.updated_at < b.updated_at ? 1 : 0);
|
|
170
|
+
}
|
|
171
|
+
function newestActiveRun(root) {
|
|
172
|
+
return openRunViews(root)[0] ?? null;
|
|
173
|
+
}
|
|
174
|
+
function str(value) {
|
|
175
|
+
return typeof value === "string" ? value : "";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// src/hooks/lib/actor.ts
|
|
179
|
+
function currentActor() {
|
|
180
|
+
const user = process.env.USER ?? process.env.USERNAME ?? "";
|
|
181
|
+
return user.trim() === "" ? "unknown" : user.trim();
|
|
182
|
+
}
|
|
183
|
+
function nowRfc3339() {
|
|
184
|
+
return `${new Date().toISOString().slice(0, 19)}Z`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// src/core/budget/RunBudget.ts
|
|
188
|
+
var ON_EXCEED = ["block", "warn"];
|
|
189
|
+
var ON_HOST_TOKENS_EXCEED = ["warn", "block"];
|
|
190
|
+
var DEFAULT_ON_HOST_TOKENS_EXCEED = "warn";
|
|
191
|
+
var ECONOMIES = ["metered-usd", "host-tokens"];
|
|
192
|
+
var DEFAULT_ECONOMY = "metered-usd";
|
|
193
|
+
var DEFAULT_WARN_AT_PCT = 80;
|
|
194
|
+
var MAX_PHASES = 5;
|
|
195
|
+
function economyFor(budget, phaseId) {
|
|
196
|
+
if (budget === null)
|
|
197
|
+
return DEFAULT_ECONOMY;
|
|
198
|
+
if (phaseId !== undefined && phaseId !== null) {
|
|
199
|
+
const phase = budget.phases.find((entry) => entry.id === phaseId);
|
|
200
|
+
if (phase !== undefined && phase.economy !== null)
|
|
201
|
+
return phase.economy;
|
|
202
|
+
}
|
|
203
|
+
return budget.economy;
|
|
204
|
+
}
|
|
205
|
+
function isHostTokens(budget, phaseId) {
|
|
206
|
+
return economyFor(budget, phaseId) === "host-tokens";
|
|
207
|
+
}
|
|
208
|
+
function validateRunBudget(input) {
|
|
209
|
+
const issues = [];
|
|
210
|
+
const deprecations = [];
|
|
211
|
+
const doc = asDocument(input, issues);
|
|
212
|
+
if (!doc)
|
|
213
|
+
return result(issues);
|
|
214
|
+
requireVersion(doc, issues, deprecations);
|
|
215
|
+
requireKeys(doc, ["run", "ceiling_usd", "per_agent_max_usd", "on_exceed", "phases"], "", issues);
|
|
216
|
+
requireString(doc.run, "run", issues);
|
|
217
|
+
requireNumber(doc.ceiling_usd, "ceiling_usd", issues);
|
|
218
|
+
requireNumber(doc.per_agent_max_usd, "per_agent_max_usd", issues);
|
|
219
|
+
requireEnum(doc.on_exceed, ON_EXCEED, "on_exceed", issues);
|
|
220
|
+
if (doc.economy !== undefined && doc.economy !== null) {
|
|
221
|
+
requireEnum(doc.economy, ECONOMIES, "economy", issues);
|
|
222
|
+
}
|
|
223
|
+
if (doc.on_host_tokens_exceed !== undefined && doc.on_host_tokens_exceed !== null) {
|
|
224
|
+
requireEnum(doc.on_host_tokens_exceed, ON_HOST_TOKENS_EXCEED, "on_host_tokens_exceed", issues);
|
|
225
|
+
}
|
|
226
|
+
if (doc.warn_at_pct !== undefined) {
|
|
227
|
+
requireNumber(doc.warn_at_pct, "warn_at_pct", issues);
|
|
228
|
+
const pct = doc.warn_at_pct;
|
|
229
|
+
if (typeof pct === "number" && (pct < 1 || pct > 99)) {
|
|
230
|
+
issues.push({ path: "warn_at_pct", message: "expected 1–99" });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (!requireArray(doc.phases, "phases", issues))
|
|
234
|
+
return result(issues, deprecations);
|
|
235
|
+
const phases = doc.phases;
|
|
236
|
+
if (phases.length > MAX_PHASES) {
|
|
237
|
+
issues.push({ path: "phases", message: `${phases.length} phases exceeds the ${MAX_PHASES} cap` });
|
|
238
|
+
}
|
|
239
|
+
let sum = 0;
|
|
240
|
+
phases.forEach((phase, i) => {
|
|
241
|
+
const path = `phases[${i}]`;
|
|
242
|
+
if (!isRecord(phase)) {
|
|
243
|
+
issues.push({ path, message: "expected a mapping" });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
requireKeys(phase, ["id", "ceiling_usd", "spent_usd"], path, issues);
|
|
247
|
+
requireString(phase.id, `${path}.id`, issues);
|
|
248
|
+
requireNumber(phase.ceiling_usd, `${path}.ceiling_usd`, issues);
|
|
249
|
+
requireNumber(phase.spent_usd, `${path}.spent_usd`, issues);
|
|
250
|
+
if (phase.economy !== undefined && phase.economy !== null) {
|
|
251
|
+
requireEnum(phase.economy, ECONOMIES, `${path}.economy`, issues);
|
|
252
|
+
}
|
|
253
|
+
if (typeof phase.ceiling_usd === "number")
|
|
254
|
+
sum += phase.ceiling_usd;
|
|
255
|
+
});
|
|
256
|
+
if (typeof doc.ceiling_usd === "number" && sum > doc.ceiling_usd + 0.000000001) {
|
|
257
|
+
issues.push({ path: "phases", message: `phase ceilings sum to ${sum} > ceiling_usd ${doc.ceiling_usd}` });
|
|
258
|
+
}
|
|
259
|
+
return result(issues, deprecations);
|
|
260
|
+
}
|
|
261
|
+
function asRunBudget(input) {
|
|
262
|
+
const doc = input;
|
|
263
|
+
return {
|
|
264
|
+
version: doc.version ?? 1,
|
|
265
|
+
run: doc.run ?? "",
|
|
266
|
+
ceiling_usd: doc.ceiling_usd ?? 0,
|
|
267
|
+
per_agent_max_usd: doc.per_agent_max_usd ?? 0,
|
|
268
|
+
warn_at_pct: doc.warn_at_pct ?? DEFAULT_WARN_AT_PCT,
|
|
269
|
+
on_exceed: doc.on_exceed ?? "block",
|
|
270
|
+
economy: doc.economy ?? DEFAULT_ECONOMY,
|
|
271
|
+
on_host_tokens_exceed: doc.on_host_tokens_exceed ?? DEFAULT_ON_HOST_TOKENS_EXCEED,
|
|
272
|
+
phases: (doc.phases ?? []).map((phase) => ({
|
|
273
|
+
id: phase.id,
|
|
274
|
+
ceiling_usd: phase.ceiling_usd,
|
|
275
|
+
spent_usd: phase.spent_usd,
|
|
276
|
+
economy: phase.economy ?? null
|
|
277
|
+
}))
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
function hostTokenCeiling(budget, phaseId) {
|
|
281
|
+
if (budget === null || !isHostTokens(budget, phaseId))
|
|
282
|
+
return null;
|
|
283
|
+
if (phaseId !== undefined && phaseId !== null) {
|
|
284
|
+
const phase = budget.phases.find((entry) => entry.id === phaseId);
|
|
285
|
+
if (phase !== undefined)
|
|
286
|
+
return phase.ceiling_usd;
|
|
287
|
+
}
|
|
288
|
+
return budget.ceiling_usd;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// src/core/budget/remainingWork.ts
|
|
292
|
+
import { existsSync as existsSync7, readFileSync as readFileSync7 } from "node:fs";
|
|
293
|
+
import { join as join8 } from "node:path";
|
|
294
|
+
|
|
295
|
+
// src/core/run/buildProgress.ts
|
|
296
|
+
import { existsSync as existsSync6, readFileSync as readFileSync6 } from "node:fs";
|
|
297
|
+
import { join as join7 } from "node:path";
|
|
298
|
+
|
|
299
|
+
// src/core/schemas/waves.ts
|
|
300
|
+
function validateWaves(input) {
|
|
301
|
+
const issues = [];
|
|
302
|
+
const doc = asDocument(input, issues);
|
|
303
|
+
if (!doc)
|
|
304
|
+
return result(issues);
|
|
305
|
+
requireKeys(doc, ["version", "waves"], "", issues);
|
|
306
|
+
requireVersion1(doc, issues);
|
|
307
|
+
if (!requireArray(doc.waves, "waves", issues))
|
|
308
|
+
return result(issues);
|
|
309
|
+
const waves = doc.waves;
|
|
310
|
+
if (waves.length === 0)
|
|
311
|
+
issues.push({ path: "waves", message: "must not be empty" });
|
|
312
|
+
if (waves.length > MAX_WAVES) {
|
|
313
|
+
issues.push({ path: "waves", message: `${waves.length} waves exceeds the ${MAX_WAVES} cap` });
|
|
314
|
+
}
|
|
315
|
+
const seenWave = new Set;
|
|
316
|
+
const seenStory = new Map;
|
|
317
|
+
let previousNumber = 0;
|
|
318
|
+
let total = 0;
|
|
319
|
+
waves.forEach((wave, i) => {
|
|
320
|
+
const path = `waves[${i}]`;
|
|
321
|
+
if (!isRecord(wave)) {
|
|
322
|
+
issues.push({ path, message: "expected a mapping" });
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
requireKeys(wave, ["id", "stories"], path, issues);
|
|
326
|
+
requirePattern(wave.id, WAVE_ID_RE, "a wave id like `W1`", `${path}.id`, issues);
|
|
327
|
+
if (typeof wave.id === "string" && WAVE_ID_RE.test(wave.id)) {
|
|
328
|
+
if (seenWave.has(wave.id)) {
|
|
329
|
+
issues.push({ path: `${path}.id`, message: `\`${wave.id}\` is listed twice` });
|
|
330
|
+
}
|
|
331
|
+
seenWave.add(wave.id);
|
|
332
|
+
const number = Number(wave.id.slice(1));
|
|
333
|
+
if (number <= previousNumber) {
|
|
334
|
+
issues.push({
|
|
335
|
+
path: `${path}.id`,
|
|
336
|
+
message: `\`${wave.id}\` comes after \`W${String(previousNumber)}\` — waves are listed in execution order, so ids ascend`
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
previousNumber = Math.max(previousNumber, number);
|
|
340
|
+
}
|
|
341
|
+
const stories = requireStringList(wave.stories, `${path}.stories`, issues, {
|
|
342
|
+
nonEmpty: true,
|
|
343
|
+
max: MAX_STORIES_PER_WAVE,
|
|
344
|
+
pattern: STORY_ID_RE,
|
|
345
|
+
patternName: "a story id like `S3`",
|
|
346
|
+
unique: true
|
|
347
|
+
});
|
|
348
|
+
total += stories.length;
|
|
349
|
+
const waveId = typeof wave.id === "string" ? wave.id : path;
|
|
350
|
+
for (const story of stories) {
|
|
351
|
+
const already = seenStory.get(story);
|
|
352
|
+
if (already !== undefined) {
|
|
353
|
+
issues.push({
|
|
354
|
+
path: `${path}.stories`,
|
|
355
|
+
message: `\`${story}\` is already scheduled in ${already} — a story runs in exactly one wave`
|
|
356
|
+
});
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
seenStory.set(story, waveId);
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
if (total > MAX_PLAN_STORIES) {
|
|
363
|
+
issues.push({ path: "waves", message: `${total} scheduled stories exceeds the ${MAX_PLAN_STORIES} cap` });
|
|
364
|
+
}
|
|
365
|
+
return result(issues);
|
|
366
|
+
}
|
|
367
|
+
function asWavesFile(input) {
|
|
368
|
+
const doc = input;
|
|
369
|
+
return { version: doc.version ?? 1, waves: doc.waves ?? [] };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// src/core/detect/walk.ts
|
|
373
|
+
var SKIPPED_DIRS = new Set([
|
|
374
|
+
".git",
|
|
375
|
+
"node_modules",
|
|
376
|
+
"dist",
|
|
377
|
+
"build",
|
|
378
|
+
"out",
|
|
379
|
+
"bin",
|
|
380
|
+
"obj",
|
|
381
|
+
"target",
|
|
382
|
+
".venv",
|
|
383
|
+
"venv",
|
|
384
|
+
"__pycache__",
|
|
385
|
+
".next",
|
|
386
|
+
".expo",
|
|
387
|
+
".gradle",
|
|
388
|
+
"vendor",
|
|
389
|
+
"coverage",
|
|
390
|
+
"graphify-out",
|
|
391
|
+
".tldrx",
|
|
392
|
+
"tldrx-work",
|
|
393
|
+
".idea",
|
|
394
|
+
".vscode",
|
|
395
|
+
"Pods",
|
|
396
|
+
"DerivedData",
|
|
397
|
+
".terraform",
|
|
398
|
+
".turbo",
|
|
399
|
+
".cache",
|
|
400
|
+
"storybook-static",
|
|
401
|
+
"playwright-report",
|
|
402
|
+
"test-results"
|
|
403
|
+
]);
|
|
404
|
+
|
|
405
|
+
// src/core/plan/branchModel.ts
|
|
406
|
+
var BRANCH_MODELS = ["per-epic", "integration"];
|
|
407
|
+
function isBranchModelKind(value) {
|
|
408
|
+
return typeof value === "string" && BRANCH_MODELS.includes(value);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// src/core/experts/expertDocument.ts
|
|
412
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
|
|
413
|
+
import { join as join3 } from "node:path";
|
|
414
|
+
|
|
415
|
+
// src/core/experts/loadExperts.ts
|
|
416
|
+
import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2, statSync } from "node:fs";
|
|
417
|
+
import { join as join2 } from "node:path";
|
|
418
|
+
|
|
419
|
+
// src/core/init/competencyLevel.ts
|
|
420
|
+
var EVIDENCE_KINDS = ["code", "run", "test", "doc", "answer"];
|
|
421
|
+
var ALLOWED_KINDS = EVIDENCE_KINDS.join(", ");
|
|
422
|
+
function isEvidenceKind(value) {
|
|
423
|
+
return EVIDENCE_KINDS.includes(value);
|
|
424
|
+
}
|
|
425
|
+
var EVIDENCE_CONFIDENCES = ["measured", "inferred", "assumed"];
|
|
426
|
+
function isEvidenceConfidence(value) {
|
|
427
|
+
return EVIDENCE_CONFIDENCES.includes(value);
|
|
428
|
+
}
|
|
429
|
+
var WEIGHTS = { code: 1, run: 1, test: 1, doc: 0.5, answer: 0.8 };
|
|
430
|
+
var CROSS_MULTIPLIER = 2;
|
|
431
|
+
var ASSUMED_MULTIPLIER = 0.5;
|
|
432
|
+
var THRESHOLDS = [0.5, 1.5, 3, 6, 20];
|
|
433
|
+
var RECENCY_FLOOR = 0.25;
|
|
434
|
+
var RECENCY_HALFLIFE_DAYS = 365;
|
|
435
|
+
var NO_RUN_CAP = 3;
|
|
436
|
+
var MIN_KINDS_FOR_TOP = 2;
|
|
437
|
+
var DAY_MS = 86400000;
|
|
438
|
+
function competencyLevel(evidence, now = new Date) {
|
|
439
|
+
if (evidence.length === 0)
|
|
440
|
+
return 0;
|
|
441
|
+
let weighted = 0;
|
|
442
|
+
const sources = new Set;
|
|
443
|
+
const kinds = new Set;
|
|
444
|
+
for (const item of evidence) {
|
|
445
|
+
const ageDays = daysBetween(item.at, now);
|
|
446
|
+
if (ageDays === null)
|
|
447
|
+
continue;
|
|
448
|
+
weighted += recency(ageDays) * weightOf(item);
|
|
449
|
+
sources.add(item.src);
|
|
450
|
+
kinds.add(item.kind);
|
|
451
|
+
}
|
|
452
|
+
let level = THRESHOLDS.filter((threshold) => weighted >= threshold).length;
|
|
453
|
+
if (!kinds.has("run"))
|
|
454
|
+
level = Math.min(level, NO_RUN_CAP);
|
|
455
|
+
if (level === 5 && kinds.size < MIN_KINDS_FOR_TOP)
|
|
456
|
+
level = 4;
|
|
457
|
+
return Math.min(level, sources.size);
|
|
458
|
+
}
|
|
459
|
+
function weightOf(item) {
|
|
460
|
+
const base = WEIGHTS[item.kind];
|
|
461
|
+
const cross = item.cross === true ? CROSS_MULTIPLIER : 1;
|
|
462
|
+
const confidence = item.confidence === "assumed" ? ASSUMED_MULTIPLIER : 1;
|
|
463
|
+
return base * cross * confidence;
|
|
464
|
+
}
|
|
465
|
+
function recency(ageDays) {
|
|
466
|
+
return Math.max(RECENCY_FLOOR, 1 - ageDays / RECENCY_HALFLIFE_DAYS);
|
|
467
|
+
}
|
|
468
|
+
function daysBetween(at, now) {
|
|
469
|
+
const then = Date.parse(at);
|
|
470
|
+
if (Number.isNaN(then))
|
|
471
|
+
return null;
|
|
472
|
+
return Math.max(0, (now.getTime() - then) / DAY_MS);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// src/core/experts/evidenceSrc.ts
|
|
476
|
+
function checkEvidenceSrc(kind, src) {
|
|
477
|
+
const parsed = classifySrc(src);
|
|
478
|
+
if ("message" in parsed)
|
|
479
|
+
return { reason: "malformed-src" };
|
|
480
|
+
return matchesKind(kind, parsed) ? null : { reason: "kind-mismatch" };
|
|
481
|
+
}
|
|
482
|
+
function matchesKind(kind, ref) {
|
|
483
|
+
switch (kind) {
|
|
484
|
+
case "code":
|
|
485
|
+
return ref.kind === "file";
|
|
486
|
+
case "run":
|
|
487
|
+
return ref.kind === "cmd" || isRunArtifact(ref);
|
|
488
|
+
case "test":
|
|
489
|
+
return ref.kind === "file" || ref.kind === "cmd";
|
|
490
|
+
case "doc":
|
|
491
|
+
return ref.kind === "doc";
|
|
492
|
+
case "answer":
|
|
493
|
+
return ref.kind === "fact";
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
function isRunArtifact(ref) {
|
|
497
|
+
return ref.kind === "file" && ref.repo === null && ref.path.startsWith("tldrx-work/");
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// src/core/experts/readEvidenceRows.ts
|
|
501
|
+
function readEvidenceRows(input) {
|
|
502
|
+
if (!Array.isArray(input))
|
|
503
|
+
return { evidence: [], ignored: [] };
|
|
504
|
+
const evidence = [];
|
|
505
|
+
const counts = new Map;
|
|
506
|
+
const refuse = (reason, kind, src) => {
|
|
507
|
+
const key = `${reason} ${reason === "unknown-kind" ? kind : `${kind} ${src}`}`;
|
|
508
|
+
const seen = counts.get(key);
|
|
509
|
+
counts.set(key, seen === undefined ? { reason, kind, src, count: 1 } : { ...seen, count: seen.count + 1 });
|
|
510
|
+
};
|
|
511
|
+
for (const raw of input) {
|
|
512
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
513
|
+
continue;
|
|
514
|
+
const row = raw;
|
|
515
|
+
const kind = str2(row.kind);
|
|
516
|
+
const src = str2(row.src);
|
|
517
|
+
const at = str2(row.at);
|
|
518
|
+
if (src === "" || at === "")
|
|
519
|
+
continue;
|
|
520
|
+
if (!isEvidenceKind(kind)) {
|
|
521
|
+
refuse("unknown-kind", kind, "");
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
const problem = checkEvidenceSrc(kind, src);
|
|
525
|
+
if (problem !== null) {
|
|
526
|
+
refuse(problem.reason, kind, src);
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
const confidence = str2(row.confidence);
|
|
530
|
+
evidence.push({
|
|
531
|
+
kind,
|
|
532
|
+
src,
|
|
533
|
+
at,
|
|
534
|
+
...row.cross === true ? { cross: true } : {},
|
|
535
|
+
...isEvidenceConfidence(confidence) ? { confidence } : {}
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
return { evidence, ignored: [...counts.values()] };
|
|
539
|
+
}
|
|
540
|
+
function str2(value) {
|
|
541
|
+
return typeof value === "string" ? value : "";
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// src/core/experts/loadExperts.ts
|
|
545
|
+
var EXPERTS_DIRNAME = "experts";
|
|
546
|
+
var COMPETENCIES_FILE = "competencies.yml";
|
|
547
|
+
var EXPERT_FILE = "expert.md";
|
|
548
|
+
function expertsDir(root) {
|
|
549
|
+
return join2(root, PROJECT_FRAMEWORK_DIR, EXPERTS_DIRNAME);
|
|
550
|
+
}
|
|
551
|
+
function expertDir(root, name) {
|
|
552
|
+
return join2(expertsDir(root), name);
|
|
553
|
+
}
|
|
554
|
+
function loadExperts(root, now = new Date) {
|
|
555
|
+
const dir = expertsDir(root);
|
|
556
|
+
if (!existsSync2(dir))
|
|
557
|
+
return [];
|
|
558
|
+
const names = readdirSync(dir).filter((entry) => statSync(join2(dir, entry)).isDirectory()).sort();
|
|
559
|
+
return names.map((name) => loadExpert(root, name, now));
|
|
560
|
+
}
|
|
561
|
+
function loadExpert(root, name, now = new Date) {
|
|
562
|
+
const dir = expertDir(root, name);
|
|
563
|
+
const path = join2(dir, COMPETENCIES_FILE);
|
|
564
|
+
const empty = { name, dir, status: "unknown", lastTrained: null, areas: [], drifted: [] };
|
|
565
|
+
if (!existsSync2(path))
|
|
566
|
+
return { ...empty, error: `${COMPETENCIES_FILE} is missing` };
|
|
567
|
+
let doc;
|
|
568
|
+
try {
|
|
569
|
+
const parsed = parseYaml(readFileSync2(path, "utf8"));
|
|
570
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
571
|
+
return { ...empty, error: `${COMPETENCIES_FILE} is not a mapping` };
|
|
572
|
+
}
|
|
573
|
+
doc = parsed;
|
|
574
|
+
} catch (error) {
|
|
575
|
+
return { ...empty, error: error instanceof Error ? error.message : String(error) };
|
|
576
|
+
}
|
|
577
|
+
const areas = Array.isArray(doc.areas) ? doc.areas.map((area) => toArea(name, area, now)).filter((area) => area !== null) : [];
|
|
578
|
+
return {
|
|
579
|
+
name: typeof doc.expert === "string" && doc.expert !== "" ? doc.expert : name,
|
|
580
|
+
dir,
|
|
581
|
+
status: typeof doc.status === "string" ? doc.status : "unknown",
|
|
582
|
+
lastTrained: typeof doc.last_trained === "string" ? doc.last_trained : null,
|
|
583
|
+
areas,
|
|
584
|
+
drifted: areas.filter((area) => area.storedLevel !== null && area.storedLevel !== area.level),
|
|
585
|
+
error: null
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
function toArea(expert, input, now) {
|
|
589
|
+
if (typeof input !== "object" || input === null || Array.isArray(input))
|
|
590
|
+
return null;
|
|
591
|
+
const raw = input;
|
|
592
|
+
const id = str3(raw.id) !== "" ? str3(raw.id) : str3(raw.area);
|
|
593
|
+
if (id === "")
|
|
594
|
+
return null;
|
|
595
|
+
const { evidence, ignored } = readEvidenceRows(raw.evidence);
|
|
596
|
+
return {
|
|
597
|
+
id,
|
|
598
|
+
title: str3(raw.title) !== "" ? str3(raw.title) : id,
|
|
599
|
+
storedLevel: typeof raw.level === "number" ? raw.level : null,
|
|
600
|
+
level: competencyLevel(evidence, now),
|
|
601
|
+
trainPrompt: str3(raw.train_prompt) !== "" ? str3(raw.train_prompt) : `tldrx expert train ${expert} --area ${id} --mode light`,
|
|
602
|
+
evidence,
|
|
603
|
+
ignored,
|
|
604
|
+
newestEvidence: newestDate(evidence)
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
function newestDate(evidence) {
|
|
608
|
+
let newest = null;
|
|
609
|
+
for (const item of evidence) {
|
|
610
|
+
if (newest === null || item.at > newest)
|
|
611
|
+
newest = item.at;
|
|
612
|
+
}
|
|
613
|
+
return newest;
|
|
614
|
+
}
|
|
615
|
+
function str3(value) {
|
|
616
|
+
return typeof value === "string" ? value : "";
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// src/core/experts/expertDocument.ts
|
|
620
|
+
var H2_RE = /^##\s+(.+?)\s*$/;
|
|
621
|
+
function readExpertDocument(root, name) {
|
|
622
|
+
const path = join3(expertDir(root, name), EXPERT_FILE);
|
|
623
|
+
if (!existsSync3(path)) {
|
|
624
|
+
return { path, exists: false, frontMatter: new Map, body: "" };
|
|
625
|
+
}
|
|
626
|
+
const text = readFileSync3(path, "utf8");
|
|
627
|
+
const { frontMatter, body } = splitFrontMatter(text);
|
|
628
|
+
return { path, exists: true, frontMatter, body };
|
|
629
|
+
}
|
|
630
|
+
function splitFrontMatter(text) {
|
|
631
|
+
const frontMatter = new Map;
|
|
632
|
+
if (!text.startsWith(`---
|
|
633
|
+
`))
|
|
634
|
+
return { frontMatter, body: text };
|
|
635
|
+
const end = text.indexOf(`
|
|
636
|
+
---`, 3);
|
|
637
|
+
if (end === -1)
|
|
638
|
+
return { frontMatter, body: text };
|
|
639
|
+
for (const line of text.slice(4, end).split(`
|
|
640
|
+
`)) {
|
|
641
|
+
const colon = line.indexOf(":");
|
|
642
|
+
if (colon <= 0 || line.trimStart().startsWith("#"))
|
|
643
|
+
continue;
|
|
644
|
+
const key = line.slice(0, colon).trim();
|
|
645
|
+
const value = line.slice(colon + 1).replace(/#.*$/, "").trim().replace(/^"(.*)"$/, "$1");
|
|
646
|
+
if (key !== "")
|
|
647
|
+
frontMatter.set(key, value);
|
|
648
|
+
}
|
|
649
|
+
const newline = text.indexOf(`
|
|
650
|
+
`, end + 1);
|
|
651
|
+
return { frontMatter, body: newline === -1 ? "" : text.slice(newline + 1) };
|
|
652
|
+
}
|
|
653
|
+
function section(body, heading) {
|
|
654
|
+
const lines = body.split(`
|
|
655
|
+
`);
|
|
656
|
+
const collected = [];
|
|
657
|
+
let inside = false;
|
|
658
|
+
for (const line of lines) {
|
|
659
|
+
const match = H2_RE.exec(line);
|
|
660
|
+
if (match !== null) {
|
|
661
|
+
if (inside)
|
|
662
|
+
break;
|
|
663
|
+
inside = match[1] === heading;
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
if (inside)
|
|
667
|
+
collected.push(line);
|
|
668
|
+
}
|
|
669
|
+
return collected.join(`
|
|
670
|
+
`).trim();
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// src/core/experts/expertDomain.ts
|
|
674
|
+
var PLACEHOLDER_PATHS = new Set(["path/to/module", "path/to/other"]);
|
|
675
|
+
function readExpertDomain(root, name) {
|
|
676
|
+
const doc = readExpertDocument(root, name);
|
|
677
|
+
if (!doc.exists)
|
|
678
|
+
return { name, exists: false, kind: "", repos: [], paths: [] };
|
|
679
|
+
return {
|
|
680
|
+
name,
|
|
681
|
+
exists: true,
|
|
682
|
+
kind: doc.frontMatter.get("kind") ?? "",
|
|
683
|
+
repos: parseList(doc.frontMatter.get("repos") ?? ""),
|
|
684
|
+
paths: domainPaths(section(doc.body, "Domain"))
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
function parseList(value) {
|
|
688
|
+
const inner = value.trim().replace(/^\[/, "").replace(/\]$/, "");
|
|
689
|
+
return inner.split(",").map((item) => item.trim().replace(/^["']|["']$/g, "")).filter((item) => item !== "");
|
|
690
|
+
}
|
|
691
|
+
function domainPaths(body) {
|
|
692
|
+
const paths = [];
|
|
693
|
+
for (const line of body.split(`
|
|
694
|
+
`)) {
|
|
695
|
+
const bullet = /^\s*[-*]\s+(.*)$/.exec(line);
|
|
696
|
+
if (bullet === null)
|
|
697
|
+
continue;
|
|
698
|
+
const text = (bullet[1] ?? "").trim();
|
|
699
|
+
if (/^repo\s/i.test(text))
|
|
700
|
+
continue;
|
|
701
|
+
const code = /`([^`]+)`/.exec(text);
|
|
702
|
+
const raw = (code?.[1] ?? text.split(/\s+—\s+/)[0] ?? "").trim();
|
|
703
|
+
const path = normalisePath(raw);
|
|
704
|
+
if (path === "" || PLACEHOLDER_PATHS.has(path) || !path.includes("/"))
|
|
705
|
+
continue;
|
|
706
|
+
if (!paths.includes(path))
|
|
707
|
+
paths.push(path);
|
|
708
|
+
}
|
|
709
|
+
return paths;
|
|
710
|
+
}
|
|
711
|
+
function normalisePath(raw) {
|
|
712
|
+
return raw.trim().replace(/^[a-z0-9-]+:(?=[^/])/i, "").replace(/^\.\//, "").replace(/\/+$/, "").replace(/\\/g, "/");
|
|
713
|
+
}
|
|
714
|
+
function pathsIntersect(a, b) {
|
|
715
|
+
const left = normalisePath(a);
|
|
716
|
+
const right = normalisePath(b);
|
|
717
|
+
if (left === "" || right === "")
|
|
718
|
+
return false;
|
|
719
|
+
if (left === right)
|
|
720
|
+
return true;
|
|
721
|
+
return left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// src/core/experts/expertKnowledge.ts
|
|
725
|
+
var DEFAULT_KNOWLEDGE_MAX_BYTES = 48 * 1024;
|
|
726
|
+
|
|
727
|
+
// src/core/facilitator/paths.ts
|
|
728
|
+
import { join as join4, relative, sep } from "node:path";
|
|
729
|
+
|
|
730
|
+
// src/core/text/evidence.ts
|
|
731
|
+
var EVIDENCE_VERSION = 1;
|
|
732
|
+
var EVIDENCE_FILE = "evidence.md";
|
|
733
|
+
var GATE_EVIDENCE_DIR = "gate-evidence";
|
|
734
|
+
function gateEvidenceRelPath(phaseId, stageId) {
|
|
735
|
+
return `${phaseId}/${GATE_EVIDENCE_DIR}/${stageId}.md`;
|
|
736
|
+
}
|
|
737
|
+
var EVIDENCE_VERDICTS = ["sign", "sign-with-fixlist", "refuse"];
|
|
738
|
+
var EVIDENCE_ROLES = ["agent"];
|
|
739
|
+
var DIFF_VS_STORIES = ["matches", "diverges", "n-a"];
|
|
740
|
+
var REQUIRED_EVIDENCE_KEYS = [
|
|
741
|
+
"version",
|
|
742
|
+
"gate",
|
|
743
|
+
"role",
|
|
744
|
+
"by",
|
|
745
|
+
"at",
|
|
746
|
+
"verdict",
|
|
747
|
+
"read",
|
|
748
|
+
"citations",
|
|
749
|
+
"touches",
|
|
750
|
+
"diff_vs_stories"
|
|
751
|
+
];
|
|
752
|
+
function parseEvidence(text) {
|
|
753
|
+
const parsed = parseFrontMatter(text);
|
|
754
|
+
const body = parseHandoff(parsed.frontMatter.body);
|
|
755
|
+
const bodyLine = parsed.frontMatter.bodyLine;
|
|
756
|
+
if (parsed.doc === null) {
|
|
757
|
+
return {
|
|
758
|
+
front: null,
|
|
759
|
+
frontIssues: [front(0, parsed.issue?.message ?? "front matter could not be read")],
|
|
760
|
+
body,
|
|
761
|
+
bodyLine
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
if (!isRecord2(parsed.doc)) {
|
|
765
|
+
return {
|
|
766
|
+
front: null,
|
|
767
|
+
frontIssues: [front(0, "front matter is empty — it must be a mapping of the keys design §A.5 names")],
|
|
768
|
+
body,
|
|
769
|
+
bodyLine
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
const issues = [];
|
|
773
|
+
const doc = parsed.doc;
|
|
774
|
+
for (const key of REQUIRED_EVIDENCE_KEYS) {
|
|
775
|
+
if (!(key in doc) || doc[key] === undefined) {
|
|
776
|
+
issues.push(front(0, `front matter is missing required key \`${key}\``));
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
const version = doc.version;
|
|
780
|
+
if (version !== undefined && version !== EVIDENCE_VERSION) {
|
|
781
|
+
issues.push(front(0, `\`version\` must be ${String(EVIDENCE_VERSION)}, got ${describe(version)}`));
|
|
782
|
+
}
|
|
783
|
+
const gate = requireText(doc.gate, "gate", issues);
|
|
784
|
+
const role = requireEnum2(doc.role, EVIDENCE_ROLES, "role", issues);
|
|
785
|
+
const by = requireText(doc.by, "by", issues);
|
|
786
|
+
const at = requireText(doc.at, "at", issues);
|
|
787
|
+
const verdict = requireEnum2(doc.verdict, EVIDENCE_VERDICTS, "verdict", issues);
|
|
788
|
+
const read = requireStrings(doc.read, "read", issues);
|
|
789
|
+
const citations = requireCitations(doc.citations, issues);
|
|
790
|
+
const touches = requireTouches(doc.touches, issues);
|
|
791
|
+
const diff = requireEnum2(doc.diff_vs_stories, DIFF_VS_STORIES, "diff_vs_stories", issues);
|
|
792
|
+
const caveats = doc.caveats === undefined ? [] : requireStrings(doc.caveats, "caveats", issues);
|
|
793
|
+
const recommend = requireRecommendations(doc.recommend, issues);
|
|
794
|
+
if (issues.length > 0)
|
|
795
|
+
return { front: null, frontIssues: issues, body, bodyLine };
|
|
796
|
+
return {
|
|
797
|
+
front: {
|
|
798
|
+
version: EVIDENCE_VERSION,
|
|
799
|
+
gate,
|
|
800
|
+
role,
|
|
801
|
+
by,
|
|
802
|
+
at,
|
|
803
|
+
verdict,
|
|
804
|
+
read,
|
|
805
|
+
citations,
|
|
806
|
+
touches,
|
|
807
|
+
diff_vs_stories: diff,
|
|
808
|
+
caveats,
|
|
809
|
+
recommend
|
|
810
|
+
},
|
|
811
|
+
frontIssues: [],
|
|
812
|
+
body,
|
|
813
|
+
bodyLine
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
var GUIDANCE = {
|
|
817
|
+
Read: "One bullet per file or artefact you opened, each ending with a `[src: …]` token for it.",
|
|
818
|
+
"Citations checked": "Which of this stage's citations you re-checked, and what each one turned out to be. " + "Sample honestly: `citations.sampled` above must match what is listed here.",
|
|
819
|
+
"Touches audited": "The touched paths you looked at, and every path outside the surface the What and the " + "stories declared. Name a new area rather than absorbing it.",
|
|
820
|
+
Verdict: "SIGN, SIGN-with-fixlist or REFUSE, and the measurement behind it — a command you ran, " + "a file you read. A verdict with no source is an opinion."
|
|
821
|
+
};
|
|
822
|
+
function front(line, message) {
|
|
823
|
+
return { kind: "front-matter", line, message };
|
|
824
|
+
}
|
|
825
|
+
function isRecord2(value) {
|
|
826
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
827
|
+
}
|
|
828
|
+
function requireText(value, key, issues) {
|
|
829
|
+
if (typeof value === "string" && value.trim() !== "")
|
|
830
|
+
return value.trim();
|
|
831
|
+
if (value !== undefined)
|
|
832
|
+
issues.push(front(0, `\`${key}\` must be a non-empty string, got ${describe(value)}`));
|
|
833
|
+
return "";
|
|
834
|
+
}
|
|
835
|
+
function requireEnum2(value, allowed, key, issues) {
|
|
836
|
+
if (typeof value === "string" && allowed.includes(value))
|
|
837
|
+
return value;
|
|
838
|
+
if (value !== undefined) {
|
|
839
|
+
issues.push(front(0, `\`${key}\` must be one of ${allowed.join(" | ")}, got ${describe(value)}`));
|
|
840
|
+
}
|
|
841
|
+
return allowed[0];
|
|
842
|
+
}
|
|
843
|
+
function requireStrings(value, key, issues) {
|
|
844
|
+
if (!Array.isArray(value)) {
|
|
845
|
+
if (value !== undefined)
|
|
846
|
+
issues.push(front(0, `\`${key}\` must be a list, got ${describe(value)}`));
|
|
847
|
+
return [];
|
|
848
|
+
}
|
|
849
|
+
const out = [];
|
|
850
|
+
for (const [i, entry] of value.entries()) {
|
|
851
|
+
if (typeof entry === "string")
|
|
852
|
+
out.push(entry);
|
|
853
|
+
else
|
|
854
|
+
issues.push(front(0, `\`${key}[${String(i)}]\` must be a string, got ${describe(entry)}`));
|
|
855
|
+
}
|
|
856
|
+
return out;
|
|
857
|
+
}
|
|
858
|
+
function requireCount(value, key, issues) {
|
|
859
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= 0)
|
|
860
|
+
return value;
|
|
861
|
+
issues.push(front(0, `\`${key}\` must be a whole number of 0 or more, got ${describe(value)}`));
|
|
862
|
+
return 0;
|
|
863
|
+
}
|
|
864
|
+
function requireCitations(value, issues) {
|
|
865
|
+
if (!isRecord2(value)) {
|
|
866
|
+
if (value !== undefined) {
|
|
867
|
+
issues.push(front(0, "`citations` must be a mapping `{sampled, of, resolved, refuted}`, got " + describe(value)));
|
|
868
|
+
}
|
|
869
|
+
return { sampled: 0, of: 0, resolved: 0, refuted: 0 };
|
|
870
|
+
}
|
|
871
|
+
return {
|
|
872
|
+
sampled: requireCount(value.sampled, "citations.sampled", issues),
|
|
873
|
+
of: requireCount(value.of, "citations.of", issues),
|
|
874
|
+
resolved: requireCount(value.resolved, "citations.resolved", issues),
|
|
875
|
+
refuted: requireCount(value.refuted, "citations.refuted", issues)
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
function requireTouches(value, issues) {
|
|
879
|
+
if (!isRecord2(value)) {
|
|
880
|
+
if (value !== undefined) {
|
|
881
|
+
issues.push(front(0, "`touches` must be a mapping `{audited, outside_surface, new_areas}`, got " + describe(value)));
|
|
882
|
+
}
|
|
883
|
+
return { audited: 0, outside_surface: 0, new_areas: [] };
|
|
884
|
+
}
|
|
885
|
+
return {
|
|
886
|
+
audited: requireCount(value.audited, "touches.audited", issues),
|
|
887
|
+
outside_surface: requireCount(value.outside_surface, "touches.outside_surface", issues),
|
|
888
|
+
new_areas: requireStrings(value.new_areas, "touches.new_areas", issues)
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
function requireRecommendations(value, issues) {
|
|
892
|
+
if (value === undefined)
|
|
893
|
+
return [];
|
|
894
|
+
if (!Array.isArray(value)) {
|
|
895
|
+
issues.push(front(0, `\`recommend\` must be a list of {q, option, why, src}, got ${describe(value)}`));
|
|
896
|
+
return [];
|
|
897
|
+
}
|
|
898
|
+
const out = [];
|
|
899
|
+
for (const [i, entry] of value.entries()) {
|
|
900
|
+
if (!isRecord2(entry)) {
|
|
901
|
+
issues.push(front(0, `\`recommend[${String(i)}]\` must be a mapping {q, option, why, src}`));
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
const path = `recommend[${String(i)}]`;
|
|
905
|
+
for (const key of ["q", "option", "why", "src"]) {
|
|
906
|
+
if (!(key in entry))
|
|
907
|
+
issues.push(front(0, `\`${path}\` is missing \`${key}\``));
|
|
908
|
+
}
|
|
909
|
+
out.push({
|
|
910
|
+
q: requireText(entry.q, `${path}.q`, issues),
|
|
911
|
+
option: requireText(entry.option, `${path}.option`, issues),
|
|
912
|
+
why: requireText(entry.why, `${path}.why`, issues),
|
|
913
|
+
src: requireText(entry.src, `${path}.src`, issues)
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
return out;
|
|
917
|
+
}
|
|
918
|
+
function describe(value) {
|
|
919
|
+
if (value === null || value === undefined)
|
|
920
|
+
return "nothing";
|
|
921
|
+
if (Array.isArray(value))
|
|
922
|
+
return "a list";
|
|
923
|
+
if (typeof value === "object")
|
|
924
|
+
return "a mapping";
|
|
925
|
+
return typeof value === "string" ? `\`${value}\`` : String(value);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// src/core/facilitator/paths.ts
|
|
929
|
+
function agentDir(runDir, stageId) {
|
|
930
|
+
return join4(runDir, ".agent", stageId);
|
|
931
|
+
}
|
|
932
|
+
function evidencePath(runDir, stageId) {
|
|
933
|
+
return join4(agentDir(runDir, stageId), EVIDENCE_FILE);
|
|
934
|
+
}
|
|
935
|
+
function gateEvidencePath(runDir, phaseId, stageId) {
|
|
936
|
+
return join4(runDir, ...gateEvidenceRelPath(phaseId, stageId).split("/"));
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// src/core/facilitator/dispatchNotes.ts
|
|
940
|
+
var DISPATCH_NOTES_MAX_BYTES = 8 * 1024;
|
|
941
|
+
|
|
942
|
+
// src/core/experts/stackExperts.ts
|
|
943
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4 } from "node:fs";
|
|
944
|
+
import { join as join5 } from "node:path";
|
|
945
|
+
function stackExpertNames(root, repos) {
|
|
946
|
+
const path = join5(root, PROJECT_FRAMEWORK_DIR, "workspace.yml");
|
|
947
|
+
if (!existsSync4(path))
|
|
948
|
+
return [];
|
|
949
|
+
let doc;
|
|
950
|
+
try {
|
|
951
|
+
doc = parseYaml(readFileSync4(path, "utf8"));
|
|
952
|
+
} catch {
|
|
953
|
+
return [];
|
|
954
|
+
}
|
|
955
|
+
const list = doc?.repos;
|
|
956
|
+
if (!Array.isArray(list))
|
|
957
|
+
return [];
|
|
958
|
+
const names = [];
|
|
959
|
+
for (const row of list) {
|
|
960
|
+
if (typeof row?.name !== "string" || !repos.includes(row.name))
|
|
961
|
+
continue;
|
|
962
|
+
const stack = Array.isArray(row.stack) ? row.stack : [];
|
|
963
|
+
for (const language of stack) {
|
|
964
|
+
if (typeof language !== "string" || language === "")
|
|
965
|
+
continue;
|
|
966
|
+
const expert = `${language}-stack`;
|
|
967
|
+
if (!names.includes(expert))
|
|
968
|
+
names.push(expert);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
return names;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// src/core/build/prompts.ts
|
|
975
|
+
var MAX_TOUCHED_BYTES = 64 * 1024;
|
|
976
|
+
|
|
977
|
+
// src/core/build/plan.ts
|
|
978
|
+
import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:fs";
|
|
979
|
+
import { join as join6 } from "node:path";
|
|
980
|
+
|
|
981
|
+
// src/core/schemas/budget.ts
|
|
982
|
+
function validateBudget(input) {
|
|
983
|
+
const issues = [];
|
|
984
|
+
const deprecations = [];
|
|
985
|
+
const doc = asDocument(input, issues);
|
|
986
|
+
if (!doc)
|
|
987
|
+
return result(issues);
|
|
988
|
+
requireVersion(doc, issues, deprecations);
|
|
989
|
+
requireKeys(doc, ["run", "ceiling_usd", "spent_usd", "per_phase_usd"], "", issues);
|
|
990
|
+
requireString(doc.run, "run", issues);
|
|
991
|
+
requireNumber(doc.ceiling_usd, "ceiling_usd", issues);
|
|
992
|
+
requireNumber(doc.spent_usd, "spent_usd", issues);
|
|
993
|
+
if (doc.economy !== undefined && doc.economy !== null) {
|
|
994
|
+
requireEnum(doc.economy, ECONOMIES, "economy", issues);
|
|
995
|
+
}
|
|
996
|
+
if (requireRecord(doc.per_phase_usd, "per_phase_usd", issues)) {
|
|
997
|
+
for (const [key, value] of Object.entries(doc.per_phase_usd)) {
|
|
998
|
+
requireNumber(value, `per_phase_usd.${key}`, issues);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
return result(issues, deprecations);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// src/core/build/plan.ts
|
|
1005
|
+
var BUILD_PHASE = "04-build";
|
|
1006
|
+
var PLAN_PHASE = "03-plan";
|
|
1007
|
+
var PLAN_BUDGET_FILE = "budget.yml";
|
|
1008
|
+
function loadPlanPrices(planDir, stories) {
|
|
1009
|
+
const empty = new Map;
|
|
1010
|
+
const path = join6(planDir, PLAN_BUDGET_FILE);
|
|
1011
|
+
if (!existsSync5(path))
|
|
1012
|
+
return { prices: empty, issue: null };
|
|
1013
|
+
let doc;
|
|
1014
|
+
try {
|
|
1015
|
+
doc = parseYaml(readFileSync5(path, "utf8"));
|
|
1016
|
+
} catch (error) {
|
|
1017
|
+
return { prices: empty, issue: budgetIgnored(`it does not parse (${String(error)})`) };
|
|
1018
|
+
}
|
|
1019
|
+
const validation = validateBudget(doc);
|
|
1020
|
+
if (!validation.ok) {
|
|
1021
|
+
const first = validation.issues[0];
|
|
1022
|
+
return {
|
|
1023
|
+
prices: empty,
|
|
1024
|
+
issue: budgetIgnored(`it does not validate (${first?.path ?? ""} ${first?.message ?? "schema error"})`)
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
const declared = doc.economy;
|
|
1028
|
+
if (declared === "host-tokens") {
|
|
1029
|
+
return {
|
|
1030
|
+
prices: empty,
|
|
1031
|
+
issue: budgetIgnored("it is priced in `host-tokens`, and those numbers are not dollars a spawn may spend")
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
const perPhase = doc.per_phase_usd ?? {};
|
|
1035
|
+
const prices = new Map;
|
|
1036
|
+
const skipped = [];
|
|
1037
|
+
for (const [key, value] of Object.entries(perPhase)) {
|
|
1038
|
+
if (!STORY_ID_RE.test(key))
|
|
1039
|
+
continue;
|
|
1040
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
1041
|
+
skipped.push(`${key} (${String(value)})`);
|
|
1042
|
+
continue;
|
|
1043
|
+
}
|
|
1044
|
+
if (!stories.has(key)) {
|
|
1045
|
+
skipped.push(`${key} (not scheduled)`);
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
prices.set(key, value);
|
|
1049
|
+
}
|
|
1050
|
+
if (prices.size === 0) {
|
|
1051
|
+
return {
|
|
1052
|
+
prices: empty,
|
|
1053
|
+
issue: skipped.length === 0 ? null : budgetIgnored(`no usable story price in it — skipped ${skipped.join(", ")}`)
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
return {
|
|
1057
|
+
prices,
|
|
1058
|
+
issue: skipped.length === 0 ? null : `note: ${PLAN_PHASE}/${PLAN_BUDGET_FILE} priced ${String(prices.size)} story(ies); ` + `skipped ${skipped.join(", ")}`
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
function budgetIgnored(why) {
|
|
1062
|
+
return `warning: ${PLAN_PHASE}/${PLAN_BUDGET_FILE} was ignored because ${why} — ` + "story caps fall back to an equal share of the stage ceiling";
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// src/core/build/implicitPlan.ts
|
|
1066
|
+
var IMPLICIT_PLAN_FILE = "implicit-plan.yml";
|
|
1067
|
+
var IMPLICIT_PLAN_REL = `${BUILD_PHASE}/${IMPLICIT_PLAN_FILE}`;
|
|
1068
|
+
var IMPLICIT_STORY_NOTE = "Plan was skipped by the scope; this single story applies the run's answered decisions " + "to the files it touches.";
|
|
1069
|
+
var EMPTY_ANSWER_INDEX = {
|
|
1070
|
+
answers: new Map,
|
|
1071
|
+
restated: new Map,
|
|
1072
|
+
lines: new Map
|
|
1073
|
+
};
|
|
1074
|
+
|
|
1075
|
+
// src/core/run/buildProgress.ts
|
|
1076
|
+
var PLAN_DIR = "03-plan";
|
|
1077
|
+
var BUILD_PHASE2 = "04-build";
|
|
1078
|
+
var STATUS_RE = /^status\s*:\s*(\w+)\s*$/m;
|
|
1079
|
+
function buildProgress(runDir) {
|
|
1080
|
+
const wavesPath = join7(runDir, PLAN_DIR, "waves.yml");
|
|
1081
|
+
const implicitPath = join7(runDir, BUILD_PHASE2, IMPLICIT_PLAN_FILE);
|
|
1082
|
+
const implicit = !existsSync6(wavesPath) && existsSync6(implicitPath);
|
|
1083
|
+
const path = implicit ? implicitPath : wavesPath;
|
|
1084
|
+
if (!existsSync6(path))
|
|
1085
|
+
return null;
|
|
1086
|
+
let doc;
|
|
1087
|
+
try {
|
|
1088
|
+
doc = parseYaml(readFileSync6(path, "utf8"));
|
|
1089
|
+
} catch {
|
|
1090
|
+
return null;
|
|
1091
|
+
}
|
|
1092
|
+
if (!validateWaves(doc).ok)
|
|
1093
|
+
return null;
|
|
1094
|
+
const costs = costByStory(runDir);
|
|
1095
|
+
const waves = [];
|
|
1096
|
+
let done = 0;
|
|
1097
|
+
let total = 0;
|
|
1098
|
+
let cost = 0;
|
|
1099
|
+
for (const wave of asWavesFile(doc).waves) {
|
|
1100
|
+
const stories = [];
|
|
1101
|
+
for (const id of wave.stories) {
|
|
1102
|
+
const status = implicit ? implicitStatus(implicitPath) : statusOf(runDir, id);
|
|
1103
|
+
const spent = costs.get(id) ?? 0;
|
|
1104
|
+
stories.push({ id, status, cost_usd: spent });
|
|
1105
|
+
total += 1;
|
|
1106
|
+
cost += spent;
|
|
1107
|
+
if (status === "done")
|
|
1108
|
+
done += 1;
|
|
1109
|
+
}
|
|
1110
|
+
waves.push({ id: wave.id, stories });
|
|
1111
|
+
}
|
|
1112
|
+
return { waves, done, total, cost_usd: round(cost), implicit };
|
|
1113
|
+
}
|
|
1114
|
+
function implicitStatus(path) {
|
|
1115
|
+
try {
|
|
1116
|
+
return STATUS_RE.exec(readFileSync6(path, "utf8"))?.[1] ?? "todo";
|
|
1117
|
+
} catch {
|
|
1118
|
+
return "todo";
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
function statusOf(runDir, id) {
|
|
1122
|
+
const path = join7(runDir, PLAN_DIR, "stories", `${id}.md`);
|
|
1123
|
+
if (!existsSync6(path))
|
|
1124
|
+
return "missing";
|
|
1125
|
+
try {
|
|
1126
|
+
return STATUS_RE.exec(readFileSync6(path, "utf8"))?.[1] ?? "todo";
|
|
1127
|
+
} catch {
|
|
1128
|
+
return "todo";
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
function costByStory(runDir) {
|
|
1132
|
+
const costs = new Map;
|
|
1133
|
+
const path = join7(runDir, "events.jsonl");
|
|
1134
|
+
if (!existsSync6(path))
|
|
1135
|
+
return costs;
|
|
1136
|
+
let text;
|
|
1137
|
+
try {
|
|
1138
|
+
text = readFileSync6(path, "utf8");
|
|
1139
|
+
} catch {
|
|
1140
|
+
return costs;
|
|
1141
|
+
}
|
|
1142
|
+
for (const line of text.split(`
|
|
1143
|
+
`)) {
|
|
1144
|
+
if (line.trim() === "")
|
|
1145
|
+
continue;
|
|
1146
|
+
let event;
|
|
1147
|
+
try {
|
|
1148
|
+
event = JSON.parse(line);
|
|
1149
|
+
} catch {
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
if (event.type !== "agent.result")
|
|
1153
|
+
continue;
|
|
1154
|
+
const payload = event.payload;
|
|
1155
|
+
const story = typeof payload.story === "string" ? payload.story : payload.key;
|
|
1156
|
+
if (typeof story !== "string" || story === "")
|
|
1157
|
+
continue;
|
|
1158
|
+
costs.set(story, round((costs.get(story) ?? 0) + (typeof event.cost_usd === "number" ? event.cost_usd : 0)));
|
|
1159
|
+
}
|
|
1160
|
+
return costs;
|
|
1161
|
+
}
|
|
1162
|
+
function round(n) {
|
|
1163
|
+
return Math.round(n * 100) / 100;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// src/core/build/review.ts
|
|
1167
|
+
var VERDICT_WORDS = ["approve", "fixlist", "changes"];
|
|
1168
|
+
var VERDICT_ENUM = VERDICT_WORDS.join("|");
|
|
1169
|
+
var REVIEWER_FAILED = "the reviewer sub-agent failed";
|
|
1170
|
+
function looksLikeReviewerError(detail) {
|
|
1171
|
+
const text = detail.trim();
|
|
1172
|
+
return text === REVIEWER_FAILED || looksLikeSpawnError(text);
|
|
1173
|
+
}
|
|
1174
|
+
function looksLikeSpawnError(detail) {
|
|
1175
|
+
const text = detail.trim();
|
|
1176
|
+
return text.startsWith("claude exited ") || text.startsWith("claude timed out") || text.startsWith("stopped after ");
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// src/core/budget/remainingWork.ts
|
|
1180
|
+
var MAX_ATTEMPTS = 2;
|
|
1181
|
+
var REVIEWER_SHARE = 0.25;
|
|
1182
|
+
var REVIEWER_FLOOR_USD = 1;
|
|
1183
|
+
function remainingWork(input) {
|
|
1184
|
+
try {
|
|
1185
|
+
return measure(input);
|
|
1186
|
+
} catch {
|
|
1187
|
+
return staticOnly(input);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
function staticOnly(input) {
|
|
1191
|
+
return {
|
|
1192
|
+
basis: "static",
|
|
1193
|
+
usd: input.stageBudgetUsd,
|
|
1194
|
+
rawUsd: input.stageBudgetUsd,
|
|
1195
|
+
staticUsd: input.stageBudgetUsd,
|
|
1196
|
+
clamped: false,
|
|
1197
|
+
economy: input.economy ?? DEFAULT_ECONOMY,
|
|
1198
|
+
attended: input.attended === true,
|
|
1199
|
+
stories: [],
|
|
1200
|
+
done: 0,
|
|
1201
|
+
total: 0,
|
|
1202
|
+
blocked: []
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
function measure(input) {
|
|
1206
|
+
const economy = input.economy ?? DEFAULT_ECONOMY;
|
|
1207
|
+
const hostPaysDeveloper = economy === "host-tokens" || input.attended === true;
|
|
1208
|
+
const fallback = {
|
|
1209
|
+
basis: "static",
|
|
1210
|
+
usd: input.stageBudgetUsd,
|
|
1211
|
+
rawUsd: input.stageBudgetUsd,
|
|
1212
|
+
staticUsd: input.stageBudgetUsd,
|
|
1213
|
+
clamped: false,
|
|
1214
|
+
economy,
|
|
1215
|
+
attended: input.attended === true,
|
|
1216
|
+
stories: [],
|
|
1217
|
+
done: 0,
|
|
1218
|
+
total: 0,
|
|
1219
|
+
blocked: []
|
|
1220
|
+
};
|
|
1221
|
+
if (input.phaseId !== BUILD_PHASE2)
|
|
1222
|
+
return fallback;
|
|
1223
|
+
const progress = buildProgress(input.runDir);
|
|
1224
|
+
if (progress === null)
|
|
1225
|
+
return fallback;
|
|
1226
|
+
const all = progress.waves.flatMap((wave) => wave.stories);
|
|
1227
|
+
if (all.length === 0)
|
|
1228
|
+
return fallback;
|
|
1229
|
+
const ids = new Set(all.map((story) => story.id));
|
|
1230
|
+
const prices = progress.implicit ? new Map : loadPlanPrices(join8(input.runDir, PLAN_DIR), ids).prices;
|
|
1231
|
+
const verdicts = reviewVerdictsByStory(input.runDir);
|
|
1232
|
+
const caps = new CapMath(input, prices, all.length);
|
|
1233
|
+
const stories = [];
|
|
1234
|
+
const blocked = [];
|
|
1235
|
+
let raw = 0;
|
|
1236
|
+
for (const story of all) {
|
|
1237
|
+
if (story.status === "done")
|
|
1238
|
+
continue;
|
|
1239
|
+
if (story.status === "blocked") {
|
|
1240
|
+
blocked.push(story.id);
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
1243
|
+
const attemptsLeft = Math.max(MAX_ATTEMPTS - (verdicts.get(story.id) ?? 0), 0);
|
|
1244
|
+
if (attemptsLeft === 0)
|
|
1245
|
+
continue;
|
|
1246
|
+
const developerTurns = Math.max(attemptsLeft - (story.status === "review" ? 1 : 0), 0);
|
|
1247
|
+
const developerCapUsd = hostPaysDeveloper ? 0 : caps.developer(story.id);
|
|
1248
|
+
const reviewerCapUsd = caps.reviewer(story.id);
|
|
1249
|
+
const usd = round2(developerCapUsd * developerTurns + reviewerCapUsd * attemptsLeft);
|
|
1250
|
+
if (usd <= 0 && developerTurns === 0 && attemptsLeft === 0)
|
|
1251
|
+
continue;
|
|
1252
|
+
stories.push({
|
|
1253
|
+
id: story.id,
|
|
1254
|
+
status: story.status,
|
|
1255
|
+
developerTurns,
|
|
1256
|
+
reviews: attemptsLeft,
|
|
1257
|
+
developerCapUsd,
|
|
1258
|
+
reviewerCapUsd,
|
|
1259
|
+
usd
|
|
1260
|
+
});
|
|
1261
|
+
raw = round2(raw + usd);
|
|
1262
|
+
}
|
|
1263
|
+
stories.sort((a, b) => b.usd - a.usd || a.id.localeCompare(b.id));
|
|
1264
|
+
const clamped = raw > input.stageBudgetUsd;
|
|
1265
|
+
return {
|
|
1266
|
+
basis: "plan",
|
|
1267
|
+
usd: clamped ? round2(input.stageBudgetUsd) : raw,
|
|
1268
|
+
rawUsd: raw,
|
|
1269
|
+
staticUsd: input.stageBudgetUsd,
|
|
1270
|
+
clamped,
|
|
1271
|
+
economy,
|
|
1272
|
+
attended: input.attended === true,
|
|
1273
|
+
stories,
|
|
1274
|
+
done: progress.done,
|
|
1275
|
+
total: progress.total,
|
|
1276
|
+
blocked
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
class CapMath {
|
|
1280
|
+
input;
|
|
1281
|
+
prices;
|
|
1282
|
+
storyCount;
|
|
1283
|
+
scale;
|
|
1284
|
+
maxBudgetUsd;
|
|
1285
|
+
constructor(input, prices, storyCount) {
|
|
1286
|
+
this.input = input;
|
|
1287
|
+
this.prices = prices;
|
|
1288
|
+
this.storyCount = storyCount;
|
|
1289
|
+
let sum = 0;
|
|
1290
|
+
for (const price of prices.values()) {
|
|
1291
|
+
if (Number.isFinite(price) && price > 0)
|
|
1292
|
+
sum += price;
|
|
1293
|
+
}
|
|
1294
|
+
this.scale = input.stageBudgetUsd <= 0 || sum <= input.stageBudgetUsd ? 1 : input.stageBudgetUsd / sum;
|
|
1295
|
+
this.maxBudgetUsd = this.agentCap(1);
|
|
1296
|
+
}
|
|
1297
|
+
developer(storyId) {
|
|
1298
|
+
const price = this.priceOf(storyId);
|
|
1299
|
+
if (price === null)
|
|
1300
|
+
return this.agentCap(1 / this.worstCaseShares());
|
|
1301
|
+
return this.agentCap(this.shareOf(price / (MAX_ATTEMPTS * (1 + REVIEWER_SHARE))));
|
|
1302
|
+
}
|
|
1303
|
+
reviewer(storyId) {
|
|
1304
|
+
const price = this.priceOf(storyId);
|
|
1305
|
+
const derived = price === null ? this.agentCap(REVIEWER_SHARE / this.worstCaseShares()) : this.agentCap(this.shareOf(price * REVIEWER_SHARE / (MAX_ATTEMPTS * (1 + REVIEWER_SHARE))));
|
|
1306
|
+
const floor = Math.min(REVIEWER_FLOOR_USD, Math.max(this.input.stageBudgetUsd - this.input.stageSpentUsd, 0));
|
|
1307
|
+
return round2(Math.min(Math.max(derived, floor), this.maxBudgetUsd));
|
|
1308
|
+
}
|
|
1309
|
+
priceOf(storyId) {
|
|
1310
|
+
const price = this.prices.get(storyId);
|
|
1311
|
+
if (price === undefined || !Number.isFinite(price) || price <= 0)
|
|
1312
|
+
return null;
|
|
1313
|
+
return price * this.scale;
|
|
1314
|
+
}
|
|
1315
|
+
shareOf(usd) {
|
|
1316
|
+
return this.input.stageBudgetUsd <= 0 ? 1 : usd / this.input.stageBudgetUsd;
|
|
1317
|
+
}
|
|
1318
|
+
worstCaseShares() {
|
|
1319
|
+
return Math.max(this.storyCount, 1) * MAX_ATTEMPTS * (1 + REVIEWER_SHARE);
|
|
1320
|
+
}
|
|
1321
|
+
agentCap(share) {
|
|
1322
|
+
const candidates = [this.input.stageBudgetUsd * share, this.input.perAgentMaxUsd];
|
|
1323
|
+
const flag = this.input.maxUsd;
|
|
1324
|
+
if (flag !== undefined && flag !== null)
|
|
1325
|
+
candidates.push(flag);
|
|
1326
|
+
return round2(Math.min(...candidates));
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
function reviewVerdictsByStory(runDir) {
|
|
1330
|
+
const counts = new Map;
|
|
1331
|
+
const path = join8(runDir, "events.jsonl");
|
|
1332
|
+
if (!existsSync7(path))
|
|
1333
|
+
return counts;
|
|
1334
|
+
let text;
|
|
1335
|
+
try {
|
|
1336
|
+
text = readFileSync7(path, "utf8");
|
|
1337
|
+
} catch {
|
|
1338
|
+
return counts;
|
|
1339
|
+
}
|
|
1340
|
+
for (const line of text.split(`
|
|
1341
|
+
`)) {
|
|
1342
|
+
if (line.trim() === "")
|
|
1343
|
+
continue;
|
|
1344
|
+
let event;
|
|
1345
|
+
try {
|
|
1346
|
+
event = JSON.parse(line);
|
|
1347
|
+
} catch {
|
|
1348
|
+
continue;
|
|
1349
|
+
}
|
|
1350
|
+
const payload = event.payload ?? {};
|
|
1351
|
+
const story = payload.story;
|
|
1352
|
+
if (typeof story !== "string" || story === "")
|
|
1353
|
+
continue;
|
|
1354
|
+
if (event.type === "story.reopened") {
|
|
1355
|
+
counts.set(story, 0);
|
|
1356
|
+
continue;
|
|
1357
|
+
}
|
|
1358
|
+
if (event.type !== "check.passed" && event.type !== "check.failed")
|
|
1359
|
+
continue;
|
|
1360
|
+
if (payload.check !== "review")
|
|
1361
|
+
continue;
|
|
1362
|
+
if (reviewEventErrored(payload))
|
|
1363
|
+
continue;
|
|
1364
|
+
counts.set(story, (counts.get(story) ?? 0) + 1);
|
|
1365
|
+
}
|
|
1366
|
+
return counts;
|
|
1367
|
+
}
|
|
1368
|
+
function reviewEventErrored(payload) {
|
|
1369
|
+
if (payload.verdict === "error")
|
|
1370
|
+
return true;
|
|
1371
|
+
if (payload.verdict !== "changes")
|
|
1372
|
+
return false;
|
|
1373
|
+
return typeof payload.detail === "string" && looksLikeReviewerError(payload.detail);
|
|
1374
|
+
}
|
|
1375
|
+
function round2(n) {
|
|
1376
|
+
return Math.round(n * 100) / 100;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// src/core/budget/wouldExceed.ts
|
|
1380
|
+
function totalSpent(budget) {
|
|
1381
|
+
return budget.phases.reduce((sum, p) => sum + p.spent_usd, 0);
|
|
1382
|
+
}
|
|
1383
|
+
function wouldExceed(budget, phaseId, estimate) {
|
|
1384
|
+
const phase = phaseId === null ? undefined : budget.phases.find((p) => p.id === phaseId);
|
|
1385
|
+
const scope = phase === undefined ? "run" : "phase";
|
|
1386
|
+
const ceiling = phase === undefined ? budget.ceiling_usd : phase.ceiling_usd;
|
|
1387
|
+
const spent = phase === undefined ? totalSpent(budget) : phase.spent_usd;
|
|
1388
|
+
const left = round3(ceiling - spent);
|
|
1389
|
+
const exceeds = round3(spent + estimate) > ceiling;
|
|
1390
|
+
const pct = ceiling === 0 ? 100 : (spent + estimate) / ceiling * 100;
|
|
1391
|
+
return {
|
|
1392
|
+
exceeds,
|
|
1393
|
+
blocked: exceeds && budget.on_exceed === "block",
|
|
1394
|
+
scope,
|
|
1395
|
+
phaseId: phase === undefined ? null : phase.id,
|
|
1396
|
+
remaining: left,
|
|
1397
|
+
ceiling,
|
|
1398
|
+
estimate,
|
|
1399
|
+
warns: !exceeds && pct >= budget.warn_at_pct
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
function round3(n) {
|
|
1403
|
+
return Math.round(n * 1e6) / 1e6;
|
|
1404
|
+
}
|
|
1405
|
+
function wouldExceedHostTokens(budget, phaseId, spentTokens) {
|
|
1406
|
+
const ceiling = hostTokenCeiling(budget, phaseId);
|
|
1407
|
+
if (ceiling === null)
|
|
1408
|
+
return null;
|
|
1409
|
+
const phase = phaseId === null ? undefined : budget.phases.find((p) => p.id === phaseId);
|
|
1410
|
+
const over = ceiling > 0 && spentTokens > ceiling;
|
|
1411
|
+
return {
|
|
1412
|
+
over,
|
|
1413
|
+
blocked: over && budget.on_host_tokens_exceed === "block",
|
|
1414
|
+
scope: phase === undefined ? "run" : "phase",
|
|
1415
|
+
spent: spentTokens,
|
|
1416
|
+
ceiling
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// src/core/run/gatePolicy.ts
|
|
1421
|
+
var GATE_POLICIES = ["human", "auto", "agent"];
|
|
1422
|
+
function isGatePolicy(value) {
|
|
1423
|
+
return typeof value === "string" && GATE_POLICIES.includes(value);
|
|
1424
|
+
}
|
|
1425
|
+
function validateGatesPolicy(value, stageIds, issues) {
|
|
1426
|
+
if (value === undefined || value === null)
|
|
1427
|
+
return;
|
|
1428
|
+
if (!isRecord(value)) {
|
|
1429
|
+
issues.push({
|
|
1430
|
+
path: "gates_policy",
|
|
1431
|
+
message: `expected a mapping of stage id -> ${GATE_POLICIES.join(" | ")}`
|
|
1432
|
+
});
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
const known = new Set(stageIds);
|
|
1436
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
1437
|
+
if (!isGatePolicy(raw)) {
|
|
1438
|
+
issues.push({
|
|
1439
|
+
path: `gates_policy.${key}`,
|
|
1440
|
+
message: `expected one of ${GATE_POLICIES.join(" | ")}, got ${JSON.stringify(raw)}`
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
if (known.size > 0 && !known.has(key)) {
|
|
1444
|
+
issues.push({ path: `gates_policy.${key}`, message: `names no stage in this run` });
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// src/core/run/RunFile.ts
|
|
1450
|
+
var STAGE_STATUSES = [
|
|
1451
|
+
"pending",
|
|
1452
|
+
"ready",
|
|
1453
|
+
"running",
|
|
1454
|
+
"awaiting_answer",
|
|
1455
|
+
"awaiting_gate",
|
|
1456
|
+
"blocked",
|
|
1457
|
+
"done",
|
|
1458
|
+
"failed",
|
|
1459
|
+
"skipped",
|
|
1460
|
+
"cancelled"
|
|
1461
|
+
];
|
|
1462
|
+
var TERMINAL_STATUSES2 = ["done", "failed", "skipped", "cancelled"];
|
|
1463
|
+
var GATE_TYPES = ["approve", "checks", "auto"];
|
|
1464
|
+
var GATE_STATUSES = ["pending", "approved", "rejected", "n-a"];
|
|
1465
|
+
var ATTENDED_BY = ["host"];
|
|
1466
|
+
var RUN_ID_RE = /^\d{6}-[a-z0-9-]{1,40}$/;
|
|
1467
|
+
var PHASE_ID_RE = /^0[1-5]-[a-z]+$/;
|
|
1468
|
+
var MAX_PHASES2 = 5;
|
|
1469
|
+
var MAX_STAGES = 40;
|
|
1470
|
+
var MAX_TASKS = 200;
|
|
1471
|
+
function isTerminal2(status) {
|
|
1472
|
+
return TERMINAL_STATUSES2.includes(status);
|
|
1473
|
+
}
|
|
1474
|
+
function isFinished(status) {
|
|
1475
|
+
return status === "done" || status === "cancelled";
|
|
1476
|
+
}
|
|
1477
|
+
function isAttendedByHost(run) {
|
|
1478
|
+
return run.attended_by === "host";
|
|
1479
|
+
}
|
|
1480
|
+
function flatten(run) {
|
|
1481
|
+
const out = [];
|
|
1482
|
+
for (const phase of run.phases)
|
|
1483
|
+
for (const stage of phase.stages)
|
|
1484
|
+
out.push({ phase, stage });
|
|
1485
|
+
return out;
|
|
1486
|
+
}
|
|
1487
|
+
function stageAt(run, cursor) {
|
|
1488
|
+
return flatten(run).find((e) => e.phase.id === cursor.phase && e.stage.id === cursor.stage) ?? null;
|
|
1489
|
+
}
|
|
1490
|
+
function deriveRunStatus(run) {
|
|
1491
|
+
const all = flatten(run);
|
|
1492
|
+
if (run.cancelled !== undefined)
|
|
1493
|
+
return "cancelled";
|
|
1494
|
+
if (all.some((e) => e.stage.status === "failed"))
|
|
1495
|
+
return "failed";
|
|
1496
|
+
if (all.length > 0 && all.every((e) => isTerminal2(e.stage.status)))
|
|
1497
|
+
return "done";
|
|
1498
|
+
return stageAt(run, run.cursor)?.stage.status ?? "pending";
|
|
1499
|
+
}
|
|
1500
|
+
function derivePhaseStatus(phase) {
|
|
1501
|
+
if (phase.stages.length === 0)
|
|
1502
|
+
return "skipped";
|
|
1503
|
+
if (phase.stages.some((s) => s.status === "failed"))
|
|
1504
|
+
return "failed";
|
|
1505
|
+
if (phase.stages.every((s) => isTerminal2(s.status)))
|
|
1506
|
+
return "done";
|
|
1507
|
+
const live = phase.stages.find((s) => !isTerminal2(s.status));
|
|
1508
|
+
return live?.status ?? "pending";
|
|
1509
|
+
}
|
|
1510
|
+
function validateRunFile(input) {
|
|
1511
|
+
const issues = [];
|
|
1512
|
+
const deprecations = [];
|
|
1513
|
+
const doc = asDocument(input, issues);
|
|
1514
|
+
if (!doc)
|
|
1515
|
+
return result(issues);
|
|
1516
|
+
requireVersion(doc, issues, deprecations);
|
|
1517
|
+
requireKeys(doc, ["run", "title", "scope", "workflow", "repos", "created_at", "updated_at", "status", "cursor", "budget", "phases"], "", issues);
|
|
1518
|
+
if (typeof doc.run !== "string" || !RUN_ID_RE.test(doc.run)) {
|
|
1519
|
+
issues.push({ path: "run", message: "run id must match ^\\d{6}-[a-z0-9-]{1,40}$" });
|
|
1520
|
+
}
|
|
1521
|
+
requireString(doc.title, "title", issues);
|
|
1522
|
+
requireString(doc.scope, "scope", issues);
|
|
1523
|
+
requireString(doc.workflow, "workflow", issues);
|
|
1524
|
+
requireString(doc.created_at, "created_at", issues);
|
|
1525
|
+
requireString(doc.updated_at, "updated_at", issues);
|
|
1526
|
+
requireEnum(doc.status, STAGE_STATUSES, "status", issues);
|
|
1527
|
+
requireArray(doc.repos, "repos", issues);
|
|
1528
|
+
let cursor = null;
|
|
1529
|
+
if (isRecord(doc.cursor)) {
|
|
1530
|
+
requireKeys(doc.cursor, ["phase", "stage", "task"], "cursor", issues);
|
|
1531
|
+
cursor = {
|
|
1532
|
+
phase: String(doc.cursor.phase ?? ""),
|
|
1533
|
+
stage: String(doc.cursor.stage ?? ""),
|
|
1534
|
+
task: typeof doc.cursor.task === "string" ? doc.cursor.task : null
|
|
1535
|
+
};
|
|
1536
|
+
} else if (doc.cursor !== undefined) {
|
|
1537
|
+
issues.push({ path: "cursor", message: "expected a mapping" });
|
|
1538
|
+
}
|
|
1539
|
+
if (isRecord(doc.budget)) {
|
|
1540
|
+
requireKeys(doc.budget, ["ceiling_usd", "spent_usd", "per_agent_max_usd"], "budget", issues);
|
|
1541
|
+
requireNumber(doc.budget.ceiling_usd, "budget.ceiling_usd", issues);
|
|
1542
|
+
requireNumber(doc.budget.spent_usd, "budget.spent_usd", issues);
|
|
1543
|
+
requireNumber(doc.budget.per_agent_max_usd, "budget.per_agent_max_usd", issues);
|
|
1544
|
+
} else if (doc.budget !== undefined) {
|
|
1545
|
+
issues.push({ path: "budget", message: "expected a mapping" });
|
|
1546
|
+
}
|
|
1547
|
+
if (doc.build !== undefined) {
|
|
1548
|
+
if (isRecord(doc.build)) {
|
|
1549
|
+
requireKeys(doc.build, ["epic_branch"], "build", issues);
|
|
1550
|
+
if (requireArray(doc.build.epic_branch, "build.epic_branch", issues)) {
|
|
1551
|
+
doc.build.epic_branch.forEach((branch, i) => {
|
|
1552
|
+
requireString(branch, `build.epic_branch[${i}]`, issues);
|
|
1553
|
+
});
|
|
1554
|
+
}
|
|
1555
|
+
if (doc.build.branch_model !== undefined && !isBranchModelKind(doc.build.branch_model)) {
|
|
1556
|
+
issues.push({
|
|
1557
|
+
path: "build.branch_model",
|
|
1558
|
+
message: `expected one of ${BRANCH_MODELS.join(" | ")}`
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
1561
|
+
} else {
|
|
1562
|
+
issues.push({ path: "build", message: "expected a mapping" });
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
if (doc.cancelled !== undefined) {
|
|
1566
|
+
if (isRecord(doc.cancelled)) {
|
|
1567
|
+
requireKeys(doc.cancelled, ["by", "at", "note"], "cancelled", issues);
|
|
1568
|
+
requireString(doc.cancelled.by, "cancelled.by", issues);
|
|
1569
|
+
requireString(doc.cancelled.at, "cancelled.at", issues);
|
|
1570
|
+
requireString(doc.cancelled.note, "cancelled.note", issues);
|
|
1571
|
+
} else {
|
|
1572
|
+
issues.push({ path: "cancelled", message: "expected a mapping" });
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
if (doc.triage !== undefined) {
|
|
1576
|
+
if (isRecord(doc.triage)) {
|
|
1577
|
+
requireKeys(doc.triage, ["split", "depends_on"], "triage", issues);
|
|
1578
|
+
requireString(doc.triage.split, "triage.split", issues);
|
|
1579
|
+
if (requireArray(doc.triage.depends_on, "triage.depends_on", issues)) {
|
|
1580
|
+
doc.triage.depends_on.forEach((slug, i) => {
|
|
1581
|
+
requireString(slug, `triage.depends_on[${i}]`, issues);
|
|
1582
|
+
});
|
|
1583
|
+
}
|
|
1584
|
+
} else {
|
|
1585
|
+
issues.push({ path: "triage", message: "expected a mapping" });
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
if (doc.attended_by !== undefined)
|
|
1589
|
+
requireEnum(doc.attended_by, ATTENDED_BY, "attended_by", issues);
|
|
1590
|
+
if (doc.keep_worktrees !== undefined && typeof doc.keep_worktrees !== "boolean") {
|
|
1591
|
+
issues.push({ path: "keep_worktrees", message: `expected a boolean, got ${typeof doc.keep_worktrees}` });
|
|
1592
|
+
}
|
|
1593
|
+
if (!requireArray(doc.phases, "phases", issues))
|
|
1594
|
+
return result(issues, deprecations);
|
|
1595
|
+
const phases = doc.phases;
|
|
1596
|
+
if (phases.length > MAX_PHASES2) {
|
|
1597
|
+
issues.push({ path: "phases", message: `${phases.length} phases exceeds the ${MAX_PHASES2} cap` });
|
|
1598
|
+
}
|
|
1599
|
+
const declaredStageIds = [];
|
|
1600
|
+
for (const phase of phases) {
|
|
1601
|
+
if (!isRecord(phase) || !Array.isArray(phase.stages))
|
|
1602
|
+
continue;
|
|
1603
|
+
for (const stage of phase.stages) {
|
|
1604
|
+
if (isRecord(stage) && typeof stage.id === "string")
|
|
1605
|
+
declaredStageIds.push(stage.id);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
validateGatesPolicy(doc.gates_policy, declaredStageIds, issues);
|
|
1609
|
+
let stageCount = 0;
|
|
1610
|
+
let taskCount = 0;
|
|
1611
|
+
let running = 0;
|
|
1612
|
+
let cursorResolves = false;
|
|
1613
|
+
let spentFromTasks = 0;
|
|
1614
|
+
const phaseIds = new Set;
|
|
1615
|
+
phases.forEach((phase, i) => {
|
|
1616
|
+
const base = `phases[${i}]`;
|
|
1617
|
+
if (!isRecord(phase)) {
|
|
1618
|
+
issues.push({ path: base, message: "expected a mapping" });
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
requireKeys(phase, ["id", "status", "stages"], base, issues);
|
|
1622
|
+
const phaseId = typeof phase.id === "string" ? phase.id : "";
|
|
1623
|
+
if (!PHASE_ID_RE.test(phaseId)) {
|
|
1624
|
+
issues.push({ path: `${base}.id`, message: "phase id must match ^0[1-5]-[a-z]+$" });
|
|
1625
|
+
}
|
|
1626
|
+
if (phaseIds.has(phaseId))
|
|
1627
|
+
issues.push({ path: `${base}.id`, message: `duplicate phase id ${phaseId}` });
|
|
1628
|
+
phaseIds.add(phaseId);
|
|
1629
|
+
requireEnum(phase.status, STAGE_STATUSES, `${base}.status`, issues);
|
|
1630
|
+
if (!requireArray(phase.stages, `${base}.stages`, issues))
|
|
1631
|
+
return;
|
|
1632
|
+
const stageIds = new Set;
|
|
1633
|
+
phase.stages.forEach((stage, j) => {
|
|
1634
|
+
const path = `${base}.stages[${j}]`;
|
|
1635
|
+
stageCount++;
|
|
1636
|
+
if (!isRecord(stage)) {
|
|
1637
|
+
issues.push({ path, message: "expected a mapping" });
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1640
|
+
requireKeys(stage, ["id", "status", "expert", "model", "budget_usd", "cost_usd", "started_at", "ended_at", "inputs", "outputs", "gate", "tasks"], path, issues);
|
|
1641
|
+
const stageId = typeof stage.id === "string" ? stage.id : "";
|
|
1642
|
+
if (stageIds.has(stageId))
|
|
1643
|
+
issues.push({ path: `${path}.id`, message: `duplicate stage id ${stageId}` });
|
|
1644
|
+
stageIds.add(stageId);
|
|
1645
|
+
requireEnum(stage.status, STAGE_STATUSES, `${path}.status`, issues);
|
|
1646
|
+
if (stage.status === "running")
|
|
1647
|
+
running++;
|
|
1648
|
+
requireNumber(stage.budget_usd, `${path}.budget_usd`, issues);
|
|
1649
|
+
requireNumber(stage.cost_usd, `${path}.cost_usd`, issues);
|
|
1650
|
+
requireArray(stage.inputs, `${path}.inputs`, issues);
|
|
1651
|
+
requireArray(stage.outputs, `${path}.outputs`, issues);
|
|
1652
|
+
if (stage.stale !== undefined && typeof stage.stale !== "boolean") {
|
|
1653
|
+
issues.push({ path: `${path}.stale`, message: "expected true or false" });
|
|
1654
|
+
}
|
|
1655
|
+
checkOrder(stage.started_at, stage.ended_at, path, issues);
|
|
1656
|
+
if (cursor !== null && cursor.phase === phaseId && cursor.stage === stageId)
|
|
1657
|
+
cursorResolves = true;
|
|
1658
|
+
if (isRecord(stage.gate)) {
|
|
1659
|
+
const gate = stage.gate;
|
|
1660
|
+
requireKeys(gate, ["type", "status", "by", "at", "note"], `${path}.gate`, issues);
|
|
1661
|
+
requireEnum(gate.type, GATE_TYPES, `${path}.gate.type`, issues);
|
|
1662
|
+
requireEnum(gate.status, GATE_STATUSES, `${path}.gate.status`, issues);
|
|
1663
|
+
if (gate.status === "approved" && (typeof gate.by !== "string" || typeof gate.at !== "string")) {
|
|
1664
|
+
issues.push({ path: `${path}.gate`, message: "an approved gate needs both `by` and `at`" });
|
|
1665
|
+
}
|
|
1666
|
+
validateGateEvidence(gate.evidence, `${path}.gate.evidence`, issues);
|
|
1667
|
+
} else if (stage.gate !== undefined) {
|
|
1668
|
+
issues.push({ path: `${path}.gate`, message: "expected a mapping" });
|
|
1669
|
+
}
|
|
1670
|
+
if (requireArray(stage.tasks, `${path}.tasks`, issues)) {
|
|
1671
|
+
const taskIds = new Set;
|
|
1672
|
+
stage.tasks.forEach((task, k) => {
|
|
1673
|
+
const tp = `${path}.tasks[${k}]`;
|
|
1674
|
+
taskCount++;
|
|
1675
|
+
if (!isRecord(task)) {
|
|
1676
|
+
issues.push({ path: tp, message: "expected a mapping" });
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
requireKeys(task, ["id", "status", "cost_usd", "error", "session_id"], tp, issues);
|
|
1680
|
+
const taskId = typeof task.id === "string" ? task.id : "";
|
|
1681
|
+
if (!/^t\d+$/.test(taskId))
|
|
1682
|
+
issues.push({ path: `${tp}.id`, message: "task id must match ^t\\d+$" });
|
|
1683
|
+
if (taskIds.has(taskId))
|
|
1684
|
+
issues.push({ path: `${tp}.id`, message: `duplicate task id ${taskId}` });
|
|
1685
|
+
taskIds.add(taskId);
|
|
1686
|
+
requireEnum(task.status, STAGE_STATUSES, `${tp}.status`, issues);
|
|
1687
|
+
if (task.cost_usd !== null)
|
|
1688
|
+
requireNumber(task.cost_usd, `${tp}.cost_usd`, issues);
|
|
1689
|
+
if (task.metered !== undefined && typeof task.metered !== "boolean") {
|
|
1690
|
+
issues.push({ path: `${tp}.metered`, message: "expected true or false" });
|
|
1691
|
+
}
|
|
1692
|
+
if (task.cost_usd === null && task.metered !== false) {
|
|
1693
|
+
issues.push({ path: `${tp}.metered`, message: "a null cost_usd must be marked `metered: false`" });
|
|
1694
|
+
}
|
|
1695
|
+
if (typeof task.cost_usd === "number")
|
|
1696
|
+
spentFromTasks += task.cost_usd;
|
|
1697
|
+
checkOrder(task.started_at, task.ended_at, tp, issues);
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
});
|
|
1701
|
+
});
|
|
1702
|
+
if (stageCount > MAX_STAGES)
|
|
1703
|
+
issues.push({ path: "phases", message: `${stageCount} stages exceeds the ${MAX_STAGES} cap` });
|
|
1704
|
+
if (taskCount > MAX_TASKS)
|
|
1705
|
+
issues.push({ path: "phases", message: `${taskCount} tasks exceeds the ${MAX_TASKS} cap` });
|
|
1706
|
+
if (running > 1)
|
|
1707
|
+
issues.push({ path: "phases", message: `${running} stages are running; tldrx is single-writer` });
|
|
1708
|
+
if (cursor !== null && !cursorResolves) {
|
|
1709
|
+
issues.push({ path: "cursor", message: `cursor ${cursor.phase}/${cursor.stage} does not resolve to a stage` });
|
|
1710
|
+
}
|
|
1711
|
+
if (isRecord(doc.budget) && typeof doc.budget.spent_usd === "number") {
|
|
1712
|
+
if (Math.abs(doc.budget.spent_usd - spentFromTasks) > 0.01) {
|
|
1713
|
+
issues.push({
|
|
1714
|
+
path: "budget.spent_usd",
|
|
1715
|
+
message: `${doc.budget.spent_usd} does not match the task total ${round5(spentFromTasks)} (tolerance 0.01)`
|
|
1716
|
+
});
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
return result(issues, deprecations);
|
|
1720
|
+
}
|
|
1721
|
+
function checkOrder(started, ended, path, issues) {
|
|
1722
|
+
if (typeof started === "string" && typeof ended === "string" && ended < started) {
|
|
1723
|
+
issues.push({ path: `${path}.ended_at`, message: "ended_at is before started_at" });
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
function round5(n) {
|
|
1727
|
+
return Math.round(n * 100) / 100;
|
|
1728
|
+
}
|
|
1729
|
+
function asRunFile(input) {
|
|
1730
|
+
return input;
|
|
1731
|
+
}
|
|
1732
|
+
var GATE_EVIDENCE_KEYS = [
|
|
1733
|
+
"path",
|
|
1734
|
+
"role",
|
|
1735
|
+
"verdict",
|
|
1736
|
+
"sampled",
|
|
1737
|
+
"of",
|
|
1738
|
+
"resolved",
|
|
1739
|
+
"refuted",
|
|
1740
|
+
"outside_surface"
|
|
1741
|
+
];
|
|
1742
|
+
function validateGateEvidence(value, base, issues) {
|
|
1743
|
+
if (value === undefined || value === null)
|
|
1744
|
+
return;
|
|
1745
|
+
if (!isRecord(value)) {
|
|
1746
|
+
issues.push({ path: base, message: "expected a mapping" });
|
|
1747
|
+
return;
|
|
1748
|
+
}
|
|
1749
|
+
requireKeys(value, GATE_EVIDENCE_KEYS, base, issues);
|
|
1750
|
+
requireString(value.path, `${base}.path`, issues);
|
|
1751
|
+
requireEnum(value.role, EVIDENCE_ROLES, `${base}.role`, issues);
|
|
1752
|
+
requireEnum(value.verdict, EVIDENCE_VERDICTS, `${base}.verdict`, issues);
|
|
1753
|
+
for (const key of ["sampled", "of", "resolved", "refuted", "outside_surface"]) {
|
|
1754
|
+
requireNumber(value[key], `${base}.${key}`, issues);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
// src/core/budget/budgetView.ts
|
|
1759
|
+
function raiseCommand(runId, phaseId, amountUsd) {
|
|
1760
|
+
return `tldrx budget raise ${phaseId} ${amountUsd.toFixed(2)} --run ${runId}`;
|
|
1761
|
+
}
|
|
1762
|
+
function shortBy(estimate, remaining) {
|
|
1763
|
+
return Math.max(0.01, Math.ceil((estimate - remaining) * 100) / 100);
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
export { loadRunView, isAttendedByHostView, runSpend, hostTokensIn, renderRunEconomies, cursorStage, openRunViews, newestActiveRun, currentActor, nowRfc3339, DEFAULT_ON_HOST_TOKENS_EXCEED, DEFAULT_ECONOMY, economyFor, isHostTokens, validateRunBudget, asRunBudget, expertsDir, loadExperts, readExpertDomain, pathsIntersect, parseEvidence, evidencePath, gateEvidencePath, stackExpertNames, remainingWork, wouldExceed, wouldExceedHostTokens, isTerminal2 as isTerminal, isFinished, isAttendedByHost, flatten, stageAt, deriveRunStatus, derivePhaseStatus, validateRunFile, asRunFile, raiseCommand, shortBy };
|