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.
Files changed (36) hide show
  1. package/CHANGELOG.md +1957 -0
  2. package/README.md +99 -12
  3. package/dist/hooks/answer-capture.js +21 -12
  4. package/dist/hooks/budget-gate.js +95 -11
  5. package/dist/hooks/{chunk-0bt6yb2q.js → chunk-39zh2e44.js} +10 -1
  6. package/dist/hooks/chunk-4cp363kv.js +1766 -0
  7. package/dist/hooks/{chunk-t8tdv11p.js → chunk-7y2dq0pj.js} +1 -1
  8. package/dist/hooks/chunk-9zsqxr6y.js +213 -0
  9. package/dist/hooks/{chunk-a8p2rc94.js → chunk-b8kxzna2.js} +1 -1
  10. package/dist/hooks/{chunk-j234zf0t.js → chunk-c6t5nx0r.js} +37 -166
  11. package/dist/hooks/{chunk-g395gk7e.js → chunk-m3mewgnw.js} +36 -17
  12. package/dist/hooks/chunk-phmdk72a.js +164 -0
  13. package/dist/hooks/chunk-rpcxsqh3.js +199 -0
  14. package/dist/hooks/{chunk-azctppjh.js → chunk-rz541e2b.js} +7 -1
  15. package/dist/hooks/{chunk-y0jdr3et.js → chunk-s1c5h7yx.js} +71 -29
  16. package/dist/hooks/{chunk-p274ckxv.js → chunk-sq44k6g2.js} +155 -12
  17. package/dist/hooks/{chunk-x98qs959.js → chunk-t1ywrfr4.js} +35 -42
  18. package/dist/hooks/{chunk-1zwcxd3f.js → chunk-tzzwddct.js} +1 -1
  19. package/dist/hooks/claim-sources.js +31 -23
  20. package/dist/hooks/dod-gate.js +12 -37
  21. package/dist/hooks/no-reask.js +9 -9
  22. package/dist/hooks/session-start.js +176 -422
  23. package/dist/hooks/statusline.js +14 -11
  24. package/dist/tldrx.js +22798 -12309
  25. package/package.json +3 -1
  26. package/plugin/.claude-plugin/plugin.json +1 -1
  27. package/plugin/skills/tldrx/SKILL.md +16 -3
  28. package/stages/build/stage.md +5 -0
  29. package/stages/build/stage.yml +2 -1
  30. package/stages/plan/stage.md +11 -0
  31. package/templates/budget.yml +5 -0
  32. package/templates/epic.md +5 -3
  33. package/dist/hooks/chunk-ae6bkfs5.js +0 -0
  34. package/dist/hooks/chunk-kw4tffzf.js +0 -139
  35. package/dist/hooks/chunk-sdjnnmzz.js +0 -497
  36. package/dist/hooks/chunk-t56k6146.js +0 -14
@@ -0,0 +1,164 @@
1
+ import {
2
+ isRecord
3
+ } from "./chunk-39zh2e44.js";
4
+ import {
5
+ parseYaml
6
+ } from "./chunk-sq44k6g2.js";
7
+
8
+ // src/core/schemas/planCommon.ts
9
+ var STORY_ID_RE = /^S\d{1,4}$/;
10
+ var WAVE_ID_RE = /^W\d{1,3}$/;
11
+ var MAX_LIST_ITEMS = 64;
12
+ var MAX_ITEM_CHARS = 512;
13
+ var MAX_WAVES = 32;
14
+ var MAX_STORIES_PER_WAVE = 32;
15
+ var MAX_PLAN_STORIES = 200;
16
+ function requireStringList(value, path, issues, rules = {}) {
17
+ if (value === undefined)
18
+ return [];
19
+ if (!Array.isArray(value)) {
20
+ issues.push({ path, message: `expected a list, got ${describe(value)}` });
21
+ return [];
22
+ }
23
+ const max = rules.max ?? MAX_LIST_ITEMS;
24
+ if (value.length > max) {
25
+ issues.push({ path, message: `${value.length} items exceeds the ${max}-item cap` });
26
+ }
27
+ if (rules.nonEmpty === true && value.length === 0) {
28
+ issues.push({ path, message: "must not be empty" });
29
+ }
30
+ const seen = new Set;
31
+ const out = [];
32
+ value.forEach((item, i) => {
33
+ const at = `${path}[${i}]`;
34
+ if (typeof item !== "string") {
35
+ issues.push({ path: at, message: `expected a string, got ${describe(item)}` });
36
+ return;
37
+ }
38
+ if (item.trim() === "") {
39
+ issues.push({ path: at, message: "must not be blank" });
40
+ return;
41
+ }
42
+ if (item.length > MAX_ITEM_CHARS) {
43
+ issues.push({
44
+ path: at,
45
+ message: `${item.length} characters exceeds the ${MAX_ITEM_CHARS}-character cap on one list item ` + "— split it into several items"
46
+ });
47
+ }
48
+ if (rules.pattern !== undefined && !rules.pattern.test(item)) {
49
+ issues.push({ path: at, message: `expected ${rules.patternName ?? String(rules.pattern)}, got \`${item}\`` });
50
+ return;
51
+ }
52
+ if (rules.unique === true && seen.has(item)) {
53
+ issues.push({ path: at, message: `\`${item}\` is listed twice` });
54
+ return;
55
+ }
56
+ seen.add(item);
57
+ out.push(item);
58
+ });
59
+ return out;
60
+ }
61
+ function requirePattern(value, pattern, patternName, path, issues) {
62
+ if (value === undefined)
63
+ return;
64
+ if (typeof value !== "string") {
65
+ issues.push({ path, message: `expected a string, got ${describe(value)}` });
66
+ return;
67
+ }
68
+ if (!pattern.test(value)) {
69
+ issues.push({ path, message: `expected ${patternName}, got \`${value}\`` });
70
+ }
71
+ }
72
+ function requireVersion1(doc, issues) {
73
+ if (doc.version !== undefined && doc.version !== 1) {
74
+ issues.push({ path: "version", message: `unknown schema version ${String(doc.version)} (expected 1)` });
75
+ }
76
+ }
77
+ function describe(value) {
78
+ if (value === null)
79
+ return "null";
80
+ if (Array.isArray(value))
81
+ return "a list";
82
+ if (isRecord(value))
83
+ return "a mapping";
84
+ return typeof value;
85
+ }
86
+
87
+ // src/core/schemas/frontMatter.ts
88
+ var FENCE = "---";
89
+ function splitFrontMatter(text) {
90
+ const lines = text.split(`
91
+ `);
92
+ if ((lines[0] ?? "").trimEnd() !== FENCE) {
93
+ return { present: false, raw: "", body: text, bodyLine: 1 };
94
+ }
95
+ for (let i = 1;i < lines.length; i++) {
96
+ if ((lines[i] ?? "").trimEnd() !== FENCE)
97
+ continue;
98
+ return {
99
+ present: true,
100
+ raw: lines.slice(1, i).join(`
101
+ `),
102
+ body: lines.slice(i + 1).join(`
103
+ `),
104
+ bodyLine: i + 2
105
+ };
106
+ }
107
+ return { present: false, raw: "", body: text, bodyLine: 1 };
108
+ }
109
+ function parseFrontMatter(text) {
110
+ const frontMatter = splitFrontMatter(text);
111
+ if (!frontMatter.present) {
112
+ return {
113
+ frontMatter,
114
+ doc: null,
115
+ issue: {
116
+ path: "",
117
+ message: "no YAML front matter — the file must open with `---` and close the block with `---`"
118
+ }
119
+ };
120
+ }
121
+ try {
122
+ return { frontMatter, doc: parseYaml(frontMatter.raw), issue: null };
123
+ } catch (error) {
124
+ return {
125
+ frontMatter,
126
+ doc: null,
127
+ issue: { path: "", message: `front matter is not valid YAML: ${message(error)}` }
128
+ };
129
+ }
130
+ }
131
+ function message(error) {
132
+ return error instanceof Error ? error.message.split(`
133
+ `)[0] ?? "parse error" : String(error);
134
+ }
135
+ // src/core/schemas/story.ts
136
+ var DOD_FENCE_OPEN_RE = /^\s*```+\s*dod\s*$/i;
137
+ var DOD_FENCE_CLOSE_RE = /^\s*```+\s*$/;
138
+ var FENCE_OPEN_RE = DOD_FENCE_OPEN_RE;
139
+ var FENCE_CLOSE_RE = DOD_FENCE_CLOSE_RE;
140
+ function parseDodBlock(text) {
141
+ const commands = [];
142
+ let present = false;
143
+ let inside = false;
144
+ for (const line of text.split(`
145
+ `)) {
146
+ if (inside) {
147
+ if (FENCE_CLOSE_RE.test(line)) {
148
+ inside = false;
149
+ continue;
150
+ }
151
+ const command = line.trim();
152
+ if (command !== "" && !command.startsWith("#"))
153
+ commands.push(command);
154
+ continue;
155
+ }
156
+ if (FENCE_OPEN_RE.test(line)) {
157
+ present = true;
158
+ inside = true;
159
+ }
160
+ }
161
+ return { present, commands };
162
+ }
163
+
164
+ export { STORY_ID_RE, WAVE_ID_RE, MAX_WAVES, MAX_STORIES_PER_WAVE, MAX_PLAN_STORIES, requireStringList, requirePattern, requireVersion1, parseFrontMatter, parseDodBlock };
@@ -0,0 +1,199 @@
1
+ import {
2
+ parseSrcToken
3
+ } from "./chunk-m3mewgnw.js";
4
+
5
+ // src/core/text/questions.ts
6
+ var REQUIRED_METADATA_KEYS = ["id", "status", "area", "asked_by", "asked_at"];
7
+ var HEADING_RE = /^##\s+(Q\d{1,6})\s+·\s+(.+?)\s*$/;
8
+ var METADATA_RE = /^<!--\s*(.*?)\s*-->$/;
9
+ var WHY_RE = /^Why asked:\s*(.*)$/;
10
+ var OPTION_RE = /^-\s+([A-E])\)\s*(.*)$/;
11
+ var ANSWER_RE = /^\[Answer\]:[ \t]*(\S.*)$/;
12
+ var ANSWER_SLOT_RE = /^\[Answer\]:/;
13
+ var FOOTER_KEYS = ["answered_by", "answered_at", "fact"];
14
+ function parseQuestions(text) {
15
+ const trailingNewline = text.endsWith(`
16
+ `);
17
+ const body = trailingNewline ? text.slice(0, -1) : text;
18
+ const lines = body === "" ? [] : body.split(`
19
+ `);
20
+ const preamble = [];
21
+ const blocks = [];
22
+ let pending = null;
23
+ const close = () => {
24
+ if (pending === null)
25
+ return;
26
+ blocks.push(buildBlock(pending.start + 1, pending.lines));
27
+ pending = null;
28
+ };
29
+ for (let i = 0;i < lines.length; i++) {
30
+ const line = lines[i] ?? "";
31
+ if (HEADING_RE.test(line)) {
32
+ close();
33
+ pending = { start: i, lines: [line] };
34
+ continue;
35
+ }
36
+ if (pending === null)
37
+ preamble.push(line);
38
+ else
39
+ pending.lines.push(line);
40
+ }
41
+ close();
42
+ return { preamble, blocks, trailingNewline };
43
+ }
44
+ function buildBlock(startLine, lines) {
45
+ const heading = HEADING_RE.exec(lines[0] ?? "");
46
+ const id = heading?.[1] ?? "";
47
+ const title = heading?.[2] ?? "";
48
+ let metadata = null;
49
+ let metadataIndex = -1;
50
+ let whyAsked = null;
51
+ let whySrc = null;
52
+ const options = [];
53
+ let answer = "";
54
+ let answerIndex = -1;
55
+ let footer = null;
56
+ for (let i = 1;i < lines.length; i++) {
57
+ const line = lines[i] ?? "";
58
+ const comment = METADATA_RE.exec(line);
59
+ if (comment !== null && comment[1] !== undefined) {
60
+ const pairs = parsePipeComment(comment[1]);
61
+ if (metadata === null && pairs.some(([k]) => k === "id")) {
62
+ metadata = toMetadata(pairs);
63
+ metadataIndex = i;
64
+ } else if (footer === null && pairs.some(([k]) => k === "answered_by")) {
65
+ footer = toFooter(pairs);
66
+ }
67
+ continue;
68
+ }
69
+ const why = WHY_RE.exec(line);
70
+ if (why !== null && whyAsked === null) {
71
+ whyAsked = why[1] ?? "";
72
+ whySrc = parseSrcToken(line);
73
+ continue;
74
+ }
75
+ const option = OPTION_RE.exec(line);
76
+ if (option !== null && option[1] !== undefined && option[2] !== undefined) {
77
+ options.push({ letter: option[1], text: option[2] });
78
+ continue;
79
+ }
80
+ if (ANSWER_SLOT_RE.test(line) && answerIndex === -1) {
81
+ answerIndex = i;
82
+ const captured = ANSWER_RE.exec(line);
83
+ answer = (captured?.[1] ?? "").trim();
84
+ }
85
+ }
86
+ return {
87
+ id,
88
+ title,
89
+ metadata,
90
+ metadataIndex,
91
+ whyAsked,
92
+ whySrc,
93
+ options,
94
+ answer,
95
+ answerIndex,
96
+ footer,
97
+ startLine,
98
+ lines: [...lines]
99
+ };
100
+ }
101
+ function parsePipeComment(inner) {
102
+ const pairs = [];
103
+ for (const part of inner.split("|")) {
104
+ const colon = part.indexOf(":");
105
+ if (colon === -1)
106
+ continue;
107
+ pairs.push([part.slice(0, colon).trim(), part.slice(colon + 1).trim()]);
108
+ }
109
+ return pairs;
110
+ }
111
+ function toMetadata(pairs) {
112
+ const map = new Map(pairs);
113
+ const extra = pairs.filter(([k]) => !REQUIRED_METADATA_KEYS.includes(k));
114
+ return {
115
+ id: map.get("id") ?? "",
116
+ status: map.get("status") ?? "",
117
+ area: map.get("area") ?? "",
118
+ asked_by: map.get("asked_by") ?? "",
119
+ asked_at: map.get("asked_at") ?? "",
120
+ extra
121
+ };
122
+ }
123
+ function toFooter(pairs) {
124
+ const map = new Map(pairs);
125
+ return {
126
+ answered_by: map.get("answered_by") ?? "",
127
+ answered_at: map.get("answered_at") ?? "",
128
+ fact: map.get("fact") ?? ""
129
+ };
130
+ }
131
+ function serializeQuestions(doc) {
132
+ const out = [...doc.preamble];
133
+ for (const block of doc.blocks)
134
+ out.push(...block.lines);
135
+ const text = out.join(`
136
+ `);
137
+ return doc.trailingNewline ? `${text}
138
+ ` : text;
139
+ }
140
+ function detectAnswered(blocks) {
141
+ return blocks.filter((b) => b.metadata?.status === "open" && b.answer !== "");
142
+ }
143
+ function openBlocks(blocks) {
144
+ return blocks.filter((b) => b.metadata?.status === "open");
145
+ }
146
+ function recordAnswer(block, footer) {
147
+ const lines = [...block.lines];
148
+ if (block.metadataIndex >= 0) {
149
+ const line = lines[block.metadataIndex] ?? "";
150
+ lines[block.metadataIndex] = line.replace(/(\|\s*status:\s*)open(\s*\|)/, "$1answered$2");
151
+ }
152
+ const footerLine = `<!-- ${FOOTER_KEYS.map((k) => `${k}: ${footer[k]}`).join(" | ")} -->`;
153
+ const insertAt = block.answerIndex >= 0 ? block.answerIndex + 1 : lines.length;
154
+ lines.splice(insertAt, 0, footerLine);
155
+ const metadata = block.metadata === null ? null : { ...block.metadata, status: "answered" };
156
+ return { ...block, lines, metadata, footer };
157
+ }
158
+ function replaceBlock(doc, block) {
159
+ return {
160
+ ...doc,
161
+ blocks: doc.blocks.map((b) => b.id === block.id ? block : b)
162
+ };
163
+ }
164
+
165
+ // src/core/facts/Fact.ts
166
+ var FACT_KINDS = ["answer", "observed", "derived"];
167
+ var FACT_CONFIDENCES = ["measured", "inferred", "stated"];
168
+ var MAX_FACTS = 5000;
169
+ var MAX_FACT_CHARS = 2000;
170
+ function isRetired(fact) {
171
+ return fact.retired !== null && fact.retired.at !== null;
172
+ }
173
+ function isSuperseded(fact) {
174
+ return fact.superseded_by !== null;
175
+ }
176
+ function isLive(fact) {
177
+ return !isRetired(fact) && !isSuperseded(fact);
178
+ }
179
+ function factNumber(id) {
180
+ return Number.parseInt(id.slice(1), 10);
181
+ }
182
+ function formatFactId(n) {
183
+ return `F${String(n).padStart(3, "0")}`;
184
+ }
185
+
186
+ // src/core/schemas/deprecationNotice.ts
187
+ var said = new Set;
188
+ function noteDeprecations(file, outcome) {
189
+ for (const note of outcome.deprecations ?? []) {
190
+ const line = `${file}: ${note}`;
191
+ if (said.has(line))
192
+ continue;
193
+ said.add(line);
194
+ process.stderr.write(`${line}
195
+ `);
196
+ }
197
+ }
198
+
199
+ export { FACT_KINDS, FACT_CONFIDENCES, MAX_FACTS, MAX_FACT_CHARS, isRetired, isLive, factNumber, formatFactId, noteDeprecations, parseQuestions, serializeQuestions, detectAnswered, openBlocks, recordAnswer, replaceBlock };
@@ -5,7 +5,7 @@ import {
5
5
  requireNumber,
6
6
  requireString,
7
7
  result
8
- } from "./chunk-0bt6yb2q.js";
8
+ } from "./chunk-39zh2e44.js";
9
9
 
10
10
  // src/core/events/EventLog.ts
11
11
  import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
@@ -17,6 +17,7 @@ var EVENT_TYPES = [
17
17
  "run.closed",
18
18
  "run.unlocked",
19
19
  "run.cancelled",
20
+ "run.attended",
20
21
  "phase.started",
21
22
  "phase.done",
22
23
  "stage.started",
@@ -33,6 +34,10 @@ var EVENT_TYPES = [
33
34
  "gate.approved",
34
35
  "gate.rejected",
35
36
  "gate.revoked",
37
+ "gate.policy_changed",
38
+ "story.reopened",
39
+ "story.base_fastforwarded",
40
+ "operator_note",
36
41
  "check.passed",
37
42
  "check.failed",
38
43
  "budget.warned",
@@ -40,6 +45,7 @@ var EVENT_TYPES = [
40
45
  "budget.raised",
41
46
  "fact.added",
42
47
  "fact.retired",
48
+ "fact.superseded",
43
49
  "map.refreshed",
44
50
  "ticket.synced",
45
51
  "error"
@@ -1,10 +1,14 @@
1
1
  import {
2
+ DEFAULT_ECONOMY,
3
+ DEFAULT_ON_HOST_TOKENS_EXCEED,
2
4
  asRunBudget,
3
5
  asRunFile,
4
6
  cursorStage,
5
7
  derivePhaseStatus,
6
8
  deriveRunStatus,
7
9
  flatten,
10
+ isAttendedByHost,
11
+ isAttendedByHostView,
8
12
  isFinished,
9
13
  isTerminal,
10
14
  nowRfc3339,
@@ -12,34 +16,37 @@ import {
12
16
  stageAt,
13
17
  validateRunBudget,
14
18
  validateRunFile
15
- } from "./chunk-sdjnnmzz.js";
19
+ } from "./chunk-4cp363kv.js";
16
20
  import {
17
21
  EventLog
18
- } from "./chunk-azctppjh.js";
22
+ } from "./chunk-rz541e2b.js";
19
23
  import {
24
+ backupPathFor,
20
25
  isAlive,
21
- openBlocks,
22
- parseQuestions,
23
26
  readLock,
24
27
  withWorkspaceLock,
25
28
  workspaceRootOfRunDir,
29
+ writeAtomic,
26
30
  yamlScalar
27
- } from "./chunk-j234zf0t.js";
31
+ } from "./chunk-c6t5nx0r.js";
28
32
  import {
29
- noteDeprecations
30
- } from "./chunk-t56k6146.js";
33
+ noteDeprecations,
34
+ openBlocks,
35
+ parseQuestions
36
+ } from "./chunk-rpcxsqh3.js";
31
37
  import {
32
38
  listRunDirs,
33
- parseYaml
34
- } from "./chunk-p274ckxv.js";
39
+ parseYaml,
40
+ parseYamlRepairing
41
+ } from "./chunk-sq44k6g2.js";
35
42
 
36
43
  // src/core/statusline/runSnapshot.ts
37
44
  import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
38
45
  import { join as join2 } from "node:path";
39
46
 
40
47
  // src/core/run/RunStore.ts
41
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
42
- import { basename, dirname, join } from "node:path";
48
+ import { existsSync, readFileSync } from "node:fs";
49
+ import { basename, join } from "node:path";
43
50
 
44
51
  // src/core/run/emitRunYaml.ts
45
52
  function inlineList(values) {
@@ -52,8 +59,12 @@ function gatesPolicy(policy) {
52
59
  const entries = Object.entries(policy).map(([id, value]) => `${yamlScalar(id)}: ${yamlScalar(value)}`);
53
60
  return `{${entries.join(", ")}}`;
54
61
  }
62
+ function gateEvidence(e) {
63
+ return `{path: ${yamlScalar(e.path)}, role: ${yamlScalar(e.role)}, verdict: ${yamlScalar(e.verdict)}, ` + `sampled: ${String(e.sampled)}, of: ${String(e.of)}, resolved: ${String(e.resolved)}, ` + `refuted: ${String(e.refuted)}, outside_surface: ${String(e.outside_surface)}}`;
64
+ }
55
65
  function gate(g) {
56
- return `{type: ${yamlScalar(g.type)}, status: ${yamlScalar(g.status)}, by: ${yamlScalar(g.by)}, ` + `at: ${yamlScalar(g.at)}, note: ${yamlScalar(g.note)}}`;
66
+ const evidence = g.evidence === undefined ? "" : `, evidence: ${gateEvidence(g.evidence)}`;
67
+ return `{type: ${yamlScalar(g.type)}, status: ${yamlScalar(g.status)}, by: ${yamlScalar(g.by)}, ` + `at: ${yamlScalar(g.at)}, note: ${yamlScalar(g.note)}${evidence}}`;
57
68
  }
58
69
  function task(t, indent) {
59
70
  const inner = `${indent} `;
@@ -115,11 +126,18 @@ function emitRunYaml(run) {
115
126
  lines.push(`triage: {split: ${yamlScalar(run.triage.split)}, depends_on: ${inlineList(run.triage.depends_on)}}`);
116
127
  }
117
128
  if (run.build !== undefined && run.build.epic_branch.length > 0) {
118
- lines.push(`build: {epic_branch: ${inlineList(run.build.epic_branch)}}`);
129
+ const model = run.build.branch_model === undefined ? "" : `, branch_model: ${yamlScalar(run.build.branch_model)}`;
130
+ lines.push(`build: {epic_branch: ${inlineList(run.build.epic_branch)}${model}}`);
119
131
  }
120
132
  if (run.cancelled !== undefined) {
121
133
  lines.push(`cancelled: {by: ${yamlScalar(run.cancelled.by)}, at: ${yamlScalar(run.cancelled.at)}, ` + `note: ${yamlScalar(run.cancelled.note)}}`);
122
134
  }
135
+ if (run.attended_by !== undefined) {
136
+ lines.push(`attended_by: ${yamlScalar(run.attended_by)}`);
137
+ }
138
+ if (run.keep_worktrees === true) {
139
+ lines.push("keep_worktrees: true");
140
+ }
123
141
  if (run.gates_policy !== undefined && Object.keys(run.gates_policy).length > 0) {
124
142
  lines.push(`gates_policy: ${gatesPolicy(run.gates_policy)}`);
125
143
  }
@@ -149,10 +167,13 @@ function emitBudgetYaml(budget) {
149
167
  `per_agent_max_usd: ${money(budget.per_agent_max_usd)}`,
150
168
  `warn_at_pct: ${budget.warn_at_pct}`,
151
169
  `on_exceed: ${yamlScalar(budget.on_exceed)}`,
170
+ ...budget.economy === DEFAULT_ECONOMY ? [] : [`economy: ${yamlScalar(budget.economy)}`],
171
+ ...budget.on_host_tokens_exceed === DEFAULT_ON_HOST_TOKENS_EXCEED ? [] : [`on_host_tokens_exceed: ${yamlScalar(budget.on_host_tokens_exceed)}`],
152
172
  "phases:"
153
173
  ];
154
174
  for (const phase of budget.phases) {
155
- lines.push(` - {id: ${yamlScalar(phase.id)}, ceiling_usd: ${money(phase.ceiling_usd)}, ` + `spent_usd: ${money(phase.spent_usd)}}`);
175
+ const economy = phase.economy === null ? "" : `, economy: ${yamlScalar(phase.economy)}`;
176
+ lines.push(` - {id: ${yamlScalar(phase.id)}, ceiling_usd: ${money(phase.ceiling_usd)}, ` + `spent_usd: ${money(phase.spent_usd)}${economy}}`);
156
177
  }
157
178
  return `${lines.join(`
158
179
  `)}
@@ -179,8 +200,8 @@ class RunStore {
179
200
  const runPath = join(runDir, "run.yml");
180
201
  if (!existsSync(runPath))
181
202
  throw new RunStoreError(`no run.yml in ${runDir}`);
182
- const doc = parseYaml(readFileSync(runPath, "utf8"));
183
- const validation = validateRunFile(doc);
203
+ const run = parseStateFile(runPath);
204
+ const validation = validateRunFile(run.doc);
184
205
  noteDeprecations(runPath, validation);
185
206
  if (!validation.ok) {
186
207
  const first = validation.issues[0];
@@ -189,14 +210,30 @@ class RunStore {
189
210
  const budgetPath = join(runDir, "budget.yml");
190
211
  if (!existsSync(budgetPath))
191
212
  throw new RunStoreError(`no budget.yml in ${runDir}`);
192
- const budgetDoc = parseYaml(readFileSync(budgetPath, "utf8"));
193
- const budgetValidation = validateRunBudget(budgetDoc);
213
+ const budget = parseStateFile(budgetPath);
214
+ const budgetValidation = validateRunBudget(budget.doc);
194
215
  noteDeprecations(budgetPath, budgetValidation);
195
216
  if (!budgetValidation.ok) {
196
217
  const first = budgetValidation.issues[0];
197
218
  throw new RunStoreError(`invalid budget.yml (${budgetPath}): ${first?.path ?? ""} ${first?.message ?? "schema error"}`);
198
219
  }
199
- return new RunStore(runDir, asRunFile(doc), asRunBudget(budgetDoc), EventLog.forRun(runDir));
220
+ const store = new RunStore(runDir, asRunFile(run.doc), asRunBudget(budget.doc), EventLog.forRun(runDir));
221
+ if (run.repaired || budget.repaired)
222
+ store.healOnDisk(run.repaired, budget.repaired);
223
+ return store;
224
+ }
225
+ healOnDisk(runRepaired, budgetRepaired) {
226
+ try {
227
+ withWorkspaceLock(workspaceRootOfRunDir(this.runDir), () => {
228
+ if (runRepaired)
229
+ writeAtomic(join(this.runDir, "run.yml"), emitRunYaml(this.current));
230
+ if (budgetRepaired)
231
+ writeAtomic(join(this.runDir, "budget.yml"), emitBudgetYaml(this.currentBudget));
232
+ });
233
+ const which = [runRepaired ? "run.yml" : "", budgetRepaired ? "budget.yml" : ""].filter((name) => name !== "").join(" and ");
234
+ process.stderr.write(`tldrx: ${this.runDir}: ${which} held text broken across lines by an emitter bug ` + "since fixed. It was repaired in memory and rewritten correctly; the version that was " + `on disk is beside it as .bak. Nothing else about the run was changed.
235
+ `);
236
+ } catch {}
200
237
  }
201
238
  static find(root, runId) {
202
239
  const dir = listRunDirs(root).find((d) => basename(d) === runId);
@@ -306,18 +343,21 @@ class RunStore {
306
343
  }
307
344
  }
308
345
  }
309
- function writeAtomic(path, content) {
310
- const temp = `${path}.tmp-${String(process.pid)}`;
311
- mkdirSync(dirname(path), { recursive: true });
346
+ function parseStateFile(path) {
347
+ const text = readFileSync(path, "utf8");
312
348
  try {
313
- writeFileSync(temp, content, "utf8");
314
- renameSync(temp, path);
349
+ return parseYamlRepairing(text);
315
350
  } catch (error) {
316
- try {
317
- if (existsSync(temp))
318
- rmSync(temp, { force: true });
319
- } catch {}
320
- throw error;
351
+ const backup = backupPathFor(path);
352
+ const detail = error instanceof Error ? error.message : String(error);
353
+ throw new RunStoreError([
354
+ `${path} does not parse: ${detail}`,
355
+ " every tldrx command on this run reads that file first, so all of them fail until it does",
356
+ existsSync(backup) ? ` the version the last save replaced is at ${backup} — recovery is MANUAL and tldrx ` + "will not do it for you:" + `
357
+ diff it, and if that version is the one you want, copy it back over ${basename(path)}` : ` there is no ${basename(backup)} beside it — this file has not been written since ` + "backups arrived, so there is no previous version to go back to",
358
+ " a hand-edit is the other way out, and it is the only one that keeps work the backup predates"
359
+ ].join(`
360
+ `));
321
361
  }
322
362
  }
323
363
  function rollUp(run) {
@@ -418,6 +458,7 @@ function fromStore(root) {
418
458
  openCount: open.length,
419
459
  autoGates: stages.filter((s) => s.gate.status === "approved" && s.gate.by === AUTO).length,
420
460
  staleStages: stages.filter((s) => s.stale === true).length,
461
+ attendedByHost: isAttendedByHost(run),
421
462
  source: "run-store"
422
463
  };
423
464
  }
@@ -446,6 +487,7 @@ function fromTolerantRead(root) {
446
487
  openCount: open.length,
447
488
  autoGates: 0,
448
489
  staleStages: 0,
490
+ attendedByHost: isAttendedByHostView(view),
449
491
  source: "tolerant"
450
492
  };
451
493
  }