@hizliemre/horse-code 0.1.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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +150 -0
  3. package/dist/app-SB2L34JW.js +6217 -0
  4. package/dist/chunk-2DGO2BUB.js +4490 -0
  5. package/dist/chunk-2SVAHH5N.js +60 -0
  6. package/dist/chunk-3XVZXTB6.js +4469 -0
  7. package/dist/chunk-5UWA2UBM.js +69 -0
  8. package/dist/chunk-7TBYMFMG.js +147 -0
  9. package/dist/chunk-B67BK5GQ.js +34 -0
  10. package/dist/chunk-BY4DP7IE.js +20 -0
  11. package/dist/chunk-DKVIN43T.js +54 -0
  12. package/dist/chunk-DTWKSZXY.js +162 -0
  13. package/dist/chunk-F2IALVBU.js +212 -0
  14. package/dist/chunk-FFYBY2NA.js +392 -0
  15. package/dist/chunk-FGVJFMK5.js +123 -0
  16. package/dist/chunk-H2FDGPVW.js +42 -0
  17. package/dist/chunk-HBSC2HT2.js +85 -0
  18. package/dist/chunk-IW2KBAVZ.js +21 -0
  19. package/dist/chunk-JWAEW7AJ.js +121 -0
  20. package/dist/chunk-NNTIACT4.js +163 -0
  21. package/dist/chunk-O74BDQKS.js +28 -0
  22. package/dist/chunk-PGOYDOI4.js +426 -0
  23. package/dist/chunk-QF4MP6BS.js +69 -0
  24. package/dist/chunk-SSDLHWSF.js +35 -0
  25. package/dist/chunk-TOPZL5SU.js +1052 -0
  26. package/dist/chunk-YBWTCXUS.js +153 -0
  27. package/dist/chunk-YILDXPSI.js +1363 -0
  28. package/dist/clean-YOQATBMZ.js +18 -0
  29. package/dist/cli.js +1495 -0
  30. package/dist/discover-5URG7C4J.js +52 -0
  31. package/dist/fix-HBBOTUWM.js +34 -0
  32. package/dist/frontmatter-UNIPNLLO.js +6 -0
  33. package/dist/git-VTSZALSR.js +6 -0
  34. package/dist/install-O34KMWJB.js +113 -0
  35. package/dist/main-branch-KGWUINYQ.js +19 -0
  36. package/dist/ongoing-OV5XROTU.js +70 -0
  37. package/dist/project-graph-IOPCSZUA.js +56 -0
  38. package/dist/run-LQOZ5I7Z.js +610 -0
  39. package/dist/save-skills-OHYGVTQ4.js +13 -0
  40. package/dist/source-cache-XEK5WN7I.js +29 -0
  41. package/dist/trace-ZMB7LT7W.js +66 -0
  42. package/dist/trace-adopt-C6TUWFJL.js +79 -0
  43. package/dist/trace-run-F23MFTY4.js +24 -0
  44. package/dist/triage-2J3T5PVQ.js +30 -0
  45. package/dist/verify-WQ3GHION.js +479 -0
  46. package/dist/worktree-F7TWLWLN.js +87 -0
  47. package/package.json +64 -0
@@ -0,0 +1,4490 @@
1
+ import {
2
+ buildAskUserTool,
3
+ extractChoicesFrom,
4
+ normalizeQuestion,
5
+ respondIn,
6
+ writerRegistry
7
+ } from "./chunk-F2IALVBU.js";
8
+ import {
9
+ Board,
10
+ LONG_CALL_MS,
11
+ WHAT_IT_COST,
12
+ applySkills,
13
+ buildRememberTool,
14
+ buildSkillTool,
15
+ changedByMerge,
16
+ commitFile,
17
+ commitStep,
18
+ constitutionNote,
19
+ constitutionPath,
20
+ createDefaultRegistry,
21
+ describeInherited,
22
+ describeTopUp,
23
+ editFileTool,
24
+ hasWorkAgainst,
25
+ nextFeatureSlug,
26
+ placedSkills,
27
+ readOnlyRegistry,
28
+ refreshAfterChange,
29
+ routeSkills,
30
+ routeTask,
31
+ runCycleWithRole,
32
+ runImplementer,
33
+ runReviewLoop,
34
+ runReviewer,
35
+ scaffoldFeature,
36
+ squashTask,
37
+ worktreeState,
38
+ writeFileTool
39
+ } from "./chunk-3XVZXTB6.js";
40
+ import {
41
+ resolveMainBranch
42
+ } from "./chunk-QF4MP6BS.js";
43
+ import {
44
+ askInUserLanguage,
45
+ inUserLanguage
46
+ } from "./chunk-HBSC2HT2.js";
47
+ import {
48
+ clearCheckpoint,
49
+ isContinuePrompt,
50
+ readCheckpoint,
51
+ writeCheckpoint
52
+ } from "./chunk-FGVJFMK5.js";
53
+ import {
54
+ defaultGitRunner
55
+ } from "./chunk-IW2KBAVZ.js";
56
+ import {
57
+ BATCH_TOOLS_NOTE,
58
+ SHORT_TTL_MS,
59
+ confidenceOf,
60
+ contextTools,
61
+ deriveAnchors,
62
+ deriveTags,
63
+ hashAnchors,
64
+ importanceOf,
65
+ isExpired,
66
+ memoryHints,
67
+ projectToolsNote,
68
+ readFileTool,
69
+ reinforceTouched,
70
+ reinforceUsed,
71
+ relationStrength,
72
+ supersedes,
73
+ verifyAnchors
74
+ } from "./chunk-TOPZL5SU.js";
75
+ import {
76
+ ToolRegistry,
77
+ handedOver,
78
+ runStructuredRole,
79
+ runToCompletion,
80
+ sanitizeForJson,
81
+ stripThinking,
82
+ telemetry
83
+ } from "./chunk-YILDXPSI.js";
84
+ import {
85
+ TRACE_INDEX,
86
+ mergeTraceIndexes,
87
+ parseTraceIndex,
88
+ serializeTraceIndex,
89
+ traceRootRel
90
+ } from "./chunk-FFYBY2NA.js";
91
+ import {
92
+ loadGraphSync
93
+ } from "./chunk-PGOYDOI4.js";
94
+ import {
95
+ inLinkedWorktree,
96
+ sessionBase,
97
+ stateRoot,
98
+ writableStateRoot
99
+ } from "./chunk-SSDLHWSF.js";
100
+ import {
101
+ writeAtomic
102
+ } from "./chunk-B67BK5GQ.js";
103
+
104
+ // src/providers/sse.ts
105
+ function* handleLine(line) {
106
+ const trimmed = line.trim();
107
+ if (trimmed.startsWith("data:")) {
108
+ const payload = trimmed.slice(5).trim();
109
+ if (payload === "[DONE]") return true;
110
+ if (payload) yield { kind: "data", value: payload };
111
+ return false;
112
+ }
113
+ if (trimmed.startsWith(":")) {
114
+ const value = trimmed.slice(1).trim();
115
+ if (value) yield { kind: "comment", value };
116
+ }
117
+ return false;
118
+ }
119
+ async function* parseSSE(body) {
120
+ const reader = body.getReader();
121
+ const decoder = new TextDecoder();
122
+ let buffer = "";
123
+ try {
124
+ while (true) {
125
+ const { value, done } = await reader.read();
126
+ if (done) break;
127
+ buffer += decoder.decode(value, { stream: true });
128
+ let nl;
129
+ while ((nl = buffer.indexOf("\n")) !== -1) {
130
+ const line = buffer.slice(0, nl);
131
+ buffer = buffer.slice(nl + 1);
132
+ const isDone = yield* handleLine(line);
133
+ if (isDone) return;
134
+ }
135
+ }
136
+ if (buffer) {
137
+ yield* handleLine(buffer);
138
+ }
139
+ } finally {
140
+ reader.releaseLock();
141
+ }
142
+ }
143
+
144
+ // src/providers/openai.ts
145
+ function objectArgs(args) {
146
+ if (!args || !args.trim()) return "{}";
147
+ try {
148
+ const v = JSON.parse(args);
149
+ return v && typeof v === "object" && !Array.isArray(v) ? args : "{}";
150
+ } catch {
151
+ return "{}";
152
+ }
153
+ }
154
+ function toOpenAIMessages(messages) {
155
+ return messages.map((m) => {
156
+ if (m.role === "assistant" && m.toolCalls?.length) {
157
+ return {
158
+ role: "assistant",
159
+ content: m.content || null,
160
+ tool_calls: m.toolCalls.map((tc) => ({
161
+ id: tc.id,
162
+ type: "function",
163
+ function: { name: tc.name, arguments: objectArgs(tc.arguments) }
164
+ }))
165
+ };
166
+ }
167
+ if (m.role === "tool") {
168
+ return { role: "tool", tool_call_id: m.toolCallId, content: m.content };
169
+ }
170
+ if (m.images?.length) {
171
+ const parts = [];
172
+ if (m.content) parts.push({ type: "text", text: m.content });
173
+ for (const url of m.images) parts.push({ type: "image_url", image_url: { url } });
174
+ const withImg = { role: m.role, content: parts };
175
+ if (m.name) withImg.name = m.name;
176
+ return withImg;
177
+ }
178
+ const base = { role: m.role, content: m.content };
179
+ if (m.name) base.name = m.name;
180
+ return base;
181
+ });
182
+ }
183
+ function toOpenAITools(tools) {
184
+ if (!tools.length) return void 0;
185
+ return tools.map((t) => ({
186
+ type: "function",
187
+ function: { name: t.name, description: t.description, parameters: t.parameters }
188
+ }));
189
+ }
190
+ function toOpenAIBody(req) {
191
+ const body = {
192
+ model: req.model,
193
+ messages: toOpenAIMessages(req.messages),
194
+ stream: true,
195
+ // Ask the backend to append a final chunk carrying token usage (streaming otherwise omits it).
196
+ stream_options: { include_usage: true }
197
+ };
198
+ const tools = toOpenAITools(req.tools);
199
+ if (tools) {
200
+ body.tools = tools;
201
+ body.tool_choice = "auto";
202
+ body.parallel_tool_calls = true;
203
+ }
204
+ return body;
205
+ }
206
+ function mapFinishReason(reason) {
207
+ if (reason === "tool_calls") return "tool_calls";
208
+ if (reason === "length") return "length";
209
+ return "stop";
210
+ }
211
+
212
+ // src/providers/anthropic.ts
213
+ var MAX_OUTPUT_TOKENS = 64e3;
214
+ function isAnthropicModel(model) {
215
+ return /(^|\/)(claude|fable|mythos)/i.test(model) || /claude/i.test(model);
216
+ }
217
+ function imageSource(uri) {
218
+ const m = /^data:([^;,]+);base64,(.*)$/s.exec(uri);
219
+ return m?.[1] && m[2] ? { type: "base64", media_type: m[1], data: m[2] } : void 0;
220
+ }
221
+ function toolInput(args) {
222
+ if (!args?.trim()) return {};
223
+ try {
224
+ const v = JSON.parse(args);
225
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
226
+ } catch {
227
+ return {};
228
+ }
229
+ }
230
+ function toAnthropicMessages(messages) {
231
+ const system = [];
232
+ const turns = [];
233
+ const push = (role, blocks) => {
234
+ if (!blocks.length) return;
235
+ const last = turns[turns.length - 1];
236
+ if (last && last.role === role) last.content.push(...blocks);
237
+ else turns.push({ role, content: blocks });
238
+ };
239
+ for (const m of messages) {
240
+ if (m.role === "system") {
241
+ if (m.content.trim()) system.push(m.content);
242
+ continue;
243
+ }
244
+ if (m.role === "tool") {
245
+ push("user", [{
246
+ type: "tool_result",
247
+ tool_use_id: m.toolCallId ?? "",
248
+ content: m.content || "(no output)"
249
+ }]);
250
+ continue;
251
+ }
252
+ const blocks = [];
253
+ for (const uri of m.images ?? []) {
254
+ const source = imageSource(uri);
255
+ if (source) blocks.push({ type: "image", source });
256
+ }
257
+ if (m.content.trim()) blocks.push({ type: "text", text: m.content });
258
+ for (const tc of m.toolCalls ?? []) {
259
+ blocks.push({ type: "tool_use", id: tc.id, name: tc.name, input: toolInput(tc.arguments) });
260
+ }
261
+ push(m.role === "assistant" ? "assistant" : "user", blocks);
262
+ }
263
+ return { system: system.join("\n\n"), turns };
264
+ }
265
+ function toAnthropicBody(req) {
266
+ const { system, turns } = toAnthropicMessages(req.messages);
267
+ const body = {
268
+ model: req.model,
269
+ max_tokens: MAX_OUTPUT_TOKENS,
270
+ stream: true,
271
+ messages: turns
272
+ };
273
+ if (system) body.system = system;
274
+ if (req.tools.length) {
275
+ body.tools = req.tools.map((t) => ({
276
+ name: t.name,
277
+ description: t.description,
278
+ input_schema: t.parameters
279
+ }));
280
+ }
281
+ if (req.effort) body.output_config = { effort: req.effort };
282
+ return body;
283
+ }
284
+ function mapStopReason(reason) {
285
+ if (reason === "tool_use") return "tool_calls";
286
+ if (reason === "max_tokens") return "length";
287
+ return "stop";
288
+ }
289
+ var PROGRESS_EVERY = 64;
290
+ function pathOf(partial) {
291
+ const m = /"(?:path|file|filename|target)"\s*:\s*"((?:[^"\\]|\\.)*)/.exec(partial);
292
+ return m?.[1] ? m[1].replace(/\\(.)/g, "$1") : void 0;
293
+ }
294
+ var AnthropicDecoder = class {
295
+ blocks = /* @__PURE__ */ new Map();
296
+ stop = "stop";
297
+ inTokens = 0;
298
+ outTokens = 0;
299
+ cachedTokens = 0;
300
+ sawUsage = false;
301
+ push(chunk) {
302
+ const c = chunk;
303
+ const out = [];
304
+ switch (c.type) {
305
+ case "message_start": {
306
+ const u = c.message?.usage;
307
+ if (u) {
308
+ this.sawUsage = true;
309
+ this.inTokens = u.input_tokens ?? 0;
310
+ this.cachedTokens = u.cache_read_input_tokens ?? 0;
311
+ }
312
+ break;
313
+ }
314
+ case "content_block_start": {
315
+ const b = c.content_block;
316
+ if (b && c.index !== void 0) {
317
+ this.blocks.set(c.index, {
318
+ type: b.type ?? "text",
319
+ id: b.id ?? "",
320
+ name: b.name ?? "",
321
+ args: "",
322
+ emitted: 0
323
+ });
324
+ }
325
+ break;
326
+ }
327
+ case "content_block_delta": {
328
+ const b = c.index === void 0 ? void 0 : this.blocks.get(c.index);
329
+ if (c.delta?.type === "text_delta" && c.delta.text) {
330
+ out.push({ type: "text-delta", text: c.delta.text });
331
+ } else if (c.delta?.type === "input_json_delta" && b) {
332
+ b.args += c.delta.partial_json ?? "";
333
+ if (b.name && b.args.length - b.emitted >= PROGRESS_EVERY) {
334
+ b.emitted = b.args.length;
335
+ out.push({ type: "tool-progress", name: b.name, chars: b.args.length, path: pathOf(b.args) });
336
+ }
337
+ }
338
+ break;
339
+ }
340
+ case "content_block_stop": {
341
+ const b = c.index === void 0 ? void 0 : this.blocks.get(c.index);
342
+ if (b?.type === "tool_use") {
343
+ out.push({ type: "tool-call", toolCall: { id: b.id, name: b.name, arguments: b.args || "{}" } });
344
+ }
345
+ if (c.index !== void 0) this.blocks.delete(c.index);
346
+ break;
347
+ }
348
+ case "message_delta": {
349
+ if (c.delta?.stop_reason) this.stop = mapStopReason(c.delta.stop_reason);
350
+ if (c.usage) {
351
+ this.sawUsage = true;
352
+ this.outTokens = c.usage.output_tokens ?? this.outTokens;
353
+ if (!this.inTokens) this.inTokens = c.usage.input_tokens ?? 0;
354
+ }
355
+ break;
356
+ }
357
+ case "error": {
358
+ out.push({ type: "error", message: c.error?.message ?? "anthropic: stream error", retryable: true });
359
+ break;
360
+ }
361
+ default:
362
+ break;
363
+ }
364
+ return out;
365
+ }
366
+ /** Usage as the transport should report it, or undefined when the stream never said. */
367
+ usage() {
368
+ return this.sawUsage ? { promptTokens: this.inTokens, completionTokens: this.outTokens, cachedTokens: this.cachedTokens } : void 0;
369
+ }
370
+ finishReason() {
371
+ return this.stop;
372
+ }
373
+ };
374
+
375
+ // src/providers/omniroute.ts
376
+ async function* withIdleTimeout(source, idleMs, onIdle) {
377
+ const it = source[Symbol.asyncIterator]();
378
+ for (; ; ) {
379
+ let timer;
380
+ const idle = new Promise((resolve) => {
381
+ timer = setTimeout(() => resolve("idle"), idleMs);
382
+ });
383
+ const next = it.next();
384
+ next.catch(() => {
385
+ });
386
+ const winner = await Promise.race([next, idle]);
387
+ if (timer) clearTimeout(timer);
388
+ if (winner === "idle") {
389
+ onIdle?.();
390
+ void Promise.resolve(it.return?.(void 0)).catch(() => {
391
+ });
392
+ throw new Error(`omniroute: stream stalled (no data for ${Math.round(idleMs / 1e3)}s) \u2014 aborted`);
393
+ }
394
+ if (winner.done) return;
395
+ yield winner.value;
396
+ }
397
+ }
398
+ async function readErrorMessage(res) {
399
+ let body;
400
+ try {
401
+ body = await res.json();
402
+ } catch {
403
+ return `omniroute ${res.status}`;
404
+ }
405
+ const err = body.error;
406
+ if (typeof err === "string") return err;
407
+ if (err && typeof err === "object" && "message" in err) {
408
+ return String(err.message);
409
+ }
410
+ return `omniroute ${res.status}`;
411
+ }
412
+ function isRetryableStatus(status) {
413
+ return status === 429 || status === 404 || status >= 500;
414
+ }
415
+ function isCapabilityError(message) {
416
+ return /long[- ]context|not (yet )?available for this subscription|context[- ](length|window)|too many tokens|maximum context|unsupported|not supported/i.test(message);
417
+ }
418
+ function isUnknownModelError(message) {
419
+ return /unable to determine provider for model|unknown model|model not found|no such model|invalid model/i.test(message);
420
+ }
421
+ function pathOf2(args) {
422
+ return args.match(/"path"\s*:\s*"([^"\\]+)"/)?.[1];
423
+ }
424
+ function argumentsComplete(args) {
425
+ if (!args.trim()) return true;
426
+ try {
427
+ JSON.parse(args);
428
+ return true;
429
+ } catch {
430
+ return false;
431
+ }
432
+ }
433
+ function isCallerAbort(signal) {
434
+ return signal.aborted && signal.reason?.name !== "TimeoutError";
435
+ }
436
+ function isDeadline(signal) {
437
+ return signal.aborted && signal.reason?.name === "TimeoutError";
438
+ }
439
+ var OmniRouteProvider = class {
440
+ apiKey;
441
+ baseUrl;
442
+ fetchFn;
443
+ idleMs;
444
+ constructor(opts) {
445
+ this.apiKey = opts.apiKey;
446
+ this.baseUrl = opts.baseUrl.replace(/\/$/, "");
447
+ this.fetchFn = opts.fetch ?? globalThis.fetch;
448
+ this.idleMs = opts.idleTimeoutMs ?? 12e4;
449
+ }
450
+ async *chat(req, signal) {
451
+ const native = isAnthropicModel(req.model);
452
+ const headers = {
453
+ "Content-Type": "application/json",
454
+ Accept: "text/event-stream"
455
+ };
456
+ if (native) headers["anthropic-version"] = "2023-06-01";
457
+ if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`;
458
+ const idleAc = new AbortController();
459
+ const combined = AbortSignal.any([signal, idleAc.signal]);
460
+ let res;
461
+ try {
462
+ res = await this.fetchFn(`${this.baseUrl}${native ? "/v1/messages" : "/api/v1/chat/completions"}`, {
463
+ method: "POST",
464
+ headers,
465
+ // Sanitised at the socket, not at each of the dozens of places that build a prompt — see
466
+ // src/core/surrogates.ts for the four-hour run this cost.
467
+ body: JSON.stringify(sanitizeForJson(native ? toAnthropicBody(req) : toOpenAIBody(req))),
468
+ signal: combined
469
+ });
470
+ } catch (e) {
471
+ if (isCallerAbort(signal)) {
472
+ yield { type: "error", message: "cancelled", retryable: false };
473
+ return;
474
+ }
475
+ if (isDeadline(signal)) {
476
+ yield { type: "error", message: "the model did not answer within its deadline", retryable: true };
477
+ return;
478
+ }
479
+ yield { type: "error", message: e instanceof Error ? e.message : String(e), retryable: true };
480
+ return;
481
+ }
482
+ if (!res.ok) {
483
+ const message = await readErrorMessage(res);
484
+ const capability = isCapabilityError(message);
485
+ const unknownModel = isUnknownModelError(message);
486
+ yield {
487
+ type: "error",
488
+ message,
489
+ retryable: isRetryableStatus(res.status) || capability || unknownModel,
490
+ ...capability && { capability: true },
491
+ ...unknownModel && { noBench: true }
492
+ };
493
+ return;
494
+ }
495
+ const stream = res.body;
496
+ if (!stream) {
497
+ yield { type: "error", message: "omniroute: empty response body" };
498
+ return;
499
+ }
500
+ const decoder = native ? new AnthropicDecoder() : void 0;
501
+ const toolCalls = /* @__PURE__ */ new Map();
502
+ const lastProgress = /* @__PURE__ */ new Map();
503
+ let finishReason = "stop";
504
+ let sawText = false;
505
+ let usage;
506
+ const billed = {};
507
+ try {
508
+ for await (const line of withIdleTimeout(parseSSE(stream), this.idleMs, () => idleAc.abort())) {
509
+ if (line.kind === "comment") {
510
+ const m = line.value.match(/^x-omniroute-tokens-(in|out)\s*=\s*(\d+)/i);
511
+ if (m) billed[m[1] === "in" ? "in" : "out"] = Number(m[2]);
512
+ continue;
513
+ }
514
+ let chunk;
515
+ try {
516
+ chunk = JSON.parse(line.value);
517
+ } catch {
518
+ continue;
519
+ }
520
+ if (decoder) {
521
+ for (const ev of decoder.push(chunk)) yield ev;
522
+ continue;
523
+ }
524
+ const u = chunk.usage;
525
+ if (u) usage = {
526
+ promptTokens: u.prompt_tokens ?? 0,
527
+ completionTokens: u.completion_tokens ?? 0,
528
+ cachedTokens: u.prompt_tokens_details?.cached_tokens ?? 0
529
+ };
530
+ const choice = chunk?.choices?.[0];
531
+ if (!choice) continue;
532
+ const delta = choice.delta ?? {};
533
+ if (typeof delta.content === "string" && delta.content.length) {
534
+ sawText = true;
535
+ yield { type: "text-delta", text: delta.content };
536
+ }
537
+ const deltaCalls = delta.tool_calls;
538
+ if (Array.isArray(deltaCalls)) {
539
+ for (const tc of deltaCalls) {
540
+ const idx = tc.index ?? 0;
541
+ const acc = toolCalls.get(idx) ?? { id: "", name: "", arguments: "" };
542
+ if (tc.id) acc.id = tc.id;
543
+ if (tc.function?.name) acc.name = tc.function.name;
544
+ if (tc.function?.arguments) acc.arguments += tc.function.arguments;
545
+ toolCalls.set(idx, acc);
546
+ if (acc.name && acc.arguments.length - (lastProgress.get(idx) ?? 0) >= 64) {
547
+ lastProgress.set(idx, acc.arguments.length);
548
+ yield { type: "tool-progress", name: acc.name, chars: acc.arguments.length, path: pathOf2(acc.arguments) };
549
+ }
550
+ }
551
+ }
552
+ if (choice.finish_reason) finishReason = mapFinishReason(choice.finish_reason);
553
+ }
554
+ } catch (e) {
555
+ if (isCallerAbort(signal)) {
556
+ yield { type: "error", message: "cancelled", retryable: false };
557
+ return;
558
+ }
559
+ if (isDeadline(signal)) {
560
+ yield { type: "error", message: "the model did not answer within its deadline", retryable: true };
561
+ return;
562
+ }
563
+ yield { type: "error", message: e instanceof Error ? e.message : String(e), retryable: true };
564
+ return;
565
+ }
566
+ const cut = sawText ? void 0 : [...toolCalls.values()].find((a) => !argumentsComplete(a.arguments));
567
+ if (cut) {
568
+ yield {
569
+ type: "error",
570
+ message: `the stream ended in the middle of ${cut.name || "a tool call"}'s arguments`,
571
+ retryable: true
572
+ };
573
+ return;
574
+ }
575
+ for (const acc of toolCalls.values()) {
576
+ const toolCall = { id: acc.id, name: acc.name, arguments: acc.arguments };
577
+ yield { type: "tool-call", toolCall };
578
+ }
579
+ if (decoder) {
580
+ finishReason = decoder.finishReason();
581
+ usage = decoder.usage() ?? usage;
582
+ }
583
+ if (billed.in !== void 0 || billed.out !== void 0) {
584
+ yield { type: "usage", promptTokens: billed.in ?? 0, completionTokens: billed.out ?? 0 };
585
+ } else if (usage) {
586
+ yield { type: "usage", promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cachedTokens: usage.cachedTokens };
587
+ } else {
588
+ const inHeader = res.headers.get("X-OmniRoute-Tokens-In");
589
+ const outHeader = res.headers.get("X-OmniRoute-Tokens-Out");
590
+ if (inHeader !== null || outHeader !== null) {
591
+ yield {
592
+ type: "usage",
593
+ promptTokens: Number(inHeader) || 0,
594
+ completionTokens: Number(outHeader) || 0
595
+ };
596
+ }
597
+ }
598
+ yield { type: "done", finishReason };
599
+ }
600
+ };
601
+
602
+ // src/adapters/pr.ts
603
+ import { spawn } from "child_process";
604
+ import { writeFile, rm } from "fs/promises";
605
+ import { tmpdir } from "os";
606
+ import { join } from "path";
607
+ var defaultCmdRunner = (cmd, args, cwd) => new Promise((resolve) => {
608
+ let stdout = "";
609
+ let stderr = "";
610
+ let child;
611
+ try {
612
+ child = spawn(cmd, args, { cwd });
613
+ } catch (e) {
614
+ resolve({ stdout, stderr: e instanceof Error ? e.message : String(e), code: -1 });
615
+ return;
616
+ }
617
+ child.stdout?.on("data", (d) => stdout += d.toString());
618
+ child.stderr?.on("data", (d) => stderr += d.toString());
619
+ child.on("error", (e) => resolve({ stdout, stderr: stderr + e.message, code: -1 }));
620
+ child.on("close", (code) => resolve({ stdout, stderr, code: code ?? -1 }));
621
+ });
622
+ function parsePRNumber(url) {
623
+ const m = url.match(/\/(?:pull|pullrequest)\/(\d+)/);
624
+ return m ? Number(m[1]) : void 0;
625
+ }
626
+ function joinComments(comments) {
627
+ return comments.map((c, i) => `${i + 1}. ${c}`).join("\n");
628
+ }
629
+ function ghAdapter(run, cwd) {
630
+ let prNumber;
631
+ return {
632
+ async createPR(input) {
633
+ const r = await run("gh", ["pr", "create", "--base", input.base, "--head", input.branch, "--title", input.title, "--body", input.body], cwd);
634
+ if (r.code !== 0) {
635
+ const open = await run("gh", ["pr", "list", "--head", input.branch, "--base", input.base, "--state", "open", "--json", "number,url"], cwd);
636
+ try {
637
+ const [first] = JSON.parse(open.stdout);
638
+ if (first?.number !== void 0) {
639
+ prNumber = first.number;
640
+ return { url: first.url ?? "", number: prNumber };
641
+ }
642
+ } catch {
643
+ }
644
+ throw new Error(`gh pr create failed (${r.code}): ${r.stderr.trim()}`);
645
+ }
646
+ const url = r.stdout.trim();
647
+ prNumber = parsePRNumber(url);
648
+ return { url, number: prNumber };
649
+ },
650
+ async postComments(comments, outcome) {
651
+ if (prNumber === void 0 || comments.length === 0 && outcome !== "approved") return void 0;
652
+ const r = await run("gh", ["pr", "comment", String(prNumber), "--body", reviewThreadBody(comments, outcome)], cwd);
653
+ if (r.code !== 0) throw new Error(`gh pr comment failed (${r.code}): ${r.stderr.trim()}`);
654
+ return void 0;
655
+ },
656
+ async replyAndResolve(_threadId, body) {
657
+ if (prNumber === void 0) return;
658
+ const r = await run("gh", ["pr", "comment", String(prNumber), "--body", body], cwd);
659
+ if (r.code !== 0) throw new Error(`gh pr comment failed (${r.code}): ${r.stderr.trim()}`);
660
+ },
661
+ async resolveOwnThreads() {
662
+ }
663
+ };
664
+ }
665
+ function isMachineTitle(title) {
666
+ return /^hc:\s/.test(title.trim());
667
+ }
668
+ var RESOLVED_STATUS = "fixed";
669
+ function threadIdOf(stdout) {
670
+ try {
671
+ const id = JSON.parse(stdout).id;
672
+ return id === void 0 ? void 0 : String(id);
673
+ } catch {
674
+ return void 0;
675
+ }
676
+ }
677
+ var REVIEW_MARKER = "**horse-code review**";
678
+ function ownOpenThreads(list) {
679
+ return (list.value ?? []).filter((t) => (t.status ?? "").toLowerCase() === "active").filter((t) => (t.comments ?? []).some((c) => (c.content ?? "").includes(REVIEW_MARKER))).map((t) => String(t.id ?? "")).filter(Boolean);
680
+ }
681
+ function reviewThreadBody(comments, outcome = "changes") {
682
+ if (outcome === "approved") {
683
+ return `${REVIEW_MARKER} \u2014 approved. The merged diff was reviewed and no changes are requested.` + (comments.length ? `
684
+
685
+ ${joinComments(comments)}` : "");
686
+ }
687
+ return `${REVIEW_MARKER} \u2014 ${comments.length} change(s) requested:
688
+
689
+ ${joinComments(comments)}`;
690
+ }
691
+ function azAdapter(run, cwd, log) {
692
+ let threadSeq = 0;
693
+ let prNumber;
694
+ let project;
695
+ let repositoryId;
696
+ return {
697
+ async createPR(input) {
698
+ const adopt = (j) => {
699
+ if (typeof j?.pullRequestId !== "number") return void 0;
700
+ prNumber = j.pullRequestId;
701
+ repositoryId = j.repository?.id;
702
+ project = j.repository?.project?.name;
703
+ return j.url ?? `(azure PR #${prNumber})`;
704
+ };
705
+ const r = await run("az", ["repos", "pr", "create", "--source-branch", input.branch, "--target-branch", input.base, "--title", input.title, "--description", input.body, "-o", "json"], cwd);
706
+ if (r.code !== 0) {
707
+ const open = await run("az", ["repos", "pr", "list", "--source-branch", input.branch, "--target-branch", input.base, "--status", "active", "-o", "json"], cwd);
708
+ try {
709
+ const existing = JSON.parse(open.stdout)[0];
710
+ const url2 = adopt(existing);
711
+ if (url2 !== void 0) {
712
+ if (isMachineTitle(existing?.title ?? "")) {
713
+ await run("az", ["repos", "pr", "update", "--id", String(prNumber), "--title", input.title, "--description", input.body, "-o", "none"], cwd);
714
+ }
715
+ return { url: url2, number: prNumber };
716
+ }
717
+ } catch {
718
+ }
719
+ throw new Error(`az repos pr create failed (${r.code}): ${r.stderr.trim()}`);
720
+ }
721
+ let url = "";
722
+ try {
723
+ url = adopt(JSON.parse(r.stdout)) ?? r.stdout.trim();
724
+ } catch {
725
+ url = r.stdout.trim() || "(azure PR)";
726
+ }
727
+ return { url, number: prNumber };
728
+ },
729
+ async postComments(comments, outcome) {
730
+ if (comments.length === 0 && outcome !== "approved") return void 0;
731
+ if (prNumber === void 0 || !project || !repositoryId) {
732
+ log(`Azure PR comments could not be addressed (no pull request identity):
733
+ ${joinComments(comments)}`);
734
+ return void 0;
735
+ }
736
+ const file = join(tmpdir(), `hc-pr-thread-${prNumber}-${process.pid}-${threadSeq++}.json`);
737
+ const body = {
738
+ comments: [{ parentCommentId: 0, content: reviewThreadBody(comments, outcome), commentType: "text" }],
739
+ status: outcome === "approved" ? "closed" : "active"
740
+ };
741
+ await writeFile(file, JSON.stringify(body), "utf8");
742
+ try {
743
+ const r = await run("az", [
744
+ "devops",
745
+ "invoke",
746
+ "--area",
747
+ "git",
748
+ "--resource",
749
+ "pullRequestThreads",
750
+ "--route-parameters",
751
+ `project=${project}`,
752
+ `repositoryId=${repositoryId}`,
753
+ `pullRequestId=${prNumber}`,
754
+ "--http-method",
755
+ "POST",
756
+ "--in-file",
757
+ file,
758
+ "--api-version",
759
+ "7.1",
760
+ "-o",
761
+ "json"
762
+ ], cwd);
763
+ if (r.code !== 0) throw new Error(`az devops invoke pullRequestThreads failed (${r.code}): ${r.stderr.trim()}`);
764
+ return threadIdOf(r.stdout);
765
+ } finally {
766
+ await rm(file, { force: true });
767
+ }
768
+ },
769
+ async replyAndResolve(threadId, body) {
770
+ if (prNumber === void 0 || !project || !repositoryId || !threadId) return;
771
+ const route = [
772
+ "--route-parameters",
773
+ `project=${project}`,
774
+ `repositoryId=${repositoryId}`,
775
+ `pullRequestId=${prNumber}`,
776
+ `threadId=${threadId}`
777
+ ];
778
+ const replyFile = join(tmpdir(), `hc-pr-reply-${prNumber}-${process.pid}-${threadSeq++}.json`);
779
+ await writeFile(replyFile, JSON.stringify({ parentCommentId: 1, content: body, commentType: "text" }), "utf8");
780
+ try {
781
+ const r = await run("az", [
782
+ "devops",
783
+ "invoke",
784
+ "--area",
785
+ "git",
786
+ "--resource",
787
+ "pullRequestThreadComments",
788
+ ...route,
789
+ "--http-method",
790
+ "POST",
791
+ "--in-file",
792
+ replyFile,
793
+ "--api-version",
794
+ "7.1",
795
+ "-o",
796
+ "none"
797
+ ], cwd);
798
+ if (r.code !== 0) {
799
+ log(`Could not reply to pull request thread ${threadId}: ${r.stderr.trim()}`);
800
+ return;
801
+ }
802
+ } finally {
803
+ await rm(replyFile, { force: true });
804
+ }
805
+ const statusFile = join(tmpdir(), `hc-pr-resolve-${prNumber}-${process.pid}-${threadSeq++}.json`);
806
+ await writeFile(statusFile, JSON.stringify({ status: RESOLVED_STATUS }), "utf8");
807
+ try {
808
+ const r = await run("az", [
809
+ "devops",
810
+ "invoke",
811
+ "--area",
812
+ "git",
813
+ "--resource",
814
+ "pullRequestThreads",
815
+ ...route,
816
+ "--http-method",
817
+ "PATCH",
818
+ "--in-file",
819
+ statusFile,
820
+ "--api-version",
821
+ "7.1",
822
+ "-o",
823
+ "none"
824
+ ], cwd);
825
+ if (r.code !== 0) log(`Could not resolve pull request thread ${threadId}: ${r.stderr.trim()}`);
826
+ } finally {
827
+ await rm(statusFile, { force: true });
828
+ }
829
+ },
830
+ async resolveOwnThreads() {
831
+ if (prNumber === void 0 || !project || !repositoryId) return;
832
+ const route = ["--route-parameters", `project=${project}`, `repositoryId=${repositoryId}`, `pullRequestId=${prNumber}`];
833
+ const list = await run("az", [
834
+ "devops",
835
+ "invoke",
836
+ "--area",
837
+ "git",
838
+ "--resource",
839
+ "pullRequestThreads",
840
+ ...route,
841
+ "--api-version",
842
+ "7.1",
843
+ "-o",
844
+ "json"
845
+ ], cwd);
846
+ if (list.code !== 0) {
847
+ log(`Could not read the pull request threads to resolve them: ${list.stderr.trim()}`);
848
+ return;
849
+ }
850
+ let ids = [];
851
+ try {
852
+ ids = ownOpenThreads(JSON.parse(list.stdout));
853
+ } catch {
854
+ log("The pull request thread list could not be read.");
855
+ return;
856
+ }
857
+ for (const id of ids) {
858
+ const file = join(tmpdir(), `hc-pr-resolve-${prNumber}-${process.pid}-${threadSeq++}.json`);
859
+ await writeFile(file, JSON.stringify({ status: RESOLVED_STATUS }), "utf8");
860
+ try {
861
+ const r = await run("az", [
862
+ "devops",
863
+ "invoke",
864
+ "--area",
865
+ "git",
866
+ "--resource",
867
+ "pullRequestThreads",
868
+ ...route,
869
+ `threadId=${id}`,
870
+ "--http-method",
871
+ "PATCH",
872
+ "--in-file",
873
+ file,
874
+ "--api-version",
875
+ "7.1",
876
+ "-o",
877
+ "none"
878
+ ], cwd);
879
+ if (r.code !== 0) log(`Could not resolve pull request thread ${id}: ${r.stderr.trim()}`);
880
+ } finally {
881
+ await rm(file, { force: true });
882
+ }
883
+ }
884
+ }
885
+ };
886
+ }
887
+ function detectPlatform(remoteUrl) {
888
+ if (remoteUrl.includes("github.com")) return "github";
889
+ if (remoteUrl.includes("dev.azure.com") || remoteUrl.includes("visualstudio.com")) return "azure";
890
+ return "unknown";
891
+ }
892
+ function makePRAdapter(opts) {
893
+ if (opts.platform === "github") return ghAdapter(opts.run, opts.cwd);
894
+ if (opts.platform === "azure") return azAdapter(opts.run, opts.cwd, opts.log);
895
+ return {
896
+ async createPR(input) {
897
+ opts.log(`PR (local \u2014 no remote/platform): ${input.branch} \u2192 ${input.base} \u2014 "${input.title}"`);
898
+ return { url: `(local: ${input.branch})` };
899
+ },
900
+ async postComments(comments, outcome) {
901
+ if (comments.length || outcome === "approved") opts.log(`PR review: ${reviewThreadBody(comments, outcome)}`);
902
+ return void 0;
903
+ },
904
+ async replyAndResolve(_threadId, body) {
905
+ opts.log(`PR thread reply: ${body}`);
906
+ },
907
+ async resolveOwnThreads() {
908
+ }
909
+ };
910
+ }
911
+
912
+ // src/engine/council.ts
913
+ import { z } from "zod";
914
+ var ArchitectPlanSchema = z.object({
915
+ rootCause: z.string(),
916
+ plan: z.array(z.string())
917
+ });
918
+ async function runEscalationCouncil(deps, board, taskId, cwd, family) {
919
+ const task = board.get(taskId);
920
+ const resolved = deps.roleRegistry.resolve("architect");
921
+ const history = task.stageHistory.map((s) => s.action).join(", ");
922
+ const diagnoseOpts = {
923
+ provider: deps.provider,
924
+ ...resolved,
925
+ tools: readOnlyRegistry(deps),
926
+ messages: [
927
+ {
928
+ role: "user",
929
+ content: `Task "${task.title}" keeps bouncing back from review.
930
+ Reviewer notes:
931
+ ${task.reviewNotes.map((n) => `- ${n}`).join("\n")}
932
+ History: ${history}
933
+ Identify the root cause and produce a concrete plan.`
934
+ }
935
+ ],
936
+ permission: deps.permission,
937
+ approve: deps.approve,
938
+ cwd,
939
+ signal: deps.signal
940
+ };
941
+ const plan = await runStructuredRole(diagnoseOpts, ArchitectPlanSchema);
942
+ board.appendStage(taskId, { role: "architect", action: "council:diagnosed", note: plan.rootCause });
943
+ const senior = family === "designer" ? "senior-designer" : "senior-coder";
944
+ board.clearReviewNotes(taskId);
945
+ board.addReviewNote(taskId, plan.rootCause);
946
+ for (const step of plan.plan) board.addReviewNote(taskId, step);
947
+ board.move(taskId, "IN-PROGRESS", senior);
948
+ board.setWorker(taskId, senior, deps.roleRegistry.peekModel(senior));
949
+ const before = await worktreeState(defaultGitRunner, cwd);
950
+ await runImplementer(deps, senior, board.get(taskId), cwd);
951
+ const after = await worktreeState(defaultGitRunner, cwd);
952
+ const idle = before !== void 0 && after !== void 0 && before === after && !(deps.baseRef && await hasWorkAgainst(defaultGitRunner, cwd, deps.baseRef));
953
+ if (idle) {
954
+ const served = deps.roleRegistry.chainFor(senior, 0)[0] ?? "";
955
+ telemetry().event("implementer.no_changes", {
956
+ "hc.task.id": taskId,
957
+ "hc.role": senior,
958
+ "hc.model": served,
959
+ "hc.council": true
960
+ });
961
+ if (served) deps.fitness?.record(senior, served, "answered in prose instead of implementing");
962
+ board.appendStage(taskId, { role: senior, action: "no-changes", note: served ? `model: ${served}` : void 0 });
963
+ board.move(taskId, "TODO", senior);
964
+ deps.note?.(`\u26A0\uFE0F **${board.get(taskId).title}** \u2014 the council's \`${senior}\` wrote nothing; not sending an unchanged worktree to review.`);
965
+ return { verdict: "fail", notes: [
966
+ "The council's implementation produced NO file changes. Write the code with write_file/edit_file \u2014 describing it is not enough."
967
+ ] };
968
+ }
969
+ board.appendStage(taskId, { role: senior, action: "council:implemented" });
970
+ board.move(taskId, "REVIEW", senior);
971
+ const v = await telemetry().span("stage.code_review", {
972
+ "hc.stage": "code review",
973
+ "hc.task.id": taskId,
974
+ "hc.council": true
975
+ }, () => runReviewer(deps, board.get(taskId), cwd));
976
+ if (v.verdict === "pass") {
977
+ board.appendStage(taskId, { role: "code-reviewer", action: "reviewed:pass" });
978
+ } else {
979
+ board.appendStage(taskId, { role: "code-reviewer", action: "reviewed:fail", note: v.notes.join("; ") });
980
+ }
981
+ return v;
982
+ }
983
+
984
+ // src/engine/escalation.ts
985
+ function autonomousAskHuman(maxRetries = 2) {
986
+ const retries = /* @__PURE__ */ new Map();
987
+ return async ({ card, verdict }) => {
988
+ const n = retries.get(card.id) ?? 0;
989
+ if (n >= maxRetries) return { action: "abandon" };
990
+ retries.set(card.id, n + 1);
991
+ const notes = verdict.notes.length > 0 ? verdict.notes : ["Address the review feedback and complete the task."];
992
+ return { action: "retry", notes };
993
+ };
994
+ }
995
+ function noChangeStreak(task) {
996
+ let n = 0;
997
+ for (let i = task.stageHistory.length - 1; i >= 0; i--) {
998
+ const a = task.stageHistory[i].action;
999
+ if (a === "no-changes") {
1000
+ n += 1;
1001
+ continue;
1002
+ }
1003
+ if (a.startsWith("\u2192") || a === "attempt-error") continue;
1004
+ break;
1005
+ }
1006
+ return n;
1007
+ }
1008
+ function tierOf(attempts, rounds) {
1009
+ return attempts < rounds ? 0 : attempts < 2 * rounds ? 1 : 2;
1010
+ }
1011
+ function attemptError(board, taskId, role, e) {
1012
+ const msg = e instanceof Error ? e.message : String(e);
1013
+ board.appendStage(taskId, { role, action: "attempt-error", note: msg });
1014
+ board.addReviewNote(taskId, `The previous attempt did not finish (${msg}). Complete the task within the turn budget.`);
1015
+ board.move(taskId, "TODO", role);
1016
+ return { verdict: "fail", notes: [msg] };
1017
+ }
1018
+ async function runTaskWithEscalation(deps, board, taskId, cwd, slot = 0) {
1019
+ const task = board.get(taskId);
1020
+ if (!task) throw new Error(`runTaskWithEscalation: unknown task: ${taskId}`);
1021
+ const noChangeCount = noChangeStreak(task);
1022
+ if (noChangeCount >= 3) {
1023
+ const notes = ["Three attempts produced no file changes. The task needs a human decision before another agent is dispatched."];
1024
+ board.appendStage(taskId, { role: "system", action: "human:required", note: notes[0] });
1025
+ const decision = await deps.askHuman({ card: board.get(taskId), verdict: { verdict: "fail", notes } });
1026
+ if (decision.action === "accept") {
1027
+ board.appendStage(taskId, { role: "human", action: "human:accept" });
1028
+ board.move(taskId, "DONE", "human");
1029
+ return { verdict: "pass", notes: [] };
1030
+ }
1031
+ if (decision.action === "retry" && decision.notes.some((note) => note.trim())) {
1032
+ board.appendStage(taskId, { role: "human", action: "human:retry", note: decision.notes.join("; ") });
1033
+ board.clearReviewNotes(taskId);
1034
+ for (const note of decision.notes) board.addReviewNote(taskId, note);
1035
+ board.move(taskId, "TODO", "human");
1036
+ } else {
1037
+ board.appendStage(taskId, { role: "human", action: decision.action === "retry" ? "human:retry-blocked" : "human:abandon" });
1038
+ }
1039
+ return { verdict: "fail", notes };
1040
+ }
1041
+ const family = await routeTask(deps, task);
1042
+ board.setWorktree(taskId, cwd);
1043
+ for (; ; ) {
1044
+ const attempts = board.get(taskId).attempts;
1045
+ const tier = tierOf(attempts, deps.rounds);
1046
+ telemetry().event("decision.tier", {
1047
+ "hc.decision": "tier",
1048
+ "hc.task.id": taskId,
1049
+ "hc.tier": tier,
1050
+ "hc.attempt": attempts,
1051
+ "hc.family": family
1052
+ });
1053
+ if (tier < 2) {
1054
+ const role = tier === 0 ? family : family === "designer" ? "senior-designer" : "senior-coder";
1055
+ let v2;
1056
+ try {
1057
+ v2 = await runCycleWithRole(deps, board, taskId, cwd, role, void 0, slot);
1058
+ } catch (e) {
1059
+ if (deps.signal.aborted) throw e;
1060
+ v2 = attemptError(board, taskId, role, e);
1061
+ }
1062
+ if (v2.verdict === "pass") return v2;
1063
+ if (v2.noProgress) {
1064
+ const tierStart = Math.floor(attempts / deps.rounds) * deps.rounds;
1065
+ const target = attempts === tierStart ? attempts + 1 : tierStart + deps.rounds;
1066
+ while (board.get(taskId).attempts < target) board.incrementAttempts(taskId);
1067
+ } else {
1068
+ board.incrementAttempts(taskId);
1069
+ }
1070
+ continue;
1071
+ }
1072
+ let v;
1073
+ try {
1074
+ v = await runEscalationCouncil(deps, board, taskId, cwd, family);
1075
+ } catch (e) {
1076
+ if (deps.signal.aborted) throw e;
1077
+ v = attemptError(board, taskId, "code-reviewer", e);
1078
+ }
1079
+ if (v.verdict === "pass") {
1080
+ board.clearReviewNotes(taskId);
1081
+ board.move(taskId, "DONE", "code-reviewer");
1082
+ return v;
1083
+ }
1084
+ const decision = await deps.askHuman({ card: board.get(taskId), verdict: v });
1085
+ if (decision.action === "accept") {
1086
+ board.appendStage(taskId, { role: "human", action: "human:accept" });
1087
+ board.clearReviewNotes(taskId);
1088
+ board.move(taskId, "DONE", "human");
1089
+ return { verdict: "pass", notes: [] };
1090
+ }
1091
+ if (decision.action === "retry") {
1092
+ board.appendStage(taskId, {
1093
+ role: "human",
1094
+ action: "human:retry",
1095
+ note: decision.notes.join("; ")
1096
+ });
1097
+ board.clearReviewNotes(taskId);
1098
+ for (const n of decision.notes) board.addReviewNote(taskId, n);
1099
+ board.incrementAttempts(taskId);
1100
+ continue;
1101
+ }
1102
+ board.appendStage(taskId, { role: "human", action: "human:abandon" });
1103
+ return { verdict: "fail", notes: v.notes };
1104
+ }
1105
+ }
1106
+
1107
+ // src/engine/pr-summary.ts
1108
+ import { z as z2 } from "zod";
1109
+ var MAX_EXAMPLES = 12;
1110
+ var MERGED_PREFIX = /^Merged PR \d+:\s*/;
1111
+ var PR_SUMMARY_MAX_TURNS = 3;
1112
+ var PRSummarySchema = z2.object({
1113
+ title: z2.string().min(1),
1114
+ body: z2.string().min(1)
1115
+ });
1116
+ async function recentSubjects(git, cwd, base) {
1117
+ const read = async (args) => {
1118
+ const r = await git(args, cwd);
1119
+ if (r.code !== 0) return [];
1120
+ return r.stdout.split("\n").map((s) => s.replace(MERGED_PREFIX, "").trim()).filter(Boolean);
1121
+ };
1122
+ const merges = await read(["log", "--merges", "--format=%s", "-n", "40", base]);
1123
+ const subjects = merges.length >= 3 ? merges : await read(["log", "--format=%s", "-n", "40", base]);
1124
+ return subjects.slice(0, MAX_EXAMPLES);
1125
+ }
1126
+ function prAskMessage(request, tasks, examples) {
1127
+ const convention = examples.length ? `Recent titles from THIS repository \u2014 follow their format, their language and their scope vocabulary:
1128
+ ` + examples.map((e) => `- ${e}`).join("\n") : `No examples were found in this repository. Use Conventional Commits: \`type(scope): subject\`.`;
1129
+ return [
1130
+ `Write the title and description for the pull request that delivers this work.`,
1131
+ ``,
1132
+ `What was asked for:
1133
+ ${request}`,
1134
+ ``,
1135
+ convention,
1136
+ ``,
1137
+ `The title is ONE conventional-commit line: \`type(scope): subject\`, in the same language as the examples,`,
1138
+ `describing the OUTCOME \u2014 not the job, not a branch name, not a slug.`,
1139
+ ``,
1140
+ `The description is short prose for a human reviewer, in the same language: what this changes, and why it`,
1141
+ `was needed. Two or three sentences, or a handful of bullets. It is NOT a task list and NOT a file list \u2014`,
1142
+ `the tasks are appended separately. Do not describe internal roles, cards, or the process that produced it.`,
1143
+ ``,
1144
+ `The tasks that were completed, for your information only:
1145
+ ${tasks.map((t) => `- ${t}`).join("\n")}`,
1146
+ ``,
1147
+ `Return {title, body} via submit.`
1148
+ ].join("\n");
1149
+ }
1150
+ function withTaskList(body, tasks) {
1151
+ if (!tasks.length) return body;
1152
+ return `${body.trim()}
1153
+
1154
+ <details>
1155
+ <summary>Completed tasks (${tasks.length})</summary>
1156
+
1157
+ ` + tasks.map((t) => `- ${t}`).join("\n") + `
1158
+ </details>
1159
+ `;
1160
+ }
1161
+ function fallbackPR(jobSlug, tasks) {
1162
+ return { title: `hc: ${jobSlug}`, body: "Completed tasks:\n" + tasks.map((t) => `- ${t}`).join("\n") };
1163
+ }
1164
+ async function prSummary(deps, opts) {
1165
+ const tasks = opts.cards.map((c) => c.title);
1166
+ try {
1167
+ const git = deps.git ?? defaultGitRunner;
1168
+ const examples = await recentSubjects(git, opts.cwd, opts.base);
1169
+ const resolved = deps.roleRegistry.resolve("operational");
1170
+ const summary = await runStructuredRole(
1171
+ {
1172
+ provider: deps.provider,
1173
+ ...resolved,
1174
+ tools: new ToolRegistry(),
1175
+ messages: [{ role: "user", content: prAskMessage(opts.request, tasks, examples) }],
1176
+ permission: deps.permission,
1177
+ approve: deps.approve,
1178
+ cwd: opts.cwd,
1179
+ signal: deps.signal,
1180
+ maxTurns: PR_SUMMARY_MAX_TURNS
1181
+ },
1182
+ PRSummarySchema
1183
+ );
1184
+ return { title: summary.title.trim(), body: withTaskList(summary.body, tasks) };
1185
+ } catch {
1186
+ return fallbackPR(opts.jobSlug, tasks);
1187
+ }
1188
+ }
1189
+
1190
+ // src/engine/revision.ts
1191
+ import { z as z3 } from "zod";
1192
+ var REVISION_CARD = "__revision__";
1193
+ var PrincipalReviewSchema = z3.object({
1194
+ decision: z3.enum(["approve", "request-changes"]).describe(
1195
+ "`request-changes` only for what must change before merge; each comment is then applied by another agent and answered on the pull request. `approve` when nothing left would block the merge."
1196
+ ),
1197
+ comments: z3.array(z3.string())
1198
+ });
1199
+ var PrincipalFinalSchema = z3.object({
1200
+ decision: z3.enum(["accept", "ask-human"]).describe(
1201
+ "The rounds are over and findings remain. `accept`: they can merge as they stand. `ask-human`: only a person can settle it \u2014 this stops the run, so use it when the remaining finding is a real decision, not when it is merely unfinished."
1202
+ ),
1203
+ question: z3.string()
1204
+ });
1205
+ async function worktreeState2(deps, base) {
1206
+ const git = deps.git ?? defaultGitRunner;
1207
+ const head = await git(["rev-parse", "HEAD"], base);
1208
+ if (head.code !== 0) return void 0;
1209
+ const status = await git(["status", "--porcelain"], base);
1210
+ return `${head.stdout.trim()}|${status.stdout.trim()}`;
1211
+ }
1212
+ async function principalReview(deps, base, prDiff, deferred) {
1213
+ const resolved = deps.roleRegistry.resolve("principal-coder");
1214
+ const carried = deferred?.length ? `
1215
+
1216
+ The per-task code review deferred these non-blocking findings \u2014 judge which genuinely deserve a fix now and include those in your comments; ignore the ones that are not worth it:
1217
+ ${deferred.map((d) => `- ${d}`).join("\n")}` : "";
1218
+ const content = (prDiff ? `PR review: review the following diff:
1219
+ ${prDiff}
1220
+ (use the read tools to inspect the worktree if needed.) Give approve or request-changes + concrete comments.` : "PR review: review all changes in the base worktree holistically. Give approve or request-changes + concrete comments.") + carried;
1221
+ const hints = memoryHints(deps, content, { role: "principal-coder", operations: true });
1222
+ const opts = {
1223
+ provider: deps.provider,
1224
+ ...resolved,
1225
+ tools: readOnlyRegistry(deps, { propose: true }),
1226
+ proposeMemory: (t, k) => deps.proposeMemory?.(t, k, "principal-coder") ?? false,
1227
+ messages: hints.message ? [{ role: "user", content: hints.message }, { role: "user", content }] : [{ role: "user", content }],
1228
+ permission: deps.permission,
1229
+ approve: deps.approve,
1230
+ cwd: base,
1231
+ signal: deps.signal
1232
+ };
1233
+ const review = await runStructuredRole(opts, PrincipalReviewSchema);
1234
+ reinforceUsed(deps, hints.ids, review.comments.join(" "), "principal-coder");
1235
+ return review;
1236
+ }
1237
+ async function principalFinal(deps, base) {
1238
+ const resolved = deps.roleRegistry.resolve("principal-coder");
1239
+ const opts = {
1240
+ provider: deps.provider,
1241
+ ...resolved,
1242
+ // The principal decides what ships; what it learns about this codebase outlives the round.
1243
+ tools: readOnlyRegistry(deps, { remember: true }),
1244
+ messages: [{ role: "user", content: "FINAL DECISION: Revision rounds are over and findings still remain. Give accept or ask-human (a question to ask the user)." }],
1245
+ permission: deps.permission,
1246
+ approve: deps.approve,
1247
+ cwd: base,
1248
+ signal: deps.signal
1249
+ };
1250
+ return runStructuredRole(opts, PrincipalFinalSchema);
1251
+ }
1252
+ var REVISE_TURNS_PER_COMMENT = 8;
1253
+ var REVISE_TURNS_MIN = 30;
1254
+ var REVISE_TURNS_MAX = 200;
1255
+ var TURN_LIMIT_RE = /maximum turn count exceeded/i;
1256
+ function reviseTurnBudget(commentCount) {
1257
+ return Math.min(REVISE_TURNS_MAX, Math.max(REVISE_TURNS_MIN, commentCount * REVISE_TURNS_PER_COMMENT));
1258
+ }
1259
+ var MAX_REPLY_CHARS = 6e3;
1260
+ async function sayHowItEnded(deps, postComments, how, round, question) {
1261
+ const said = how === "accepted" ? `The revision rounds are over after round ${round}. The remaining findings above were judged acceptable to merge as they stand \u2014 they were not silently dropped, and nothing further will be applied automatically.` : `The revision rounds are over after round ${round} and this needs a decision that is not mine to make. The question put to the user was:
1262
+
1263
+ > ${question ?? "(not recorded)"}`;
1264
+ try {
1265
+ await postComments([said], "changes");
1266
+ } catch (e) {
1267
+ deps.note?.(`\u26A0\uFE0F Could not post how the revision ended (${e instanceof Error ? e.message : String(e)}).`);
1268
+ }
1269
+ }
1270
+ function revisionReplyBody(round, account, commit) {
1271
+ const head = `${REVIEW_MARKER} \u2014 round ${round} applied in \`${commit}\`.`;
1272
+ const rest = account.ok ? "" : `
1273
+
1274
+ _The reviser reached its turn budget on this round; what it changed is in the commit above and the next review round picks up the remainder._`;
1275
+ const said = account.said.length > MAX_REPLY_CHARS ? `${account.said.slice(0, MAX_REPLY_CHARS)}\u2026` : account.said;
1276
+ return said ? `${head}
1277
+
1278
+ ${said}${rest}` : `${head}
1279
+
1280
+ _The reviser left no written account of the round; the commit is the record._${rest}`;
1281
+ }
1282
+ async function seniorRevise(deps, base, comments) {
1283
+ const resolved = deps.roleRegistry.resolve("senior-coder");
1284
+ const tools = createDefaultRegistry();
1285
+ tools.register(buildSkillTool(deps.skillRegistry));
1286
+ for (const t of contextTools(deps)) tools.register(t);
1287
+ const hints = memoryHints(deps, comments.join(" "), { role: "senior-coder", operations: true });
1288
+ const ask = { role: "user", content: `PR revision: address the following comments \u2014 fix each one, or say plainly which is wrong and why. Start by editing; you have already been given what to change:
1289
+ ${comments.map((c) => `- ${c}`).join("\n")}` };
1290
+ const spoken = [];
1291
+ const opts = {
1292
+ provider: deps.provider,
1293
+ ...resolved,
1294
+ tools,
1295
+ messages: hints.message ? [{ role: "user", content: hints.message }, ask] : [ask],
1296
+ permission: deps.permission,
1297
+ approve: deps.approve,
1298
+ cwd: base,
1299
+ signal: deps.signal,
1300
+ maxTurns: reviseTurnBudget(comments.length),
1301
+ // The last code to enter the pull request: what the reviser says about each comment — fixed it, or why
1302
+ // it is by design — is the argument the user is being asked to accept. Kept, not just shown: it is what
1303
+ // gets posted back into the review thread.
1304
+ onSay: (t) => {
1305
+ spoken.push(t);
1306
+ deps.note?.(t);
1307
+ }
1308
+ };
1309
+ try {
1310
+ await runToCompletion(opts);
1311
+ return { ok: true, said: spoken.join("\n\n").trim() };
1312
+ } catch (e) {
1313
+ const why = e instanceof Error ? e.message : String(e);
1314
+ if (TURN_LIMIT_RE.test(why)) {
1315
+ deps.note?.(`\u26A0\uFE0F The reviser used its whole turn budget on ${comments.length} comment(s). What it changed is kept and committed with this round; the next review round reads the result and asks for the rest.`);
1316
+ return { ok: false, said: spoken.join("\n\n").trim() };
1317
+ }
1318
+ deps.note?.(`\u26A0\uFE0F The revision pass stopped: ${why}. It had ${comments.length} comment(s) to address and its work up to that point is committed on the branch \u2014 re-run to continue from there.`);
1319
+ throw e;
1320
+ }
1321
+ }
1322
+ async function runRevision(deps, session, board, postComments, askUser, maxRounds, prDiff, deferred, resolveThreads, replyAndResolve) {
1323
+ if (!board.get(REVISION_CARD)) board.addCard({ id: REVISION_CARD, title: "PR revision" });
1324
+ const base = session.baseWorktree;
1325
+ const rounds = Math.max(1, maxRounds);
1326
+ let extra = 0;
1327
+ const cap = rounds * 2;
1328
+ for (let round = 1; round <= rounds + extra; round++) {
1329
+ deps.note?.(`\u{1F501} PR revision, round ${round}/${rounds + extra} \u2014 the principal reviewer is reading the merged diff.`);
1330
+ const v = await principalReview(deps, base, prDiff, round === 1 ? deferred : void 0);
1331
+ if (v.decision === "approve") {
1332
+ board.appendStage(REVISION_CARD, { role: "principal-coder", action: "pr:approved" });
1333
+ deps.note?.(`\u2705 PR revision: the merged diff was reviewed and approved.`);
1334
+ try {
1335
+ await postComments(v.comments, "approved");
1336
+ } catch (e) {
1337
+ deps.note?.(`\u26A0\uFE0F Could not post the approval to the pull request (${e instanceof Error ? e.message : String(e)}).`);
1338
+ }
1339
+ try {
1340
+ await resolveThreads?.();
1341
+ } catch (e) {
1342
+ deps.note?.(`\u26A0\uFE0F Could not resolve the review threads (${e instanceof Error ? e.message : String(e)}).`);
1343
+ }
1344
+ return { status: "approved", rounds: round - 1 };
1345
+ }
1346
+ board.appendStage(REVISION_CARD, { role: "principal-coder", action: "pr:changes", note: v.comments.join("; ") });
1347
+ if (round === rounds + extra) {
1348
+ const f = await principalFinal(deps, base);
1349
+ if (f.decision === "accept") {
1350
+ board.appendStage(REVISION_CARD, { role: "principal-coder", action: "pr:final:accept" });
1351
+ await sayHowItEnded(deps, postComments, "accepted", round);
1352
+ return { status: "accepted", rounds };
1353
+ }
1354
+ const answer = await askUser(f.question);
1355
+ board.appendStage(REVISION_CARD, { role: "human", action: "pr:human", note: answer });
1356
+ await sayHowItEnded(deps, postComments, "human", round, f.question);
1357
+ return { status: "human", rounds, answer };
1358
+ }
1359
+ deps.note?.(`\u{1F501} Round ${round}: ${v.comments.length} change(s) requested \u2014 applying them.`);
1360
+ let threadId;
1361
+ try {
1362
+ threadId = await postComments(v.comments);
1363
+ } catch (e) {
1364
+ deps.note?.(`\u26A0\uFE0F Could not post the review comments to the pull request (${e instanceof Error ? e.message : String(e)}) \u2014 applying them anyway.`);
1365
+ }
1366
+ const beforeRevise = await worktreeState2(deps, base);
1367
+ const account = await seniorRevise(deps, base, v.comments);
1368
+ if (!account.ok && rounds + extra < cap) extra++;
1369
+ if (beforeRevise !== void 0 && await worktreeState2(deps, base) === beforeRevise) {
1370
+ board.appendStage(REVISION_CARD, { role: "senior-coder", action: "pr:no-changes" });
1371
+ const retry = await seniorRevise(deps, base, [...v.comments, "Your previous attempt changed NO files. Apply the fixes with write_file/edit_file, or state clearly which comment is wrong and why."]);
1372
+ account.said = [account.said, retry.said].filter(Boolean).join("\n\n");
1373
+ if (await worktreeState2(deps, base) === beforeRevise) {
1374
+ const f = await principalFinal(deps, base);
1375
+ if (f.decision === "accept") {
1376
+ board.appendStage(REVISION_CARD, { role: "principal-coder", action: "pr:final:accept" });
1377
+ await sayHowItEnded(deps, postComments, "accepted", round);
1378
+ return { status: "accepted", rounds: round };
1379
+ }
1380
+ const answer = await askUser(f.question);
1381
+ board.appendStage(REVISION_CARD, { role: "human", action: "pr:human", note: answer });
1382
+ await sayHowItEnded(deps, postComments, "human", round, f.question);
1383
+ return { status: "human", rounds: round, answer };
1384
+ }
1385
+ }
1386
+ board.appendStage(REVISION_CARD, { role: "senior-coder", action: "pr:revised" });
1387
+ deps.note?.(`\u{1F501} Round ${round}: revised and committed.`);
1388
+ const headBefore = (await gitOf(deps)(["rev-parse", "HEAD"], base)).stdout.trim();
1389
+ await deps.manager.commitMerge(session, `hc: revision ${round}`);
1390
+ await refreshAfterRevision(deps, base, headBefore);
1391
+ await deps.manager.push(session);
1392
+ if (threadId && replyAndResolve) {
1393
+ const body = revisionReplyBody(round, account, `hc: revision ${round}`);
1394
+ try {
1395
+ await replyAndResolve(threadId, body);
1396
+ } catch (e) {
1397
+ deps.note?.(`\u26A0\uFE0F Could not answer the review thread (${e instanceof Error ? e.message : String(e)}).`);
1398
+ }
1399
+ }
1400
+ }
1401
+ return { status: "accepted", rounds };
1402
+ }
1403
+ var gitOf = (deps) => deps.git ?? defaultGitRunner;
1404
+ async function refreshAfterRevision(deps, base, headBefore) {
1405
+ const files = await changedByMerge(gitOf(deps), base, headBefore);
1406
+ await refreshAfterChange({
1407
+ cwd: base,
1408
+ files,
1409
+ provider: deps.provider,
1410
+ models: deps.roleRegistry.chainFor("tracer", 0),
1411
+ signal: deps.signal,
1412
+ git: gitOf(deps),
1413
+ ...deps.note ? { note: (t) => deps.note?.(t) } : {}
1414
+ });
1415
+ }
1416
+ function closeRevision(board, result, failure) {
1417
+ if (!board.get(REVISION_CARD)) return;
1418
+ if (failure) board.appendStage(REVISION_CARD, { role: "principal-coder", action: "pr:failed", note: failure });
1419
+ board.move(REVISION_CARD, "DONE", "principal-coder");
1420
+ }
1421
+
1422
+ // src/engine/wave-task.ts
1423
+ async function runWaveTask(deps, session, board, taskId, slot = 0) {
1424
+ const card = board.get(taskId);
1425
+ if (!card) throw new Error(`runWaveTask: unknown task: ${taskId}`);
1426
+ const git = deps.git ?? defaultGitRunner;
1427
+ const ser = deps.serialize ?? ((f) => f());
1428
+ const derive = () => ser(() => deps.manager.deriveTask(session, card.title));
1429
+ const tw = await (deps.timings ? deps.timings.time("git", derive) : derive());
1430
+ const rounds = Math.max(1, deps.rounds);
1431
+ let v;
1432
+ try {
1433
+ v = await telemetry().span("task", {
1434
+ "hc.task.id": taskId,
1435
+ "hc.task.title": card.title.slice(0, 120),
1436
+ "hc.slot": slot,
1437
+ "hc.attempt": card.attempts
1438
+ }, () => runTaskWithEscalation({ ...deps, rounds }, board, taskId, tw.worktree, slot));
1439
+ } catch (e) {
1440
+ if (deps.signal.aborted) throw e;
1441
+ const msg = e instanceof Error ? e.message : String(e);
1442
+ board.appendStage(taskId, { role: "team-lead", action: "task-failed", note: msg });
1443
+ board.move(taskId, "ABANDONED", "team-lead");
1444
+ return { status: "task-failed", task: tw };
1445
+ }
1446
+ deps.signal.throwIfAborted();
1447
+ if (v.verdict === "fail") {
1448
+ board.appendStage(taskId, { role: "team-lead", action: "task-failed" });
1449
+ board.move(taskId, "ABANDONED", "team-lead");
1450
+ return { status: "task-failed", task: tw };
1451
+ }
1452
+ const baseBefore = (await git(["rev-parse", "HEAD"], session.baseWorktree)).stdout.trim();
1453
+ const land = async () => {
1454
+ await deps.manager.commitTask(tw, `hc: ${card.title}`);
1455
+ if (deps.baseRef) await squashTask(deps, tw.worktree, deps.baseRef, card.title);
1456
+ return ser(async () => {
1457
+ const r = await deps.manager.mergeTask(session, tw);
1458
+ if (r.status === "conflict" && deps.resolveConflict) return deps.resolveConflict(tw, r.files);
1459
+ return r;
1460
+ });
1461
+ };
1462
+ const mr = await (deps.timings ? deps.timings.time("git", land) : land());
1463
+ if (mr.status === "merged") {
1464
+ board.move(taskId, "MERGED", "team-lead");
1465
+ board.appendStage(taskId, { role: "team-lead", action: "merged" });
1466
+ await refreshAfterMerge(deps, session, git, baseBefore);
1467
+ return { status: "merged", task: tw };
1468
+ }
1469
+ board.appendStage(taskId, { role: "team-lead", action: "merge-conflict", note: mr.files.join(", ") });
1470
+ return { status: "conflict", files: mr.files, task: tw };
1471
+ }
1472
+ async function refreshAfterMerge(deps, session, git, baseBefore) {
1473
+ const files = await changedByMerge(git, session.baseWorktree, baseBefore);
1474
+ await refreshAfterChange({
1475
+ cwd: session.baseWorktree,
1476
+ files,
1477
+ provider: deps.provider,
1478
+ models: deps.roleRegistry.chainFor("tracer", 0),
1479
+ signal: deps.signal,
1480
+ git,
1481
+ ...deps.note ? { note: (t) => deps.note?.(t) } : {}
1482
+ });
1483
+ }
1484
+
1485
+ // src/engine/conflict.ts
1486
+ import { spawn as spawn2 } from "child_process";
1487
+ import { readFile } from "fs/promises";
1488
+ import { join as join2, dirname } from "path";
1489
+ function resolverRegistry(remember) {
1490
+ const r = new ToolRegistry();
1491
+ r.register(buildRememberTool(remember));
1492
+ r.register(readFileTool);
1493
+ r.register(writeFileTool);
1494
+ r.register(editFileTool);
1495
+ return r;
1496
+ }
1497
+ var RESOLVE_TURNS_PER_FILE = 6;
1498
+ var RESOLVE_TURNS_MIN = 12;
1499
+ function resolveTurnBudget(fileCount) {
1500
+ return Math.max(RESOLVE_TURNS_MIN, fileCount * RESOLVE_TURNS_PER_FILE);
1501
+ }
1502
+ function conflictHunks(text, maxChars = 4e3) {
1503
+ const out = [];
1504
+ const re = /^<<<<<<<[^\n]*\n([\s\S]*?)^>>>>>>>[^\n]*$/gm;
1505
+ let m;
1506
+ let used = 0;
1507
+ while ((m = re.exec(text)) !== null) {
1508
+ const hunk = m[0];
1509
+ if (used + hunk.length > maxChars) {
1510
+ out.push("\u2026 (further conflicts in this file, not shown)");
1511
+ break;
1512
+ }
1513
+ used += hunk.length;
1514
+ out.push(hunk);
1515
+ }
1516
+ return out.join("\n\n");
1517
+ }
1518
+ async function hasConflictMarkers(baseWorktree, files) {
1519
+ for (const f of files) {
1520
+ try {
1521
+ const content = await readFile(join2(baseWorktree, f), "utf8");
1522
+ if (content.includes("<<<<<<<")) return true;
1523
+ } catch {
1524
+ }
1525
+ }
1526
+ return false;
1527
+ }
1528
+ var LOCKFILE = /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb|Cargo\.lock|poetry\.lock|Gemfile\.lock|composer\.lock|go\.sum)$/;
1529
+ var REGENERATE = [
1530
+ { file: /package-lock\.json$|npm-shrinkwrap\.json$/, cmd: ["npm", "install", "--package-lock-only"] },
1531
+ { file: /pnpm-lock\.yaml$/, cmd: ["pnpm", "install", "--lockfile-only"] },
1532
+ { file: /Cargo\.lock$/, cmd: ["cargo", "generate-lockfile"] },
1533
+ { file: /poetry\.lock$/, cmd: ["poetry", "lock", "--no-update"] },
1534
+ { file: /Gemfile\.lock$/, cmd: ["bundle", "lock"] },
1535
+ { file: /composer\.lock$/, cmd: ["composer", "update", "--lock"] },
1536
+ { file: /go\.sum$/, cmd: ["go", "mod", "tidy"] }
1537
+ ];
1538
+ var REGENERATE_TIMEOUT_MS = 18e4;
1539
+ function runCmd(cmd, cwd) {
1540
+ return new Promise((resolve) => {
1541
+ const child = spawn2(cmd[0], cmd.slice(1), { cwd, stdio: ["ignore", "pipe", "pipe"] });
1542
+ let out = "";
1543
+ const take = (d) => {
1544
+ if (out.length < 8e3) out += d.toString();
1545
+ };
1546
+ child.stdout.on("data", take);
1547
+ child.stderr.on("data", take);
1548
+ const timer = setTimeout(() => child.kill("SIGKILL"), REGENERATE_TIMEOUT_MS);
1549
+ child.on("error", (e) => {
1550
+ clearTimeout(timer);
1551
+ resolve({ ok: false, out: e.message });
1552
+ });
1553
+ child.on("close", (c) => {
1554
+ clearTimeout(timer);
1555
+ resolve({ ok: c === 0, out });
1556
+ });
1557
+ });
1558
+ }
1559
+ async function regenerateLockfile(file, baseWorktree) {
1560
+ const rule = REGENERATE.find((r2) => r2.file.test(file));
1561
+ if (!rule) return void 0;
1562
+ const dir = join2(baseWorktree, dirname(file));
1563
+ const r = await runCmd(rule.cmd, dir);
1564
+ return r.ok ? `regenerated with \`${rule.cmd.join(" ")}\`` : `could not regenerate (\`${rule.cmd.join(" ")}\`: ${r.out.trim().split("\n").slice(-1)[0]?.slice(0, 120) ?? "failed"}) \u2014 kept the base's copy; run it yourself before merging`;
1565
+ }
1566
+ async function mergeConflictedTraceIndex(deps, session, file) {
1567
+ if (!deps.manager.conflictSide || !deps.manager.resolveWith) return void 0;
1568
+ try {
1569
+ const ourText = await deps.manager.conflictSide(session, file, "ours");
1570
+ const theirText = await deps.manager.conflictSide(session, file, "theirs");
1571
+ if (ourText === void 0 || theirText === void 0) return void 0;
1572
+ const ours = parseTraceIndex(ourText);
1573
+ const theirs = parseTraceIndex(theirText);
1574
+ if (!ours || !theirs) return void 0;
1575
+ const merged = mergeTraceIndexes(ours, theirs);
1576
+ await deps.manager.resolveWith(session, file, serializeTraceIndex(merged));
1577
+ return Object.keys(merged.traces).length;
1578
+ } catch {
1579
+ return void 0;
1580
+ }
1581
+ }
1582
+ async function resolveGeneratedConflicts(deps, session, conflictedFiles) {
1583
+ const base = session.baseWorktree;
1584
+ const indexPath = `${traceRootRel().replace(/\\/g, "/")}/${TRACE_INDEX}`;
1585
+ const rest = [];
1586
+ let allConflicted = conflictedFiles;
1587
+ for (const f of allConflicted) {
1588
+ if (f !== indexPath) {
1589
+ rest.push(f);
1590
+ continue;
1591
+ }
1592
+ const merged = await mergeConflictedTraceIndex(deps, session, f);
1593
+ if (merged === void 0) {
1594
+ rest.push(f);
1595
+ continue;
1596
+ }
1597
+ deps.note?.(`\u{1F5C2}\uFE0F \`${f}\` \u2014 trace index; combined both sides (${merged} entries).`);
1598
+ }
1599
+ allConflicted = rest;
1600
+ const isTrace = (f) => {
1601
+ const root = traceRootRel().replace(/\\/g, "/");
1602
+ return f.startsWith(`${root}/`) && /\.[A-Za-z0-9]{1,5}\.md$/.test(f);
1603
+ };
1604
+ const generated = allConflicted.filter((f) => LOCKFILE.test(f) || isTrace(f));
1605
+ const lockfiles = allConflicted.filter((f) => LOCKFILE.test(f));
1606
+ const conflicted = allConflicted.filter((f) => !generated.includes(f));
1607
+ const traces = generated.filter((f) => !LOCKFILE.test(f));
1608
+ if (traces.length) {
1609
+ deps.note?.(`\u{1F4DD} ${traces.length} trace file(s) conflicted \u2014 taking the base's copy; the refresh after this merge re-derives them from the code that just landed.`);
1610
+ for (const f of traces) {
1611
+ try {
1612
+ await deps.manager.resolveWithBase(session, f);
1613
+ } catch {
1614
+ }
1615
+ }
1616
+ }
1617
+ if (lockfiles.length) {
1618
+ deps.note?.(`\u{1F512} ${lockfiles.join(", ")} \u2014 generated file, taking the base's copy and regenerating rather than merging it by hand.`);
1619
+ for (const f of lockfiles) {
1620
+ try {
1621
+ await deps.manager.resolveWithBase(session, f);
1622
+ const said = await regenerateLockfile(f, base);
1623
+ if (said) deps.note?.(` \`${f}\` \u2014 ${said}`);
1624
+ await deps.manager.resolveWithBase(session, f);
1625
+ } catch {
1626
+ }
1627
+ }
1628
+ }
1629
+ return { conflicted, generated };
1630
+ }
1631
+ async function handedHunks(files, cwd) {
1632
+ const parts = [];
1633
+ for (const f of files.slice(0, 10)) {
1634
+ try {
1635
+ const text = await readFile(join2(cwd, f), "utf8");
1636
+ const hunks = conflictHunks(text);
1637
+ if (hunks) parts.push(`--- ${f}
1638
+ ${hunks}`);
1639
+ } catch {
1640
+ }
1641
+ }
1642
+ return parts.length ? `The conflicted regions:
1643
+
1644
+ ${parts.join("\n\n")}
1645
+
1646
+ ` : "";
1647
+ }
1648
+ async function runConflictResolver(deps, base, conflicted, notes = "") {
1649
+ const op = deps.roleRegistry.resolve("operational");
1650
+ const hints = memoryHints(deps, conflicted.join(" "), { role: "operational", operations: true });
1651
+ const ask = { role: "user", content: `A git merge left conflicts in the base worktree. Resolve them: for EACH file, remove all conflict markers (<<<<<<<, =======, >>>>>>>) and combine BOTH sides' changes so the intent of each is preserved (don't just pick one side unless the changes are truly incompatible). Conflicted files: ${conflicted.join(", ")}.
1652
+
1653
+ ${await handedHunks(conflicted, base)}${notes}` };
1654
+ const resolveOpts = {
1655
+ provider: deps.provider,
1656
+ ...op,
1657
+ tools: resolverRegistry(deps.rememberFact),
1658
+ messages: hints.message ? [{ role: "user", content: hints.message }, ask] : [ask],
1659
+ permission: deps.permission,
1660
+ approve: deps.approve,
1661
+ cwd: base,
1662
+ signal: deps.signal,
1663
+ perAttemptMs: LONG_CALL_MS,
1664
+ // each model in the chain gets its own clock — see RoleAgentOptions
1665
+ maxTurns: resolveTurnBudget(conflicted.length),
1666
+ // It is combining two sides of the user's own code; how it read the conflict is the thing to check.
1667
+ ...deps.note ? { onSay: (t) => deps.note?.(` \u21B3 ${t}`) } : {}
1668
+ };
1669
+ await runToCompletion(resolveOpts);
1670
+ reinforceTouched(deps, hints.ids, conflicted, "operational");
1671
+ }
1672
+ async function resolveMergeConflict(deps, session, board, taskId, task) {
1673
+ if (!board.get(taskId)) throw new Error(`resolveMergeConflict: unknown task: ${taskId}`);
1674
+ const rounds = Math.max(1, deps.rounds);
1675
+ const base = session.baseWorktree;
1676
+ const { conflicted, generated } = await resolveGeneratedConflicts(deps, session, await deps.manager.unmergedFiles(session));
1677
+ if (!conflicted.length && generated.length) {
1678
+ return { status: "resolved" };
1679
+ }
1680
+ deps.note?.(`\u{1F500} Merge conflict in ${conflicted.join(", ")} \u2014 operational resolving\u2026`);
1681
+ for (; ; ) {
1682
+ for (let i = 0; i < rounds; i++) {
1683
+ const card = board.get(taskId);
1684
+ const notes = card.reviewNotes.length ? `
1685
+ Hints from the last attempt:
1686
+ ${card.reviewNotes.map((n) => `- ${n}`).join("\n")}` : "";
1687
+ await runConflictResolver(deps, base, conflicted, notes);
1688
+ board.appendStage(taskId, { role: "operational", action: "conflict:resolve-attempt" });
1689
+ if (await hasConflictMarkers(base, conflicted)) {
1690
+ board.clearReviewNotes(taskId);
1691
+ board.addReviewNote(taskId, `conflict markers still present: ${conflicted.join(", ")}`);
1692
+ continue;
1693
+ }
1694
+ const v = await runReviewer(deps, board.get(taskId), base);
1695
+ if (v.verdict === "pass") {
1696
+ await deps.manager.commitMerge(session, `chore: resolve merge conflict \u2014 ${card.title}`);
1697
+ board.appendStage(taskId, { role: "operational", action: "conflict:merged" });
1698
+ deps.note?.(`\u{1F516} chore: resolve merge conflict \u2014 ${card.title}`);
1699
+ return { status: "resolved" };
1700
+ }
1701
+ board.clearReviewNotes(taskId);
1702
+ for (const n of v.notes) board.addReviewNote(taskId, n);
1703
+ }
1704
+ const decision = await deps.askHuman({
1705
+ card: board.get(taskId),
1706
+ verdict: { verdict: "fail", notes: [`merge conflict not resolved in ${rounds} rounds`] }
1707
+ });
1708
+ if (decision.action === "retry") {
1709
+ board.clearReviewNotes(taskId);
1710
+ for (const n of decision.notes) board.addReviewNote(taskId, n);
1711
+ continue;
1712
+ }
1713
+ await deps.manager.abortMerge(session);
1714
+ board.appendStage(taskId, { role: "human", action: "conflict:aborted" });
1715
+ deps.note?.(`\u26A0 Merge conflict could not be resolved after ${rounds} rounds \u2014 aborted.`);
1716
+ return { status: "unresolved", task };
1717
+ }
1718
+ }
1719
+
1720
+ // src/engine/team-lead.ts
1721
+ import { z as z4 } from "zod";
1722
+
1723
+ // src/engine/waves.ts
1724
+ function computeWaves(board) {
1725
+ const cards = board.list();
1726
+ const placed = /* @__PURE__ */ new Set();
1727
+ const waves = [];
1728
+ let remaining = cards;
1729
+ while (remaining.length) {
1730
+ const layer = remaining.filter((c) => c.deps.every((d) => placed.has(d)));
1731
+ if (layer.length === 0) {
1732
+ throw new Error("computeWaves: dependency cycle or unresolved dependency");
1733
+ }
1734
+ waves.push(layer.map((c) => c.id));
1735
+ for (const c of layer) placed.add(c.id);
1736
+ remaining = remaining.filter((c) => !placed.has(c.id));
1737
+ }
1738
+ return waves;
1739
+ }
1740
+ function normalizePath(p) {
1741
+ return p.trim().replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase();
1742
+ }
1743
+ function fileClashes(ids, board) {
1744
+ const files = /* @__PURE__ */ new Map();
1745
+ for (const id of ids) {
1746
+ const card = board.get(id);
1747
+ if (card) files.set(id, new Set(card.files.map(normalizePath).filter(Boolean)));
1748
+ }
1749
+ const out = [];
1750
+ for (let i = 0; i < ids.length; i++) {
1751
+ for (let j = i + 1; j < ids.length; j++) {
1752
+ const a = files.get(ids[i]), b = files.get(ids[j]);
1753
+ if (!a || !b) continue;
1754
+ const shared = [...a].filter((f) => b.has(f));
1755
+ if (shared.length) out.push({ a: ids[i], b: ids[j], files: shared });
1756
+ }
1757
+ }
1758
+ return out;
1759
+ }
1760
+ function splitFileConflicts(waves, board) {
1761
+ const clashes = [];
1762
+ const out = [];
1763
+ for (const wave of waves) {
1764
+ const found = fileClashes(wave, board);
1765
+ clashes.push(...found);
1766
+ if (found.length === 0) {
1767
+ out.push(wave);
1768
+ continue;
1769
+ }
1770
+ const bins = [];
1771
+ for (const id of wave) {
1772
+ const own = new Set((board.get(id)?.files ?? []).map(normalizePath).filter(Boolean));
1773
+ const bin = bins.find((b) => ![...own].some((f) => b.files.has(f)));
1774
+ if (bin) {
1775
+ bin.ids.push(id);
1776
+ for (const f of own) bin.files.add(f);
1777
+ } else bins.push({ ids: [id], files: own });
1778
+ }
1779
+ out.push(...bins.map((b) => b.ids));
1780
+ }
1781
+ return { waves: out, clashes };
1782
+ }
1783
+ function waveStats(board, waves, clashes = []) {
1784
+ const cards = board.list();
1785
+ const count = (action) => cards.filter((c) => c.stageHistory.some((e) => e.action === action)).length;
1786
+ return {
1787
+ tasks: cards.length,
1788
+ waves: waves.length,
1789
+ width: waves.length ? Number((cards.length / waves.length).toFixed(1)) : 0,
1790
+ widest: waves.reduce((n, w) => Math.max(n, w.length), 0),
1791
+ clashes: clashes.length,
1792
+ conflicts: count("merge-conflict"),
1793
+ failed: count("task-failed"),
1794
+ skipped: count("skipped"),
1795
+ // A task that reached a senior/council tier cost several times what a first-pass task did.
1796
+ escalated: cards.filter((c) => c.attempts > 1).length,
1797
+ attempts: cards.reduce((n, c) => n + c.attempts, 0)
1798
+ };
1799
+ }
1800
+ function describeWaves(s) {
1801
+ const parts = [
1802
+ `${s.tasks} task, ${s.waves} dependency layer(s) deep \u2014 ${s.width} per layer, widest ${s.widest}`,
1803
+ s.clashes ? `${s.clashes} file clash(es) split apart` : "",
1804
+ s.conflicts ? `${s.conflicts} merge conflict(s)` : "",
1805
+ s.escalated ? `${s.escalated} task(s) took more than one attempt (${s.attempts} total)` : "",
1806
+ s.failed ? `${s.failed} failed` : "",
1807
+ s.skipped ? `${s.skipped} skipped` : ""
1808
+ ];
1809
+ return parts.filter(Boolean).join(" \xB7 ");
1810
+ }
1811
+
1812
+ // src/engine/team-lead.ts
1813
+ var fix = z4.object({ task: z4.string(), needs: z4.string(), why: z4.string() });
1814
+ var DepAuditSchema = z4.object({
1815
+ missing: z4.array(fix).default([]),
1816
+ spurious: z4.array(fix).default([])
1817
+ });
1818
+ var AUDIT_TIMEOUT_MS = 9e4;
1819
+ var MAX_AUDIT_CARDS = 40;
1820
+ function describeCards(board, ids) {
1821
+ return board.list().filter((c) => ids.has(c.id)).map((c) => {
1822
+ const files = c.files.length ? `
1823
+ writes: ${c.files.join(", ")}` : "";
1824
+ const acc = c.acceptance.length ? `
1825
+ done when: ${c.acceptance.join("; ")}` : "";
1826
+ return `- ${c.id}: "${c.title}" deps=[${c.deps.join(", ")}]${files}${acc}`;
1827
+ }).join("\n");
1828
+ }
1829
+ async function runTeamLead(opts, board) {
1830
+ const suggested = computeWaves(board);
1831
+ const parallel = suggested.filter((w) => w.length > 1);
1832
+ if (parallel.length === 0) return { waves: suggested, added: [], suspected: [] };
1833
+ const subject = new Set(parallel.flat());
1834
+ for (const id of [...subject]) for (const d of board.get(id)?.deps ?? []) subject.add(d);
1835
+ if (subject.size > MAX_AUDIT_CARDS) {
1836
+ return { waves: suggested, added: [], suspected: [], skipped: `${subject.size} tasks would run in parallel \u2014 more than one reading can judge` };
1837
+ }
1838
+ const msg = {
1839
+ role: "user",
1840
+ content: `Tasks:
1841
+ ${describeCards(board, subject)}
1842
+
1843
+ Given those dependencies, these groups would run AT THE SAME TIME, in separate worktrees, each starting from the same base:
1844
+ ${parallel.map((w) => `- ${w.join(", ")}`).join("\n")}
1845
+
1846
+ Audit the dependencies. Two questions:
1847
+ 1. missing \u2014 is there a task in one of those groups that CANNOT run yet, because it needs something another task in the same group creates (a function, a type, a table, a config key)? Report only what would BREAK the work, not what would merely be more convenient in a different order.
1848
+ 2. spurious \u2014 is any declared dependency unnecessary, holding a task back for no reason?
1849
+
1850
+ Both lists are normally empty. Return {missing, spurious} via submit; each entry is {task, needs, why} where "task" cannot start until "needs" is done.`
1851
+ };
1852
+ let audit;
1853
+ try {
1854
+ audit = await runStructuredRole({
1855
+ ...opts,
1856
+ messages: [...opts.messages, msg],
1857
+ // Bounded: an advisory check must never be the reason a run does nothing.
1858
+ signal: AbortSignal.any([opts.signal, AbortSignal.timeout(AUDIT_TIMEOUT_MS)])
1859
+ }, DepAuditSchema);
1860
+ } catch (e) {
1861
+ if (opts.signal.aborted) throw e;
1862
+ return { waves: suggested, added: [], suspected: [], skipped: "the check did not come back in time" };
1863
+ }
1864
+ const added = [];
1865
+ for (const f of audit.missing) {
1866
+ if (!board.addDep(f.task, f.needs)) continue;
1867
+ try {
1868
+ computeWaves(board);
1869
+ added.push(f);
1870
+ } catch {
1871
+ board.removeDep(f.task, f.needs);
1872
+ }
1873
+ }
1874
+ return { waves: computeWaves(board), added, suspected: audit.spurious };
1875
+ }
1876
+
1877
+ // src/engine/timings.ts
1878
+ var Timings = class {
1879
+ constructor(now = () => Date.now()) {
1880
+ this.now = now;
1881
+ }
1882
+ now;
1883
+ totals = /* @__PURE__ */ new Map();
1884
+ record(stage, ms) {
1885
+ const t = this.totals.get(stage) ?? { ms: 0, n: 0 };
1886
+ t.ms += Math.max(0, ms);
1887
+ t.n += 1;
1888
+ this.totals.set(stage, t);
1889
+ }
1890
+ /** Times `fn`, recording it whether it succeeds or throws — a stage that fails still consumed the time. */
1891
+ async time(stage, fn) {
1892
+ const started = this.now();
1893
+ try {
1894
+ return await fn();
1895
+ } finally {
1896
+ this.record(stage, this.now() - started);
1897
+ }
1898
+ }
1899
+ /** Every stage, heaviest first. */
1900
+ summary() {
1901
+ return [...this.totals.entries()].map(([stage, t]) => ({ stage, ms: t.ms, n: t.n })).sort((a, b) => b.ms - a.ms);
1902
+ }
1903
+ get empty() {
1904
+ return this.totals.size === 0;
1905
+ }
1906
+ };
1907
+ var minutes = (ms) => ms < 6e4 ? `${Math.round(ms / 1e3)}s` : `${Math.round(ms / 6e4)}m`;
1908
+ function describeTimings(t) {
1909
+ const rows = t.summary();
1910
+ if (!rows.length) return "";
1911
+ const total = rows.reduce((n, r) => n + r.ms, 0);
1912
+ if (total <= 0) return "";
1913
+ const parts = rows.filter((r) => r.ms / total >= 0.01).map((r) => `${r.stage} ${minutes(r.ms)} (${Math.round(r.ms / total * 100)}% \xB7 ${r.n}\xD7)`);
1914
+ return `\u23F1\uFE0F Slot time \u2014 ${parts.join(" \xB7 ")}`;
1915
+ }
1916
+
1917
+ // src/engine/wave-engine.ts
1918
+ var MAX_PARALLEL_TASKS = 4;
1919
+ var TEAM_LEAD_MAX_TURNS = 3;
1920
+ var MAX_WAKES = 3;
1921
+ var RESTART_AFTER_CONFLICTS = 3;
1922
+ var MAX_RESTARTS = 2;
1923
+ function createMutex() {
1924
+ let tail = Promise.resolve();
1925
+ return (fn) => {
1926
+ const result = tail.then(() => fn());
1927
+ tail = result.then(() => void 0, () => void 0);
1928
+ return result;
1929
+ };
1930
+ }
1931
+ async function runReady(deps, session, board) {
1932
+ const ser = createMutex();
1933
+ const cards = board.list().filter((c) => c.id !== REVISION_CARD);
1934
+ const done = new Set(cards.filter((c) => c.column === "MERGED").map((c) => c.id));
1935
+ const merged = [...done];
1936
+ const failed = [];
1937
+ const skipped = [];
1938
+ const parked = /* @__PURE__ */ new Map();
1939
+ const restartOn = /* @__PURE__ */ new Map();
1940
+ const conflictedAt = /* @__PURE__ */ new Map();
1941
+ const conflictRuns = (cardId) => {
1942
+ const h = board.get(cardId)?.stageHistory ?? [];
1943
+ const since = h.map((e) => e.action).lastIndexOf("restarted");
1944
+ return h.slice(since + 1).filter((e) => e.action === "conflict:resolve-failed").length;
1945
+ };
1946
+ const restartsSoFar = (cardId) => (board.get(cardId)?.stageHistory ?? []).filter((e) => e.action === "restarted").length;
1947
+ const pending = new Set(cards.filter((c) => !done.has(c.id)).map((c) => c.id));
1948
+ for (const id of pending) {
1949
+ const c = board.get(id);
1950
+ if (!c || c.attempts === 0) continue;
1951
+ board.resetAttempts(id);
1952
+ }
1953
+ const busy = /* @__PURE__ */ new Set();
1954
+ const running = /* @__PURE__ */ new Map();
1955
+ const free = [];
1956
+ let minted = 0;
1957
+ const ceiling = () => Math.max(1, deps.maxParallel ?? MAX_PARALLEL_TASKS);
1958
+ const depsOf = (id) => board.get(id)?.deps ?? [];
1959
+ const filesOf = (id) => (board.get(id)?.files ?? []).map(normalizePath).filter(Boolean);
1960
+ const park = (id, reason, on, startedAt) => {
1961
+ parked.set(id, { reason, on, mergedAt: startedAt ?? merged.length, wakes: wakeCount.get(id) ?? 0 });
1962
+ pending.delete(id);
1963
+ board.appendStage(id, {
1964
+ role: "team-lead",
1965
+ action: "parked",
1966
+ note: reason === "waiting" ? `waiting for ${on}` : reason === "conflict" ? "merge conflicted" : "ladder exhausted"
1967
+ });
1968
+ board.move(id, "PARKED", "team-lead");
1969
+ };
1970
+ const wake = () => {
1971
+ let woken = 0;
1972
+ for (const [id, p] of [...parked]) {
1973
+ if (p.wakes >= MAX_WAKES) continue;
1974
+ const ready = p.reason === "waiting" ? p.on !== void 0 && done.has(p.on) : merged.length > p.mergedAt;
1975
+ if (!ready) continue;
1976
+ parked.delete(id);
1977
+ pending.add(id);
1978
+ board.resetAttempts(id);
1979
+ board.appendStage(id, { role: "team-lead", action: "woken", note: `${p.reason} \u2192 retrying (wake ${p.wakes + 1})` });
1980
+ board.move(id, "TODO", "team-lead");
1981
+ wakeCount.set(id, p.wakes + 1);
1982
+ woken += 1;
1983
+ }
1984
+ return woken;
1985
+ };
1986
+ const wakeCount = /* @__PURE__ */ new Map();
1987
+ const parkUnreachable = () => {
1988
+ for (let changed = true; changed; ) {
1989
+ changed = false;
1990
+ for (const id of [...pending]) {
1991
+ const blocker = depsOf(id).find((d) => parked.has(d));
1992
+ if (blocker === void 0) continue;
1993
+ park(id, "waiting", blocker);
1994
+ changed = true;
1995
+ }
1996
+ }
1997
+ };
1998
+ while (pending.size > 0 || running.size > 0 || parked.size > 0) {
1999
+ parkUnreachable();
2000
+ const limit = ceiling();
2001
+ for (const id of [...pending]) {
2002
+ if (running.size >= limit) break;
2003
+ if (!depsOf(id).every((d) => done.has(d))) continue;
2004
+ const files = filesOf(id);
2005
+ if (files.some((f) => busy.has(f))) continue;
2006
+ pending.delete(id);
2007
+ telemetry().event("decision.schedule", {
2008
+ "hc.decision": "schedule",
2009
+ "hc.task.id": id,
2010
+ "hc.slot": free[free.length - 1] ?? minted,
2011
+ "hc.running": running.size + 1,
2012
+ "hc.limit": limit,
2013
+ "hc.pending": pending.size
2014
+ });
2015
+ for (const f of files) busy.add(f);
2016
+ const mergedAtStart = merged.length;
2017
+ const slot = free.pop() ?? minted++;
2018
+ running.set(slot, (async () => {
2019
+ try {
2020
+ const resolveConflict = async (tw, conflicted) => {
2021
+ try {
2022
+ const r = await resolveMergeConflict(deps, session, board, id, tw);
2023
+ return r.status === "resolved" ? { status: "merged" } : { status: "conflict", files: conflicted };
2024
+ } catch (e) {
2025
+ if (deps.signal.aborted) throw e;
2026
+ board.appendStage(id, {
2027
+ role: "operational",
2028
+ action: "conflict:resolve-failed",
2029
+ note: e instanceof Error ? e.message.slice(0, 200) : String(e).slice(0, 200)
2030
+ });
2031
+ conflictedAt.set(id, tw);
2032
+ if (conflictRuns(id) >= RESTART_AFTER_CONFLICTS && restartsSoFar(id) < MAX_RESTARTS) {
2033
+ restartOn.set(id, tw);
2034
+ }
2035
+ try {
2036
+ await deps.manager.abortMerge(session);
2037
+ } catch {
2038
+ }
2039
+ return { status: "conflict", files: conflicted };
2040
+ }
2041
+ };
2042
+ const res = await runWaveTask({ ...deps, serialize: ser, resolveConflict, baseRef: session.baseBranch }, session, board, id, slot);
2043
+ if (res.status === "merged") {
2044
+ merged.push(id);
2045
+ done.add(id);
2046
+ try {
2047
+ await deps.manager.removeTask(session, res.task);
2048
+ } catch {
2049
+ }
2050
+ } else if (res.status === "conflict" && restartOn.has(id)) {
2051
+ const tw = restartOn.get(id);
2052
+ restartOn.delete(id);
2053
+ try {
2054
+ const retired = await deps.manager.restartTask(session, tw);
2055
+ board.appendStage(id, {
2056
+ role: "team-lead",
2057
+ action: "restarted",
2058
+ note: `merge unresolvable after ${RESTART_AFTER_CONFLICTS} attempts \u2014 rewriting from the current base (the reviewed work is kept on ${retired})`
2059
+ });
2060
+ board.resetAttempts(id);
2061
+ board.move(id, "TODO", "team-lead");
2062
+ pending.add(id);
2063
+ } catch (e) {
2064
+ board.appendStage(id, {
2065
+ role: "team-lead",
2066
+ action: "restart-failed",
2067
+ note: e instanceof Error ? e.message.slice(0, 200) : String(e).slice(0, 200)
2068
+ });
2069
+ park(id, "conflict", void 0, mergedAtStart);
2070
+ }
2071
+ } else park(id, res.status === "conflict" ? "conflict" : "exhausted", void 0, mergedAtStart);
2072
+ } finally {
2073
+ for (const f of files) busy.delete(f);
2074
+ free.push(slot);
2075
+ }
2076
+ return slot;
2077
+ })());
2078
+ }
2079
+ if (running.size === 0) {
2080
+ if (wake() > 0) continue;
2081
+ const stuck = [...parked].filter(([id, p]) => p.reason === "conflict" && conflictedAt.has(id) && restartsSoFar(id) < MAX_RESTARTS);
2082
+ let rewrote = 0;
2083
+ for (const [id] of stuck) {
2084
+ const tw = conflictedAt.get(id);
2085
+ conflictedAt.delete(id);
2086
+ restartOn.delete(id);
2087
+ try {
2088
+ const retired = await deps.manager.restartTask(session, tw);
2089
+ board.appendStage(id, {
2090
+ role: "team-lead",
2091
+ action: "restarted",
2092
+ note: `merge unresolvable and nothing left to wake it \u2014 rewriting from the current base (the reviewed work is kept on ${retired})`
2093
+ });
2094
+ board.resetAttempts(id);
2095
+ board.move(id, "TODO", "team-lead");
2096
+ parked.delete(id);
2097
+ pending.add(id);
2098
+ rewrote++;
2099
+ } catch (e) {
2100
+ board.appendStage(id, {
2101
+ role: "team-lead",
2102
+ action: "restart-failed",
2103
+ note: e instanceof Error ? e.message.slice(0, 200) : String(e).slice(0, 200)
2104
+ });
2105
+ }
2106
+ }
2107
+ if (rewrote > 0) continue;
2108
+ for (const [id, p] of parked) {
2109
+ (p.reason === "waiting" ? skipped : failed).push(id);
2110
+ board.appendStage(id, {
2111
+ role: "team-lead",
2112
+ action: "abandoned",
2113
+ note: p.wakes >= MAX_WAKES ? `out of wakes after ${p.wakes}` : `${p.reason}: nothing left that could change it`
2114
+ });
2115
+ board.move(id, "ABANDONED", "team-lead");
2116
+ }
2117
+ for (const id of pending) {
2118
+ skipped.push(id);
2119
+ board.appendStage(id, { role: "team-lead", action: "abandoned", note: "its dependencies never completed" });
2120
+ board.move(id, "ABANDONED", "team-lead");
2121
+ }
2122
+ parked.clear();
2123
+ pending.clear();
2124
+ break;
2125
+ }
2126
+ running.delete(await Promise.race(running.values()));
2127
+ wake();
2128
+ }
2129
+ return { merged, failed, skipped };
2130
+ }
2131
+ function teamLeadOpts(deps, session) {
2132
+ const tl = deps.roleRegistry.resolve("team-lead");
2133
+ const tools = new ToolRegistry();
2134
+ tools.register(buildSkillTool(deps.skillRegistry));
2135
+ return {
2136
+ provider: deps.provider,
2137
+ ...tl,
2138
+ tools,
2139
+ messages: [],
2140
+ permission: deps.permission,
2141
+ approve: deps.approve,
2142
+ cwd: session.baseWorktree,
2143
+ signal: deps.signal,
2144
+ /**
2145
+ * The question is answerable from the prompt alone — the tasks, their files and their criteria are all
2146
+ * in it. Left at the default fifty turns per attempt, a model that would not call `submit` walked its
2147
+ * whole fallback chain instead: seventy-three calls and 1.5M tokens re-sending the same task list, four
2148
+ * and a half minutes before a single agent started, and nothing on screen to say so.
2149
+ */
2150
+ maxTurns: TEAM_LEAD_MAX_TURNS
2151
+ };
2152
+ }
2153
+ async function runWaves(deps, session, board, opts) {
2154
+ const todo = board.list().filter((c) => c.column !== "MERGED").length;
2155
+ deps.note?.(`\u{1F9ED} Planning the run \u2014 ${todo} task(s) left, up to ${deps.maxParallel ?? MAX_PARALLEL_TASKS} at a time. Checking their dependencies before scheduling.`);
2156
+ const plan = await runTeamLead(teamLeadOpts(deps, session), board);
2157
+ if (plan.skipped) deps.note?.(`\u{1F9ED} Dependency check skipped \u2014 ${plan.skipped}. Scheduling from the plan as written.`);
2158
+ if (plan.added.length) {
2159
+ deps.note?.(
2160
+ `\u{1F517} ${plan.added.length} dependency the breakdown did not state \u2014 ${plan.added.map((f) => `${f.task} needs ${f.needs}`).join(", ")}. Those tasks no longer run together.`
2161
+ );
2162
+ }
2163
+ if (plan.suspected.length) {
2164
+ deps.note?.(
2165
+ `\u{1F4A4} ${plan.suspected.length} dependency looks unnecessary and is holding work back: ${plan.suspected.map((f) => `${f.task}\u2192${f.needs}`).join(", ")}. Left in place.`
2166
+ );
2167
+ }
2168
+ const { waves, clashes } = splitFileConflicts(plan.waves, board);
2169
+ if (clashes.length) {
2170
+ deps.note?.(
2171
+ `\u{1F500} ${clashes.length} task pair(s) write the same file \u2014 they will not run at the same time: ${clashes.slice(0, 3).map((c) => `${c.a}/${c.b} (${c.files[0]})`).join(", ")}${clashes.length > 3 ? ", \u2026" : ""}`
2172
+ );
2173
+ }
2174
+ const outcome = await runReady(deps, session, board);
2175
+ const failed = outcome.failed;
2176
+ const skipped = outcome.skipped;
2177
+ const delivery = { branch: session.baseBranch, worktree: session.baseWorktree };
2178
+ deps.note?.(`\u{1F4CA} ${describeWaves(waveStats(board, waves, clashes))}`);
2179
+ if (deps.timings && !deps.timings.empty) deps.note?.(describeTimings(deps.timings));
2180
+ const shape = waveStats(board, waves, clashes);
2181
+ telemetry().event("run.summary", {
2182
+ "hc.tasks": shape.tasks,
2183
+ "hc.layers": shape.waves,
2184
+ "hc.widest": shape.widest,
2185
+ "hc.clashes": shape.clashes,
2186
+ "hc.conflicts": shape.conflicts,
2187
+ "hc.failed": shape.failed,
2188
+ "hc.skipped": shape.skipped,
2189
+ "hc.attempts": shape.attempts
2190
+ });
2191
+ if (failed.length === 0 && skipped.length === 0) {
2192
+ await deps.manager.push(session);
2193
+ if (await deps.manager.hasRemote(session)) {
2194
+ const summary = await prSummary(deps, {
2195
+ request: opts.request ?? session.jobSlug,
2196
+ // …every card EXCEPT the revision row: it is bookkeeping, and it was published as work. Measured on
2197
+ // PR #765, where the task list ended "- PR revision" among the 27 things that were actually built.
2198
+ cards: board.list().filter((c) => c.id !== REVISION_CARD),
2199
+ cwd: session.baseWorktree,
2200
+ base: opts.base,
2201
+ jobSlug: session.jobSlug
2202
+ });
2203
+ const pr = await deps.manager.openPR(session, deps.prAdapter, {
2204
+ base: opts.base,
2205
+ title: opts.prTitle ?? summary.title,
2206
+ body: summary.body
2207
+ });
2208
+ return { status: "completed", session, pr, delivery, waves };
2209
+ }
2210
+ return { status: "completed", session, delivery, waves };
2211
+ }
2212
+ return { status: "partial", session, failed, skipped, delivery, waves };
2213
+ }
2214
+
2215
+ // src/terminal.ts
2216
+ import { createInterface } from "readline/promises";
2217
+ function makeAskUser(read) {
2218
+ return (question, opts) => {
2219
+ const steps = opts?.steps ?? [];
2220
+ if (!steps.length) return read(`
2221
+ [question] ${question}`, opts);
2222
+ const body = steps.map((s, i) => `${i + 1}. ${s}`).join("\n");
2223
+ return read(`
2224
+ [action] ${body}
2225
+
2226
+ ${question}`, opts);
2227
+ };
2228
+ }
2229
+ function makeApprove(read) {
2230
+ return async (req) => {
2231
+ const ans = (await read(`
2232
+ [permission] ${req.preview}
2233
+ approve? (y/n)`)).trim().toLowerCase();
2234
+ return ans === "y" || ans === "yes";
2235
+ };
2236
+ }
2237
+ function nodeLineReader(input = process.stdin, output = process.stdout) {
2238
+ const rl = createInterface({ input, output });
2239
+ const buffered = [];
2240
+ const waiters = [];
2241
+ let closed = false;
2242
+ rl.on("line", (line) => {
2243
+ const w = waiters.shift();
2244
+ if (w) w(line);
2245
+ else buffered.push(line);
2246
+ });
2247
+ rl.on("close", () => {
2248
+ closed = true;
2249
+ while (waiters.length) waiters.shift()("");
2250
+ });
2251
+ const read = async (prompt, opts) => {
2252
+ const shown = opts?.options?.length ? `${prompt}
2253
+ ${opts.options.map((o, i) => ` ${i + 1}. ${o}`).join("\n")}` + (opts.multiSelect ? "\n(enter one or more numbers, comma-separated)" : "") : prompt;
2254
+ rl.setPrompt(shown + "\n> ");
2255
+ rl.prompt();
2256
+ let line;
2257
+ if (buffered.length) line = buffered.shift();
2258
+ else if (closed) line = "";
2259
+ else line = await new Promise((resolve) => {
2260
+ waiters.push(resolve);
2261
+ });
2262
+ const options = opts?.options;
2263
+ if (options?.length) {
2264
+ const picks = line.split(",").map((s) => s.trim()).filter(Boolean).map((s) => {
2265
+ const n = Number(s);
2266
+ return Number.isInteger(n) && n >= 1 && n <= options.length ? options[n - 1] : s;
2267
+ });
2268
+ if (picks.length) return picks.join("; ");
2269
+ }
2270
+ return line;
2271
+ };
2272
+ return { read, close: () => rl.close() };
2273
+ }
2274
+
2275
+ // src/session/memory.ts
2276
+ import { mkdir, writeFile as writeFile2, readFile as readFile2 } from "fs/promises";
2277
+ import { existsSync } from "fs";
2278
+ import { join as join3, dirname as dirname2 } from "path";
2279
+ import { createHash } from "crypto";
2280
+
2281
+ // src/engine/memory-dedupe.ts
2282
+ var CANDIDATE_BAR = 0.6;
2283
+ var MAX_GROUPS = 12;
2284
+ function duplicateCandidates(entries) {
2285
+ const groups = [];
2286
+ const placed = /* @__PURE__ */ new Set();
2287
+ for (let i = 0; i < entries.length; i++) {
2288
+ const a = entries[i];
2289
+ if (placed.has(a.id)) continue;
2290
+ const group = [a];
2291
+ for (let j = i + 1; j < entries.length; j++) {
2292
+ const b = entries[j];
2293
+ if (placed.has(b.id)) continue;
2294
+ if ((a.kind ?? "fact") !== (b.kind ?? "fact")) continue;
2295
+ if (relationStrength(a, b) < CANDIDATE_BAR) continue;
2296
+ group.push(b);
2297
+ placed.add(b.id);
2298
+ }
2299
+ if (group.length > 1) {
2300
+ placed.add(a.id);
2301
+ groups.push(group);
2302
+ }
2303
+ }
2304
+ return groups.map((g) => ({ ids: g.map((e) => e.id), texts: g.map((e) => e.text) }));
2305
+ }
2306
+ var SYSTEM = "You reconcile a project's memory. You are conservative: two notes about the same file are not the same note, and you never merge claims that could both be true and separately useful.";
2307
+ var PROMPT = (groups) => `Each group below holds notes that MIGHT be the same thing said twice, by different workers that could not see each other.
2308
+
2309
+ For each group, decide which notes state the SAME claim. Merge only those.
2310
+
2311
+ - If they say the same thing, give ONE wording that keeps every specific detail from all of them (file names, identifiers, exceptions, numbers). Losing a detail is worse than keeping two notes.
2312
+ - If they are about the same subject but state DIFFERENT things, do not merge them.
2313
+ - If one is a strictly more complete version of another, merge into the complete one.
2314
+
2315
+ ` + groups.map((g, i) => `GROUP ${i}:
2316
+ ${g.texts.map((t, j) => ` [${j}] ${t}`).join("\n")}`).join("\n\n") + `
2317
+
2318
+ Answer with a fenced json block: {"merges":[{"group":0,"indexes":[0,1],"text":"..."}]}
2319
+ Include only the groups you are merging; omit the rest.`;
2320
+ function parse(out, groups) {
2321
+ const fence = /```(?:json)?\s*([\s\S]*?)```/.exec(out);
2322
+ const raw = fence ? fence[1] : out.slice(out.indexOf("{"));
2323
+ let parsed;
2324
+ try {
2325
+ parsed = JSON.parse(raw);
2326
+ } catch {
2327
+ return [];
2328
+ }
2329
+ if (!Array.isArray(parsed.merges)) return [];
2330
+ const out2 = [];
2331
+ for (const m of parsed.merges) {
2332
+ const g = groups[typeof m.group === "number" ? m.group : -1];
2333
+ if (!g || typeof m.text !== "string" || !m.text.trim() || !Array.isArray(m.indexes)) continue;
2334
+ const ids = m.indexes.filter((n) => typeof n === "number" && n >= 0 && n < g.ids.length).map((n) => g.ids[n]);
2335
+ if (new Set(ids).size > 1) out2.push({ ids: [...new Set(ids)], text: m.text.trim() });
2336
+ }
2337
+ return out2;
2338
+ }
2339
+ async function dedupeMemories(opts) {
2340
+ const groups = duplicateCandidates(opts.entries).slice(0, MAX_GROUPS);
2341
+ if (!groups.length) return [];
2342
+ for (const model of opts.models.filter(Boolean)) {
2343
+ const req = {
2344
+ model,
2345
+ messages: [{ role: "system", content: SYSTEM }, { role: "user", content: PROMPT(groups) }],
2346
+ tools: []
2347
+ };
2348
+ let out = "";
2349
+ try {
2350
+ for await (const ev of opts.provider.chat(req, opts.signal ?? new AbortController().signal)) {
2351
+ if (ev.type === "text-delta") out += ev.text;
2352
+ else if (ev.type === "error") throw new Error(ev.message);
2353
+ }
2354
+ } catch {
2355
+ continue;
2356
+ }
2357
+ return parse(out, groups);
2358
+ }
2359
+ return [];
2360
+ }
2361
+ function applyMerges(entries, merges) {
2362
+ const byId = new Map(entries.map((e) => [e.id, e]));
2363
+ const dropped = /* @__PURE__ */ new Set();
2364
+ for (const m of merges) {
2365
+ const keeper = byId.get(m.ids[0]);
2366
+ if (!keeper) continue;
2367
+ const others = m.ids.slice(1).map((id) => byId.get(id)).filter((e) => !!e);
2368
+ if (!others.length) continue;
2369
+ keeper.text = m.text;
2370
+ keeper.uses = (keeper.uses ?? 0) + others.reduce((n, o) => n + (o.uses ?? 0), 0);
2371
+ keeper.injections = (keeper.injections ?? 0) + others.reduce((n, o) => n + (o.injections ?? 0), 0);
2372
+ keeper.observedInjections = (keeper.observedInjections ?? 0) + others.reduce((n, o) => n + (o.observedInjections ?? 0), 0);
2373
+ for (const o of others) dropped.add(o.id);
2374
+ }
2375
+ return { entries: entries.filter((e) => !dropped.has(e.id)), removed: dropped.size };
2376
+ }
2377
+
2378
+ // src/session/memory.ts
2379
+ import { readFileSync, statSync } from "fs";
2380
+
2381
+ // src/engine/memory-hygiene.ts
2382
+ var DAY = 24 * 60 * 60 * 1e3;
2383
+ var REVIEW_AGE_MS = 30 * DAY;
2384
+ var STALE_AGE_MS = 90 * DAY;
2385
+ var UNUSED_INJECTIONS = 10;
2386
+ var LOW_CONFIDENCE = 0.5;
2387
+ function normalizeTextKey(text) {
2388
+ return text.toLowerCase().replace(/[^a-z0-9\s]+/g, " ").replace(/\s+/g, " ").trim();
2389
+ }
2390
+ var union = (a, b) => [.../* @__PURE__ */ new Set([...a, ...b])];
2391
+ function absorb(keeper, loser) {
2392
+ return {
2393
+ ...keeper,
2394
+ anchors: union(keeper.anchors, loser.anchors),
2395
+ tags: union(keeper.tags, loser.tags),
2396
+ uses: (keeper.uses ?? 0) + (loser.uses ?? 0),
2397
+ injections: (keeper.injections ?? 0) + (loser.injections ?? 0),
2398
+ importance: Math.max(importanceOf(keeper), importanceOf(loser)),
2399
+ confidence: Math.max(confidenceOf(keeper), confidenceOf(loser)),
2400
+ // The keeper's own fingerprints win: they describe the anchors its text was actually written against.
2401
+ anchorHashes: { ...loser.anchorHashes, ...keeper.anchorHashes },
2402
+ // A permanent duplicate makes the survivor permanent — the stronger claim on lifetime wins.
2403
+ ...loser.persistence === "permanent" ? { persistence: "permanent" } : {}
2404
+ };
2405
+ }
2406
+ function betterKeeper(a, b) {
2407
+ if (importanceOf(a) !== importanceOf(b)) return importanceOf(a) > importanceOf(b) ? a : b;
2408
+ if ((a.uses ?? 0) !== (b.uses ?? 0)) return (a.uses ?? 0) > (b.uses ?? 0) ? a : b;
2409
+ return a.createdAt <= b.createdAt ? a : b;
2410
+ }
2411
+ function reviewReason(e, now) {
2412
+ if (e.persistence === "permanent") return void 0;
2413
+ const age = now - e.createdAt;
2414
+ if (e.expiresAt !== void 0 && e.expiresAt <= now) return "expired";
2415
+ if ((e.observedInjections ?? 0) >= UNUSED_INJECTIONS && (e.uses ?? 0) === 0 && age >= REVIEW_AGE_MS) return "injected-never-used";
2416
+ if (confidenceOf(e) < LOW_CONFIDENCE && age >= REVIEW_AGE_MS) return "low-confidence";
2417
+ if (e.stale && age >= STALE_AGE_MS) return "long-stale";
2418
+ return void 0;
2419
+ }
2420
+ function hygiene(entries, now) {
2421
+ const groups = /* @__PURE__ */ new Map();
2422
+ const order = [];
2423
+ for (const e of entries) {
2424
+ const key = `${e.kind ?? "fact"}::${normalizeTextKey(e.text)}`;
2425
+ if (!groups.has(key)) {
2426
+ groups.set(key, []);
2427
+ order.push(key);
2428
+ }
2429
+ groups.get(key).push(e);
2430
+ }
2431
+ const merged = [];
2432
+ const kept = [];
2433
+ for (const key of order) {
2434
+ const group = groups.get(key);
2435
+ if (group.length === 1) {
2436
+ kept.push(group[0]);
2437
+ continue;
2438
+ }
2439
+ const keeper = group.reduce(betterKeeper);
2440
+ const losers = group.filter((e) => e.id !== keeper.id);
2441
+ kept.push(losers.reduce(absorb, keeper));
2442
+ merged.push({ keeper: keeper.id, absorbed: losers.map((l) => l.text) });
2443
+ }
2444
+ const candidates = [];
2445
+ for (const e of kept) {
2446
+ const reason = reviewReason(e, now);
2447
+ if (reason) candidates.push({ id: e.id, text: e.text, reason });
2448
+ }
2449
+ return { entries: kept, merged, candidates };
2450
+ }
2451
+
2452
+ // src/session/memory.ts
2453
+ var HASH_MAX_BYTES = 512 * 1024;
2454
+ var USAGE_FILE = "memory-usage.json";
2455
+ var MemoryStore = class {
2456
+ file;
2457
+ /** Per-id counters, machine-local. Loaded with the store, written when they change. */
2458
+ usage = {};
2459
+ now;
2460
+ cache;
2461
+ queue = Promise.resolve();
2462
+ // serializes mutations (safe under parallel writers)
2463
+ /** Runs a mutation with exclusive access → no interleaved read-modify-write across concurrent callers. */
2464
+ serialize(fn) {
2465
+ const run = this.queue.then(fn, fn);
2466
+ this.queue = run.then(() => void 0, () => void 0);
2467
+ return run;
2468
+ }
2469
+ cwd;
2470
+ lastVerify = 0;
2471
+ /**
2472
+ * Set while a JOB is running and its session has not opened yet — the window in which the root must not be
2473
+ * touched. Not derived from the directory: a person typing `/remember` in chat is writing to the project on
2474
+ * purpose, and that is the feature. This is only about what a run does as a SIDE EFFECT.
2475
+ */
2476
+ deferred = false;
2477
+ /**
2478
+ * Entries learned before a session existed, held until one does.
2479
+ *
2480
+ * The store is built when the process starts, and the only directory it can be given then is the PROJECT.
2481
+ * Refining, sizing and triage all run in that window and all can learn something. Writing it at the root
2482
+ * is what left `memory.jsonl` modified there for every later merge to trip over; dropping it would lose
2483
+ * the lessons those phases paid for. So it waits, and lands in the session the moment one opens.
2484
+ */
2485
+ pending = [];
2486
+ candidates = [];
2487
+ // ids the last hygiene run put up for review
2488
+ constructor(opts) {
2489
+ this.now = opts.now ?? (() => Date.now());
2490
+ this.cwd = opts.cwd;
2491
+ this.file = join3(stateRoot(opts.cwd), ".horsecode", "memory.jsonl");
2492
+ }
2493
+ /**
2494
+ * Hold everything learned from here until a session exists.
2495
+ *
2496
+ * Called when a job starts, because refining, sizing and triage all run before the worktree is opened and
2497
+ * all can learn something. Writing it at the root left `memory.jsonl` modified in the project checkout —
2498
+ * one of four files (with `graphify-out/` and the traces) that both sides of a merge regenerate, so every
2499
+ * later pull refused to apply. Dropping it instead would lose what those phases paid for.
2500
+ */
2501
+ deferUntilSession() {
2502
+ this.deferred = writableStateRoot(this.cwd) === void 0;
2503
+ }
2504
+ /**
2505
+ * Points the store at a session's base worktree, or back at the project when the session ends.
2506
+ *
2507
+ * `stateRoot` resolves the right file for a given directory — but the store is built once, when the
2508
+ * process starts, and the only directory it can be given then is the PROJECT. A session opens later, so
2509
+ * the path computed in the constructor stayed the project's for the whole run.
2510
+ *
2511
+ * Measured on a real job: the session's inherited `memory.jsonl` was never written after the moment it was
2512
+ * copied, while the project's gained 26 uses and 85 injections during the same hour. Everything the run
2513
+ * learned landed in the reference copy and outside the pull request — the opposite of the rule that the
2514
+ * root is read, and the session is what ships.
2515
+ *
2516
+ * The cache is dropped rather than carried across: the two files are separate records, and keeping entries
2517
+ * loaded from one while writing to the other is how a session would overwrite the project's memory with a
2518
+ * stale snapshot of itself.
2519
+ */
2520
+ retarget(cwd) {
2521
+ if (writableStateRoot(cwd) !== void 0) this.deferred = false;
2522
+ const next = join3(stateRoot(cwd), ".horsecode", "memory.jsonl");
2523
+ if (next === this.file) return;
2524
+ this.file = next;
2525
+ this.cache = void 0;
2526
+ }
2527
+ /** Where entries are being written right now — for tests, and for anything that reports on state. */
2528
+ filePath() {
2529
+ return this.file;
2530
+ }
2531
+ /** Project-relative content fingerprint, used to detect that an anchored file moved on. */
2532
+ anchorFs = {
2533
+ fingerprint: (rel) => {
2534
+ try {
2535
+ const abs = join3(this.cwd, rel);
2536
+ const st = statSync(abs);
2537
+ if (!st.isFile()) return void 0;
2538
+ if (st.size > HASH_MAX_BYTES) return `s${st.size}:${Math.floor(st.mtimeMs)}`;
2539
+ return createHash("sha256").update(readFileSync(abs)).digest("hex").slice(0, 16);
2540
+ } catch {
2541
+ return void 0;
2542
+ }
2543
+ }
2544
+ };
2545
+ /**
2546
+ * Re-checks every entry's file anchors and flags the ones whose anchored code changed. Throttled: retrieval
2547
+ * asks for `all()` on every turn, and re-hashing on each call would be pure waste.
2548
+ */
2549
+ verify(force = false) {
2550
+ if (!this.cache) return;
2551
+ const t = this.now();
2552
+ if (!force && t - this.lastVerify < 2e3) return;
2553
+ this.lastVerify = t;
2554
+ for (const e of this.cache) {
2555
+ const fresh = verifyAnchors(e, this.anchorFs);
2556
+ if (e.stale !== !fresh) e.stale = !fresh;
2557
+ }
2558
+ }
2559
+ /** Load entries from disk (memoized). Corrupt lines are skipped. */
2560
+ async load() {
2561
+ if (this.cache) return this.cache;
2562
+ const out = [];
2563
+ try {
2564
+ const raw = await readFile2(this.file, "utf8");
2565
+ for (const line of raw.split("\n")) {
2566
+ if (!line.trim()) continue;
2567
+ try {
2568
+ out.push(JSON.parse(line));
2569
+ } catch {
2570
+ }
2571
+ }
2572
+ } catch {
2573
+ }
2574
+ this.cache = out;
2575
+ await this.loadUsage(out);
2576
+ if (!this.deferred && this.pending.length) {
2577
+ for (const e of this.pending) out.push({ ...e, id: this.mintId() });
2578
+ this.pending = [];
2579
+ await this.persist();
2580
+ }
2581
+ const repaired = this.dedupeIds(out);
2582
+ const retagged = this.retag(out);
2583
+ if (repaired || retagged) await this.persist();
2584
+ return out;
2585
+ }
2586
+ /**
2587
+ * Re-derives tags for entries written under an older rule.
2588
+ *
2589
+ * Tags are stored, so fixing how they are derived does nothing for memories already on disk — and the rule
2590
+ * that was fixed had been dropping the meaningful words: a fact about `DomainException` kept `types`,
2591
+ * `must` and `including` while losing `domain` and `exception`, which is what a query would have matched.
2592
+ * On the real pool that entry scored ZERO for the question it was written to answer.
2593
+ *
2594
+ * `deriveTags` is pure, so recomputing is safe and idempotent: an entry already tagged correctly produces
2595
+ * exactly what it has. Returns whether anything changed.
2596
+ */
2597
+ retag(entries) {
2598
+ let changed = false;
2599
+ for (const e of entries) {
2600
+ const fresh = deriveTags(e.text, e.anchors ?? []);
2601
+ if (fresh.length === e.tags?.length && fresh.every((t, i) => e.tags[i] === t)) continue;
2602
+ e.tags = fresh;
2603
+ changed = true;
2604
+ }
2605
+ return changed;
2606
+ }
2607
+ /**
2608
+ * Gives a fresh id to every entry that shares one, keeping the first.
2609
+ *
2610
+ * Files written before ids were minted uniquely already carry collisions — the real one measured 1471
2611
+ * entries under 1344 ids — and nothing downstream can tell those rows apart: every consumer resolves an id
2612
+ * with `find`, which returns the first match. Repairing on load fixes them without asking the user to
2613
+ * notice a problem they cannot see.
2614
+ *
2615
+ * Returns whether anything changed, so a healthy file is never rewritten.
2616
+ */
2617
+ dedupeIds(entries) {
2618
+ const seen = /* @__PURE__ */ new Set();
2619
+ let changed = false;
2620
+ for (const e of entries) {
2621
+ if (!seen.has(e.id)) {
2622
+ seen.add(e.id);
2623
+ continue;
2624
+ }
2625
+ let id = `${e.id}-2`;
2626
+ for (let n = 3; seen.has(id); n++) id = `${e.id}-${n}`;
2627
+ e.id = id;
2628
+ seen.add(id);
2629
+ changed = true;
2630
+ }
2631
+ return changed;
2632
+ }
2633
+ /**
2634
+ * Synchronous snapshot of the entries — LOADING them if nobody has yet.
2635
+ *
2636
+ * It used to be `this.cache ?? []`, which is a snapshot of whatever happened to have been read already, and
2637
+ * `retarget` — the very call that points the store at a newly opened session — ends by clearing the cache.
2638
+ * So from the moment a session opened, every retrieval saw an empty store, for the rest of the run.
2639
+ *
2640
+ * It was invisible for three runs because it looks exactly like a project with nothing worth recalling: the
2641
+ * start-up banner said "746 entries" (read before the session existed), the file on disk had all of them,
2642
+ * selection returned five hits when run by hand against the same file, and the injector reported nothing.
2643
+ * What finally named it was recording the MISS: `reason: empty-store, available: 0` on a role whose store
2644
+ * demonstrably had 721 selectable entries.
2645
+ *
2646
+ * Synchronous because every caller is: retrieval happens while a prompt is being assembled, and an `async`
2647
+ * load there would mean changing every role's assembly for a file read that takes under a millisecond.
2648
+ */
2649
+ all() {
2650
+ if (!this.cache) this.loadSync();
2651
+ this.verify();
2652
+ return this.cache ?? [];
2653
+ }
2654
+ /**
2655
+ * The same read as {@link load}, without awaiting.
2656
+ *
2657
+ * Deliberately does NOT do `load`'s extra work — adopting `pending` entries and re-minting their ids writes
2658
+ * to disk, and a read must not. Those still land through `load`, which every write path already awaits.
2659
+ */
2660
+ loadSync() {
2661
+ const out = [];
2662
+ try {
2663
+ for (const line of readFileSync(this.file, "utf8").split("\n")) {
2664
+ if (!line.trim()) continue;
2665
+ try {
2666
+ out.push(JSON.parse(line));
2667
+ } catch {
2668
+ }
2669
+ }
2670
+ } catch {
2671
+ }
2672
+ this.cache = out;
2673
+ }
2674
+ /**
2675
+ * Drops entries past their hard expiry. `permanent` is exempt by definition; everything else only expires if
2676
+ * it was given an explicit TTL, so nothing silently disappears that was not marked short-lived.
2677
+ */
2678
+ async pruneExpired() {
2679
+ return this.serialize(async () => {
2680
+ await this.load();
2681
+ const now = this.now();
2682
+ const before = this.cache.length;
2683
+ this.cache = this.cache.filter((e) => !isExpired(e, now));
2684
+ const dropped = before - this.cache.length;
2685
+ if (dropped > 0) await this.persist();
2686
+ return dropped;
2687
+ });
2688
+ }
2689
+ /**
2690
+ * Reconciles the pool: merges duplicates and returns the entries now up for review. Never deletes — a wrong
2691
+ * automatic deletion is unrecoverable here (the file is the only copy), so questionable entries are only
2692
+ * flagged. Runs once per session and on demand.
2693
+ */
2694
+ async runHygiene(opts) {
2695
+ return this.serialize(async () => {
2696
+ await this.load();
2697
+ this.verify(true);
2698
+ const report = hygiene(this.cache, this.now());
2699
+ let entries = report.entries;
2700
+ let semantic = 0;
2701
+ if (opts) {
2702
+ const merges = await dedupeMemories({ ...opts, entries });
2703
+ const applied = applyMerges(entries, merges);
2704
+ entries = applied.entries;
2705
+ semantic = applied.removed;
2706
+ }
2707
+ if (report.merged.length || semantic > 0) {
2708
+ this.cache = entries;
2709
+ await this.persist();
2710
+ }
2711
+ this.candidates = report.candidates.map((c) => c.id);
2712
+ return { ...report, entries, semanticMerged: semantic };
2713
+ });
2714
+ }
2715
+ /** Ids flagged by the last hygiene run — surfaced by /memories next to the lifecycle state. */
2716
+ reviewCandidates() {
2717
+ return [...this.candidates];
2718
+ }
2719
+ /**
2720
+ * Records that these memories were put into a prompt. Without the count there is no way to tell a memory that
2721
+ * is genuinely never relevant from one that has simply never come up — and the first should stop winning slots.
2722
+ */
2723
+ async recordInjection(ids) {
2724
+ if (!ids.length) return;
2725
+ return this.serialize(async () => {
2726
+ await this.load();
2727
+ let touched = false;
2728
+ for (const id of ids) {
2729
+ const e = this.cache.find((m) => m.id === id);
2730
+ if (!e) continue;
2731
+ e.injections = (e.injections ?? 0) + 1;
2732
+ e.observedInjections = (e.observedInjections ?? 0) + 1;
2733
+ this.usage[id] = { injections: e.injections, observedInjections: e.observedInjections };
2734
+ touched = true;
2735
+ }
2736
+ if (touched) await this.persistUsage();
2737
+ });
2738
+ }
2739
+ /** Entries currently flagged stale — surfaced by /memory so the user can re-confirm or delete them. */
2740
+ stale() {
2741
+ this.verify();
2742
+ return (this.cache ?? []).filter((e) => e.stale);
2743
+ }
2744
+ /**
2745
+ * A NEW id, unique against everything already held.
2746
+ *
2747
+ * The id used to be the millisecond clock alone, which is unique only while writes are seconds apart. A
2748
+ * migration importing 1878 facts in one loop put several into the same millisecond: measured on the real
2749
+ * file, 1471 entries carried 1344 distinct ids — 101 ids shared by up to five entries each.
2750
+ *
2751
+ * That is not cosmetic. Every consumer resolves an id with `find`, which returns the FIRST match: a use
2752
+ * credited to one memory lands on another, an injection is counted against the wrong entry, and `/forget`
2753
+ * deletes a memory the user was not looking at. The whole usage record silently describes the wrong rows.
2754
+ */
2755
+ mintId() {
2756
+ const stamp = this.now();
2757
+ const taken = new Set((this.cache ?? []).map((e) => e.id));
2758
+ let id = `m${stamp}`;
2759
+ for (let n = 2; taken.has(id); n++) id = `m${stamp}-${n}`;
2760
+ return id;
2761
+ }
2762
+ usageFile() {
2763
+ return join3(dirname2(this.file), USAGE_FILE);
2764
+ }
2765
+ /**
2766
+ * Folds the machine-local counts onto the entries, and takes over any the shared file still carries.
2767
+ *
2768
+ * The shared file HAS these fields on every entry written before the sidecar existed. Taking the larger of
2769
+ * the two is what makes the move lossless: the history already recorded stays, and it stops growing there.
2770
+ */
2771
+ async loadUsage(entries) {
2772
+ try {
2773
+ const raw = await readFile2(this.usageFile(), "utf8");
2774
+ const parsed = JSON.parse(raw);
2775
+ if (parsed && typeof parsed === "object") this.usage = parsed;
2776
+ } catch {
2777
+ }
2778
+ for (const e of entries) {
2779
+ const u = this.usage[e.id] ?? {};
2780
+ const injections = Math.max(u.injections ?? 0, e.injections ?? 0);
2781
+ const observed = Math.max(u.observedInjections ?? 0, e.observedInjections ?? 0);
2782
+ if (injections || observed) {
2783
+ this.usage[e.id] = { injections, observedInjections: observed };
2784
+ e.injections = injections;
2785
+ e.observedInjections = observed;
2786
+ }
2787
+ }
2788
+ }
2789
+ /** Adds one name to an existing ignore list, once. Best-effort: an unreadable file is left alone. */
2790
+ async ensureIgnored(file, name) {
2791
+ try {
2792
+ const raw = await readFile2(file, "utf8");
2793
+ if (raw.split("\n").some((l) => l.trim() === name)) return;
2794
+ await writeFile2(file, raw.endsWith("\n") ? `${raw}${name}
2795
+ ` : `${raw}
2796
+ ${name}
2797
+ `, "utf8");
2798
+ } catch {
2799
+ }
2800
+ }
2801
+ /** Best-effort, like every other save here: a lost count is a weaker heuristic, not a lost memory. */
2802
+ async persistUsage() {
2803
+ if (this.deferred) return;
2804
+ try {
2805
+ const dir = dirname2(this.file);
2806
+ await mkdir(dir, { recursive: true });
2807
+ if (existsSync(join3(dir, ".gitignore"))) await this.ensureIgnored(join3(dir, ".gitignore"), USAGE_FILE);
2808
+ await writeAtomic(this.usageFile(), JSON.stringify(this.usage));
2809
+ } catch {
2810
+ }
2811
+ }
2812
+ async persist() {
2813
+ if (this.deferred) {
2814
+ telemetry().event("memory.deferred", {
2815
+ "hc.memory.pending": this.pending.length,
2816
+ "hc.memory.file": this.file
2817
+ });
2818
+ return;
2819
+ }
2820
+ const dir = dirname2(this.file);
2821
+ await mkdir(dir, { recursive: true });
2822
+ const gi = join3(dir, ".gitignore");
2823
+ if (!existsSync(gi)) {
2824
+ await writeFile2(gi, `# horse-code: local state stays out of git; memory.jsonl + skills are shared
2825
+ config.json
2826
+ sources.json
2827
+ worktrees/
2828
+ last-turn.json
2829
+ ${USAGE_FILE}
2830
+ `, "utf8");
2831
+ } else {
2832
+ await this.ensureIgnored(gi, USAGE_FILE);
2833
+ }
2834
+ const ga = join3(dir, ".gitattributes");
2835
+ if (!existsSync(ga)) {
2836
+ await writeFile2(ga, "# Append-only: keep both sides of a merge, then let the dedupe pass reconcile them.\nmemory.jsonl merge=union\n", "utf8");
2837
+ }
2838
+ if (this.cache === void 0) return;
2839
+ await writeAtomic(this.file, this.cache.map(({ injections: _i, observedInjections: _o, ...shared }) => JSON.stringify(shared)).join("\n") + "\n");
2840
+ }
2841
+ /**
2842
+ * Remember a fact (anchors/tags auto-derived). A new fact that supersedes existing same-topic facts
2843
+ * replaces them (returned in `superseded`) so contradictions don't accumulate.
2844
+ */
2845
+ async add(text, kind = "fact", opts = {}) {
2846
+ return this.serialize(async () => {
2847
+ await this.load();
2848
+ const t = text.trim();
2849
+ if (!t) return { ok: false, error: "empty memory" };
2850
+ if (this.cache.some((e) => e.text === t)) return { ok: false, error: "already remembered" };
2851
+ const anchors = deriveAnchors(t);
2852
+ const hashes = hashAnchors(anchors, this.anchorFs);
2853
+ const persistence = opts.persistence ?? (kind === "rule" ? "permanent" : "long");
2854
+ const entry = {
2855
+ id: this.mintId(),
2856
+ text: t,
2857
+ anchors,
2858
+ tags: deriveTags(t, anchors),
2859
+ createdAt: this.now(),
2860
+ uses: 0,
2861
+ kind,
2862
+ persistence,
2863
+ ...persistence === "short" ? { expiresAt: this.now() + SHORT_TTL_MS } : {},
2864
+ ...opts.audience?.length ? { audience: opts.audience } : {},
2865
+ ...opts.learnedBy ? { learnedBy: opts.learnedBy } : {},
2866
+ // Stored only when the writer states them: an absent field means "the defaults for this kind apply",
2867
+ // which is exactly right for a memory the user dictated.
2868
+ ...opts.importance !== void 0 ? { importance: opts.importance } : {},
2869
+ ...opts.confidence !== void 0 ? { confidence: opts.confidence } : {},
2870
+ ...Object.keys(hashes).length ? { anchorHashes: hashes } : {}
2871
+ };
2872
+ const sameKind = (e) => (e.kind ?? "fact") === kind;
2873
+ const superseded = this.cache.filter((e) => sameKind(e) && supersedes(entry, e)).map((e) => e.text);
2874
+ this.cache = this.cache.filter((e) => !(sameKind(e) && supersedes(entry, e)));
2875
+ this.cache.push(entry);
2876
+ if (this.deferred) this.pending.push(entry);
2877
+ await this.persist();
2878
+ return { ok: true, entry, superseded };
2879
+ });
2880
+ }
2881
+ /** Reinforce a memory the model actually cited (bumps its use count → ranks higher on future ties). */
2882
+ async reinforce(id) {
2883
+ return this.serialize(async () => {
2884
+ await this.load();
2885
+ const e = this.cache.find((m) => m.id === id);
2886
+ if (!e) return;
2887
+ e.uses = (e.uses ?? 0) + 1;
2888
+ await this.persist();
2889
+ });
2890
+ }
2891
+ /** Forget the 1-based N-th memory. Returns the removed entry text, or undefined if out of range. */
2892
+ async remove(n) {
2893
+ return this.serialize(async () => {
2894
+ await this.load();
2895
+ if (n < 1 || n > this.cache.length) return void 0;
2896
+ const [removed] = this.cache.splice(n - 1, 1);
2897
+ await this.persist();
2898
+ return removed.text;
2899
+ });
2900
+ }
2901
+ };
2902
+
2903
+ // src/engine/turn-effect.ts
2904
+ import { readFile as readFile3, mkdir as mkdir2 } from "fs/promises";
2905
+ import { existsSync as existsSync2 } from "fs";
2906
+ import { dirname as dirname3, join as join4 } from "path";
2907
+ var MAX_SNAPSHOT_BYTES = 512e3;
2908
+ var MAX_SNAPSHOT_FILES = 40;
2909
+ var TURN_FILE = join4(".horsecode", "last-turn.json");
2910
+ var path = (cwd) => join4(stateRoot(cwd), TURN_FILE);
2911
+ async function snapshot(cwd, file) {
2912
+ const abs = join4(stateRoot(cwd), file);
2913
+ if (!existsSync2(abs)) return { file, created: true };
2914
+ try {
2915
+ const before = await readFile3(abs, "utf8");
2916
+ if (before.length > MAX_SNAPSHOT_BYTES) return { file, created: false };
2917
+ return { file, before, created: false };
2918
+ } catch {
2919
+ return { file, created: false };
2920
+ }
2921
+ }
2922
+ async function recordTurn(cwd, effect, now = Date.now()) {
2923
+ const kept = effect.files.slice(0, MAX_SNAPSHOT_FILES);
2924
+ const dropped = effect.files.slice(MAX_SNAPSHOT_FILES).map((f) => f.file);
2925
+ const rec = {
2926
+ version: 1,
2927
+ at: now,
2928
+ ...effect,
2929
+ files: kept,
2930
+ unsnapshotted: [...effect.unsnapshotted, ...dropped]
2931
+ };
2932
+ const p = path(cwd);
2933
+ await mkdir2(dirname3(p), { recursive: true });
2934
+ await writeAtomic(p, `${JSON.stringify(rec, null, 2)}
2935
+ `);
2936
+ }
2937
+ async function lastTurn(cwd) {
2938
+ try {
2939
+ const raw = JSON.parse(await readFile3(path(cwd), "utf8"));
2940
+ return raw?.version === 1 && Array.isArray(raw.files) ? raw : void 0;
2941
+ } catch {
2942
+ return void 0;
2943
+ }
2944
+ }
2945
+ async function clearTurn(cwd) {
2946
+ try {
2947
+ await writeAtomic(path(cwd), `${JSON.stringify({ version: 1, at: 0, prompt: "", kind: "none", files: [], unsnapshotted: [] })}
2948
+ `);
2949
+ } catch {
2950
+ }
2951
+ }
2952
+ async function undoTurn(cwd, effect) {
2953
+ const out = { restored: [], removed: [], failed: [] };
2954
+ if (effect?.kind === "branch") {
2955
+ out.refused = `The last turn's work is on the branch \`${effect.branch ?? "?"}\`, not in your working tree. Nothing here was overwritten, so there is nothing to restore \u2014 drop the branch yourself if you want it gone.`;
2956
+ return out;
2957
+ }
2958
+ if (!effect || effect.kind === "none" || !effect.files.length) {
2959
+ out.refused = "There is nothing recorded to undo \u2014 the last turn changed no files in your working tree.";
2960
+ return out;
2961
+ }
2962
+ const { rm: rm3 } = await import("fs/promises");
2963
+ for (const f of effect.files) {
2964
+ const abs = join4(stateRoot(cwd), f.file);
2965
+ try {
2966
+ if (f.created) {
2967
+ await rm3(abs, { force: true });
2968
+ out.removed.push(f.file);
2969
+ continue;
2970
+ }
2971
+ if (f.before === void 0) {
2972
+ out.failed.push({ file: f.file, error: "too large to snapshot \u2014 restore it from git" });
2973
+ continue;
2974
+ }
2975
+ await mkdir2(dirname3(abs), { recursive: true });
2976
+ await writeAtomic(abs, f.before);
2977
+ out.restored.push(f.file);
2978
+ } catch (e) {
2979
+ out.failed.push({ file: f.file, error: e instanceof Error ? e.message : String(e) });
2980
+ }
2981
+ }
2982
+ return out;
2983
+ }
2984
+ function describeForContext(effect) {
2985
+ if (!effect || effect.kind === "none" || !effect.files.length && !effect.branch) return void 0;
2986
+ if (effect.kind === "branch") {
2987
+ return `Your previous turn produced work on the branch \`${effect.branch}\` for: "${effect.prompt}".`;
2988
+ }
2989
+ const names = effect.files.map((f) => f.file).join(", ");
2990
+ return `Your previous turn wrote ${names} in the user's working tree, for: "${effect.prompt}". The previous contents are recorded, so a request to undo or revert refers to THESE files.`;
2991
+ }
2992
+ function describeUndo(r) {
2993
+ if (r.refused) return r.refused;
2994
+ const bits = [];
2995
+ if (r.restored.length) bits.push(`**Restored** ${r.restored.map((f) => `\`${f}\``).join(", ")} to the version before the last change.`);
2996
+ if (r.removed.length) bits.push(`**Removed** ${r.removed.map((f) => `\`${f}\``).join(", ")} \u2014 the last turn created ${r.removed.length === 1 ? "it" : "them"}.`);
2997
+ if (r.failed.length) {
2998
+ bits.push(`\u26A0\uFE0F Could not restore ${r.failed.map((f) => `\`${f.file}\` (${f.error})`).join(", ")}.`);
2999
+ }
3000
+ return bits.join("\n\n");
3001
+ }
3002
+
3003
+ // src/engine/upstream.ts
3004
+ import { execFileSync } from "child_process";
3005
+ import { existsSync as existsSync6 } from "fs";
3006
+ import { relative as relative3, dirname as dirname4 } from "path";
3007
+
3008
+ // src/engine/refiner.ts
3009
+ import { z as z5 } from "zod";
3010
+ var RefinerSchema = z5.object({
3011
+ refinedPrompt: z5.string().describe("The refined instruction, ALWAYS in English \u2014 translate from the user's language if needed; never output the user's original language here."),
3012
+ /**
3013
+ * The choice that decides what a run COSTS, described where it is made.
3014
+ *
3015
+ * The enum carried no descriptions at all: every definition lived in one dense paragraph of the system
3016
+ * prompt, competing with the rules for refinedPrompt, language and title. Measured live — "canlı db ve loki
3017
+ * kanıtlarını da sorgula ve işle", refined by this same call into "Query live database and Loki logs, and
3018
+ * document the evidence in the test report", was classified `feature`. It then ran constitution and
3019
+ * brainstorm and opened `specs/006-db-loki-evidence` before anyone noticed that producing a test report
3020
+ * needs no specification. The prompt's own words for `verify` describe that request exactly; they were just
3021
+ * nowhere near the field being filled in.
3022
+ */
3023
+ intent: z5.enum(["chat", "feature", "bugfix", "govern", "undo", "verify"]).describe(
3024
+ "What the request PRODUCES, not what it mentions. `verify`: a record of what EXISTING software DID \u2014 running scenarios, querying the database or logs for evidence, writing or extending a test report. Anything whose output is findings rather than changed behaviour is verify, including a follow-up that only adds more evidence to a report already being written. `feature`: new or changed behaviour in the product. `bugfix`: existing behaviour is wrong and must be corrected. `govern`: the output is a governing document (constitution, conventions), no source changes. `undo`: reverse what the previous turn did. `chat`: a question or conversation, nothing to build. A verify request never needs a specification or a plan: if the answer is 'run it and write down what happened', it is verify."
3025
+ ),
3026
+ // The natural language the user wrote in (English name, e.g. "Turkish") → the coach replies in it.
3027
+ language: z5.string().default("English"),
3028
+ // A concise 2-5 word English kebab-case summary → used as the worktree/branch name (e.g. "add-login-page").
3029
+ title: z5.string().default("task")
3030
+ });
3031
+ async function runRefiner(deps, prompt, history = []) {
3032
+ const resolved = deps.roleRegistry.resolve("refiner");
3033
+ const tools = new ToolRegistry();
3034
+ tools.register(buildSkillTool(deps.skillRegistry));
3035
+ const opts = {
3036
+ provider: deps.provider,
3037
+ ...resolved,
3038
+ tools,
3039
+ messages: [...history, { role: "user", content: prompt }],
3040
+ permission: deps.permission,
3041
+ approve: deps.approve,
3042
+ cwd: ".",
3043
+ signal: deps.signal
3044
+ };
3045
+ return runStructuredRole(opts, RefinerSchema);
3046
+ }
3047
+ function routeIntent(intent) {
3048
+ if (intent === "chat") return "chat";
3049
+ if (intent === "undo") return "undo";
3050
+ if (intent === "verify") return "verify";
3051
+ return intent === "govern" ? "govern" : "pipeline";
3052
+ }
3053
+
3054
+ // src/engine/compaction.ts
3055
+ var estTokens = (s) => Math.ceil(s.length / 4);
3056
+ function historyTokens(history) {
3057
+ return history.reduce((a, m) => a + estTokens(m.content), 0);
3058
+ }
3059
+ var charsOf = (history) => history.reduce((a, m) => a + m.content.length, 0);
3060
+ var summaryMsg = (summary) => ({ role: "user", content: `[Summary of the earlier conversation]
3061
+ ${summary}` });
3062
+ var render = (history) => history.map((m) => `${m.role}: ${m.content}`).join("\n\n");
3063
+ function cacheValid(cache, history, upTo) {
3064
+ return !!cache && cache.coveredCount > 0 && cache.coveredCount <= upTo && charsOf(history.slice(0, cache.coveredCount)) === cache.coveredChars;
3065
+ }
3066
+ async function compactHistory(history, opts, cache) {
3067
+ if (history.length <= opts.keepRecent + 1) return { messages: history, cache };
3068
+ if (historyTokens(history) <= opts.maxTokens) return { messages: history, cache };
3069
+ const ancientEnd = history.length - opts.keepRecent;
3070
+ const recent = history.slice(ancientEnd);
3071
+ const base = cacheValid(cache, history, ancientEnd) ? { coveredCount: cache.coveredCount, summary: cache.summary } : { coveredCount: 0, summary: "" };
3072
+ const delta = history.slice(base.coveredCount, ancientEnd);
3073
+ if (base.coveredCount > 0 && historyTokens(delta) < opts.reSummarizeTokens) {
3074
+ return { messages: [summaryMsg(base.summary), ...delta, ...recent], cache };
3075
+ }
3076
+ const conversation = base.summary ? `Existing summary:
3077
+ ${base.summary}
3078
+
3079
+ Additional earlier turns to fold in:
3080
+ ${render(delta)}` : render(delta);
3081
+ const summary = await opts.summarize(conversation);
3082
+ const next = { coveredCount: ancientEnd, coveredChars: charsOf(history.slice(0, ancientEnd)), summary };
3083
+ return { messages: [summaryMsg(summary), ...recent], cache: next };
3084
+ }
3085
+
3086
+ // src/engine/coach.ts
3087
+ var COMPACT_MAX_TOKENS = 1e5;
3088
+ var COMPACT_KEEP_RECENT = 8;
3089
+ var COMPACT_RESUMMARIZE_TOKENS = 4e3;
3090
+ async function summarizeConversation(deps, conversation) {
3091
+ const model = deps.roleRegistry.peekModel("refiner") || deps.roleRegistry.peekModel("coach");
3092
+ const opts = {
3093
+ provider: deps.provider,
3094
+ model,
3095
+ systemPrompt: "You compress earlier conversation turns into a concise briefing. PRESERVE verbatim-important facts: decisions made, corrections, errors encountered, file paths, names, and the user's stated preferences. Drop pleasantries and filler. Output terse bullet points, no preamble.",
3096
+ tools: new ToolRegistry(),
3097
+ messages: [{ role: "user", content: `Summarize the earlier conversation below:
3098
+
3099
+ ${conversation}` }],
3100
+ permission: deps.permission,
3101
+ approve: deps.approve,
3102
+ cwd: ".",
3103
+ signal: deps.signal
3104
+ /**
3105
+ * Deliberately no `onSay`.
3106
+ *
3107
+ * This one is not talking to anyone: it compresses earlier turns into a briefing that goes back into the
3108
+ * coach's own context. It has no tools, so it produces exactly one message, and that message IS the
3109
+ * summary — printing it would show the user a condensed replay of the conversation they just had.
3110
+ */
3111
+ };
3112
+ const msg = await runToCompletion(opts);
3113
+ return msg.content;
3114
+ }
3115
+ async function runCoachChat(deps, prompt, cwd, history = [], language, images) {
3116
+ const { model, fallbacks, systemPrompt, onExhausted, onFallback } = deps.roleRegistry.resolve("coach");
3117
+ const pins = deps.pins?.() ?? [];
3118
+ const pinBlock = pins.length ? `
3119
+
3120
+ User pins (always honor these):
3121
+ ${pins.map((p) => `- ${p}`).join("\n")}` : "";
3122
+ const context = `
3123
+
3124
+ Context: this session is powered by the "${model}" model.` + (language ? ` Respond in ${language}.` : "") + ` When it helps, end your reply with a <nextsteps> block: 2-4 short, concrete follow-up actions the user might pick next, one per line prefixed with "- ". Omit the block when there is no useful next step. If the user states a durable BEHAVIORAL RULE about HOW you should work \u2014 language, style, conventions, or an "always/never" directive (e.g. "always answer in Turkish", "keep code comments in English", "always use pnpm") \u2014 emit a <rule> block with 1-3 short rules, one per line prefixed with "- ". Separately, if the user states a durable FACT worth recalling (an endpoint, version, or decision \u2014 e.g. "the API base is X", "we target Node 22"), emit a <remember> block with 1-3 short facts, one per line prefixed with "- ". Never remember transient, trivial, or one-off things. Separately, if the user corrects you, points out a mistake, or an approach fails and is then fixed, emit a <lesson> block: 1-2 short lessons, each stating what went wrong AND the correct approach, one per line prefixed with "- ". Only for genuine corrections or failures \u2014 not routine answers.` + pinBlock;
3125
+ const { messages: compacted, cache } = await compactHistory(
3126
+ history,
3127
+ {
3128
+ maxTokens: COMPACT_MAX_TOKENS,
3129
+ keepRecent: COMPACT_KEEP_RECENT,
3130
+ reSummarizeTokens: COMPACT_RESUMMARIZE_TOKENS,
3131
+ summarize: (conversation) => summarizeConversation(deps, conversation)
3132
+ },
3133
+ deps.compactionState?.value
3134
+ );
3135
+ if (deps.compactionState) deps.compactionState.value = cache;
3136
+ const load = historyTokens(compacted) / COMPACT_MAX_TOKENS;
3137
+ const hints = memoryHints(deps, prompt, { load, role: "coach" });
3138
+ const memoryMsg = hints.message ? [{ role: "user", content: hints.message }] : [];
3139
+ const opts = {
3140
+ provider: deps.provider,
3141
+ model,
3142
+ fallbacks,
3143
+ systemPrompt: systemPrompt + context,
3144
+ onExhausted,
3145
+ onFallback,
3146
+ tools: readOnlyRegistry(deps, { remember: true, mcp: true, gitWrite: true }),
3147
+ remember: deps.rememberFact,
3148
+ messages: [...compacted, ...memoryMsg, { role: "user", content: prompt, ...images?.length ? { images } : {} }],
3149
+ permission: deps.permission,
3150
+ approve: deps.approve,
3151
+ cwd,
3152
+ signal: deps.signal,
3153
+ inbox: deps.inbox,
3154
+ // "by-the-way" notes typed mid-run are folded into the coach's turn
3155
+ /**
3156
+ * What the coach says while it works, not only its verdict.
3157
+ *
3158
+ * A read-only exploration turn can run for minutes over a dozen tool calls, and until it finished the
3159
+ * screen held a spinner and nothing else — no way to tell a coach reading the right files from one that
3160
+ * is not, and no chance to redirect it before the tokens were spent.
3161
+ */
3162
+ // …but not the final message: the caller renders that as the reply.
3163
+ ...deps.note ? { onSay: (t, final) => {
3164
+ if (!final) deps.note?.(t);
3165
+ } } : {}
3166
+ };
3167
+ const msg = await runToCompletion(opts);
3168
+ reinforceUsed(deps, hints.ids, msg.content, "coach");
3169
+ return msg.content;
3170
+ }
3171
+
3172
+ // src/engine/next-steps.ts
3173
+ function extractListBlock(raw, tag) {
3174
+ const re = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, "i");
3175
+ const m = raw.match(re);
3176
+ if (!m || m.index === void 0) return { text: raw.trim(), items: [] };
3177
+ const items = m[1].split("\n").map((l) => l.replace(/^\s*(?:[-*•]|\d+[.)])\s*/, "").trim()).filter(Boolean).slice(0, 6);
3178
+ const text = (raw.slice(0, m.index) + raw.slice(m.index + m[0].length)).trim();
3179
+ return { text, items };
3180
+ }
3181
+
3182
+ // src/engine/review-notes.ts
3183
+ import { appendFileSync, existsSync as existsSync3, writeFileSync } from "fs";
3184
+ import { join as join5 } from "path";
3185
+ var REVIEW_NOTES_FILE = "review-notes.md";
3186
+ var HEADER = "# Deferred review notes\n\nMedium/low findings the review deliberately did not block on. They are carried to the next stage as context (spec \u2192 plan \u2192 tasks) and, for code findings, adjudicated in the PR revision pass. This file is the durable record so nothing is silently lost \u2014 prompt context alone is not a guarantee.\n";
3187
+ function appendReviewNotes(featureDir, notes) {
3188
+ if (!notes.length) return false;
3189
+ try {
3190
+ const file = join5(featureDir, REVIEW_NOTES_FILE);
3191
+ if (!existsSync3(file)) writeFileSync(file, HEADER, "utf8");
3192
+ appendFileSync(file, `
3193
+ ${notes.map((n) => `- ${n}`).join("\n")}
3194
+ `, "utf8");
3195
+ return true;
3196
+ } catch {
3197
+ return false;
3198
+ }
3199
+ }
3200
+
3201
+ // src/speckit/phases.ts
3202
+ import { existsSync as existsSync4 } from "fs";
3203
+ import { relative } from "path";
3204
+ var SKIP = "The workspace is already scaffolded \u2014 do NOT run any shell scripts. Use write_file to write the output file exactly at the path given below.";
3205
+ var PHASE_MAX_TURNS = 200;
3206
+ var BRAINSTORM_COMMAND = "You are the brainstormer. Produce a decided design brief for the request below, then write it to the file named in the message. Explore the repo before proposing anything, ask only questions that change the design, and have the user choose between real alternatives.";
3207
+ async function runRole(p, role, command, message, extraTools = false, subject) {
3208
+ const law = p.deps.home ? await constitutionNote({ ...p.deps, home: p.deps.home, note: p.deps.note }, p.workdir, { role, title: subject }) : "";
3209
+ const { role: agentRole, model, fallbacks, onExhausted, onFallback } = p.deps.roleRegistry.fallbackOpts(role);
3210
+ const tools = writerRegistry(p.deps.skillRegistry, [
3211
+ ...extraTools ? [buildAskUserTool(p.askUser, (q) => normalizeQuestion(p.deps, q))] : [],
3212
+ ...contextTools(p.deps),
3213
+ // the spec and the plan are written about a codebase — let them see it
3214
+ // A phase that reads the codebase to write about it is exactly where durable facts are found.
3215
+ buildRememberTool(p.deps.rememberFact)
3216
+ ]);
3217
+ const hints = memoryHints(p.deps, subject ?? message, { role });
3218
+ const routed = routeSkills(subject ?? message, p.deps.skillRegistry, [], { role, placed: placedSkills() });
3219
+ if (routed.length) p.deps.note?.(`\u{1F4CE} \`${role}\` \xB7 ${routed.map((m) => `**${m.name}**`).join(", ")}`);
3220
+ const opts = {
3221
+ provider: p.deps.provider,
3222
+ role: agentRole,
3223
+ model,
3224
+ fallbacks,
3225
+ onExhausted,
3226
+ onFallback,
3227
+ // What a specification or plan may say is constrained by the project's own stack and boundaries.
3228
+ systemPrompt: (routed.length ? applySkills(`${command}
3229
+
3230
+ ${SKIP}${p.deps.roleRegistry.ruleSuffix()}`, routed.map((m) => m.name), p.deps.skillRegistry) : `${command}
3231
+
3232
+ ${SKIP}${p.deps.roleRegistry.ruleSuffix()}`) + law + projectToolsNote(tools.list(), !!loadGraphSync(p.workdir)) + BATCH_TOOLS_NOTE + `
3233
+
3234
+ ${WHAT_IT_COST}` + respondIn(p.language),
3235
+ tools,
3236
+ maxTurns: PHASE_MAX_TURNS,
3237
+ // Project memory (conventions/decisions/lessons) reaches the authoring roles too, not just the coach.
3238
+ /**
3239
+ * A screenshot named in the request comes with it, here as everywhere else.
3240
+ *
3241
+ * Measured from a live run: the request carried the path of a pasted screenshot, no image was attached,
3242
+ * and the analyst did the only thing left to it — `read_file` on a PNG, which cannot read one. The
3243
+ * evidence the user handed over never reached the model that needed it.
3244
+ */
3245
+ messages: (hints.message ? [{ role: "user", content: hints.message }] : []).concat([
3246
+ { role: "user", ...handedOver(message, p.workdir) }
3247
+ ]),
3248
+ permission: p.deps.permission,
3249
+ approve: p.deps.approve,
3250
+ cwd: p.workdir,
3251
+ signal: p.deps.signal,
3252
+ onActivity: p.deps.onActivity,
3253
+ onLiveActivity: p.deps.onLiveActivity,
3254
+ onWrite: (path2) => commitFile(p.deps, p.workdir, path2).then(() => {
3255
+ }),
3256
+ // per-write conventional commit
3257
+ /**
3258
+ * A phase can be interrupted with a correction.
3259
+ *
3260
+ * These are the longest interactive stretches in the tool — writing a constitution, a spec, a plan — and
3261
+ * they were the only ones a mid-run note could not reach. "Actually, keep it to one page" arriving four
3262
+ * minutes in is worth more than the same sentence after the document is finished and reviewed.
3263
+ */
3264
+ inbox: p.deps.inbox,
3265
+ /**
3266
+ * What the role says reaches the user.
3267
+ *
3268
+ * A phase used to show tool cards and an artefact and nothing else. Reported twice in the same shape:
3269
+ * the analyst offered "I'll show you the skeleton first and wait for approval", the user chose it, the
3270
+ * analyst wrote the skeleton — and asked "do you approve the skeleton above?" with nothing above it.
3271
+ * The model did exactly what it promised; the words were thrown away.
3272
+ */
3273
+ ...p.deps.note ? { onSay: p.deps.note } : {}
3274
+ };
3275
+ const said = await runToCompletion(opts);
3276
+ reinforceUsed(p.deps, hints.ids, said.content, role);
3277
+ }
3278
+ async function runConstitution(p, request) {
3279
+ const rel = relative(p.workdir, constitutionPath(p.workdir));
3280
+ const asked = request?.trim() ? `What the user asked for:
3281
+ "${request.trim()}"
3282
+
3283
+ ` : "";
3284
+ const exists = existsSync4(constitutionPath(p.workdir));
3285
+ const msg = exists ? `${asked}The project already has a constitution at "${rel}". AMEND it: make the smallest change that satisfies the request, in the document's own language and style, and leave everything else exactly as it is. Bump the version and its amendment record the way the document itself prescribes. Do not reshape it to a template, do not re-derive principles it already ratified, and do not ask what to change when the request already says. If the request genuinely cannot be expressed as an amendment, say why in one sentence and stop.` : `${asked}Establish the project constitution. Ask the user about core principles with ask_user if needed.
3286
+ Follow this template:
3287
+
3288
+ ${p.templates.template("constitution")}
3289
+
3290
+ Write it to "${rel}".`;
3291
+ await runRole(p, "analyst", p.templates.command("constitution"), msg, true, request);
3292
+ }
3293
+ async function runBrainstorm(p, paths, prompt) {
3294
+ const rel = relative(p.workdir, paths.brainstorm);
3295
+ const msg = `Request: "${prompt}".
3296
+
3297
+ Explore this repository first, then decide the approach WITH the user, then write the decision to "${rel}".`;
3298
+ await runRole(p, "brainstormer", BRAINSTORM_COMMAND, msg, true, prompt);
3299
+ }
3300
+ function specifyMessage(prompt, specRel, briefRel, hasBrief, template, feedback) {
3301
+ if (feedback?.length) {
3302
+ return `Revise the spec at "${specRel}" with these reviewer notes:
3303
+ ${feedback.map((f) => `- ${f}`).join("\n")}
3304
+ Original request: ${prompt}`;
3305
+ }
3306
+ const source = hasBrief ? `Read "${briefRel}" FIRST \u2014 the approach was already decided with the user there, and the spec must honor it (do not re-litigate the choice or reintroduce a rejected alternative).
3307
+ ` : `There is no design brief for this work \u2014 none was written, and there is nothing to look for. Work from the request itself and from the code, and decide what the spec needs by reading the repository.
3308
+ `;
3309
+ return `Feature request: "${prompt}". ${source}Ask clarifying questions with ask_user only if strictly necessary.
3310
+ Follow this template:
3311
+
3312
+ ${template}
3313
+
3314
+ Write the spec to "${specRel}".`;
3315
+ }
3316
+ async function runSpecify(p, paths, prompt, feedback) {
3317
+ const rel = relative(p.workdir, paths.spec);
3318
+ const brief = relative(p.workdir, paths.brainstorm);
3319
+ const msg = specifyMessage(prompt, rel, brief, existsSync4(paths.brainstorm), p.templates.template("spec"), feedback);
3320
+ await runRole(p, "analyst", p.templates.command("specify"), msg, true, prompt);
3321
+ }
3322
+ async function runPlan(p, paths, feedback, carryOver) {
3323
+ const rel = relative(p.workdir, paths.plan);
3324
+ const specRel = relative(p.workdir, paths.spec);
3325
+ const cRel = relative(p.workdir, constitutionPath(p.workdir));
3326
+ const carried = carryOver?.length ? `
3327
+
3328
+ Known non-blocking notes carried over from the spec review \u2014 address them in the plan where they apply (they are context, not blockers):
3329
+ ${carryOver.map((c) => `- ${c}`).join("\n")}` : "";
3330
+ const msg = feedback?.length ? `Revise the plan at "${rel}" with these reviewer notes:
3331
+ ${feedback.map((f) => `- ${f}`).join("\n")}` : `Read the spec "${specRel}" and the constitution "${cRel}" (if present).
3332
+ Follow this template:
3333
+
3334
+ ${p.templates.template("plan")}
3335
+
3336
+ Write the plan to "${rel}".${carried}`;
3337
+ await runRole(p, "planner", p.templates.command("plan"), msg);
3338
+ }
3339
+ function tasksMessage(planRel, tasksRel, template, carryOver = []) {
3340
+ const carried = carryOver.length ? `
3341
+
3342
+ Known non-blocking notes carried over from the earlier reviews \u2014 fold them into a task only where they genuinely apply (they are context, not new requirements):
3343
+ ${carryOver.map((c) => `- ${c}`).join("\n")}` : "";
3344
+ return `Read the plan "${planRel}" and break it into an actionable task list.
3345
+ This codebase ALREADY EXISTS. The template below is written for a project being created from scratch, so its Setup and Foundational phases describe work that was done years ago here, and its Polish phase lists things that are not tasks. Take the template's FORMAT \u2014 ids, [P] markers, file paths, phases \u2014 and let the plan decide what is actually in it.
3346
+ Every task must leave the repository DIFFERENT \u2014 code, a test, a document. Looking at something is not a task: an implementer reads the code, checks the versions and finds its way around as part of doing the work, so "verify X", "inspect Y", "confirm Z" belong inside the task that needs the answer, not beside it. If the only thing a task would deliver is knowing something, it is not on the list.
3347
+ Nor is running a command: linting, formatting, building and typechecking are how a task is known to be FINISHED. Put them in the acceptance criteria of the tasks that changed the code \u2014 the implementer runs them there anyway \u2014 instead of giving each one a task of its own.
3348
+ And one file is usually one task. Tasks that write the same file cannot run at the same time, so splitting a single file across several of them buys nothing and pays for a full implement-and-review round each time. Split by what is genuinely independent, not by what is separately describable \u2014 the template's "one entity per task" examples are about creating new files, not changing existing ones.
3349
+ Follow this template:
3350
+
3351
+ ${template}
3352
+
3353
+ Write the tasks to "${tasksRel}".${carried}`;
3354
+ }
3355
+ async function runTasks(p, paths, carryOver) {
3356
+ const rel = relative(p.workdir, paths.tasks);
3357
+ const planRel = relative(p.workdir, paths.plan);
3358
+ const msg = tasksMessage(planRel, rel, p.templates.template("tasks"), carryOver ?? []);
3359
+ await runRole(p, "project-manager", p.templates.command("tasks"), msg);
3360
+ }
3361
+
3362
+ // src/speckit/clarify.ts
3363
+ import { existsSync as existsSync5, readFileSync as readFileSync2 } from "fs";
3364
+ import { relative as relative2 } from "path";
3365
+ import { z as z6 } from "zod";
3366
+ var ClarifyStepSchema = z6.object({
3367
+ // The single most important clarifying question, or null when the spec is sufficiently clear.
3368
+ nextQuestion: z6.string().nullable()
3369
+ });
3370
+ async function runClarify(p, paths, maxRounds = 5) {
3371
+ const specRel = relative2(p.workdir, paths.spec);
3372
+ const specText = () => existsSync5(paths.spec) ? readFileSync2(paths.spec, "utf8") : "";
3373
+ const before = specText();
3374
+ const { role: agentRole, model, fallbacks, onExhausted, onFallback } = p.deps.roleRegistry.fallbackOpts("analyst");
3375
+ const qa = [];
3376
+ for (let round = 0; round < maxRounds; round++) {
3377
+ const context = qa.length ? `
3378
+
3379
+ Answers so far:
3380
+ ${qa.join("\n")}` : "";
3381
+ const opts = {
3382
+ provider: p.deps.provider,
3383
+ role: agentRole,
3384
+ model,
3385
+ fallbacks,
3386
+ onExhausted,
3387
+ onFallback,
3388
+ systemPrompt: `${p.templates.command("clarify")}
3389
+
3390
+ Ask at most one question per turn.${p.deps.roleRegistry.ruleSuffix()}`,
3391
+ tools: writerRegistry(p.deps.skillRegistry),
3392
+ messages: [{
3393
+ role: "user",
3394
+ content: `Read the spec "${specRel}". Identify the single most important underspecified point and return it as nextQuestion, or null if the spec is clear enough. If you already have an answer below, first update the spec at "${specRel}" (write_file/edit_file) to incorporate it, then decide the next question.${context}`
3395
+ }],
3396
+ permission: p.deps.permission,
3397
+ approve: p.deps.approve,
3398
+ cwd: p.workdir,
3399
+ signal: p.deps.signal,
3400
+ onActivity: p.deps.onActivity
3401
+ };
3402
+ const step = await runStructuredRole(opts, ClarifyStepSchema);
3403
+ if (!step.nextQuestion) break;
3404
+ const structured = extractChoicesFrom(step.nextQuestion);
3405
+ const answer = structured.choices.length >= 2 ? await p.askUser(structured.question, { options: structured.choices }) : await p.askUser(step.nextQuestion);
3406
+ qa.push(`Q: ${step.nextQuestion}
3407
+ A: ${answer}`);
3408
+ }
3409
+ if (qa.length > 0 && specText() === before) {
3410
+ const opts = {
3411
+ provider: p.deps.provider,
3412
+ role: agentRole,
3413
+ model,
3414
+ fallbacks,
3415
+ onExhausted,
3416
+ onFallback,
3417
+ systemPrompt: `${p.templates.command("clarify")}${p.deps.roleRegistry.ruleSuffix()}`,
3418
+ tools: writerRegistry(p.deps.skillRegistry),
3419
+ messages: [{
3420
+ role: "user",
3421
+ content: `The clarification answers below were NOT written into the spec. Update "${specRel}" now with write_file/edit_file so every answer is reflected in the requirements \u2014 this is the only step that persists them.
3422
+
3423
+ ${qa.join("\n\n")}`
3424
+ }],
3425
+ permission: p.deps.permission,
3426
+ approve: p.deps.approve,
3427
+ cwd: p.workdir,
3428
+ signal: p.deps.signal,
3429
+ onActivity: p.deps.onActivity,
3430
+ // Clarify rewrites the spec from the user's own answers; what it says about that is addressed to them.
3431
+ ...p.deps.note ? { onSay: p.deps.note } : {}
3432
+ };
3433
+ await runToCompletion(opts);
3434
+ }
3435
+ }
3436
+
3437
+ // src/engine/upstream.ts
3438
+ var WANTS_WORKTREE = /\bworktree\b/i;
3439
+ function gitSync(cwd) {
3440
+ return (args) => {
3441
+ try {
3442
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
3443
+ } catch {
3444
+ return void 0;
3445
+ }
3446
+ };
3447
+ }
3448
+ async function documentWorkdir(cwd, prompt, ensureWorktree, nameHint) {
3449
+ if (inLinkedWorktree(cwd, gitSync(cwd)) && !WANTS_WORKTREE.test(prompt)) return cwd;
3450
+ return ensureWorktree(nameHint);
3451
+ }
3452
+ function laneCheckpoint(cwd, lane, resume, prompt, r) {
3453
+ const root = sessionBase(cwd);
3454
+ if (root === void 0) return;
3455
+ writeCheckpoint(dirname4(root), {
3456
+ rawPrompt: resume?.rawPrompt ?? prompt,
3457
+ refinedPrompt: r.refinedPrompt,
3458
+ title: r.title,
3459
+ language: r.language,
3460
+ featureSlug: resume?.featureSlug ?? "",
3461
+ done: [],
3462
+ lane,
3463
+ intent: r.intent
3464
+ });
3465
+ }
3466
+ function laneFor(r, prompt, resume) {
3467
+ const intent = routeIntent(r.intent);
3468
+ if (intent !== "chat") return intent;
3469
+ return isContinuePrompt(prompt) && resume?.lane ? resume.lane : intent;
3470
+ }
3471
+ async function runUpstream(deps, ensureWorktree, prompt, askUser, maxRounds, history = [], emit = () => {
3472
+ }, images, resume, hasPreservedWork = false, workingIn) {
3473
+ const PHASE_ROLE = { constitution: "analyst", brainstorm: "brainstormer", specify: "analyst", clarify: "analyst", plan: "planner", tasks: "project-manager" };
3474
+ const emitPhase = (phase) => {
3475
+ const role = PHASE_ROLE[phase];
3476
+ const model = role ? deps.roleRegistry.peekModel(role) : "";
3477
+ emit({ kind: "phase", phase, detail: role ? `${role} \xB7 ${model}` : void 0 });
3478
+ };
3479
+ const priorContext = describeForContext(await lastTurn(process.cwd()));
3480
+ const withPrior = priorContext ? [{ role: "assistant", content: priorContext }, ...history] : history;
3481
+ const r = resume ? { intent: resume.intent ?? "feature", refinedPrompt: resume.refinedPrompt, title: resume.title, language: resume.language } : await runRefiner(deps, prompt, withPrior);
3482
+ emit({ kind: "refined", refinedPrompt: r.refinedPrompt });
3483
+ if (!resume && routeIntent(r.intent) === "chat") {
3484
+ emit({ kind: "phase", phase: "chat" });
3485
+ const raw = await runCoachChat(deps, r.refinedPrompt, ".", withPrior, r.language, images);
3486
+ const ns = extractListBlock(raw, "nextsteps");
3487
+ const ru = extractListBlock(ns.text, "rule");
3488
+ const rm3 = extractListBlock(ru.text, "remember");
3489
+ const ls = extractListBlock(rm3.text, "lesson");
3490
+ return { intent: r.intent, refinedPrompt: r.refinedPrompt, kind: "chat", response: ls.text, nextSteps: ns.items, rules: ru.items, remembered: rm3.items, lessons: ls.items };
3491
+ }
3492
+ if (!resume && routeIntent(r.intent) === "undo") {
3493
+ const cwd = process.cwd();
3494
+ const effect = await lastTurn(cwd);
3495
+ const res = await undoTurn(cwd, effect);
3496
+ if (!res.refused) await clearTurn(cwd);
3497
+ return { intent: r.intent, refinedPrompt: r.refinedPrompt, kind: "undone", report: describeUndo(res) };
3498
+ }
3499
+ if (laneFor(r, prompt, resume) === "verify") {
3500
+ emitPhase("verify");
3501
+ const cwd = await documentWorkdir(process.cwd(), prompt, ensureWorktree, r.title);
3502
+ laneCheckpoint(cwd, "verify", resume, prompt, r);
3503
+ const { runVerify, describeVerify, currentBranchOf } = await import("./verify-WQ3GHION.js");
3504
+ const branch = await currentBranchOf(cwd);
3505
+ const res = await runVerify({
3506
+ deps,
3507
+ workdir: cwd,
3508
+ prompt: r.refinedPrompt,
3509
+ title: r.title,
3510
+ askUser,
3511
+ // The refined prompt is English by design; without this the tester answers a Turkish session in English.
3512
+ language: r.language,
3513
+ note: (text) => emit({ kind: "note", text })
3514
+ });
3515
+ return {
3516
+ intent: r.intent,
3517
+ refinedPrompt: r.refinedPrompt,
3518
+ kind: "verified",
3519
+ report: describeVerify(res, branch, cwd),
3520
+ reportPath: res.reportPath,
3521
+ written: res.reportWritten
3522
+ };
3523
+ }
3524
+ if (laneFor(r, prompt, resume) === "govern") {
3525
+ emitPhase("constitution");
3526
+ const templates2 = await deps.specKit();
3527
+ const cwd = await documentWorkdir(process.cwd(), prompt, ensureWorktree, r.title);
3528
+ laneCheckpoint(cwd, "govern", resume, prompt, r);
3529
+ const p2 = { deps, templates: templates2, workdir: cwd, askUser, ...r.language ? { language: r.language } : {} };
3530
+ const rel = relative3(cwd, constitutionPath(cwd));
3531
+ const before = await snapshot(cwd, rel);
3532
+ await runConstitution(p2, r.refinedPrompt);
3533
+ const path2 = constitutionPath(cwd);
3534
+ const written = existsSync6(path2);
3535
+ if (written) {
3536
+ await recordTurn(cwd, { prompt: r.refinedPrompt, kind: "in-place", files: [before], unsnapshotted: [] });
3537
+ }
3538
+ if (!written) {
3539
+ emit({ kind: "note", text: `\u26A0\uFE0F The constitution phase never wrote \`${relative3(cwd, path2)}\` \u2014 nothing was changed.` });
3540
+ }
3541
+ return { intent: r.intent, refinedPrompt: r.refinedPrompt, kind: "governed", path: path2, written };
3542
+ }
3543
+ if (!resume && !hasPreservedWork && routeIntent(r.intent) === "pipeline") {
3544
+ const cwd = workingIn?.() ?? process.cwd();
3545
+ emitPhase("sizing");
3546
+ const { sizeRequest } = await import("./triage-2J3T5PVQ.js");
3547
+ const size = await sizeRequest(deps, cwd, r.refinedPrompt);
3548
+ let small = size.verdict === "small";
3549
+ if (size.verdict === "unsure") {
3550
+ const { describeSizeDoubt } = await import("./triage-2J3T5PVQ.js");
3551
+ const answer = await askInUserLanguage(
3552
+ deps,
3553
+ askUser,
3554
+ r.language,
3555
+ `${describeSizeDoubt(r.refinedPrompt, size.reason)}
3556
+
3557
+ Which is it?`,
3558
+ [
3559
+ { label: "Small \u2014 just do it", description: "Implemented, reviewed and checked against the acceptance criteria, in your working tree." },
3560
+ { label: "Full piece of work", description: "Spec, plan, tasks, then waves \u2014 on its own branch." }
3561
+ ]
3562
+ );
3563
+ small = /^small/i.test(answer.trim());
3564
+ }
3565
+ if (small) {
3566
+ emitPhase("small change");
3567
+ emit({ kind: "note", text: `\u26A1 Small change \u2014 ${size.reason}. No branch, no spec, no plan.` });
3568
+ const { runSmallChange, describeSmallChange } = await import("./fix-HBBOTUWM.js");
3569
+ const { currentBranchOf } = await import("./verify-WQ3GHION.js");
3570
+ const res = await runSmallChange(deps, cwd, r.title, r.refinedPrompt, size);
3571
+ return {
3572
+ intent: r.intent,
3573
+ refinedPrompt: r.refinedPrompt,
3574
+ kind: "tweaked",
3575
+ report: describeSmallChange(res, size.reason, await currentBranchOf(cwd)),
3576
+ done: res.fixed
3577
+ };
3578
+ }
3579
+ }
3580
+ const workdir = await ensureWorktree(r.title);
3581
+ const templates = await deps.specKit();
3582
+ const p = { deps, templates, workdir, askUser, ...r.language ? { language: r.language } : {} };
3583
+ const root = dirname4(workdir);
3584
+ const prior = readCheckpoint(root);
3585
+ const done = new Set(prior?.done ?? []);
3586
+ const slug = prior?.featureSlug ?? nextFeatureSlug(workdir, r.title);
3587
+ const paths = scaffoldFeature(workdir, slug);
3588
+ const specRel = relative3(workdir, paths.spec);
3589
+ const planRel = relative3(workdir, paths.plan);
3590
+ let carryOver = prior?.carryOver ?? [];
3591
+ const rawPrompt = prior?.rawPrompt ?? prompt;
3592
+ const save = () => writeCheckpoint(root, { rawPrompt, refinedPrompt: r.refinedPrompt, title: r.title, language: r.language, intent: r.intent, featureSlug: slug, done: [...done], carryOver });
3593
+ const mark = (phase) => {
3594
+ done.add(phase);
3595
+ save();
3596
+ };
3597
+ if (done.size > 0) emit({ kind: "note", text: `\u23E9 Resuming \u2014 already done: ${[...done].join(", ")}. Continuing from the next phase.` });
3598
+ save();
3599
+ const ensureWritten = async (file, rel, phase, run, optional = false) => {
3600
+ if (existsSync6(file)) return;
3601
+ emit({ kind: "note", text: `\u26A0\uFE0F The ${phase} phase produced no \`${rel}\` \u2014 retrying it once.` });
3602
+ await run();
3603
+ if (existsSync6(file)) return;
3604
+ if (!optional) throw new Error(`${phase} did not produce ${rel} (the role never wrote the file)`);
3605
+ emit({ kind: "note", text: `\u26A0\uFE0F Continuing without \`${rel}\` \u2014 the ${phase} phase never wrote it.` });
3606
+ };
3607
+ if (!done.has("constitution")) {
3608
+ if (!existsSync6(constitutionPath(workdir))) {
3609
+ emitPhase("constitution");
3610
+ await runConstitution(p);
3611
+ await ensureWritten(constitutionPath(workdir), relative3(workdir, constitutionPath(workdir)), "constitution", () => runConstitution(p), true);
3612
+ await commitStep(deps, workdir, "establish the project constitution");
3613
+ }
3614
+ mark("constitution");
3615
+ }
3616
+ if (!done.has("brainstorm") && !existsSync6(paths.spec)) {
3617
+ emitPhase("brainstorm");
3618
+ await ensureWritten(
3619
+ paths.brainstorm,
3620
+ relative3(workdir, paths.brainstorm),
3621
+ "brainstorm",
3622
+ () => runBrainstorm(p, paths, r.refinedPrompt),
3623
+ true
3624
+ );
3625
+ await commitStep(deps, workdir, "record the design decision");
3626
+ }
3627
+ mark("brainstorm");
3628
+ if (!done.has("spec")) {
3629
+ emitPhase("specify");
3630
+ if (existsSync6(paths.spec)) {
3631
+ emit({ kind: "note", text: `\u23E9 \`${specRel}\` already written \u2014 resuming at its review instead of rewriting it.` });
3632
+ } else {
3633
+ await runSpecify(p, paths, r.refinedPrompt);
3634
+ await ensureWritten(paths.spec, specRel, "specify", () => runSpecify(p, paths, r.refinedPrompt));
3635
+ }
3636
+ const specOut = await runReviewLoop(deps, {
3637
+ stage: "spec",
3638
+ workdir,
3639
+ target: specRel,
3640
+ request: r.refinedPrompt,
3641
+ revise: (fb) => runSpecify(p, paths, r.refinedPrompt, fb),
3642
+ askUser,
3643
+ maxRounds,
3644
+ emit,
3645
+ language: r.language
3646
+ });
3647
+ if (!specOut.approved) return { intent: r.intent, refinedPrompt: r.refinedPrompt, kind: "rejected", stage: "spec" };
3648
+ carryOver = [...carryOver, ...specOut.deferred ?? []];
3649
+ save();
3650
+ if (appendReviewNotes(paths.dir, specOut.deferred ?? [])) emit({ kind: "note", text: `\u{1F4DD} ${specOut.deferred.length} deferred spec note(s) recorded in \`${relative3(workdir, paths.dir)}/review-notes.md\`.` });
3651
+ if (!existsSync6(paths.spec)) throw new Error(`specify did not produce a spec: ${specRel}`);
3652
+ await commitStep(deps, workdir, "add the feature specification");
3653
+ mark("spec");
3654
+ }
3655
+ if (!done.has("clarify")) {
3656
+ emitPhase("clarify");
3657
+ await runClarify(p, paths);
3658
+ await commitStep(deps, workdir, "clarify the feature specification");
3659
+ mark("clarify");
3660
+ }
3661
+ if (!done.has("plan")) {
3662
+ emitPhase("plan");
3663
+ if (existsSync6(paths.plan)) {
3664
+ emit({ kind: "note", text: `\u23E9 \`${planRel}\` already written \u2014 resuming at its review instead of rewriting it.` });
3665
+ } else {
3666
+ await runPlan(p, paths, void 0, carryOver);
3667
+ await ensureWritten(paths.plan, planRel, "plan", () => runPlan(p, paths, void 0, carryOver));
3668
+ }
3669
+ const planOut = await runReviewLoop(deps, {
3670
+ stage: "plan",
3671
+ workdir,
3672
+ target: planRel,
3673
+ request: r.refinedPrompt,
3674
+ revise: (fb) => runPlan(p, paths, fb),
3675
+ askUser,
3676
+ maxRounds,
3677
+ emit,
3678
+ language: r.language
3679
+ });
3680
+ if (!planOut.approved) return { intent: r.intent, refinedPrompt: r.refinedPrompt, kind: "rejected", stage: "plan" };
3681
+ carryOver = [...carryOver, ...planOut.deferred ?? []];
3682
+ save();
3683
+ if (appendReviewNotes(paths.dir, planOut.deferred ?? [])) emit({ kind: "note", text: `\u{1F4DD} ${planOut.deferred.length} deferred plan note(s) recorded in \`${relative3(workdir, paths.dir)}/review-notes.md\`.` });
3684
+ if (!existsSync6(paths.plan)) throw new Error(`plan did not produce a plan: ${planRel}`);
3685
+ await commitStep(deps, workdir, "add the implementation plan");
3686
+ mark("plan");
3687
+ }
3688
+ if (!done.has("tasks")) {
3689
+ emitPhase("tasks");
3690
+ await runTasks(p, paths, carryOver);
3691
+ await ensureWritten(paths.tasks, relative3(workdir, paths.tasks), "tasks", () => runTasks(p, paths, carryOver));
3692
+ await commitStep(deps, workdir, "break the plan into tasks");
3693
+ mark("tasks");
3694
+ }
3695
+ return {
3696
+ intent: r.intent,
3697
+ refinedPrompt: r.refinedPrompt,
3698
+ kind: "approved",
3699
+ specPath: specRel,
3700
+ planPath: planRel,
3701
+ tasksPath: relative3(workdir, paths.tasks)
3702
+ };
3703
+ }
3704
+
3705
+ // src/engine/sync-main.ts
3706
+ var SYNC_RESOLVE_ROUNDS = 2;
3707
+ async function syncMainBranch(deps, session, opts) {
3708
+ const branch = await resolveMainBranch({
3709
+ cwd: deps.manager.projectRoot,
3710
+ git: opts.git,
3711
+ askUser: opts.askUser,
3712
+ phrase: (t) => inUserLanguage(deps, t, opts.language),
3713
+ ...deps.note ? { note: deps.note } : {}
3714
+ });
3715
+ if (!branch) return { status: "skipped", why: "no main branch is set for this project" };
3716
+ const ref = await deps.manager.fetchBranch(branch);
3717
+ if (await deps.manager.containsRef(session, ref)) return { status: "current", branch };
3718
+ const commits = await deps.manager.commitsBehind(session, ref);
3719
+ let merge;
3720
+ try {
3721
+ merge = await deps.manager.mergeRef(session, ref);
3722
+ } catch (e) {
3723
+ const why = e instanceof Error ? e.message.split("\n")[0] : String(e);
3724
+ deps.note?.(`\u26A0 Could not sync \`${branch}\` into this session (${why}) \u2014 continuing on the branch as it is.`);
3725
+ return { status: "skipped", why };
3726
+ }
3727
+ if (merge.status === "merged") {
3728
+ deps.note?.(`\u{1F504} Synced \`${ref}\` into the session branch \u2014 ${commits} commit(s) brought in.`);
3729
+ return { status: "synced", branch, commits, conflicts: [] };
3730
+ }
3731
+ const { conflicted } = await resolveGeneratedConflicts(deps, session, merge.files);
3732
+ if (!conflicted.length) {
3733
+ await deps.manager.commitMerge(session, `chore: sync ${branch} into session branch`);
3734
+ deps.note?.(`\u{1F504} Synced \`${ref}\` \u2014 ${commits} commit(s); generated files re-derived rather than merged.`);
3735
+ return { status: "synced", branch, commits, conflicts: merge.files };
3736
+ }
3737
+ deps.note?.(`\u{1F500} Syncing \`${ref}\` conflicts in ${conflicted.join(", ")} \u2014 operational resolving\u2026`);
3738
+ for (let round = 0; round < SYNC_RESOLVE_ROUNDS; round++) {
3739
+ const notes = round === 0 ? "" : `
3740
+ The last attempt left conflict markers in: ${conflicted.join(", ")}`;
3741
+ await runConflictResolver(deps, session.baseWorktree, conflicted, notes);
3742
+ if (await hasConflictMarkers(session.baseWorktree, conflicted)) continue;
3743
+ await deps.manager.commitMerge(session, `chore: resolve conflicts syncing ${branch} into session branch`);
3744
+ deps.note?.(`\u{1F504} Synced \`${ref}\` \u2014 ${commits} commit(s), ${conflicted.length} conflict(s) resolved.`);
3745
+ return { status: "synced", branch, commits, conflicts: merge.files };
3746
+ }
3747
+ await deps.manager.abortMerge(session);
3748
+ deps.note?.(`\u26A0 Could not merge \`${ref}\` cleanly (${conflicted.join(", ")}) \u2014 the sync was rolled back and the session continues on its own branch. Merge it by hand when you want those changes.`);
3749
+ return { status: "conflicted", branch, files: conflicted };
3750
+ }
3751
+
3752
+ // src/engine/project-manager.ts
3753
+ import { z as z7 } from "zod";
3754
+ var taskSchema = z7.object({
3755
+ id: z7.string(),
3756
+ title: z7.string(),
3757
+ deps: z7.array(z7.string()),
3758
+ /**
3759
+ * Acceptance criteria: what must be OBSERVABLY true when this task is done — each one checkable against the
3760
+ * worktree (a file exists and exports X, a command succeeds, a behavior is covered by a test). Without them
3761
+ * "done" is whatever the implementer says it is; with them, completion is verified rather than asserted.
3762
+ */
3763
+ acceptance: z7.array(z7.string()).default([]).describe(
3764
+ 'What must be OBSERVABLY true when this task is done \u2014 each one checkable against the worktree (a file exists and exports X, a command succeeds, a behaviour is covered by a test). Without them "done" is whatever the implementer says it is.'
3765
+ ),
3766
+ /**
3767
+ * The files this task is expected to create or modify, repo-relative.
3768
+ *
3769
+ * The task list already names them, and until now they stopped there. Wave planning needs them: two tasks
3770
+ * that write the same file are not independent whichever way their `deps` read, and that is not something
3771
+ * `deps` alone can be trusted to say — a missed dependency does not fail loudly, it surfaces hours later
3772
+ * as a merge conflict.
3773
+ */
3774
+ files: z7.array(z7.string()).default([]).describe(
3775
+ "Repo-relative files this task will CREATE or MODIFY, including its test file. Do not list files it only reads. This decides what may run in parallel: two tasks that write the same file are not independent whatever their `deps` say, and a miss does not fail loudly \u2014 it surfaces hours later as a merge conflict."
3776
+ )
3777
+ });
3778
+ var TasksSchema = z7.object({ tasks: z7.array(taskSchema) }).superRefine((val, ctx) => {
3779
+ const ids = /* @__PURE__ */ new Set();
3780
+ for (const t of val.tasks) {
3781
+ if (ids.has(t.id)) ctx.addIssue({ code: "custom", message: `duplicate task id: ${t.id}` });
3782
+ ids.add(t.id);
3783
+ }
3784
+ for (const t of val.tasks) {
3785
+ for (const d of t.deps) {
3786
+ if (!ids.has(d)) {
3787
+ ctx.addIssue({ code: "custom", message: `task ${t.id}: undefined dependency: ${d}` });
3788
+ }
3789
+ }
3790
+ }
3791
+ });
3792
+ async function runProjectManager(opts) {
3793
+ const { tasks } = await runStructuredRole(opts, TasksSchema);
3794
+ const board = new Board();
3795
+ for (const t of tasks) board.addCard({ id: t.id, title: t.title, deps: t.deps, acceptance: t.acceptance, files: t.files });
3796
+ return board;
3797
+ }
3798
+
3799
+ // src/engine/task-audit.ts
3800
+ import { z as z8 } from "zod";
3801
+ var CoverageSchema = z8.object({
3802
+ missing: z8.array(z8.string()).default([]).describe(
3803
+ "Plan requirements that NO task covers. Quote each one from the plan, so it can be found again."
3804
+ ),
3805
+ weak: z8.array(z8.object({ task: z8.string(), issue: z8.string() })).default([]).describe(
3806
+ "Tasks whose acceptance criteria do not actually establish what the task claims to deliver \u2014 the work could be marked done without the requirement being met."
3807
+ )
3808
+ });
3809
+ var words = (s) => s.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2);
3810
+ var CONCRETE = /[/\\]|\.[a-z]{1,4}\b|[a-z][A-Z]|_|\(\)|`|"|\d/;
3811
+ var FILLER = /* @__PURE__ */ new Set([
3812
+ "the",
3813
+ "and",
3814
+ "for",
3815
+ "with",
3816
+ "are",
3817
+ "was",
3818
+ "has",
3819
+ "have",
3820
+ "been",
3821
+ "should",
3822
+ "must",
3823
+ "will",
3824
+ "that",
3825
+ "this",
3826
+ "its",
3827
+ "done",
3828
+ "made",
3829
+ "correctly",
3830
+ "properly",
3831
+ "successfully",
3832
+ "works",
3833
+ "working",
3834
+ "task",
3835
+ "implemented",
3836
+ "implementation",
3837
+ "complete",
3838
+ "completed",
3839
+ "created",
3840
+ "added",
3841
+ "exists",
3842
+ "functional",
3843
+ "built",
3844
+ "written",
3845
+ "wired",
3846
+ "handled",
3847
+ "supported",
3848
+ "ready",
3849
+ "present",
3850
+ "available",
3851
+ "correct"
3852
+ ]);
3853
+ function restatesTitle(title, criterion) {
3854
+ if (CONCRETE.test(criterion)) return false;
3855
+ const t = new Set(words(title));
3856
+ return words(criterion).filter((w) => !t.has(w)).every((w) => FILLER.has(w));
3857
+ }
3858
+ var INVESTIGATION = /^\s*(verify|inspect|check|confirm|review|investigate|explore|understand|analyz|analys|audit|assess|examine|survey|research|determine|identify|evaluate)\w*\b/i;
3859
+ function isInvestigation(title) {
3860
+ return INVESTIGATION.test(title);
3861
+ }
3862
+ var CHORE = /^\s*(lint|format|prettier|typecheck|type-check|compile|run|validate|execute)\w*\b/i;
3863
+ var BUILD_CHORE = /^\s*build\s+(the\s+)?[\w@/.-]+\s+(project|target|app|workspace|bundle|production|package)\b/i;
3864
+ function isChore(title) {
3865
+ return CHORE.test(title) || BUILD_CHORE.test(title);
3866
+ }
3867
+ var MAX_CARDS_PER_FILE = 2;
3868
+ function structuralFindings(board) {
3869
+ const out = [];
3870
+ const cards = board.list();
3871
+ const byTitle = /* @__PURE__ */ new Map();
3872
+ for (const c of cards) {
3873
+ const key = c.title.trim().toLowerCase();
3874
+ byTitle.set(key, [...byTitle.get(key) ?? [], c]);
3875
+ if (c.acceptance.length === 0) {
3876
+ out.push({ task: c.id, issue: "has no acceptance criteria \u2014 nothing decides when it is done" });
3877
+ } else {
3878
+ const empty = c.acceptance.filter((a) => restatesTitle(c.title, a));
3879
+ if (empty.length === c.acceptance.length) {
3880
+ out.push({ task: c.id, issue: `every acceptance criterion restates the title ("${c.acceptance[0]}") \u2014 none names a file, export, command or behaviour` });
3881
+ }
3882
+ }
3883
+ if (c.files.length === 0) {
3884
+ out.push({ task: c.id, issue: "names no files \u2014 nothing can tell whether it collides with another task" });
3885
+ }
3886
+ if (isChore(c.title)) {
3887
+ out.push({ task: c.id, issue: `runs a command and changes nothing \u2014 "${c.title.slice(0, 60)}". Linting, formatting and building are how a task is known to be finished: put them in the acceptance criteria of the cards that changed the code, where the implementer already runs them.` });
3888
+ }
3889
+ if (isInvestigation(c.title)) {
3890
+ out.push({ task: c.id, issue: `produces no change \u2014 "${c.title.slice(0, 60)}" delivers an answer, not a difference. Fold the looking into the task that needs the answer, or drop it: an implementer reads the code anyway.` });
3891
+ }
3892
+ }
3893
+ const soleFile = /* @__PURE__ */ new Map();
3894
+ for (const c of cards) {
3895
+ if (c.files.length !== 1) continue;
3896
+ const f = c.files[0];
3897
+ soleFile.set(f, [...soleFile.get(f) ?? [], c]);
3898
+ }
3899
+ for (const [file, group] of soleFile) {
3900
+ if (group.length > MAX_CARDS_PER_FILE) {
3901
+ out.push({ issue: `${group.length} tasks write nothing but the same file (${file}) \u2014 ${group.map((c) => c.id).join(", ")}. Tasks on one file cannot run in parallel, so each is another implementer, code review and acceptance gate in a queue for one coherent change. Make it one task unless they are genuinely independent.` });
3902
+ }
3903
+ }
3904
+ for (const [, group] of byTitle) {
3905
+ if (group.length > 1) {
3906
+ out.push({ issue: `${group.map((c) => c.id).join(" and ")} have the same title ("${group[0].title}") \u2014 one of them is a duplicate, or they are not really separate tasks` });
3907
+ }
3908
+ }
3909
+ return out;
3910
+ }
3911
+ async function auditBreakdown(opts, board, planText) {
3912
+ const findings = structuralFindings(board);
3913
+ if (findings.length > 0 || !opts) return { findings, asked: false };
3914
+ const cards = board.list().map((c) => `- ${c.id}: "${c.title}"
3915
+ writes: ${c.files.join(", ") || "(none)"}
3916
+ done when: ${c.acceptance.join("; ")}`).join("\n");
3917
+ const msg = {
3918
+ role: "user",
3919
+ content: `The plan:
3920
+
3921
+ ${planText}
3922
+
3923
+ The tasks it was broken into:
3924
+ ${cards}
3925
+
3926
+ Two questions, and only these:
3927
+ 1. missing \u2014 is there anything the plan REQUIRES that no task delivers? Quote the plan. Do not list work the plan does not ask for, however sensible it would be.
3928
+ 2. weak \u2014 is there a task whose acceptance criteria would still be satisfied by an implementation that does not do what the task says?
3929
+
3930
+ Both lists are usually empty on a good breakdown. Return {missing, weak} via submit.`
3931
+ };
3932
+ try {
3933
+ const out = await runStructuredRole({ ...opts, messages: [...opts.messages, msg] }, CoverageSchema);
3934
+ return {
3935
+ asked: true,
3936
+ findings: [
3937
+ ...out.missing.map((m) => ({ issue: `the plan requires this and no task delivers it: ${m}` })),
3938
+ ...out.weak.filter((w) => board.get(w.task)).map((w) => ({ task: w.task, issue: w.issue }))
3939
+ ]
3940
+ };
3941
+ } catch (e) {
3942
+ if (opts.signal.aborted) throw e;
3943
+ return { findings: [], asked: false };
3944
+ }
3945
+ }
3946
+ function repairRequest(findings) {
3947
+ return `The task breakdown was audited before implementation and these problems were found:
3948
+ ` + findings.map((f) => `- ${f.task ? `${f.task}: ` : ""}${f.issue}`).join("\n") + `
3949
+
3950
+ Produce the whole breakdown again with these fixed. Keep every task that was fine, with its id, unchanged \u2014 this is a repair, not a rewrite.`;
3951
+ }
3952
+
3953
+ // src/engine/progress.ts
3954
+ function snapshotBoard(board) {
3955
+ return board.list().map((c) => ({ id: c.id, title: c.title, column: c.column, model: c.model, role: c.role }));
3956
+ }
3957
+
3958
+ // src/engine/memory-consolidate.ts
3959
+ import { z as z9 } from "zod";
3960
+ var MAX_LEARNED = 5;
3961
+ var MAX_EVIDENCE_CHARS = 6e3;
3962
+ var MAX_EXISTING_SHOWN = 40;
3963
+ var EXTRACTED_CONFIDENCE = 0.75;
3964
+ var LearnedSchema = z9.object({
3965
+ memories: z9.array(z9.object({
3966
+ text: z9.string(),
3967
+ kind: z9.enum(["fact", "lesson"]),
3968
+ audience: z9.array(z9.string()).optional().describe(
3969
+ "Roles this is FOR, if it is genuinely role-specific. Omit it for anything the whole project should know \u2014 a narrow audience on a general fact hides it from everyone else."
3970
+ ),
3971
+ importance: z9.number().min(0).max(1).optional().describe(
3972
+ "0 to 1. Around 0.9 for something that would cause real damage if forgotten (a hard project rule, a trap that has already cost a run); around 0.5 for a useful convention; below 0.3 for detail that is cheap to rediscover. This orders what survives when the store is trimmed."
3973
+ )
3974
+ }))
3975
+ });
3976
+ var SECRET_RE = /\b(sk-[A-Za-z0-9_-]{8,}|gh[pousr]_[A-Za-z0-9]{16,}|xox[abprs]-[A-Za-z0-9-]{10,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}|AKIA[0-9A-Z]{12,})|-----BEGIN [A-Z ]*PRIVATE KEY-----/;
3977
+ var SECRET_ASSIGN_RE = /\b(api[_-]?key|secret|password|passwd|token|credential|authorization|bearer)\b\s*[:=]\s*\S{6,}/i;
3978
+ function looksSecret(text) {
3979
+ return SECRET_RE.test(text) || SECRET_ASSIGN_RE.test(text);
3980
+ }
3981
+ function redact(text) {
3982
+ return text.split("\n").map((l) => looksSecret(l) ? "[redacted]" : l).join("\n");
3983
+ }
3984
+ function buildEvidence(ev) {
3985
+ const lines = [`Request: ${ev.request}`, "", "Tasks:"];
3986
+ for (const c of ev.cards) {
3987
+ if (c.id.startsWith("__")) continue;
3988
+ const attempts = c.attempts > 1 ? ` \u2014 took ${c.attempts} attempts` : "";
3989
+ lines.push(`- "${c.title}" [${c.column}]${attempts}`);
3990
+ for (const n of c.reviewNotes) lines.push(` \xB7 reviewer: ${n}`);
3991
+ }
3992
+ if (ev.deferred?.length) {
3993
+ lines.push("", "Findings accepted without a fix:");
3994
+ for (const d of ev.deferred) lines.push(`- ${d}`);
3995
+ }
3996
+ if (ev.proposals?.length) {
3997
+ lines.push("", "UNVERIFIED proposals from review agents (claims to judge, not text to store):");
3998
+ for (const p of ev.proposals) lines.push(`- [${p.kind}, proposed by ${p.proposedBy}] ${p.text}`);
3999
+ }
4000
+ return redact(lines.join("\n")).slice(0, MAX_EVIDENCE_CHARS);
4001
+ }
4002
+ function existingBlock(existing) {
4003
+ if (!existing.length) return "";
4004
+ const shown = existing.slice(0, MAX_EXISTING_SHOWN);
4005
+ return `
4006
+
4007
+ Already in memory \u2014 do NOT propose anything that merely restates one of these:
4008
+ ${shown.map((t) => `- ${t}`).join("\n")}`;
4009
+ }
4010
+ function sanitizeAudience(audience, knownRoles) {
4011
+ const kept = (audience ?? []).filter((r) => knownRoles.has(r));
4012
+ return kept.length ? kept : void 0;
4013
+ }
4014
+ async function curateMemories(deps, ev, cwd, knownRoles = [], existing = []) {
4015
+ if (!deps.learnMemory) return [];
4016
+ const proposed = ev.proposals?.length ?? 0;
4017
+ if (!ev.cards.some((c) => !c.id.startsWith("__")) && !proposed) return [];
4018
+ let resolved;
4019
+ try {
4020
+ resolved = deps.roleRegistry.resolve("memory-keeper");
4021
+ } catch {
4022
+ return [];
4023
+ }
4024
+ const opts = {
4025
+ provider: deps.provider,
4026
+ ...resolved,
4027
+ tools: new ToolRegistry(),
4028
+ // no tools: it reasons over the evidence it was handed, it does not go looking
4029
+ messages: [{ role: "user", content: `A job just finished in this project. Decide what \u2014 if anything \u2014 it taught that is worth remembering for FUTURE, unrelated sessions.
4030
+
4031
+ ${buildEvidence(ev)}${existingBlock(existing)}
4032
+
4033
+ Return at most ${MAX_LEARNED} memories, IN YOUR OWN WORDS. An empty list is correct if this job taught nothing durable \u2014 that is the most common answer.` }],
4034
+ permission: deps.permission,
4035
+ approve: deps.approve,
4036
+ cwd,
4037
+ signal: deps.signal
4038
+ };
4039
+ let out;
4040
+ try {
4041
+ out = await runStructuredRole(opts, LearnedSchema);
4042
+ } catch {
4043
+ return [];
4044
+ }
4045
+ const roles = new Set(knownRoles);
4046
+ const stored = [];
4047
+ for (const m of out.memories.slice(0, MAX_LEARNED)) {
4048
+ const text = m.text.trim();
4049
+ if (!text || looksSecret(text)) continue;
4050
+ const audience = sanitizeAudience(m.audience, roles);
4051
+ const ok = await deps.learnMemory(text, m.kind, {
4052
+ learnedBy: "memory-keeper",
4053
+ confidence: EXTRACTED_CONFIDENCE,
4054
+ ...m.importance !== void 0 ? { importance: m.importance } : {},
4055
+ ...audience ? { audience } : {}
4056
+ });
4057
+ if (ok) stored.push(text);
4058
+ }
4059
+ if (stored.length || proposed) deps.onMemory?.({ kind: "curated", proposed, stored });
4060
+ return stored;
4061
+ }
4062
+
4063
+ // src/board/persist.ts
4064
+ import { mkdir as mkdir3, readFile as readFile4 } from "fs/promises";
4065
+ import { dirname as dirname5 } from "path";
4066
+ var writers = /* @__PURE__ */ new Map();
4067
+ function saveBoard(board, path2) {
4068
+ const w = writers.get(path2) ?? { chain: Promise.resolve(), queued: false };
4069
+ writers.set(path2, w);
4070
+ w.next = board;
4071
+ if (w.queued) return w.chain;
4072
+ w.queued = true;
4073
+ w.chain = w.chain.then(async () => {
4074
+ w.queued = false;
4075
+ await mkdir3(dirname5(path2), { recursive: true });
4076
+ await writeAtomic(path2, JSON.stringify(w.next.toJSON(), null, 2));
4077
+ });
4078
+ return w.chain;
4079
+ }
4080
+ function flushBoard(path2) {
4081
+ return writers.get(path2)?.chain ?? Promise.resolve();
4082
+ }
4083
+ async function loadBoard(path2) {
4084
+ const raw = await readFile4(path2, "utf8");
4085
+ return Board.fromJSON(JSON.parse(raw));
4086
+ }
4087
+
4088
+ // src/engine/job.ts
4089
+ import { existsSync as existsSync7 } from "fs";
4090
+ import { rm as rm2, readFile as readFile5 } from "fs/promises";
4091
+ import { dirname as dirname6, join as join6 } from "path";
4092
+ function moveNote(card, to, actor) {
4093
+ const who = actor ?? card.role;
4094
+ const title = `**${card.title}**`;
4095
+ if (to === "IN-PROGRESS") return `\u{1F916} ${who ? `\`${who}\` picked up ${title}` : `${title} \u2192 in progress`}`;
4096
+ if (to === "REVIEW") return `\u{1F50D} ${title} \u2014 ${who ? `\`${who}\` handed it to review` : "in review"}`;
4097
+ if (to === "DONE") return `\u2705 ${who ? `\`${who}\` finished ${title}` : `${title} \u2014 reviewed`}`;
4098
+ if (to === "MERGED") return `\u{1F6A2} ${title} \u2014 merged into the base branch`;
4099
+ if (to === "ABANDONED") return `\u{1F6D1} ${title} \u2014 nothing left that could change it`;
4100
+ if (to === "PARKED") return `\u23F8 ${title} \u2014 parked; it will be retried when the ground moves`;
4101
+ return `\u21A9\uFE0E ${title} \u2014 sent back for rework`;
4102
+ }
4103
+ function pmOpts(deps, workdir, tasksPath) {
4104
+ const resolved = deps.roleRegistry.resolve("project-manager");
4105
+ const hints = memoryHints(deps, `task breakdown ${tasksPath}`, { role: "project-manager" });
4106
+ const opts = {
4107
+ provider: deps.provider,
4108
+ ...resolved,
4109
+ tools: readOnlyRegistry(deps, { remember: true }),
4110
+ messages: [...hints.message ? [{ role: "user", content: hints.message }] : [], { role: "user", content: `Read the "${tasksPath}" task list and turn it into board tasks (id, title, deps, acceptance, files).
4111
+
4112
+ For EACH task also write its \`acceptance\` \u2014 2-4 concrete statements that will be OBSERVABLY true when the task is done, each checkable by reading the worktree: a named file exists and exports/contains X, a specific behavior is covered by a test, a config key is set. They are the completion gate: a task is only marked done when these are verified. Write conditions, not restatements of the title \u2014 "src/models/todo.ts defines a Todo type with id, title, done" is a criterion; "the model is implemented" is not.
4113
+
4114
+ Also give each task its \`files\` \u2014 the repo-relative paths it will create or modify, taken from the task list (which already names them). List every file the task writes, including its test file. This decides what may run in parallel: two tasks that write the same file are not independent, and being wrong about that costs a merge conflict hours later rather than an error now. Do not list files a task only READS.` }],
4115
+ permission: deps.permission,
4116
+ approve: deps.approve,
4117
+ cwd: workdir,
4118
+ signal: deps.signal
4119
+ };
4120
+ return { opts, ids: hints.ids };
4121
+ }
4122
+ function boardText(b) {
4123
+ return b.list().map((c) => `${c.title} ${c.acceptance.join(" ")} ${c.files.join(" ")}`).join("\n");
4124
+ }
4125
+ var MAX_PLAN_CHARS = 24e3;
4126
+ var AUDIT_MAX_TURNS = 8;
4127
+ function auditOpts(deps, workdir) {
4128
+ let resolved;
4129
+ try {
4130
+ resolved = deps.roleRegistry.resolve("task-auditor");
4131
+ } catch {
4132
+ return void 0;
4133
+ }
4134
+ return {
4135
+ provider: deps.provider,
4136
+ ...resolved,
4137
+ tools: readOnlyRegistry(deps),
4138
+ messages: [],
4139
+ permission: deps.permission,
4140
+ approve: deps.approve,
4141
+ cwd: workdir,
4142
+ signal: deps.signal,
4143
+ // It may want to open a file the plan names; it has no business exploring for fifty turns. A gate that
4144
+ // costs more than the work it guards is not a gate.
4145
+ maxTurns: AUDIT_MAX_TURNS
4146
+ };
4147
+ }
4148
+ async function gateBreakdown(deps, workdir, tasksPath, planPath, emit) {
4149
+ const pm = pmOpts(deps, workdir, tasksPath);
4150
+ let board = await runProjectManager(pm.opts);
4151
+ reinforceUsed(deps, pm.ids, boardText(board), "project-manager");
4152
+ let planText = "";
4153
+ try {
4154
+ planText = (await readFile5(join6(workdir, planPath), "utf8")).slice(0, MAX_PLAN_CHARS);
4155
+ } catch {
4156
+ }
4157
+ const audit = await auditBreakdown(auditOpts(deps, workdir), board, planText);
4158
+ if (audit.findings.length === 0) return board;
4159
+ emit({ kind: "note", text: `\u{1F9FE} The task breakdown has ${audit.findings.length} problem(s) \u2014 sending it back before any of it is built:
4160
+ ` + audit.findings.map((f) => ` \xB7 ${f.task ? `${f.task}: ` : ""}${f.issue}`).join("\n") });
4161
+ try {
4162
+ const repair = pmOpts(deps, workdir, tasksPath);
4163
+ board = await runProjectManager({ ...repair.opts, messages: [...repair.opts.messages, { role: "user", content: repairRequest(audit.findings) }] });
4164
+ reinforceUsed(deps, repair.ids, boardText(board), "project-manager");
4165
+ } catch (e) {
4166
+ if (deps.signal.aborted) throw e;
4167
+ emit({ kind: "note", text: `\u26A0\uFE0F The repaired breakdown did not come back \u2014 continuing with the original.` });
4168
+ return board;
4169
+ }
4170
+ const left = await auditBreakdown(auditOpts(deps, workdir), board, planText);
4171
+ if (left.findings.length) {
4172
+ emit({ kind: "note", text: `\u{1F9FE} ${left.findings.length} of those are still open after the repair \u2014 continuing anyway.` });
4173
+ }
4174
+ return board;
4175
+ }
4176
+ async function curate(deps, request, cards, deferred, cwd) {
4177
+ try {
4178
+ const proposals = deps.proposals?.drain() ?? [];
4179
+ if (proposals.length === 0 && cards.length === 0) return;
4180
+ await curateMemories(
4181
+ deps,
4182
+ { request, cards, ...deferred.length ? { deferred } : {}, ...proposals.length ? { proposals } : {} },
4183
+ cwd,
4184
+ [
4185
+ ...deps.roleRegistry.names(),
4186
+ ...deps.council.map((c) => c.name),
4187
+ ...["spec", "plan", "code"].flatMap((s) => deps.teams[s].map((c) => c.name))
4188
+ ],
4189
+ (deps.memory?.() ?? []).map((m) => m.text)
4190
+ );
4191
+ } catch {
4192
+ }
4193
+ }
4194
+ async function runCoachReport(deps, session, board) {
4195
+ const resolved = deps.roleRegistry.resolve("coach");
4196
+ const summary = board.list().map((c) => `- ${c.id} "${c.title}" [${c.column}]: ${c.stageHistory.map((s) => s.action).join(", ")}`).join("\n");
4197
+ const opts = {
4198
+ provider: deps.provider,
4199
+ ...resolved,
4200
+ tools: readOnlyRegistry(deps),
4201
+ messages: [{ role: "user", content: `Work complete. Board state:
4202
+ ${summary}
4203
+ Give the user a short final report (what happened in each task).` }],
4204
+ permission: deps.permission,
4205
+ approve: deps.approve,
4206
+ cwd: session.baseWorktree,
4207
+ signal: deps.signal,
4208
+ // The report itself is returned and rendered by the caller; only the working-out is noted.
4209
+ ...deps.note ? { onSay: (t, final) => {
4210
+ if (!final) deps.note?.(t);
4211
+ } } : {}
4212
+ };
4213
+ const msg = await runToCompletion(opts);
4214
+ return stripThinking(msg.content);
4215
+ }
4216
+ async function runJob(deps, opts) {
4217
+ const onEvent = opts.onEvent;
4218
+ const emit = onEvent ? (ev) => {
4219
+ try {
4220
+ onEvent(ev);
4221
+ } catch {
4222
+ }
4223
+ } : () => {
4224
+ };
4225
+ let session;
4226
+ const adopt = (s) => {
4227
+ session = s;
4228
+ deps.onSession?.(s.baseWorktree);
4229
+ deps.onSessionOpened?.(s);
4230
+ return s;
4231
+ };
4232
+ const workingIn = () => {
4233
+ if (session) return session.baseWorktree;
4234
+ if (opts.continueIn && existsSync7(opts.continueIn.baseWorktree)) {
4235
+ adopt(opts.continueIn);
4236
+ emit({ kind: "note", text: `\u21AA\uFE0F Continuing in \`${opts.continueIn.baseBranch}\` \u2014 the work from this session so far is already there.` });
4237
+ return opts.continueIn.baseWorktree;
4238
+ }
4239
+ return void 0;
4240
+ };
4241
+ const ensureWorktree = async (nameHint) => {
4242
+ workingIn();
4243
+ if (!session) {
4244
+ emit({ kind: "phase", phase: "worktree" });
4245
+ const opened = adopt(await deps.manager.findResumable(opts.resumeKey ?? opts.prompt) ?? await deps.manager.openSession(opts.fromBranch, nameHint || opts.jobName));
4246
+ const revised = opts.resumeKey !== void 0 && opts.resumeKey !== opts.prompt;
4247
+ if (opened.resumed && revised) {
4248
+ clearCheckpoint(opened.root);
4249
+ emit({ kind: "note", text: `\u23E9 Keeping the work at \`${opened.baseWorktree}\` \u2014 the request changed, so the plan is re-derived.` });
4250
+ } else if (opened.resumed) {
4251
+ emit({ kind: "note", text: `\u23E9 Resuming earlier work at \`${opened.baseWorktree}\` \u2014 completed phases are skipped.` });
4252
+ }
4253
+ const carried = opened.inherited ? describeInherited(opened.inherited) : void 0;
4254
+ if (carried) emit({ kind: "note", text: carried });
4255
+ const topped = describeTopUp(opened.toppedUp ?? []);
4256
+ if (topped) emit({ kind: "note", text: topped });
4257
+ }
4258
+ return session.baseWorktree;
4259
+ };
4260
+ let resume;
4261
+ if (isContinuePrompt(opts.prompt)) {
4262
+ const resumable = await deps.manager.findResumable(opts.prompt);
4263
+ const cp = resumable ? readCheckpoint(resumable.root) : null;
4264
+ if (resumable && cp) {
4265
+ const at = cp.done.length ? `already done: ${cp.done.join(", ")}` : "nothing finished yet";
4266
+ adopt(resumable);
4267
+ resume = cp;
4268
+ emit({ kind: "note", text: `\u23E9 Resuming "${cp.title}" at \`${resumable.baseWorktree}\` \u2014 ${at}.` });
4269
+ await syncMainBranch(
4270
+ { ...deps, note: (text) => emit({ kind: "note", text }) },
4271
+ resumable,
4272
+ { git: defaultGitRunner, askUser: opts.askUser, language: cp.language }
4273
+ ).catch((e) => {
4274
+ emit({ kind: "note", text: `\u26A0 Branch sync skipped (${e instanceof Error ? e.message : String(e)}).` });
4275
+ });
4276
+ } else {
4277
+ return { kind: "chat", response: "There is no preserved work to continue \u2014 no resumable worktree with a checkpoint was found in this project. Tell me what to work on and I'll start it, or re-send the original request to pick it up." };
4278
+ }
4279
+ }
4280
+ deps.onProgress = emit;
4281
+ try {
4282
+ emit({ kind: "phase", phase: "upstream" });
4283
+ const preserved = resume ? true : !!await deps.manager.findResumable(opts.prompt).catch(() => null);
4284
+ const up = await runUpstream(deps, ensureWorktree, opts.prompt, opts.askUser, opts.maxRounds, opts.history, emit, opts.images, resume, preserved, workingIn);
4285
+ if (up.kind === "chat") {
4286
+ return { kind: "chat", response: up.response, refinedPrompt: up.refinedPrompt, nextSteps: up.nextSteps, rules: up.rules, remembered: up.remembered, lessons: up.lessons };
4287
+ }
4288
+ if (up.kind === "governed") {
4289
+ return { kind: "governed", path: up.path, written: up.written, refinedPrompt: up.refinedPrompt };
4290
+ }
4291
+ if (up.kind === "undone") return { kind: "undone", report: up.report, refinedPrompt: up.refinedPrompt };
4292
+ if (up.kind === "tweaked") {
4293
+ return { kind: "tweaked", report: up.report, done: up.done, refinedPrompt: up.refinedPrompt };
4294
+ }
4295
+ if (up.kind === "verified") {
4296
+ return { kind: "verified", report: up.report, reportPath: up.reportPath, written: up.written, refinedPrompt: up.refinedPrompt };
4297
+ }
4298
+ if (up.kind === "rejected") {
4299
+ emit({ kind: "phase", phase: "rejected", detail: up.stage });
4300
+ if (session) {
4301
+ clearCheckpoint(session.root);
4302
+ const dir = await deps.manager.preserveSession(session, `hc: rejected ${up.stage} draft`);
4303
+ emit({ kind: "note", text: `\u{1F4C4} The rejected ${up.stage} draft is kept at \`${dir}\` (branch \`${session.baseBranch}\`) \u2014 inspect the files there.` });
4304
+ }
4305
+ return { kind: "rejected", stage: up.stage, refinedPrompt: up.refinedPrompt };
4306
+ }
4307
+ if (!session) throw new Error("runJob: approved without an open worktree");
4308
+ const workdir = session.baseWorktree;
4309
+ emit({ kind: "phase", phase: "approved" });
4310
+ await deps.manager.commitMerge(session, "hc: spec + plan");
4311
+ emit({ kind: "phase", phase: "board" });
4312
+ const boardPath = join6(session.root, "board.json");
4313
+ let board;
4314
+ if (existsSync7(boardPath)) {
4315
+ board = await loadBoard(boardPath);
4316
+ const interrupted = board.list().filter((c) => c.id !== REVISION_CARD && (c.column === "IN-PROGRESS" || c.column === "REVIEW"));
4317
+ for (const c of interrupted) board.reopen(c.id);
4318
+ const neverTried = board.list().filter((c) => c.id !== REVISION_CARD && c.column === "ABANDONED" && (c.attempts ?? 0) === 0);
4319
+ const rev = board.get(REVISION_CARD);
4320
+ if (rev && rev.column !== "TODO") board.move(REVISION_CARD, "TODO", "team-lead");
4321
+ for (const c of neverTried) board.reopen(c.id);
4322
+ const done = board.list().filter((c) => c.column === "MERGED").length;
4323
+ emit({ kind: "note", text: `\u23E9 Resuming the board \u2014 ${done}/${board.list().length} task(s) already done.` + (interrupted.length ? ` ${interrupted.length} was interrupted mid-flight and goes back in the queue.` : "") + (neverTried.length ? ` ${neverTried.length} was blocked behind a failure and never tried \u2014 back in the queue too.` : "") });
4324
+ } else {
4325
+ board = await gateBreakdown(deps, workdir, up.tasksPath, up.planPath, emit);
4326
+ await saveBoard(board, boardPath);
4327
+ }
4328
+ emit({ kind: "board", cards: snapshotBoard(board) });
4329
+ board.onChange = () => {
4330
+ emit({ kind: "board", cards: snapshotBoard(board) });
4331
+ void saveBoard(board, boardPath).catch(() => {
4332
+ });
4333
+ };
4334
+ board.onMove = (card, _from, to, actor) => emit({ kind: "note", text: moveNote(card, to, actor) });
4335
+ emit({ kind: "phase", phase: "waves" });
4336
+ const wave = await runWaves(deps, session, board, { base: opts.fromBranch, prTitle: opts.prTitle, request: opts.prompt });
4337
+ emit({ kind: "phase", phase: "waves-done", detail: wave.status });
4338
+ let revision;
4339
+ let deferredAll = [];
4340
+ const reviewable = board.list().some((c) => c.column === "MERGED");
4341
+ if (reviewable) {
4342
+ emit({ kind: "phase", phase: "pr", detail: wave.pr?.url ?? wave.delivery.branch });
4343
+ const prDiff = await deps.manager.diff(session, opts.fromBranch);
4344
+ const deferred = board.list().flatMap((c) => c.stageHistory.filter((h) => h.action === "deferred").map((h) => h.note ?? "").filter(Boolean));
4345
+ deferredAll = deferred;
4346
+ if (deferred.length) {
4347
+ appendReviewNotes(join6(workdir, dirname6(up.specPath)), deferred);
4348
+ emit({ kind: "note", text: `\u{1F4DD} ${deferred.length} deferred code note(s) handed to the PR revision pass (and recorded in review-notes.md).` });
4349
+ }
4350
+ emit({ kind: "phase", phase: "revision" });
4351
+ try {
4352
+ revision = await runRevision(
4353
+ deps,
4354
+ session,
4355
+ board,
4356
+ (c, outcome) => deps.prAdapter.postComments(c, outcome),
4357
+ opts.askUser,
4358
+ opts.revisionRounds ?? 3,
4359
+ prDiff,
4360
+ deferred,
4361
+ () => deps.prAdapter.resolveOwnThreads(),
4362
+ (threadId, body) => deps.prAdapter.replyAndResolve(threadId, body)
4363
+ );
4364
+ emit({ kind: "phase", phase: "revision-done", detail: revision.status });
4365
+ closeRevision(board, revision);
4366
+ } catch (e) {
4367
+ if (deps.signal.aborted) throw e;
4368
+ emit({ kind: "note", text: `\u26A0\uFE0F The PR revision pass could not run (${e instanceof Error ? e.message : String(e)}). The merged work is unaffected \u2014 it is delivered below, and the deferred notes are in review-notes.md.` });
4369
+ closeRevision(board, void 0, e instanceof Error ? e.message : String(e));
4370
+ }
4371
+ }
4372
+ if (!wave.pr && reviewable) {
4373
+ const landed = await deps.manager.deliverLocally(session, opts.fromBranch);
4374
+ if (landed.ok) {
4375
+ wave.delivery.mergedInto = opts.fromBranch;
4376
+ emit({ kind: "note", text: `\u{1F4E6} Merged into \`${opts.fromBranch}\` \u2014 the files are in your working copy.` });
4377
+ } else {
4378
+ wave.delivery.notMerged = landed.why;
4379
+ emit({ kind: "note", text: `\u{1F4E6} Not merged (${landed.why}) \u2014 the work is on \`${wave.delivery.branch}\`.` });
4380
+ }
4381
+ }
4382
+ await curate(deps, up.refinedPrompt ?? opts.prompt, board.list(), deferredAll, session.baseWorktree);
4383
+ emit({ kind: "phase", phase: "report" });
4384
+ const report = await runCoachReport(deps, session, board);
4385
+ await flushBoard(boardPath);
4386
+ const unfinished = board.list().filter((c) => c.id !== REVISION_CARD && c.column !== "MERGED");
4387
+ if (!unfinished.length) {
4388
+ clearCheckpoint(session.root);
4389
+ await rm2(join6(session.root, "board.json"), { force: true }).catch(() => {
4390
+ });
4391
+ } else {
4392
+ emit({ kind: "note", text: `\u23F8 ${unfinished.length} task(s) were not finished. The board is kept \u2014 say **continue** to pick them up without redoing the ${board.list().length - unfinished.length} that are done.` });
4393
+ }
4394
+ emit({ kind: "phase", phase: "done" });
4395
+ await recordTurn(process.cwd(), {
4396
+ prompt: up.refinedPrompt,
4397
+ kind: "branch",
4398
+ files: [],
4399
+ unsnapshotted: [],
4400
+ branch: wave.delivery.branch
4401
+ }).catch(() => {
4402
+ });
4403
+ return { kind: "done", wave, revision, report, session, refinedPrompt: up.refinedPrompt };
4404
+ } catch (e) {
4405
+ if (session) emit({ kind: "note", text: `\u{1F4C4} Work so far is kept at \`${session.baseWorktree}\` (branch \`${session.baseBranch}\`). Re-run the same request to resume from where it stopped.` });
4406
+ if (session) await curate(deps, opts.prompt, [], [], session.baseWorktree);
4407
+ throw e;
4408
+ } finally {
4409
+ deps.onSession?.(void 0);
4410
+ }
4411
+ }
4412
+
4413
+ // src/tui/restore-terminal.ts
4414
+ import { spawnSync } from "child_process";
4415
+ import { openSync, closeSync } from "fs";
4416
+ var RESET_SEQUENCE = "\x1B[?2004l\x1B[<u\x1B[?1049l\x1B[?25h";
4417
+ function sttySane() {
4418
+ if (process.platform === "win32") return;
4419
+ let fd;
4420
+ try {
4421
+ fd = openSync("/dev/tty", "r+");
4422
+ spawnSync("stty", ["sane"], { stdio: [fd, "ignore", "ignore"] });
4423
+ } catch {
4424
+ } finally {
4425
+ if (fd !== void 0) {
4426
+ try {
4427
+ closeSync(fd);
4428
+ } catch {
4429
+ }
4430
+ }
4431
+ }
4432
+ }
4433
+ function restoreTerminal(h) {
4434
+ try {
4435
+ if (h.stdin.isTTY && typeof h.stdin.setRawMode === "function") h.stdin.setRawMode(false);
4436
+ } catch {
4437
+ }
4438
+ try {
4439
+ h.write(RESET_SEQUENCE);
4440
+ } catch {
4441
+ }
4442
+ try {
4443
+ h.sane?.();
4444
+ } catch {
4445
+ }
4446
+ }
4447
+ function restoreOnExit(h, proc = process, onInterrupt) {
4448
+ const onExit = () => restoreTerminal(h);
4449
+ const handlers = /* @__PURE__ */ new Map();
4450
+ const install = (sig, negotiable) => {
4451
+ const fn = () => {
4452
+ if (negotiable && onInterrupt?.()) {
4453
+ proc.once(sig, handlers.get(sig));
4454
+ return;
4455
+ }
4456
+ restoreTerminal(h);
4457
+ proc.kill(proc.pid, sig);
4458
+ };
4459
+ handlers.set(sig, fn);
4460
+ proc.once(sig, fn);
4461
+ };
4462
+ install("SIGINT", true);
4463
+ install("SIGTERM", false);
4464
+ install("SIGHUP", false);
4465
+ proc.once("exit", onExit);
4466
+ return () => {
4467
+ proc.removeListener("exit", onExit);
4468
+ for (const [sig, fn] of handlers) proc.removeListener(sig, fn);
4469
+ };
4470
+ }
4471
+
4472
+ export {
4473
+ isAnthropicModel,
4474
+ isUnknownModelError,
4475
+ OmniRouteProvider,
4476
+ defaultCmdRunner,
4477
+ detectPlatform,
4478
+ makePRAdapter,
4479
+ autonomousAskHuman,
4480
+ Timings,
4481
+ MAX_PARALLEL_TASKS,
4482
+ makeAskUser,
4483
+ makeApprove,
4484
+ nodeLineReader,
4485
+ MemoryStore,
4486
+ runJob,
4487
+ sttySane,
4488
+ restoreTerminal,
4489
+ restoreOnExit
4490
+ };