ralphctl 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -399,7 +399,10 @@ import { z } from "zod";
399
399
  var CLAUDE_MODELS = [
400
400
  "claude-haiku-4-5",
401
401
  "claude-sonnet-4-6",
402
- "claude-opus-4-8"
402
+ "claude-opus-4-8",
403
+ "claude-opus-4-8[1m]",
404
+ "claude-fable-5",
405
+ "claude-fable-5[1m]"
403
406
  ];
404
407
  var isClaudeModel = (s) => CLAUDE_MODELS.includes(s);
405
408
 
@@ -408,6 +411,7 @@ var CODEX_MODELS = [
408
411
  "gpt-5.5",
409
412
  "gpt-5.4",
410
413
  "gpt-5.4-mini",
414
+ "gpt-5.3-codex-spark",
411
415
  "gpt-5.3-codex",
412
416
  "gpt-5.2",
413
417
  "codex-auto-review"
@@ -420,6 +424,7 @@ var COPILOT_MODELS = [
420
424
  "gpt-5.4-mini",
421
425
  "gpt-5.1",
422
426
  "gpt-5.4",
427
+ "gpt-5.5",
423
428
  "gpt-5.3-codex",
424
429
  "gpt-5.2-codex",
425
430
  "gpt-5.2",
@@ -431,10 +436,18 @@ var COPILOT_MODELS = [
431
436
  "claude-sonnet-4.6",
432
437
  "claude-sonnet-4.5",
433
438
  "claude-sonnet-4",
439
+ "claude-opus-4.8",
440
+ "claude-opus-4.7",
434
441
  "claude-opus-4.6",
435
442
  "claude-opus-4.6-fast",
436
443
  "claude-opus-4.5",
437
- "gemini-3-pro-preview"
444
+ "gemini-3.1-pro-preview",
445
+ "gemini-3-pro-preview",
446
+ "gemini-3-flash-preview",
447
+ "gemini-3.5-flash",
448
+ "gemini-2.5-pro",
449
+ "mai-code-1-flash",
450
+ "raptor-mini-preview"
438
451
  ];
439
452
  var isCopilotModel = (s) => COPILOT_MODELS.includes(s);
440
453
 
@@ -522,6 +535,18 @@ var migrateRetiredClaudeOpus = (ai) => {
522
535
  return next;
523
536
  };
524
537
  var promoteLegacyAiRows = (ai) => migrateRetiredClaudeOpus(seedLegacyCreatePrRow(promoteLegacyImplementRow(ai)));
538
+ var healHarnessCrossKnobs = (harness) => {
539
+ if (typeof harness !== "object" || harness === null) return harness;
540
+ const h = harness;
541
+ const turns = h["maxTurns"];
542
+ if (typeof turns !== "number" || !Number.isInteger(turns)) return harness;
543
+ const rawThreshold = h["plateauThreshold"];
544
+ const threshold = typeof rawThreshold === "number" && Number.isInteger(rawThreshold) ? rawThreshold : 3;
545
+ if (turns >= threshold) return harness;
546
+ const healedThreshold = Math.max(2, Math.min(threshold, turns));
547
+ const healedTurns = Math.max(turns, healedThreshold);
548
+ return { ...h, maxTurns: healedTurns, plateauThreshold: healedThreshold };
549
+ };
525
550
  var AiSettingsSchema = z.preprocess(
526
551
  promoteLegacyAiRows,
527
552
  z.object({
@@ -542,40 +567,64 @@ var SettingsSchema = z.object({
542
567
  */
543
568
  schemaVersion: z.literal(CURRENT_SCHEMA_VERSION).default(CURRENT_SCHEMA_VERSION),
544
569
  ai: AiSettingsSchema,
545
- harness: z.object({
546
- /** Generator–evaluator turns budgeted per `Attempt` (1–10). */
547
- maxTurns: z.number().int().min(1).max(10),
548
- /** Default cap on attempts per task before transitioning to `blocked` (1–10). */
549
- maxAttempts: z.number().int().min(1).max(10),
550
- /** Adapter-side retries on `RateLimitError` before surfacing the failure (0–10). */
551
- rateLimitRetries: z.number().int().min(0).max(10),
552
- /**
553
- * Consecutive evaluator turns flagging the same failed-dimension set before the loop
554
- * exits with a `plateau` warning (2–5). Setting to 3+ gives the AI extra retries before
555
- * the harness gives up. See `business/task/plateau-detection.ts` for the exemption rules
556
- * (score improvement / commit-message change / critique-prose shift) that can soften or
557
- * skip the plateau even when the threshold is met.
558
- */
559
- plateauThreshold: z.number().int().min(2).max(5).default(2),
560
- /**
561
- * When the gen-eval loop exits on a plateau, grant one more attempt instead of settling
562
- * immediately: escalate the generator one rung up the ladder ({@link escalationMap} merged
563
- * with the built-in `DEFAULT_ESCALATION_MAP`) when a stronger model exists, and ALWAYS inject
564
- * a "change your approach" directive into that attempt's generator turn (so a top-of-ladder
565
- * model — e.g. the default Opus generator — still acts differently rather than re-iterating).
566
- * A plateau never blocks: after the one retry, or when no attempt budget remains, the work is
567
- * preserved (done-with-warning). Defaults `true`.
568
- */
569
- escalateOnPlateau: z.boolean().default(true),
570
- /**
571
- * User overrides for the built-in `DEFAULT_ESCALATION_MAP` (in
572
- * `business/task/escalation-map.ts`). Keys are the current model id, values the model id
573
- * to escalate to. Empty by default; merged at read time with user keys winning on
574
- * conflict and extending the default ladder. Non-string entries fail schema validation
575
- * with a typed Zod error naming the offending field.
576
- */
577
- escalationMap: z.record(z.string(), z.string()).default({})
578
- }),
570
+ harness: z.preprocess(
571
+ healHarnessCrossKnobs,
572
+ z.object({
573
+ /** Generator–evaluator turns budgeted per `Attempt` (1–10). */
574
+ maxTurns: z.number().int().min(1).max(10),
575
+ /** Default cap on attempts per task before transitioning to `blocked` (1–10). */
576
+ maxAttempts: z.number().int().min(1).max(10),
577
+ /** Adapter-side retries on `RateLimitError` before surfacing the failure (0–10). */
578
+ rateLimitRetries: z.number().int().min(0).max(10),
579
+ /**
580
+ * Consecutive evaluator turns flagging the same failed-dimension set before the loop
581
+ * exits with a `plateau` warning (2–5, default 3 — patient: the graduated remedy ladder
582
+ * climbs cheapest-first across plateaus, so a slightly higher threshold avoids spending an
583
+ * escalation rung on a stall the generator would have broken on its own). See
584
+ * `business/task/plateau-detection.ts` for the exemption rules (score improvement /
585
+ * commit-message change / critique-prose shift) that can soften or skip the plateau even
586
+ * when the threshold is met.
587
+ */
588
+ plateauThreshold: z.number().int().min(2).max(5).default(3),
589
+ /**
590
+ * Master switch for failure-driven generator-model escalation. The flag name is retained for
591
+ * backward compatibility, but it now gates ALL failure-driven escalation — not only plateau:
592
+ * `plateau` AND `budget-exhausted` (the turn budget ran out without a terminal verdict) exits
593
+ * both climb the ladder, and `malformed` exits (the evaluator emitted no parseable verdict) get
594
+ * a plain same-model fresh-attempt retry — instead of settling immediately.
595
+ *
596
+ * On an escalatable exit the generator climbs one rung up the merged ladder ({@link
597
+ * escalationMap} over the built-in `DEFAULT_ESCALATION_MAP`) across successive failures, bounded
598
+ * by `maxAttempts`; each climb hands the targeted prior critique to the stronger model. The
599
+ * "change your approach" directive is NOT injected on a model bump — it fires only once the
600
+ * generator reaches the top of the ladder, as a same-model nudge (one more attempt on the same
601
+ * model, where no fresh capability remains so a change of approach is the only lever). A
602
+ * malformed exit never burns a ladder rung (it is the evaluator's failure, not the generator's)
603
+ * — it retries on the same model while budget remains.
604
+ *
605
+ * A failure-driven retry never blocks: after the ladder tops out (a further failure on the
606
+ * nudged top-tier model) or the attempt budget is exhausted, the work is preserved
607
+ * (done-with-warning). Defaults `true`.
608
+ */
609
+ escalateOnPlateau: z.boolean().default(true),
610
+ /**
611
+ * User overrides for the built-in `DEFAULT_ESCALATION_MAP` (in
612
+ * `business/task/escalation-map.ts`). Keys are the current model id, values the model id
613
+ * to escalate to. Empty by default; merged at read time with user keys winning on
614
+ * conflict and extending the default ladder. Non-string entries fail schema validation
615
+ * with a typed Zod error naming the offending field.
616
+ */
617
+ escalationMap: z.record(z.string(), z.string()).default({})
618
+ }).superRefine((harness, ctx) => {
619
+ if (harness.maxTurns < harness.plateauThreshold) {
620
+ ctx.addIssue({
621
+ code: z.ZodIssueCode.custom,
622
+ path: ["maxTurns"],
623
+ message: `maxTurns (${String(harness.maxTurns)}) must be \u2265 plateauThreshold (${String(harness.plateauThreshold)}); when maxTurns < plateauThreshold the plateau window can never fill within a single attempt and escalation becomes unreachable. Increase maxTurns or lower plateauThreshold.`
624
+ });
625
+ }
626
+ })
627
+ ),
579
628
  logging: z.object({
580
629
  level: LogLevelSchema
581
630
  }),
@@ -674,7 +723,10 @@ var DEFAULT_MODELS_BY_PROVIDER = {
674
723
  "openai-codex": {
675
724
  refine: "gpt-5.4-mini",
676
725
  plan: "gpt-5.5",
677
- implement: "gpt-5.3-codex",
726
+ // `gpt-5.3-codex` is deprecated for ChatGPT sign-in — the "reset implement to Codex" path
727
+ // rides the frontier default `gpt-5.5` instead, matching the CODEX_ONLY preset decision so
728
+ // the everyday autonomous loop works under ChatGPT auth and sits at the top of the ladder.
729
+ implement: "gpt-5.5",
678
730
  readiness: "gpt-5.4-mini",
679
731
  ideate: "gpt-5.5",
680
732
  createPr: "gpt-5.4-mini"
@@ -705,7 +757,7 @@ var DEFAULT_SETTINGS = {
705
757
  maxTurns: 5,
706
758
  maxAttempts: 3,
707
759
  rateLimitRetries: 3,
708
- plateauThreshold: 2,
760
+ plateauThreshold: 3,
709
761
  escalateOnPlateau: true,
710
762
  escalationMap: {}
711
763
  },
@@ -902,13 +954,24 @@ var createGitRunner = (deps = {}) => {
902
954
  );
903
955
  return;
904
956
  }
905
- finish(
906
- Result.ok({
907
- stdout: Buffer.concat(stdoutChunks).toString("utf8"),
908
- stderr: Buffer.concat(stderrChunks).toString("utf8"),
909
- exitCode: code ?? -1
910
- })
911
- );
957
+ try {
958
+ finish(
959
+ Result.ok({
960
+ stdout: Buffer.concat(stdoutChunks).toString("utf8"),
961
+ stderr: Buffer.concat(stderrChunks).toString("utf8"),
962
+ exitCode: code ?? -1
963
+ })
964
+ );
965
+ } catch (cause) {
966
+ finish(
967
+ Result.error(
968
+ new StorageError({
969
+ subCode: "io",
970
+ message: `git output too large to materialize: git ${args.join(" ")} \u2014 ${stringifyError(cause)}`
971
+ })
972
+ )
973
+ );
974
+ }
912
975
  });
913
976
  });
914
977
  return { run };
@@ -1892,10 +1955,18 @@ var markTaskBlocked = (task, reason, blockKind) => {
1892
1955
  var unblockTask = (task) => {
1893
1956
  const guard2 = requireStatus("task", task, ["blocked"], "unblock");
1894
1957
  if (!guard2.ok) return Result.error(guard2.error);
1895
- const { blockedReason: _reason, blockKind: _kind, ...rest } = guard2.value;
1958
+ const {
1959
+ blockedReason: _reason,
1960
+ blockKind: _kind,
1961
+ escalatedFromModel: _from,
1962
+ escalatedToModel: _to,
1963
+ ...rest
1964
+ } = guard2.value;
1896
1965
  void _reason;
1897
1966
  void _kind;
1898
- return Result.ok({ ...rest, status: "todo" });
1967
+ void _from;
1968
+ void _to;
1969
+ return Result.ok({ ...rest, status: "todo", attempts: [] });
1899
1970
  };
1900
1971
  var resetTaskToTodo = (task) => {
1901
1972
  if (task.status === "todo") return Result.ok(task);
@@ -2661,7 +2732,10 @@ var CONTEXT_WINDOW = {
2661
2732
  // Claude (claude-code adapter — direct from Anthropic)
2662
2733
  "claude-haiku-4-5": 2e5,
2663
2734
  "claude-sonnet-4-6": 2e5,
2664
- "claude-opus-4-8": 2e5
2735
+ "claude-opus-4-8": 2e5,
2736
+ // `[1m]` is Claude Code's 1M-token long-context selector — the window is part of the id.
2737
+ "claude-opus-4-8[1m]": 1e6,
2738
+ "claude-fable-5[1m]": 1e6
2665
2739
  };
2666
2740
  var contextWindowFor = (model) => {
2667
2741
  if (model === void 0) return void 0;
@@ -4305,22 +4379,24 @@ var parseIssueUrl = (url) => {
4305
4379
  const repo = segments[1];
4306
4380
  const num = Number(segments[3]);
4307
4381
  if (owner && repo && Number.isInteger(num) && num > 0) {
4308
- return { host: "github", owner, repo, number: num };
4382
+ return { host: "github", hostname: parsed.hostname, owner, repo, number: num };
4309
4383
  }
4310
4384
  }
4311
4385
  return null;
4312
4386
  }
4313
4387
  const dashIdx = segments.indexOf("-");
4314
- if (dashIdx >= 2 && segments[dashIdx + 1] === "issues") {
4388
+ const kind = dashIdx >= 0 ? segments[dashIdx + 1] : void 0;
4389
+ if (dashIdx >= 2 && (kind === "issues" || kind === "work_items")) {
4315
4390
  const num = Number(segments[dashIdx + 2]);
4316
4391
  const repo = segments[dashIdx - 1];
4317
4392
  if (repo && Number.isInteger(num) && num > 0) {
4318
4393
  const owner = segments.slice(0, dashIdx - 1).join("/");
4319
- return { host: "gitlab", owner, repo, number: num };
4394
+ return { host: "gitlab", hostname: parsed.hostname, owner, repo, number: num };
4320
4395
  }
4321
4396
  }
4322
4397
  return null;
4323
4398
  };
4399
+ var glabRepoArg = (parsed) => `${parsed.hostname}/${parsed.owner}/${parsed.repo}`;
4324
4400
  var looksLikeNotFound = (stderr) => {
4325
4401
  const s = stderr.toLowerCase();
4326
4402
  return s.includes("not found") || s.includes("404") || s.includes("could not resolve") || s.includes("does not exist");
@@ -4378,7 +4454,7 @@ var fetchGitLabNotes = async (spawn3, parsed) => {
4378
4454
  const result = await runCli(
4379
4455
  spawn3,
4380
4456
  "glab",
4381
- ["issue", "note", "list", String(parsed.number), "--repo", `${parsed.owner}/${parsed.repo}`, "--output", "json"],
4457
+ ["issue", "note", "list", String(parsed.number), "--repo", glabRepoArg(parsed), "--output", "json"],
4382
4458
  { timeoutMs: CLI_TIMEOUT_MS }
4383
4459
  );
4384
4460
  if (!result.ok) return { comments: [], failure: result.error.message };
@@ -4409,7 +4485,7 @@ var fetchGitLab = async (spawn3, parsed, url, logger) => {
4409
4485
  const result = await runCli(
4410
4486
  spawn3,
4411
4487
  "glab",
4412
- ["issue", "view", String(parsed.number), "--repo", `${parsed.owner}/${parsed.repo}`, "--output", "json"],
4488
+ ["issue", "view", String(parsed.number), "--repo", glabRepoArg(parsed), "--output", "json"],
4413
4489
  { timeoutMs: CLI_TIMEOUT_MS }
4414
4490
  );
4415
4491
  if (!result.ok) return Result.error(result.error);
@@ -4477,7 +4553,15 @@ var commentGitLab = async (spawn3, url, body, parsed) => {
4477
4553
  const r = await runCli(
4478
4554
  spawn3,
4479
4555
  "glab",
4480
- ["issue", "comment", String(parsed.number), "--repo", `${parsed.owner}/${parsed.repo}`, "--body", body],
4556
+ [
4557
+ "issue",
4558
+ "comment",
4559
+ String(parsed.number),
4560
+ "--repo",
4561
+ `${parsed.hostname}/${parsed.owner}/${parsed.repo}`,
4562
+ "--body",
4563
+ body
4564
+ ],
4481
4565
  { timeoutMs: CLI_TIMEOUT_MS2 }
4482
4566
  );
4483
4567
  if (!r.ok) return Result.error(r.error);
@@ -5075,7 +5159,7 @@ var createNpmVersionChecker = (deps) => {
5075
5159
  // package.json
5076
5160
  var package_default = {
5077
5161
  name: "ralphctl",
5078
- version: "0.10.0",
5162
+ version: "0.11.0",
5079
5163
  description: "Agent harness for long-running AI coding tasks \u2014 orchestrates Claude Code, GitHub Copilot, and OpenAI Codex across repositories",
5080
5164
  homepage: "https://github.com/lukas-grigis/ralphctl",
5081
5165
  type: "module",
@@ -5185,12 +5269,17 @@ var CLI_METADATA = {
5185
5269
 
5186
5270
  // src/business/task/escalation-map.ts
5187
5271
  var DEFAULT_ESCALATION_MAP = {
5188
- // Claude — Sonnet escalates to Opus; Haiku escalates to Sonnet.
5272
+ // Claude (Claude-Code / Codex dash-form) — Haiku → Sonnet → Opus.
5189
5273
  "claude-haiku-4-5": "claude-sonnet-4-6",
5190
5274
  "claude-sonnet-4-6": "claude-opus-4-8",
5191
- // Copilot/Codex — mini variants step up to their full-tier frontier.
5275
+ // Claude (Copilot dot-form) — Haiku → Sonnet → Opus.
5276
+ "claude-haiku-4.5": "claude-sonnet-4.6",
5277
+ "claude-sonnet-4.6": "claude-opus-4.8",
5278
+ // Copilot/Codex GPT — mini variants step up to their full-tier frontier, and the
5279
+ // economic full tier (`gpt-5.4`) climbs to the flagship.
5192
5280
  "gpt-5-mini": "gpt-5.5",
5193
- "gpt-5.4-mini": "gpt-5.5"
5281
+ "gpt-5.4-mini": "gpt-5.5",
5282
+ "gpt-5.4": "gpt-5.5"
5194
5283
  };
5195
5284
  var mergeEscalationMap = (user) => ({
5196
5285
  ...DEFAULT_ESCALATION_MAP,
@@ -5203,6 +5292,16 @@ var warnEscalationMapSelfLoops = (escalationMap, logger) => {
5203
5292
  }
5204
5293
  }
5205
5294
  };
5295
+ var escalationLadderCyclicFrom = (map, start) => {
5296
+ const seen = /* @__PURE__ */ new Set([start]);
5297
+ let cur = map[start];
5298
+ while (cur !== void 0) {
5299
+ if (seen.has(cur)) return true;
5300
+ seen.add(cur);
5301
+ cur = map[cur];
5302
+ }
5303
+ return false;
5304
+ };
5206
5305
 
5207
5306
  // src/integration/ai/skills/_engine/filesystem-skills-adapter.ts
5208
5307
  import { existsSync } from "fs";
@@ -10212,7 +10311,8 @@ var TaskBlock = ({
10212
10311
  cardExpanded,
10213
10312
  cardFocused,
10214
10313
  nowMs,
10215
- blockedReason
10314
+ blockedReason,
10315
+ warningSummary
10216
10316
  }) => {
10217
10317
  const presentation = STATUS_PRESENTATION[task.status];
10218
10318
  const isSpinning = task.status === "running";
@@ -10224,6 +10324,7 @@ var TaskBlock = ({
10224
10324
  const evalElided = task.evaluations.length - evalRows.length;
10225
10325
  const criteriaBullets = taskCriteria;
10226
10326
  const blockedReasonText = blockedReason?.trim() ?? "";
10327
+ const warningSummaryText = task.status === "completed" ? warningSummary?.trim() ?? "" : "";
10227
10328
  const latestCommitSha = useMemo8(() => {
10228
10329
  const sha = taskProjection?.lastAttempt?.commitSha;
10229
10330
  return sha !== void 0 ? String(sha).slice(0, 7) : void 0;
@@ -10313,6 +10414,14 @@ var TaskBlock = ({
10313
10414
  " ",
10314
10415
  collapseWhitespace(blockedReasonText)
10315
10416
  ] }) }) }),
10417
+ warningSummaryText.length > 0 && // A done card that carries a final-attempt warning — surfaced collapsed OR expanded so the
10418
+ // operator never reads a flagged completion as a clean pass. Same warning glyph as the
10419
+ // attempt-card / blocked line; the tasks list previously showed it only for blockedReason.
10420
+ /* @__PURE__ */ jsx33(Box22, { paddingLeft: 2, children: /* @__PURE__ */ jsx33(Box22, { flexGrow: 1, flexShrink: 1, children: /* @__PURE__ */ jsxs23(Text23, { color: inkColors.warning, wrap: "truncate-end", children: [
10421
+ glyphs.warningGlyph,
10422
+ " ",
10423
+ collapseWhitespace(warningSummaryText)
10424
+ ] }) }) }),
10316
10425
  cardExpanded && idleSnippets.length > 0 && /* @__PURE__ */ jsx33(Box22, { paddingLeft: 2, children: /* @__PURE__ */ jsx33(Box22, { flexGrow: 1, flexShrink: 1, children: /* @__PURE__ */ jsxs23(Text23, { dimColor: true, wrap: "truncate-end", children: [
10317
10426
  glyphs.activityArrow,
10318
10427
  " ",
@@ -10531,6 +10640,7 @@ var TasksPanel = ({
10531
10640
  inputActive = false,
10532
10641
  taskCriteriaById,
10533
10642
  blockedReasonById,
10643
+ warningSummaryById,
10534
10644
  showEvaluatorFailureUI = false,
10535
10645
  sprintState,
10536
10646
  nowMs
@@ -10634,6 +10744,7 @@ var TasksPanel = ({
10634
10744
  const taskCriteria = criteriaBullets !== void 0 && criteriaBullets.length > 0 ? criteriaBullets : void 0;
10635
10745
  const taskProjection = sprintState?.tasks.find((t) => t.id === task.id);
10636
10746
  const blockedReason = blockedReasonById?.get(task.id);
10747
+ const warningSummary = warningSummaryById?.get(task.id);
10637
10748
  const isActive = idx === activeTaskIdx;
10638
10749
  return /* @__PURE__ */ jsx34(
10639
10750
  TaskBlock,
@@ -10658,7 +10769,8 @@ var TasksPanel = ({
10658
10769
  ...recovering !== void 0 ? { recovering } : {},
10659
10770
  ...taskCriteria !== void 0 ? { taskCriteria } : {},
10660
10771
  ...taskProjection !== void 0 ? { taskProjection } : {},
10661
- ...blockedReason !== void 0 ? { blockedReason } : {}
10772
+ ...blockedReason !== void 0 ? { blockedReason } : {},
10773
+ ...warningSummary !== void 0 ? { warningSummary } : {}
10662
10774
  },
10663
10775
  task.id
10664
10776
  );
@@ -11670,11 +11782,13 @@ var formatDuration = (ms) => {
11670
11782
  return minutes > 0 ? `${String(hours)}h ${String(minutes)}m` : `${String(hours)}h`;
11671
11783
  };
11672
11784
  var SHA_DISPLAY_LENGTH = 7;
11785
+ var sanitizeInline = (text) => text.replace(/[\r\n\t\v\f]+/g, " ").trim();
11786
+ var neutralizeProseHeadings = (text) => text.split("\n").map((line) => line.startsWith("#") ? ` ${line}` : line).join("\n");
11673
11787
  var appendSubsection = (lines, heading, entries) => {
11674
11788
  if (entries.length === 0) return;
11675
11789
  lines.push(`### ${heading}`);
11676
11790
  for (const entry of entries) {
11677
- lines.push(`- ${entry}`);
11791
+ lines.push(`- ${entry.split("\n").join("\n ")}`);
11678
11792
  }
11679
11793
  lines.push("");
11680
11794
  };
@@ -11682,22 +11796,64 @@ var appendLearningsSubsection = (lines, entries) => {
11682
11796
  if (entries.length === 0) return;
11683
11797
  lines.push("### Learnings");
11684
11798
  for (const entry of entries) {
11685
- lines.push(`- **${entry.text}**`);
11799
+ lines.push(`- **${entry.text.split("\n").join("\n ")}**`);
11686
11800
  if (entry.context !== void 0 && entry.context.trim().length > 0) {
11687
- lines.push(` - Context: ${entry.context.trim()}`);
11801
+ lines.push(` - Context: ${entry.context.trim().split("\n").join("\n ")}`);
11688
11802
  }
11689
11803
  if (entry.appliesTo !== void 0 && entry.appliesTo.trim().length > 0) {
11690
- lines.push(` - Applies to: ${entry.appliesTo.trim()}`);
11804
+ lines.push(` - Applies to: ${entry.appliesTo.trim().split("\n").join("\n ")}`);
11805
+ }
11806
+ }
11807
+ lines.push("");
11808
+ };
11809
+ var warningSentence = (warning) => {
11810
+ switch (warning.kind) {
11811
+ case "budget-exhausted": {
11812
+ const turns = warning.turnsUsed !== void 0 && warning.turnBudget !== void 0 ? ` after exhausting the turn budget (${String(warning.turnsUsed)} of ${String(warning.turnBudget)} turns used)` : " after exhausting the turn budget";
11813
+ return `The evaluator did not pass${turns}.`;
11814
+ }
11815
+ case "plateau": {
11816
+ const dims = warning.dimensions !== void 0 && warning.dimensions.length > 0 ? ` on the same failed dimension${warning.dimensions.length === 1 ? "" : "s"}: ${warning.dimensions.join(", ")}` : "";
11817
+ return `The evaluator plateaued \u2014 two consecutive evaluations flagged the identical failure${dims}.`;
11691
11818
  }
11819
+ case "malformed": {
11820
+ const detail = warning.detail !== void 0 && warning.detail.trim().length > 0 ? ` (${warning.detail.trim()})` : "";
11821
+ return `The evaluator output could not be parsed${detail}.`;
11822
+ }
11823
+ case "verify-failed": {
11824
+ const detail = warning.detail !== void 0 && warning.detail.trim().length > 0 ? `: ${warning.detail.trim()}` : "";
11825
+ return `The post-task verify script ran red after the commit${detail}.`;
11826
+ }
11827
+ }
11828
+ };
11829
+ var remedySentence = (verdict, escalation, warning) => {
11830
+ if (escalation !== void 0) {
11831
+ return escalation.from === escalation.to ? `Remedy: retried the same model (${escalation.to}) \u2014 already at the top of the escalation ladder.` : `Remedy: escalated the generator model from ${escalation.from} to ${escalation.to}.`;
11832
+ }
11833
+ if (verdict === "escalated" && warning?.kind === "malformed") {
11834
+ return "Remedy: retried on the same model \u2014 the evaluator failed to produce a verdict, so no escalation rung was spent.";
11692
11835
  }
11836
+ if (verdict === "pass-with-warning") {
11837
+ return "Remedy: kept the attempt with the warning attached for operator review.";
11838
+ }
11839
+ return void 0;
11840
+ };
11841
+ var appendOutcomeDetail = (lines, input) => {
11842
+ const bullets = [];
11843
+ if (input.warning !== void 0) bullets.push(warningSentence(input.warning));
11844
+ const remedy = remedySentence(input.verdict, input.escalation, input.warning);
11845
+ if (remedy !== void 0) bullets.push(remedy);
11846
+ if (bullets.length === 0) return;
11847
+ lines.push("### Outcome detail");
11848
+ for (const b of bullets) lines.push(`- ${b}`);
11693
11849
  lines.push("");
11694
11850
  };
11695
11851
  var renderJournalEntry = (input) => {
11696
11852
  const sha = input.commitSha !== void 0 ? input.commitSha.slice(0, SHA_DISPLAY_LENGTH) : EM_DASH;
11697
- const outcome = input.outcome.trim().length > 0 ? input.outcome.trim() : "_(no outcome paragraph)_";
11853
+ const outcome = input.outcome.trim().length > 0 ? neutralizeProseHeadings(input.outcome.trim()) : "_(no outcome paragraph)_";
11698
11854
  const lines = [];
11699
11855
  lines.push("");
11700
- lines.push(`## Task: ${input.taskName} \u2014 Attempt ${String(input.attemptN)}`);
11856
+ lines.push(`## Task: ${sanitizeInline(input.taskName)} \u2014 Attempt ${String(input.attemptN)}`);
11701
11857
  lines.push("");
11702
11858
  lines.push(`_${String(input.timestamp)}_`);
11703
11859
  lines.push("");
@@ -11708,6 +11864,7 @@ var renderJournalEntry = (input) => {
11708
11864
  lines.push(`- Duration: ${formatDuration(input.durationMs)}`);
11709
11865
  lines.push(`- Commit: ${sha}`);
11710
11866
  lines.push("");
11867
+ appendOutcomeDetail(lines, input);
11711
11868
  appendSubsection(lines, "Changes", input.changes);
11712
11869
  appendSubsection(lines, "Decisions", input.decisions);
11713
11870
  appendLearningsSubsection(lines, input.learnings);
@@ -12756,6 +12913,22 @@ var refineTicketInteractiveLeaf = (deps, ticket) => leaf(`refine-ticket-${String
12756
12913
  }
12757
12914
  });
12758
12915
 
12916
+ // src/integration/ai/prompts/_engine/extract-placeholders.ts
12917
+ var extractPlaceholders = (template) => {
12918
+ const PATTERN = /\{\{([A-Z][A-Z0-9_]*)\}\}/g;
12919
+ const seen = /* @__PURE__ */ new Set();
12920
+ const ordered = [];
12921
+ let m;
12922
+ while ((m = PATTERN.exec(template)) !== null) {
12923
+ const name = m[1];
12924
+ if (name !== void 0 && !seen.has(name)) {
12925
+ seen.add(name);
12926
+ ordered.push(name);
12927
+ }
12928
+ }
12929
+ return ordered;
12930
+ };
12931
+
12759
12932
  // src/integration/ai/prompts/_engine/substitute.ts
12760
12933
  var PLACEHOLDER_PATTERN = /\{\{([A-Z][A-Z0-9_]*)\}\}/g;
12761
12934
  var substitute = (template, values) => template.replace(PLACEHOLDER_PATTERN, (match, key) => {
@@ -12763,14 +12936,17 @@ var substitute = (template, values) => template.replace(PLACEHOLDER_PATTERN, (ma
12763
12936
  const value = values[key];
12764
12937
  return value !== void 0 ? value : match;
12765
12938
  });
12766
- var assertFullySubstituted = (rendered, where) => {
12767
- const matches = rendered.match(PLACEHOLDER_PATTERN);
12768
- if (matches === null) return Result.ok(rendered);
12769
- const unique = Array.from(new Set(matches));
12939
+ var assertTemplateKeysFilled = (rendered, template, partialBodies, values, where) => {
12940
+ const required = new Set(extractPlaceholders(template));
12941
+ for (const body of partialBodies) {
12942
+ for (const key of extractPlaceholders(body)) required.add(key);
12943
+ }
12944
+ const missing = Array.from(required).filter((key) => values[key] === void 0);
12945
+ if (missing.length === 0) return Result.ok(rendered);
12770
12946
  return Result.error(
12771
12947
  new ParseError({
12772
12948
  subCode: "schema-mismatch",
12773
- message: `${where}: rendered prompt has ${String(unique.length)} unresolved placeholder(s): ${unique.join(", ")}. Either fill the slot in the builder's substitution map or remove the placeholder from the template.`
12949
+ message: `${where}: template declares ${String(missing.length)} unfilled placeholder(s): ${missing.map((k) => `{{${k}}}`).join(", ")}. Either fill the slot in the builder's substitution map or remove the placeholder from the template.`
12774
12950
  })
12775
12951
  );
12776
12952
  };
@@ -12780,11 +12956,13 @@ var buildPrompt = async (loader, def, input) => {
12780
12956
  const template = await loader.load(def.templateName);
12781
12957
  if (!template.ok) return Result.error(template.error);
12782
12958
  const values = {};
12959
+ const partialBodies = [];
12783
12960
  if (def.partials !== void 0) {
12784
12961
  for (const [placeholder, name] of Object.entries(def.partials)) {
12785
12962
  const partial = await loader.load(name);
12786
12963
  if (!partial.ok) return Result.error(partial.error);
12787
12964
  values[placeholder] = partial.value.trim();
12965
+ partialBodies.push(partial.value);
12788
12966
  }
12789
12967
  }
12790
12968
  for (const [field, rawSpec] of Object.entries(def.parameters)) {
@@ -12808,7 +12986,7 @@ var buildPrompt = async (loader, def, input) => {
12808
12986
  values[spec.placeholder] = String(validated.value);
12809
12987
  }
12810
12988
  const rendered = substitute(template.value, values);
12811
- return assertFullySubstituted(rendered, `buildPrompt(${def.templateName})`);
12989
+ return assertTemplateKeysFilled(rendered, template.value, partialBodies, values, `buildPrompt(${def.templateName})`);
12812
12990
  };
12813
12991
 
12814
12992
  // src/integration/ai/prompts/refine/definition.ts
@@ -13883,7 +14061,10 @@ var parseTaskList = (rawTasks, input) => {
13883
14061
  const normalisedExtras = t.extraDimensions !== void 0 ? t.extraDimensions.map((d) => d.trim().toLowerCase()).filter((d) => d.length > 0) : void 0;
13884
14062
  const created = createTask({
13885
14063
  ...mappedId !== void 0 ? { id: mappedId } : {},
13886
- name: t.name,
14064
+ // Single-line names everywhere: the name is interpolated into the journal's structural
14065
+ // `## Task: <name> — Attempt <N>` header line that cap-progress splits and attributes on —
14066
+ // a planner-emitted newline would break the task's own section boundary.
14067
+ name: t.name.replace(/[\r\n\t\v\f]+/g, " ").trim(),
13887
14068
  ...t.description !== void 0 && t.description.trim().length > 0 ? { description: t.description } : {},
13888
14069
  steps: t.steps,
13889
14070
  verificationCriteria: t.verificationCriteria.map((c) => ({
@@ -13897,7 +14078,8 @@ var parseTaskList = (rawTasks, input) => {
13897
14078
  repositoryId: repoId,
13898
14079
  ...dependsOn.length > 0 ? { dependsOn } : {},
13899
14080
  ...normalisedExtras !== void 0 && normalisedExtras.length > 0 ? { extraDimensions: normalisedExtras } : {},
13900
- ...externalRefs !== void 0 ? { externalRefs } : {}
14081
+ ...externalRefs !== void 0 ? { externalRefs } : {},
14082
+ ...input.defaultMaxAttempts !== void 0 ? { maxAttempts: input.defaultMaxAttempts } : {}
13901
14083
  });
13902
14084
  if (!created.ok) {
13903
14085
  return Result.error(
@@ -14022,7 +14204,8 @@ var parsePlanOutput = (raw, ctx) => {
14022
14204
  return parseTaskList(json, {
14023
14205
  project: ctx.project,
14024
14206
  mode: { kind: "lookup", tickets: approvedTickets },
14025
- ...ctx.logger !== void 0 ? { logger: ctx.logger } : {}
14207
+ ...ctx.logger !== void 0 ? { logger: ctx.logger } : {},
14208
+ ...ctx.defaultMaxAttempts !== void 0 ? { defaultMaxAttempts: ctx.defaultMaxAttempts } : {}
14026
14209
  });
14027
14210
  };
14028
14211
 
@@ -14068,7 +14251,8 @@ var callPlannerInteractiveLeaf = (deps) => leaf("call-planner-interactive", {
14068
14251
  const parsed = parsePlanOutput(planSignal.tasksJson, {
14069
14252
  project: input.project,
14070
14253
  sprint: input.sprint,
14071
- logger: deps.logger
14254
+ logger: deps.logger,
14255
+ defaultMaxAttempts: deps.maxAttempts
14072
14256
  });
14073
14257
  if (!parsed.ok) return Result.error(parsed.error);
14074
14258
  return planSprintUseCase({
@@ -14246,6 +14430,7 @@ var createPlanFlow = (deps, opts) => {
14246
14430
  eventBus: deps.eventBus,
14247
14431
  clock: deps.clock,
14248
14432
  model: opts.model,
14433
+ maxAttempts: opts.maxAttempts,
14249
14434
  ...opts.effort !== void 0 ? { effort: opts.effort } : {},
14250
14435
  ...opts.additionalRoots !== void 0 && opts.additionalRoots.length > 0 ? { additionalRoots: opts.additionalRoots } : {},
14251
14436
  ...deps.reviewBeforeApprove !== void 0 ? { reviewBeforeApprove: deps.reviewBeforeApprove } : {}
@@ -14327,6 +14512,7 @@ var launchPlan = async (ctx) => {
14327
14512
  additionalRoots: snapshot.project.repositories.map((r) => r.path),
14328
14513
  providerId: settings.ai.plan.provider,
14329
14514
  model: settings.ai.plan.model,
14515
+ maxAttempts: settings.harness.maxAttempts,
14330
14516
  ...effort !== void 0 ? { effort } : {},
14331
14517
  planRoot: planRoot.value
14332
14518
  }
@@ -15495,6 +15681,15 @@ var recordRunningAttemptWarning = (task, warning) => {
15495
15681
  if (!guard2.ok) return Result.error(guard2.error);
15496
15682
  return Result.ok(replaceLastAttempt(task, recordAttemptWarning(guard2.value.running, warning)));
15497
15683
  };
15684
+ var clearRunningAttemptPlateauWarning = (task) => {
15685
+ const guard2 = requireRunningAttempt(task);
15686
+ if (!guard2.ok) return task;
15687
+ const running = guard2.value.running;
15688
+ if (running.warning?.kind !== "plateau") return task;
15689
+ const { warning: _cleared, ...rest } = running;
15690
+ void _cleared;
15691
+ return replaceLastAttempt(task, rest);
15692
+ };
15498
15693
  var appendAttemptVerifyRun = (task, run) => {
15499
15694
  const guard2 = requireRunningAttempt(task);
15500
15695
  if (!guard2.ok) return Result.error(guard2.error);
@@ -15743,17 +15938,6 @@ var failCurrentAttempt = (task, now, reason, abortMeta) => {
15743
15938
  return Result.ok(inProgressNext);
15744
15939
  };
15745
15940
  var recordTaskEscalation = (task, fromModel, toModel) => {
15746
- if (task.escalatedFromModel !== void 0 || task.escalatedToModel !== void 0) {
15747
- return Result.error(
15748
- new InvalidStateError({
15749
- entity: "task",
15750
- currentState: "in_progress",
15751
- attemptedAction: "record-escalation",
15752
- message: `task '${task.id}' already escalated (${String(task.escalatedFromModel)} \u2192 ${String(task.escalatedToModel)})`,
15753
- hint: "The once-per-task cap blocks a second escalation; transition to blocked instead."
15754
- })
15755
- );
15756
- }
15757
15941
  const from = parseRequiredString("task.escalatedFromModel", fromModel);
15758
15942
  if (!from.ok) return Result.error(from.error);
15759
15943
  const to = parseRequiredString("task.escalatedToModel", toModel);
@@ -15762,35 +15946,33 @@ var recordTaskEscalation = (task, fromModel, toModel) => {
15762
15946
  };
15763
15947
 
15764
15948
  // src/business/task/escalation-policy.ts
15949
+ var triggerLabel = (trigger) => trigger === "plateau" ? "plateau" : "turn budget exhausted";
15765
15950
  var decideEscalation = (props) => {
15766
15951
  if (!props.flagOn) return { kind: "flag-off" };
15767
- if (props.task.escalatedFromModel !== void 0 && props.task.escalatedToModel !== void 0) {
15768
- return {
15769
- kind: "already-escalated",
15770
- from: props.task.escalatedFromModel,
15771
- to: props.task.escalatedToModel
15772
- };
15773
- }
15774
- if (props.task.maxAttempts !== void 0 && props.task.attempts.length >= props.task.maxAttempts) {
15952
+ const effectiveMaxAttempts = props.task.maxAttempts ?? props.fallbackMaxAttempts;
15953
+ if (props.task.attempts.length >= effectiveMaxAttempts) {
15775
15954
  return {
15776
15955
  kind: "budget-exhausted",
15777
15956
  attemptsUsed: props.task.attempts.length,
15778
- maxAttempts: props.task.maxAttempts
15957
+ maxAttempts: effectiveMaxAttempts
15779
15958
  };
15780
15959
  }
15781
- const effective = mergeEscalationMap(props.userMap);
15782
- const to = effective[props.generatorModel];
15783
- if (to === void 0 || to === props.generatorModel) {
15784
- return { kind: "nudge", currentModel: props.generatorModel };
15960
+ const merged = mergeEscalationMap(props.userMap);
15961
+ const next = merged[props.generatorModel];
15962
+ if (next !== void 0 && next !== props.generatorModel && !escalationLadderCyclicFrom(merged, props.generatorModel)) {
15963
+ return { kind: "escalate", from: props.generatorModel, to: next };
15785
15964
  }
15786
- return { kind: "escalate", from: props.generatorModel, to };
15965
+ const nudgedAtTop = props.task.escalatedFromModel !== void 0 && props.task.escalatedFromModel === props.task.escalatedToModel && props.task.escalatedToModel === props.generatorModel;
15966
+ if (nudgedAtTop) return { kind: "topped-out", model: props.generatorModel };
15967
+ return { kind: "nudge", currentModel: props.generatorModel };
15787
15968
  };
15788
15969
  var escalationBannerId = (taskId) => `model-escalation-${taskId}`;
15789
15970
  var applyEscalation = (props) => {
15790
- const { task, decision, eventBus, clock } = props;
15971
+ const { task, decision, trigger, eventBus, clock } = props;
15791
15972
  const log = props.logger.named("task.escalation-policy");
15792
15973
  const bannerId = escalationBannerId(String(task.id));
15793
15974
  const now = clock();
15975
+ const cause = triggerLabel(trigger);
15794
15976
  switch (decision.kind) {
15795
15977
  case "flag-off":
15796
15978
  return Result.ok({ task });
@@ -15803,7 +15985,7 @@ var applyEscalation = (props) => {
15803
15985
  attemptN: task.attempts.length,
15804
15986
  from: decision.from,
15805
15987
  to: decision.to,
15806
- reason: "plateau",
15988
+ reason: trigger,
15807
15989
  at: now
15808
15990
  });
15809
15991
  eventBus.publish({
@@ -15811,7 +15993,7 @@ var applyEscalation = (props) => {
15811
15993
  id: bannerId,
15812
15994
  tier: "info",
15813
15995
  message: `escalated generator model: ${decision.from} \u2192 ${decision.to}`,
15814
- cause: "plateau",
15996
+ cause,
15815
15997
  at: now
15816
15998
  });
15817
15999
  log.info(`escalating generator model: ${decision.from} \u2192 ${decision.to}`, {
@@ -15819,7 +16001,7 @@ var applyEscalation = (props) => {
15819
16001
  attemptN: task.attempts.length,
15820
16002
  from: decision.from,
15821
16003
  to: decision.to,
15822
- reason: "plateau"
16004
+ reason: trigger
15823
16005
  });
15824
16006
  return Result.ok({ task: stamped.value });
15825
16007
  }
@@ -15830,46 +16012,49 @@ var applyEscalation = (props) => {
15830
16012
  type: "banner-show",
15831
16013
  id: bannerId,
15832
16014
  tier: "info",
15833
- message: `plateau on '${decision.currentModel}' (top of ladder) \u2014 retrying with a change-of-approach directive`,
15834
- cause: "plateau",
16015
+ message: `${cause} on '${decision.currentModel}' (top of ladder) \u2014 retrying with a change-of-approach directive`,
16016
+ cause,
15835
16017
  at: now
15836
16018
  });
15837
- log.info("plateau nudge: retrying on the same model with a change-of-approach directive", {
16019
+ log.info("top-of-ladder nudge: retrying on the same model with a change-of-approach directive", {
15838
16020
  taskId: String(task.id),
15839
- currentModel: decision.currentModel
16021
+ currentModel: decision.currentModel,
16022
+ reason: trigger
15840
16023
  });
15841
16024
  return Result.ok({ task: stamped.value });
15842
16025
  }
15843
- case "already-escalated": {
15844
- const message = `plateau persists after the retry; keeping the work`;
16026
+ case "topped-out": {
16027
+ const message = `ladder exhausted on '${decision.model}' (${cause}); keeping the work`;
15845
16028
  eventBus.publish({
15846
16029
  type: "banner-show",
15847
16030
  id: bannerId,
15848
16031
  tier: "warn",
15849
- message: "plateau persists after retry \u2014 keeping the work",
16032
+ message: "ladder exhausted \u2014 keeping the work",
16033
+ cause,
15850
16034
  at: now
15851
16035
  });
15852
16036
  log.warn(message, {
15853
16037
  taskId: String(task.id),
15854
- from: decision.from,
15855
- to: decision.to
16038
+ model: decision.model,
16039
+ reason: trigger
15856
16040
  });
15857
16041
  return Result.ok({ task });
15858
16042
  }
15859
16043
  case "budget-exhausted": {
15860
- const message = `plateau with attempt budget exhausted (attempts=${String(decision.attemptsUsed)}, maxAttempts=${String(decision.maxAttempts)}); keeping the work`;
16044
+ const message = `${cause} with attempt budget exhausted (attempts=${String(decision.attemptsUsed)}, maxAttempts=${String(decision.maxAttempts)}); keeping the work`;
15861
16045
  eventBus.publish({
15862
16046
  type: "banner-show",
15863
16047
  id: bannerId,
15864
16048
  tier: "warn",
15865
- message: "plateau, attempt budget exhausted \u2014 keeping the work",
16049
+ message: `${cause}, attempt budget exhausted \u2014 keeping the work`,
15866
16050
  cause: `attempts=${String(decision.attemptsUsed)}/${String(decision.maxAttempts)}`,
15867
16051
  at: now
15868
16052
  });
15869
16053
  log.warn(message, {
15870
16054
  taskId: String(task.id),
15871
16055
  attemptsUsed: decision.attemptsUsed,
15872
- maxAttempts: decision.maxAttempts
16056
+ maxAttempts: decision.maxAttempts,
16057
+ reason: trigger
15873
16058
  });
15874
16059
  return Result.ok({ task });
15875
16060
  }
@@ -15886,7 +16071,7 @@ var mapExit = (exit) => {
15886
16071
  case "malformed":
15887
16072
  return { verdict: "malformed", warning: { kind: "malformed", detail: exit.detail } };
15888
16073
  case "plateau":
15889
- return { verdict: "failed", warning: { kind: "plateau", dimensions: exit.dimensions } };
16074
+ return { verdict: "failed" };
15890
16075
  case "budget-exhausted":
15891
16076
  return {
15892
16077
  verdict: "failed",
@@ -15894,6 +16079,47 @@ var mapExit = (exit) => {
15894
16079
  };
15895
16080
  }
15896
16081
  };
16082
+ var isEscalatableExit = (exit) => exit.kind === "plateau" || exit.kind === "budget-exhausted";
16083
+ var resolveEscalatableRemedy = (exit, props, cfg) => {
16084
+ const trigger = exit.kind;
16085
+ const decision = decideEscalation({
16086
+ task: props.task,
16087
+ generatorModel: props.generatorModel,
16088
+ flagOn: cfg.escalateOnPlateau,
16089
+ userMap: cfg.escalationMap,
16090
+ fallbackMaxAttempts: cfg.maxAttempts
16091
+ });
16092
+ const applied = applyEscalation({
16093
+ task: props.task,
16094
+ decision,
16095
+ trigger,
16096
+ eventBus: props.eventBus,
16097
+ logger: props.logger,
16098
+ clock: props.clock
16099
+ });
16100
+ if (!applied.ok) return Result.error(applied.error);
16101
+ const shouldFailAttempt = decision.kind === "escalate" || decision.kind === "nudge";
16102
+ return Result.ok({
16103
+ task: applied.value.task,
16104
+ ...applied.value.blockedReason !== void 0 ? { blockedReason: applied.value.blockedReason } : {},
16105
+ shouldFailAttempt
16106
+ });
16107
+ };
16108
+ var resolveMalformedRemedy = (props, cfg, log) => {
16109
+ const effectiveMaxAttempts = props.task.maxAttempts ?? cfg.maxAttempts;
16110
+ const budgetRemains = props.task.attempts.length < effectiveMaxAttempts;
16111
+ const shouldFailAttempt = cfg.escalateOnPlateau && budgetRemains;
16112
+ log.info(
16113
+ shouldFailAttempt ? "malformed exit: retrying on the same model (no escalation)" : "malformed exit: keeping the work (done-with-warning)",
16114
+ {
16115
+ taskId: props.task.id,
16116
+ attemptsUsed: props.task.attempts.length,
16117
+ maxAttempts: effectiveMaxAttempts,
16118
+ flagOn: cfg.escalateOnPlateau
16119
+ }
16120
+ );
16121
+ return { task: props.task, shouldFailAttempt };
16122
+ };
15897
16123
  var finalizeGenEvalUseCase = async (props) => {
15898
16124
  const log = props.logger.named("task.finalize-gen-eval");
15899
16125
  const cfg = await props.readConfig();
@@ -15905,28 +16131,21 @@ var finalizeGenEvalUseCase = async (props) => {
15905
16131
  }
15906
16132
  log.debug(`finalizing gen-eval (${exit.kind})`, { taskId: props.task.id, exitKind: exit.kind });
15907
16133
  const mapped = mapExit(exit);
15908
- let taskForPersist = props.task;
15909
- let blockedReason = mapped.blockedReason;
15910
- let shouldFailAttempt = false;
15911
- if (exit.kind === "plateau") {
15912
- const decision = decideEscalation({
15913
- task: props.task,
15914
- generatorModel: props.generatorModel,
15915
- flagOn: cfg.escalateOnPlateau,
15916
- userMap: cfg.escalationMap
15917
- });
15918
- const applied = applyEscalation({
15919
- task: props.task,
15920
- decision,
15921
- eventBus: props.eventBus,
15922
- logger: props.logger,
15923
- clock: props.clock
15924
- });
15925
- if (!applied.ok) return Result.error(applied.error);
15926
- taskForPersist = applied.value.task;
15927
- if (applied.value.blockedReason !== void 0) blockedReason = applied.value.blockedReason;
15928
- if (decision.kind === "escalate" || decision.kind === "nudge") shouldFailAttempt = true;
15929
- }
16134
+ let remedy = {
16135
+ task: exit.kind === "passed" ? clearRunningAttemptPlateauWarning(props.task) : props.task,
16136
+ ...mapped.blockedReason !== void 0 ? { blockedReason: mapped.blockedReason } : {},
16137
+ shouldFailAttempt: false
16138
+ };
16139
+ if (isEscalatableExit(exit)) {
16140
+ const resolved = resolveEscalatableRemedy(exit, props, cfg);
16141
+ if (!resolved.ok) return Result.error(resolved.error);
16142
+ remedy = resolved.value;
16143
+ } else if (exit.kind === "malformed") {
16144
+ remedy = resolveMalformedRemedy(props, cfg, log);
16145
+ }
16146
+ const taskForPersist = remedy.task;
16147
+ const blockedReason = remedy.blockedReason ?? mapped.blockedReason;
16148
+ const shouldFailAttempt = remedy.shouldFailAttempt;
15930
16149
  const persisted = await props.taskRepo.update(props.sprintId, taskForPersist);
15931
16150
  if (!persisted.ok) {
15932
16151
  log.error("persist failed", { taskId: taskForPersist.id, error: persisted.error.message });
@@ -16020,14 +16239,6 @@ var failedDimensions = (signal) => {
16020
16239
  }
16021
16240
  return names;
16022
16241
  };
16023
- var setsEqual = (a, b) => {
16024
- if (a.size === 0 || b.size === 0) return false;
16025
- if (a.size !== b.size) return false;
16026
- for (const name of a) {
16027
- if (!b.has(name)) return false;
16028
- }
16029
- return true;
16030
- };
16031
16242
  var trigrams = (text) => {
16032
16243
  const normalised = text.trim().toLowerCase().replace(/\s+/g, " ");
16033
16244
  if (normalised.length < 3) return /* @__PURE__ */ new Set([normalised]);
@@ -16050,27 +16261,71 @@ var trigramJaccard = (a, b) => {
16050
16261
  return intersection / union;
16051
16262
  };
16052
16263
  var CRITIQUE_SHIFT_SIMILARITY = 0.5;
16264
+ var WARNING_SOFTEN_CAP = 2;
16265
+ var countNeverDecreased = (turns) => {
16266
+ let prev = -1;
16267
+ for (const t of turns) {
16268
+ const n = failedDimensions(t.evaluation).size;
16269
+ if (prev !== -1 && n < prev) return false;
16270
+ prev = n;
16271
+ }
16272
+ return true;
16273
+ };
16274
+ var trailingWarningStreak = (priorTurns) => {
16275
+ let streak = 0;
16276
+ for (let i = priorTurns.length - 1; i >= 0; i--) {
16277
+ if (priorTurns[i]?.verdict === "warning") streak += 1;
16278
+ else break;
16279
+ }
16280
+ return streak;
16281
+ };
16282
+ var critiqueShiftedFromAll = (window, current) => {
16283
+ const currentCritique = current.critique;
16284
+ if (currentCritique === void 0 || currentCritique.trim().length === 0) return false;
16285
+ let maxSimilarity = 0;
16286
+ let comparedAny = false;
16287
+ for (const prior of window) {
16288
+ const priorCritique = prior.critique;
16289
+ if (priorCritique === void 0 || priorCritique.trim().length === 0) continue;
16290
+ comparedAny = true;
16291
+ const sim = trigramJaccard(priorCritique, currentCritique);
16292
+ if (sim > maxSimilarity) maxSimilarity = sim;
16293
+ }
16294
+ return comparedAny && maxSimilarity < CRITIQUE_SHIFT_SIMILARITY;
16295
+ };
16296
+ var hasHash = (r) => r.changedFilesHash !== void 0 && r.changedFilesHash.length > 0;
16297
+ var workProductChanged = (window, current) => {
16298
+ const anyPriorHash = window.some(hasHash);
16299
+ if (hasHash(current)) {
16300
+ let comparedAny = false;
16301
+ for (const prior of window) {
16302
+ if (!hasHash(prior)) continue;
16303
+ comparedAny = true;
16304
+ if (prior.changedFilesHash === current.changedFilesHash) return false;
16305
+ }
16306
+ return comparedAny;
16307
+ }
16308
+ if (anyPriorHash) {
16309
+ return false;
16310
+ }
16311
+ const lastPrior = window[window.length - 1];
16312
+ const priorSubject = lastPrior?.commitSubject;
16313
+ const currentSubject = current.commitSubject;
16314
+ return priorSubject !== void 0 && priorSubject.trim().length > 0 && currentSubject !== void 0 && currentSubject.trim().length > 0 && priorSubject.trim() !== currentSubject.trim();
16315
+ };
16053
16316
  var computePlateauVerdict = (priorTurns, current, options) => {
16054
16317
  const threshold = Math.max(2, Math.min(5, Math.trunc(options.threshold)));
16055
16318
  if (priorTurns.length < threshold - 1) return { kind: "none" };
16056
16319
  const window = priorTurns.slice(-(threshold - 1));
16057
16320
  const currentFailed = failedDimensions(current.evaluation);
16058
16321
  if (currentFailed.size === 0) return { kind: "none" };
16059
- for (const turn of window) {
16060
- if (!setsEqual(failedDimensions(turn.evaluation), currentFailed)) return { kind: "none" };
16061
- }
16062
- const lastPrior = window[window.length - 1];
16063
- if (lastPrior === void 0) return { kind: "none" };
16064
- const priorCritique = lastPrior.critique;
16065
- const currentCritique = current.critique;
16066
- if (priorCritique !== void 0 && priorCritique.trim().length > 0 && currentCritique !== void 0 && currentCritique.trim().length > 0 && trigramJaccard(priorCritique, currentCritique) < CRITIQUE_SHIFT_SIMILARITY) {
16322
+ if (!countNeverDecreased([...window, current])) return { kind: "none" };
16323
+ const dimensions = [...currentFailed];
16324
+ if (critiqueShiftedFromAll(window, current)) {
16067
16325
  return { kind: "progress", reason: "critique-shifted" };
16068
16326
  }
16069
- const dimensions = [...currentFailed];
16070
- const priorSubject = lastPrior.commitSubject;
16071
- const currentSubject = current.commitSubject;
16072
- if (priorSubject !== void 0 && priorSubject.trim().length > 0 && currentSubject !== void 0 && currentSubject.trim().length > 0 && priorSubject.trim() !== currentSubject.trim()) {
16073
- return { kind: "warning", dimensions, reason: "commit-progress" };
16327
+ if (workProductChanged(window, current) && trailingWarningStreak(priorTurns) < WARNING_SOFTEN_CAP) {
16328
+ return { kind: "warning", dimensions, reason: "work-product-changed" };
16074
16329
  }
16075
16330
  return { kind: "plateau", dimensions };
16076
16331
  };
@@ -16080,6 +16335,12 @@ var isRecoverableTurnError = (err) => err.code !== ErrorCode.Aborted && err.code
16080
16335
 
16081
16336
  // src/business/task/run-evaluator-turn.ts
16082
16337
  var findEvaluation = (signals) => signals.find((s) => s.type === "evaluation");
16338
+ var resolveCritique = (evaluation) => {
16339
+ const explicit = evaluation.critique;
16340
+ if (explicit !== void 0 && explicit.trim().length > 0) return explicit;
16341
+ const synthesized = evaluation.dimensions.filter((d) => !d.passed && d.finding.trim().length > 0).map((d) => `[${d.dimension}] ${d.finding.trim()}`).join("\n");
16342
+ return synthesized.length > 0 ? synthesized : void 0;
16343
+ };
16083
16344
  var runEvaluatorTurnUseCase = async (props) => {
16084
16345
  const log = props.logger.named("task.evaluator-turn");
16085
16346
  log.debug("running evaluator turn", { taskId: props.task.id });
@@ -16130,17 +16391,19 @@ var runEvaluatorTurnUseCase = async (props) => {
16130
16391
  exit: { kind: "malformed", detail: "evaluator emitted dimension scores but no terminal verdict" }
16131
16392
  });
16132
16393
  }
16133
- const critique = evaluation.critique;
16134
- const currentRecord = {
16394
+ const critique = resolveCritique(evaluation);
16395
+ const baseRecord = {
16135
16396
  evaluation,
16136
16397
  ...critique !== void 0 && critique.trim().length > 0 ? { critique } : {},
16137
- ...props.currentCommitSubject !== void 0 && props.currentCommitSubject.trim().length > 0 ? { commitSubject: props.currentCommitSubject } : {}
16398
+ ...props.currentCommitSubject !== void 0 && props.currentCommitSubject.trim().length > 0 ? { commitSubject: props.currentCommitSubject } : {},
16399
+ ...props.changedFilesHash !== void 0 && props.changedFilesHash.length > 0 ? { changedFilesHash: props.changedFilesHash } : {}
16138
16400
  };
16139
- const verdict = computePlateauVerdict(props.priorTurns ?? [], currentRecord, {
16401
+ const verdict = computePlateauVerdict(props.priorTurns ?? [], baseRecord, {
16140
16402
  threshold: props.plateauThreshold
16141
16403
  });
16404
+ const currentRecord = { ...baseRecord, verdict: verdict.kind };
16142
16405
  if (verdict.kind === "plateau") {
16143
- log.warn("evaluator plateaued on the same failed dimensions", {
16406
+ log.warn("evaluator plateaued \u2014 no net progress across the window", {
16144
16407
  taskId: recorded.value.id,
16145
16408
  dimensions: verdict.dimensions,
16146
16409
  threshold: props.plateauThreshold
@@ -16164,7 +16427,7 @@ var runEvaluatorTurnUseCase = async (props) => {
16164
16427
  });
16165
16428
  return Result.error(warned.error);
16166
16429
  }
16167
- log.info("plateau softened to warning \u2014 commit-message changed; continuing", {
16430
+ log.info("plateau softened to warning \u2014 work product changed; continuing", {
16168
16431
  taskId: warned.value.id,
16169
16432
  dimensions: verdict.dimensions,
16170
16433
  reason: verdict.reason
@@ -16288,6 +16551,150 @@ var buildEvaluatePrompt = async (deps, input) => buildPrompt(deps, evaluatePromp
16288
16551
  priorProgress: input.priorProgress ?? ""
16289
16552
  });
16290
16553
 
16554
+ // src/integration/ai/prompts/evaluate-continuation/definition.ts
16555
+ var requireNonEmpty3 = (field, message) => (v) => v.trim().length === 0 ? Result.error(new ValidationError({ field, value: v, message })) : Result.ok(v);
16556
+ var evaluateContinuationPromptDef = {
16557
+ templateName: "evaluate-continuation",
16558
+ description: "Resumed evaluator turn (round 2+ of a session thread). Carries only the per-round delta \u2014 round number, recent journal \u2014 because the specification and rubric are already in the conversation.",
16559
+ parameters: {
16560
+ roundNumber: {
16561
+ placeholder: "ROUND_NUMBER",
16562
+ description: "Current gen-eval round number rendered as a string.",
16563
+ validate: requireNonEmpty3("roundNumber", "round number must not be empty")
16564
+ },
16565
+ contractPath: {
16566
+ placeholder: "CONTRACT_PATH",
16567
+ description: "Absolute path to the per-task contract.md sidecar \u2014 re-read hedge for a context-free resume.",
16568
+ validate: requireNonEmpty3("contractPath", "contract path must not be empty")
16569
+ },
16570
+ progressFile: {
16571
+ placeholder: "PROGRESS_FILE",
16572
+ description: "Absolute path to the sprint progress.md journal \u2014 full history when the inline excerpt is short.",
16573
+ validate: requireNonEmpty3("progressFile", "progress file path must not be empty")
16574
+ },
16575
+ priorProgress: {
16576
+ placeholder: "PRIOR_PROGRESS",
16577
+ description: "Capped recent slice of progress.md inlined into the prior-progress block \u2014 empty when none yet."
16578
+ },
16579
+ outputContractSection: {
16580
+ placeholder: "OUTPUT_CONTRACT_SECTION",
16581
+ description: "Audit-[09] output contract block rendered for THIS round's evaluator output directory \u2014 names the current signals.json path.",
16582
+ validate: requireNonEmpty3(
16583
+ "outputContractSection",
16584
+ "output-contract section must not be empty (renderContractSectionFor always emits a body)"
16585
+ )
16586
+ }
16587
+ },
16588
+ partials: {
16589
+ HARNESS_CONTEXT: "harness-context"
16590
+ },
16591
+ // Same single-signal contract as the full evaluate prompt — a continuation turn still emits
16592
+ // exactly one `evaluation` verdict.
16593
+ expectedSignals: ["evaluation"]
16594
+ };
16595
+ var buildEvaluateContinuationPrompt = async (deps, input) => buildPrompt(deps, evaluateContinuationPromptDef, {
16596
+ roundNumber: String(input.roundNumber),
16597
+ contractPath: input.contractPath,
16598
+ progressFile: input.progressFile,
16599
+ priorProgress: input.priorProgress,
16600
+ outputContractSection: input.outputContractSection
16601
+ });
16602
+
16603
+ // src/integration/ai/contract/_engine/corrective-retry.ts
16604
+ import { ZodError } from "zod";
16605
+ var isCorrectableContractError = (err) => err.code !== ErrorCode.Aborted && err.code !== ErrorCode.RateLimit && err.code !== ErrorCode.MigrationGap;
16606
+ var MAX_ENUMERATED_ISSUES = 10;
16607
+ var correctiveBody = (err, signalsPath, selfContainedContext) => {
16608
+ const lines = [];
16609
+ lines.push("Your previous `signals.json` did not satisfy the output contract, so the harness could");
16610
+ lines.push("not read a verdict. Fix it now in ONE follow-up \u2014 re-read the output-contract section");
16611
+ lines.push("below and re-write the file. Do not change anything else.");
16612
+ lines.push("");
16613
+ if (err instanceof ParseError && err.subCode === "schema-mismatch" && err.cause instanceof ZodError) {
16614
+ lines.push("The shape failed schema validation. Each line below is one problem \u2014 fix every one:");
16615
+ lines.push("");
16616
+ const issues = err.cause.issues;
16617
+ for (const issue of issues.slice(0, MAX_ENUMERATED_ISSUES)) {
16618
+ const path = issue.path.length > 0 ? issue.path.join(".") : "(root)";
16619
+ lines.push(`- at \`${path}\`: ${issue.message}`);
16620
+ }
16621
+ if (issues.length > MAX_ENUMERATED_ISSUES) {
16622
+ lines.push(
16623
+ `- \u2026plus ${String(issues.length - MAX_ENUMERATED_ISSUES)} more of the same kinds \u2014 fix the pattern, not just the listed lines.`
16624
+ );
16625
+ }
16626
+ lines.push("");
16627
+ lines.push("Common cause: a terminal `evaluation` verdict (`passed` / `failed`) MUST grade all four");
16628
+ lines.push("floor dimensions \u2014 correctness, completeness, safety, consistency \u2014 each with a finding.");
16629
+ } else if (err instanceof ParseError && err.subCode === "invalid-json") {
16630
+ lines.push(`The file at \`${signalsPath}\` existed but was not valid JSON. Re-write it as a single`);
16631
+ lines.push("valid JSON object matching the `{ schemaVersion, signals }` shape. Check for trailing");
16632
+ lines.push("commas, unescaped quotes inside string fields, and truncated output.");
16633
+ } else {
16634
+ lines.push(`You did not write \`${signalsPath}\`. Write that exact absolute path now with your`);
16635
+ lines.push("`Write` tool, matching the `{ schemaVersion, signals }` shape from the contract above.");
16636
+ }
16637
+ lines.push("");
16638
+ lines.push(`Write the corrected file to the exact absolute path \`${signalsPath}\` and stop.`);
16639
+ lines.push("");
16640
+ lines.push("If this session does not already hold the task context (fresh session), re-read the");
16641
+ lines.push("referenced on-disk files before acting \u2014 do NOT invent a verdict or content from this");
16642
+ lines.push("message alone.");
16643
+ lines.push("");
16644
+ lines.push(selfContainedContext);
16645
+ return lines.join("\n");
16646
+ };
16647
+ var validateSignalsFileWithCorrectiveRetry = async (deps, contract) => {
16648
+ const first = await validateSignalsFile(deps.outputDir, contract);
16649
+ if (first.ok) return first;
16650
+ const err = first.error;
16651
+ if (!isCorrectableContractError(err)) return Result.error(err);
16652
+ const log = deps.logger.named("ai.contract.corrective-retry");
16653
+ const signalsPath = `${String(deps.outputDir)}/signals.json`;
16654
+ log.warn("signals.json failed the contract \u2014 issuing one corrective retry", {
16655
+ outputDir: String(deps.outputDir),
16656
+ error: err.message
16657
+ });
16658
+ const corrective = correctiveBody(err, signalsPath, deps.selfContainedContext);
16659
+ const respawn = await deps.reinvoke(corrective);
16660
+ if (!respawn.ok) {
16661
+ log.warn("corrective retry spawn failed \u2014 self-blocking on the spawn error", {
16662
+ outputDir: String(deps.outputDir),
16663
+ error: respawn.error.message
16664
+ });
16665
+ return Result.error(respawn.error);
16666
+ }
16667
+ const second = await validateSignalsFile(deps.outputDir, contract);
16668
+ if (second.ok) {
16669
+ log.info("corrective retry produced a valid signals.json", { outputDir: String(deps.outputDir) });
16670
+ return second;
16671
+ }
16672
+ log.warn("corrective retry still failed the contract \u2014 self-blocking", {
16673
+ outputDir: String(deps.outputDir),
16674
+ error: second.error.message
16675
+ });
16676
+ return Result.error(second.error);
16677
+ };
16678
+
16679
+ // src/application/flows/implement/leaves/work-product-fingerprint.ts
16680
+ import { createHash as createHash2 } from "crypto";
16681
+ var computeWorkProductFingerprint = async (gitRunner, cwd) => {
16682
+ const status = await gitRunner.run(cwd, ["status", "--porcelain"]);
16683
+ if (!status.ok || status.value.exitCode !== 0) return void 0;
16684
+ const diff = await gitRunner.run(cwd, ["diff", "HEAD"]);
16685
+ if (!diff.ok || diff.value.exitCode !== 0) return void 0;
16686
+ const hash = createHash2("sha1").update(status.value.stdout).update("\0").update(diff.value.stdout);
16687
+ const untracked = await gitRunner.run(cwd, ["ls-files", "--others", "--exclude-standard"]);
16688
+ if (!untracked.ok || untracked.value.exitCode !== 0) return void 0;
16689
+ const paths = untracked.value.stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
16690
+ if (paths.length > 0) {
16691
+ const blobs = await gitRunner.run(cwd, ["hash-object", "--", ...paths]);
16692
+ if (!blobs.ok || blobs.value.exitCode !== 0) return void 0;
16693
+ hash.update("\0").update(blobs.value.stdout);
16694
+ }
16695
+ return hash.digest("hex");
16696
+ };
16697
+
16291
16698
  // src/application/flows/implement/leaves/implement-session.ts
16292
16699
  import { dirname as dirname14 } from "path";
16293
16700
 
@@ -16336,6 +16743,11 @@ import { z as z33 } from "zod";
16336
16743
 
16337
16744
  // src/integration/ai/contract/_engine/signals/evaluation/schema.ts
16338
16745
  import { z as z32 } from "zod";
16746
+
16747
+ // src/domain/signal.ts
16748
+ var FLOOR_DIMENSIONS = ["correctness", "completeness", "safety", "consistency"];
16749
+
16750
+ // src/integration/ai/contract/_engine/signals/evaluation/schema.ts
16339
16751
  var dimensionScoreSchema = z32.object({
16340
16752
  dimension: z32.string(),
16341
16753
  passed: z32.boolean(),
@@ -16357,6 +16769,16 @@ var evaluationSignalSchema = z32.object({
16357
16769
  critique: z32.string().optional(),
16358
16770
  timestamp: IsoTimestampSchema
16359
16771
  }).superRefine((s, ctx) => {
16772
+ if (s.status === "malformed") return;
16773
+ const present = new Set(s.dimensions.map((d) => d.dimension.trim().toLowerCase()));
16774
+ const missing = FLOOR_DIMENSIONS.filter((name) => !present.has(name));
16775
+ if (missing.length > 0) {
16776
+ ctx.addIssue({
16777
+ code: "custom",
16778
+ message: `status is "${s.status}" but the required floor dimension(s) ${missing.join(", ")} are missing \u2014 every terminal verdict must grade correctness, completeness, safety, and consistency`,
16779
+ path: ["dimensions"]
16780
+ });
16781
+ }
16360
16782
  if (s.status === "passed") {
16361
16783
  const failedIndex = s.dimensions.findIndex((d) => !d.passed);
16362
16784
  if (failedIndex !== -1) {
@@ -16366,7 +16788,7 @@ var evaluationSignalSchema = z32.object({
16366
16788
  path: ["dimensions", failedIndex, "passed"]
16367
16789
  });
16368
16790
  }
16369
- } else if (s.status === "failed") {
16791
+ } else {
16370
16792
  const anyFailed = s.dimensions.some((d) => !d.passed);
16371
16793
  if (!anyFailed) {
16372
16794
  ctx.addIssue({
@@ -16502,14 +16924,68 @@ var writeRoundPrompt = async (workspaceRoot, round, role, prompt, logger) => {
16502
16924
  }
16503
16925
  };
16504
16926
 
16927
+ // src/application/flows/implement/leaves/_shared/cap-progress.ts
16928
+ var RECENT_ATTEMPT_SECTIONS = 3;
16929
+ var ATTEMPT_DELIMITER = /^(?=## Task: )/m;
16930
+ var elisionNote = (droppedCount) => `_${String(droppedCount)} earlier attempt section${droppedCount === 1 ? "" : "s"} omitted from this inline excerpt \u2014 read the full \`progress.md\` on disk for the complete history._
16931
+
16932
+ `;
16933
+ var capProgressBody = (body, recentSections = RECENT_ATTEMPT_SECTIONS, currentTaskName) => {
16934
+ if (body.trim().length === 0) return "";
16935
+ const pieces = body.split(ATTEMPT_DELIMITER);
16936
+ if (pieces.length <= 1) return body;
16937
+ const [header, ...sections] = pieces;
16938
+ const isCurrentTask = (section) => currentTaskName !== void 0 && section.startsWith(`## Task: ${currentTaskName} \u2014 Attempt`);
16939
+ const otherIndexes = sections.flatMap((s, i) => isCurrentTask(s) ? [] : [i]);
16940
+ const keptOtherIndexes = new Set(otherIndexes.slice(Math.max(0, otherIndexes.length - recentSections)));
16941
+ const keep = (i) => isCurrentTask(sections[i] ?? "") || keptOtherIndexes.has(i);
16942
+ if (sections.every((_s, i) => keep(i))) return body;
16943
+ const parts = [header ?? ""];
16944
+ let droppedRun = 0;
16945
+ for (let i = 0; i < sections.length; i += 1) {
16946
+ if (keep(i)) {
16947
+ if (droppedRun > 0) {
16948
+ parts.push(elisionNote(droppedRun));
16949
+ droppedRun = 0;
16950
+ }
16951
+ parts.push(sections[i] ?? "");
16952
+ } else {
16953
+ droppedRun += 1;
16954
+ }
16955
+ }
16956
+ if (droppedRun > 0) parts.push(elisionNote(droppedRun));
16957
+ return parts.join("");
16958
+ };
16959
+
16505
16960
  // src/application/flows/implement/leaves/evaluator.ts
16506
- var readProgressFile = async (path) => {
16961
+ var readCappedProgress = async (path, currentTaskName) => {
16507
16962
  try {
16508
- return await fs22.readFile(path, "utf8");
16963
+ return capProgressBody(await fs22.readFile(path, "utf8"), RECENT_ATTEMPT_SECTIONS, currentTaskName);
16509
16964
  } catch {
16510
16965
  return "";
16511
16966
  }
16512
16967
  };
16968
+ var buildEvaluatorPrompt = async (deps, args) => {
16969
+ const priorProgress = await readCappedProgress(String(deps.progressFile), args.task.name);
16970
+ const contractPath = join30(String(args.workspaceRoot), "contract.md");
16971
+ if (args.priorEvaluatorSessionId === void 0) {
16972
+ return buildEvaluatePrompt(deps.templateLoader, {
16973
+ task: args.task,
16974
+ projectPath: String(deps.cwd),
16975
+ contractPath,
16976
+ outputContractSection: args.outputContractSection,
16977
+ priorProgress,
16978
+ ...deps.verifyScript !== void 0 ? { verifyScript: deps.verifyScript } : {}
16979
+ });
16980
+ }
16981
+ return buildEvaluateContinuationPrompt(deps.templateLoader, {
16982
+ roundNumber: args.roundNum,
16983
+ contractPath,
16984
+ progressFile: String(deps.progressFile),
16985
+ priorProgress,
16986
+ outputContractSection: args.outputContractSection
16987
+ });
16988
+ };
16513
16989
  var evaluatorLeaf = (deps, taskId) => leaf(`evaluator-${String(taskId)}`, {
16514
16990
  useCase: {
16515
16991
  execute: async (input, signal) => {
@@ -16520,14 +16996,13 @@ var evaluatorLeaf = (deps, taskId) => leaf(`evaluator-${String(taskId)}`, {
16520
16996
  if (!outputDirPath.ok) return Result.error(outputDirPath.error);
16521
16997
  const outputDir = outputDirPath.value;
16522
16998
  const callEvaluate = async (task) => {
16523
- const priorProgress = await readProgressFile(String(deps.progressFile));
16524
- const prompt = await buildEvaluatePrompt(deps.templateLoader, {
16999
+ const outputContractSection = renderContractSectionFor(evaluatorOutputContract, outputDir);
17000
+ const prompt = await buildEvaluatorPrompt(deps, {
16525
17001
  task,
16526
- projectPath: String(deps.cwd),
16527
- contractPath: join30(String(input.workspaceRoot), "contract.md"),
16528
- outputContractSection: renderContractSectionFor(evaluatorOutputContract, outputDir),
16529
- priorProgress,
16530
- ...deps.verifyScript !== void 0 ? { verifyScript: deps.verifyScript } : {}
17002
+ workspaceRoot: input.workspaceRoot,
17003
+ roundNum: input.roundNum,
17004
+ outputContractSection,
17005
+ priorEvaluatorSessionId: input.priorEvaluatorSessionId
16531
17006
  });
16532
17007
  if (!prompt.ok) return Result.error(prompt.error);
16533
17008
  await writeRoundPrompt(input.workspaceRoot, input.roundNum, "evaluator", String(prompt.value), deps.logger);
@@ -16546,7 +17021,43 @@ var evaluatorLeaf = (deps, taskId) => leaf(`evaluator-${String(taskId)}`, {
16546
17021
  )
16547
17022
  );
16548
17023
  if (!spawn3.ok) return Result.error(spawn3.error);
16549
- const validated = await validateSignalsFile(outputDir, evaluatorOutputContract);
17024
+ const validated = await validateSignalsFileWithCorrectiveRetry(
17025
+ {
17026
+ outputDir,
17027
+ logger: deps.logger,
17028
+ // Self-containment for a COLD corrective spawn (no resumable id / codex stale-resume
17029
+ // fallback): the per-round output contract plus the reviewer's grounding — without
17030
+ // this, a context-free retry's whole prompt is the error text, which is exactly
17031
+ // enough scaffolding to fabricate a schema-valid verdict for unseen work.
17032
+ selfContainedContext: [
17033
+ `Task spec (read it): \`${join30(String(input.workspaceRoot), "contract.md")}\``,
17034
+ "Your PRIMARY INPUT is the uncommitted working-tree diff \u2014 inspect it via shell",
17035
+ "(`git status` / `git diff HEAD`) before grading. A verdict must reflect the actual",
17036
+ "work, never this message.",
17037
+ "",
17038
+ outputContractSection
17039
+ ].join("\n"),
17040
+ reinvoke: async (corrective) => {
17041
+ const resume = await readRoundSessionId(input.workspaceRoot, input.roundNum, "evaluator") ?? input.priorEvaluatorSessionId;
17042
+ const respawn = await deps.provider.generate(
17043
+ implementSession(
17044
+ input.workspaceRoot,
17045
+ deps.cwd,
17046
+ deps.sprintDir,
17047
+ corrective,
17048
+ deps.model,
17049
+ signalsFile,
17050
+ "evaluator",
17051
+ resume,
17052
+ deps.effort,
17053
+ signal
17054
+ )
17055
+ );
17056
+ return respawn.ok ? Result.ok(void 0) : Result.error(respawn.error);
17057
+ }
17058
+ },
17059
+ evaluatorOutputContract
17060
+ );
16550
17061
  if (!validated.ok) return Result.error(validated.error);
16551
17062
  const signals = validated.value;
16552
17063
  for (const sig of signals) {
@@ -16556,11 +17067,13 @@ var evaluatorLeaf = (deps, taskId) => leaf(`evaluator-${String(taskId)}`, {
16556
17067
  await renderSidecars(deps.writeFile, outputDir, signals, evaluatorOutputContract.sidecars, deps.logger);
16557
17068
  return Result.ok(signals);
16558
17069
  };
17070
+ const changedFilesHash = await computeWorkProductFingerprint(deps.gitRunner, deps.cwd);
16559
17071
  const result = await runEvaluatorTurnUseCase({
16560
17072
  task: input.task,
16561
17073
  priorTurns: input.priorTurns,
16562
17074
  plateauThreshold: deps.plateauThreshold,
16563
17075
  ...input.currentCommitSubject !== void 0 ? { currentCommitSubject: input.currentCommitSubject } : {},
17076
+ ...changedFilesHash !== void 0 ? { changedFilesHash } : {},
16564
17077
  callEvaluate,
16565
17078
  evaluationFile: roundEvaluationRelativePath(input.roundNum),
16566
17079
  logger: deps.logger
@@ -16685,14 +17198,112 @@ var runGeneratorTurnUseCase = async (props) => {
16685
17198
  });
16686
17199
  };
16687
17200
 
17201
+ // src/integration/ai/prompts/implement-continuation/definition.ts
17202
+ var requireNonEmpty4 = (field, message) => (v) => v.trim().length === 0 ? Result.error(new ValidationError({ field, value: v, message })) : Result.ok(v);
17203
+ var implementContinuationPromptDef = {
17204
+ templateName: "implement-continuation",
17205
+ description: "Resumed generator turn (round 2+ of a session thread). Carries only the per-round delta \u2014 critique, round number, plateau directive \u2014 because the full brief is already in the conversation.",
17206
+ parameters: {
17207
+ roundNumber: {
17208
+ placeholder: "ROUND_NUMBER",
17209
+ description: "Current gen-eval round number rendered as a string.",
17210
+ validate: requireNonEmpty4("roundNumber", "round number must not be empty")
17211
+ },
17212
+ contractPath: {
17213
+ placeholder: "CONTRACT_PATH",
17214
+ description: "Absolute path to the per-task contract.md sidecar \u2014 re-read hedge for a context-free resume.",
17215
+ validate: requireNonEmpty4("contractPath", "contract path must not be empty")
17216
+ },
17217
+ progressFile: {
17218
+ placeholder: "PROGRESS_FILE",
17219
+ description: "Absolute path to the sprint progress.md journal \u2014 full history when the inline excerpt is short.",
17220
+ validate: requireNonEmpty4("progressFile", "progress file path must not be empty")
17221
+ },
17222
+ priorProgress: {
17223
+ placeholder: "PRIOR_PROGRESS",
17224
+ description: "Capped recent slice of progress.md inlined into the prior-progress block \u2014 empty when none yet."
17225
+ },
17226
+ priorCritiqueSection: {
17227
+ placeholder: "PRIOR_CRITIQUE_SECTION",
17228
+ description: `"## Prior Critique" markdown block \u2014 the prior round's failed evaluator critique, verbatim.`
17229
+ },
17230
+ plateauDirectiveSection: {
17231
+ placeholder: "PLATEAU_DIRECTIVE_SECTION",
17232
+ description: '"## \u26A0 You have plateaued \u2014 change your approach" block \u2014 empty unless this round is a plateau-break attempt.'
17233
+ },
17234
+ outputContractSection: {
17235
+ placeholder: "OUTPUT_CONTRACT_SECTION",
17236
+ description: "Audit-[09] output contract block rendered for THIS round's output directory \u2014 names the current signals.json path.",
17237
+ validate: requireNonEmpty4(
17238
+ "outputContractSection",
17239
+ "output-contract section must not be empty (renderContractSectionFor always emits a body)"
17240
+ )
17241
+ }
17242
+ },
17243
+ partials: {
17244
+ HARNESS_CONTEXT: "harness-context"
17245
+ },
17246
+ // Same accepted signal union as the full implement prompt — a continuation turn is still a
17247
+ // generator turn and may emit the full narrative + lifecycle set.
17248
+ expectedSignals: [
17249
+ "change",
17250
+ "decision",
17251
+ "learning",
17252
+ "note",
17253
+ "task-verified",
17254
+ "task-complete",
17255
+ "task-blocked",
17256
+ "commit-message"
17257
+ ]
17258
+ };
17259
+ var buildImplementContinuationPrompt = async (deps, input) => buildPrompt(deps, implementContinuationPromptDef, {
17260
+ roundNumber: String(input.roundNumber),
17261
+ contractPath: input.contractPath,
17262
+ progressFile: input.progressFile,
17263
+ priorProgress: input.priorProgress,
17264
+ priorCritiqueSection: renderPriorCritiqueSection(input.priorCritique),
17265
+ plateauDirectiveSection: renderPlateauDirectiveSection(input.plateauBreak ?? false),
17266
+ outputContractSection: input.outputContractSection
17267
+ });
17268
+
16688
17269
  // src/application/flows/implement/leaves/generator.ts
16689
- var readProgressFile2 = async (path) => {
17270
+ var readCappedProgress2 = async (path, currentTaskName) => {
16690
17271
  try {
16691
- return await fs23.readFile(path, "utf8");
17272
+ return capProgressBody(await fs23.readFile(path, "utf8"), RECENT_ATTEMPT_SECTIONS, currentTaskName);
16692
17273
  } catch {
16693
17274
  return "";
16694
17275
  }
16695
17276
  };
17277
+ var buildGeneratorPrompt = async (deps, args) => {
17278
+ const priorCritique = latestCritique(args.task);
17279
+ const priorProgress = await readCappedProgress2(String(deps.progressFile), args.task.name);
17280
+ const contractPath = join31(String(args.workspaceRoot), "contract.md");
17281
+ const lastSettled = [...args.task.attempts].reverse().find((a) => a.status !== "running");
17282
+ const stallDriven = lastSettled?.warning?.kind === "plateau" || lastSettled?.warning?.kind === "budget-exhausted";
17283
+ const plateauBreak = args.task.escalatedFromModel !== void 0 && args.task.escalatedFromModel === args.task.escalatedToModel && stallDriven;
17284
+ if (args.priorGeneratorSessionId === void 0) {
17285
+ return buildImplementPrompt(deps.templateLoader, {
17286
+ task: args.task,
17287
+ projectPath: String(deps.cwd),
17288
+ contractPath,
17289
+ progressFile: String(deps.progressFile),
17290
+ priorProgress,
17291
+ outputContractSection: args.outputContractSection,
17292
+ ...deps.verifyScript !== void 0 ? { verifyScript: deps.verifyScript } : {},
17293
+ ...priorCritique !== void 0 ? { priorCritique } : {},
17294
+ ...plateauBreak ? { plateauBreak: true } : {}
17295
+ });
17296
+ }
17297
+ return buildImplementContinuationPrompt(deps.templateLoader, {
17298
+ roundNumber: args.roundNum,
17299
+ contractPath,
17300
+ progressFile: String(deps.progressFile),
17301
+ priorProgress,
17302
+ outputContractSection: args.outputContractSection,
17303
+ ...priorCritique !== void 0 ? { priorCritique } : {},
17304
+ ...plateauBreak ? { plateauBreak: true } : {}
17305
+ });
17306
+ };
16696
17307
  var generatorLeaf = (deps, taskId) => leaf(`generator-${String(taskId)}`, {
16697
17308
  useCase: {
16698
17309
  execute: async (input, signal) => {
@@ -16727,21 +17338,13 @@ var generatorLeaf = (deps, taskId) => leaf(`generator-${String(taskId)}`, {
16727
17338
  const learningsEmitted = [];
16728
17339
  const notesEmitted = [];
16729
17340
  const callImplement = async (task) => {
16730
- const priorCritique = latestCritique(task);
16731
- const priorProgress = await readProgressFile2(String(deps.progressFile));
16732
- const prompt = await buildImplementPrompt(deps.templateLoader, {
17341
+ const outputContractSection = renderContractSectionFor(generatorOutputContract, outputDir);
17342
+ const prompt = await buildGeneratorPrompt(deps, {
16733
17343
  task,
16734
- projectPath: String(deps.cwd),
16735
- contractPath: join31(String(input.workspaceRoot), "contract.md"),
16736
- progressFile: String(deps.progressFile),
16737
- priorProgress,
16738
- outputContractSection: renderContractSectionFor(generatorOutputContract, outputDir),
16739
- ...deps.verifyScript !== void 0 ? { verifyScript: deps.verifyScript } : {},
16740
- ...priorCritique !== void 0 ? { priorCritique } : {},
16741
- // Plateau-break attempt: the escalation policy stamped the task (model bump and/or a
16742
- // change-of-approach nudge) after the gen-eval loop stalled. Surface the "change your
16743
- // approach" directive so the generator abandons the non-converging path.
16744
- ...task.escalatedFromModel !== void 0 ? { plateauBreak: true } : {}
17344
+ workspaceRoot: input.workspaceRoot,
17345
+ roundNum,
17346
+ outputContractSection,
17347
+ priorGeneratorSessionId: input.priorGeneratorSessionId
16745
17348
  });
16746
17349
  if (!prompt.ok) return Result.error(prompt.error);
16747
17350
  await writeRoundPrompt(input.workspaceRoot, roundNum, "generator", String(prompt.value), deps.logger);
@@ -16761,7 +17364,39 @@ var generatorLeaf = (deps, taskId) => leaf(`generator-${String(taskId)}`, {
16761
17364
  )
16762
17365
  );
16763
17366
  if (!spawn3.ok) return Result.error(spawn3.error);
16764
- const validated = await validateSignalsFile(outputDir, generatorOutputContract);
17367
+ const validated = await validateSignalsFileWithCorrectiveRetry(
17368
+ {
17369
+ outputDir,
17370
+ logger: deps.logger,
17371
+ // Self-containment for a COLD corrective spawn (no resumable id / codex stale-resume
17372
+ // fallback): the per-round output contract + the on-disk task spec, so a fresh
17373
+ // session re-reads its grounding instead of emitting signals from the error text.
17374
+ selfContainedContext: [
17375
+ `Task spec (read it): \`${join31(String(input.workspaceRoot), "contract.md")}\``,
17376
+ "",
17377
+ outputContractSection
17378
+ ].join("\n"),
17379
+ reinvoke: async (corrective) => {
17380
+ const resume = await readRoundSessionId(input.workspaceRoot, roundNum, "generator") ?? input.priorGeneratorSessionId;
17381
+ const respawn = await deps.provider.generate(
17382
+ implementSession(
17383
+ input.workspaceRoot,
17384
+ deps.cwd,
17385
+ deps.sprintDir,
17386
+ corrective,
17387
+ effectiveModel,
17388
+ signalsFile,
17389
+ "generator",
17390
+ resume,
17391
+ deps.effort,
17392
+ signal
17393
+ )
17394
+ );
17395
+ return respawn.ok ? Result.ok(void 0) : Result.error(respawn.error);
17396
+ }
17397
+ },
17398
+ generatorOutputContract
17399
+ );
16765
17400
  if (!validated.ok) return Result.error(validated.error);
16766
17401
  const signals = validated.value;
16767
17402
  for (const sig of signals) {
@@ -17076,6 +17711,9 @@ var createGenEvalLoop = (deps, opts, taskId) => {
17076
17711
  };
17077
17712
  const evaluatorLeafDeps = {
17078
17713
  ...sharedLeafDeps,
17714
+ // Evaluator-only: the work-product fingerprint for the plateau predicate. The generator
17715
+ // leaf neither needs nor accepts the git runner.
17716
+ gitRunner: deps.gitRunner,
17079
17717
  provider: deps.evaluatorProvider,
17080
17718
  model: opts.evaluator.model,
17081
17719
  ...opts.evaluator.effort !== void 0 ? { effort: opts.evaluator.effort } : {}
@@ -17130,9 +17768,16 @@ var createGenEvalLoop = (deps, opts, taskId) => {
17130
17768
  )
17131
17769
  ]),
17132
17770
  {
17133
- shouldContinue: async (_ctx, i) => {
17771
+ // Loop-entry guard. Refuse to enter a turn when a terminal exit is ALREADY on ctx — the
17772
+ // only way `lastExit` is set at loop entry is a pre-task-verify block/skip (start-attempt
17773
+ // runs before pre-verify and settle-attempt clears `lastExit` at the end of every attempt,
17774
+ // so no stale exit can leak across attempts/tasks; the parallel merge-wave classifies
17775
+ // `lastExit` PER_TASK). Without this check a pre-blocked task would still claim a
17776
+ // `rounds/<N>/` dir, stamp two meta sidecars, and spawn one full generator session on the
17777
+ // exact broken tree the gate refused — the most expensive unit in the system.
17778
+ shouldContinue: async (ctx, i) => {
17134
17779
  const cfg = await deps.readConfig();
17135
- return i <= Math.max(1, cfg.maxTurns);
17780
+ return ctx.lastExit === void 0 && i <= Math.max(1, cfg.maxTurns);
17136
17781
  },
17137
17782
  shouldStop: (ctx) => ctx.lastExit !== void 0
17138
17783
  }
@@ -17484,6 +18129,17 @@ var legacyVerifyResult = (run, rawOutput, spawnErrorMessage) => {
17484
18129
  var postTaskVerifyLeaf = (deps, opts, taskId) => leaf(`post-task-verify-${String(taskId)}`, {
17485
18130
  useCase: {
17486
18131
  execute: async (input, signal) => {
18132
+ if (input.preVerifyBlockedZeroTurn) {
18133
+ const skipped = {
18134
+ phase: "post",
18135
+ ranAt: deps.clock(),
18136
+ command: "",
18137
+ exitCode: 0,
18138
+ durationMs: 0,
18139
+ outcome: "skipped"
18140
+ };
18141
+ return Result.ok({ task: input.task, run: skipped, rawOutput: "" });
18142
+ }
17487
18143
  const { run, rawOutput, spawnErrorMessage } = await runVerifyScriptUseCase({
17488
18144
  cwd: opts.cwd,
17489
18145
  phase: "post",
@@ -17599,9 +18255,11 @@ var postTaskVerifyLeaf = (deps, opts, taskId) => leaf(`post-task-verify-${String
17599
18255
  message: `post-task-verify-${String(taskId)}: expected in_progress task \u2014 got '${ctx.currentTask.status}'`
17600
18256
  });
17601
18257
  }
18258
+ const preVerifyBlockedZeroTurn = ctx.lastBlockReason !== void 0 && ctx.genEvalTurn === void 0;
17602
18259
  return {
17603
18260
  task: ctx.currentTask,
17604
18261
  sprintId: ctx.sprintId,
18262
+ preVerifyBlockedZeroTurn,
17605
18263
  ...ctx.lastPreVerifyOutcome !== void 0 ? { preOutcome: ctx.lastPreVerifyOutcome } : {}
17606
18264
  };
17607
18265
  },
@@ -17627,6 +18285,148 @@ var postTaskVerifyLeaf = (deps, opts, taskId) => leaf(`post-task-verify-${String
17627
18285
  }
17628
18286
  });
17629
18287
 
18288
+ // src/business/task/record-quarantine.ts
18289
+ var QUARANTINE_LINE_PREFIX = "Rejected diff quarantined to git stash";
18290
+ var buildReason = (current, stashMessage) => {
18291
+ const line = `${QUARANTINE_LINE_PREFIX} (recover via \`git stash list\`): ${stashMessage}`;
18292
+ if (current.includes(line)) return current;
18293
+ return `${current}
18294
+ ${line}`;
18295
+ };
18296
+ var recordQuarantineUseCase = async (props) => {
18297
+ const log = props.logger.named("task.record-quarantine");
18298
+ if (props.task.status !== "blocked") {
18299
+ return Result.error(
18300
+ new InvalidStateError({
18301
+ entity: "task",
18302
+ currentState: props.task.status,
18303
+ attemptedAction: "record-quarantine",
18304
+ message: `record-quarantine: expected a blocked task \u2014 got '${props.task.status}'`
18305
+ })
18306
+ );
18307
+ }
18308
+ const nextReason = buildReason(props.task.blockedReason, props.stashMessage);
18309
+ if (nextReason === props.task.blockedReason) {
18310
+ log.debug("quarantine pointer already recorded; skipping re-write", {
18311
+ taskId: props.task.id,
18312
+ sprintId: props.sprintId
18313
+ });
18314
+ return Result.ok(props.task);
18315
+ }
18316
+ const updated = { ...props.task, blockedReason: nextReason };
18317
+ const persisted = await props.taskRepo.update(props.sprintId, updated);
18318
+ if (!persisted.ok) {
18319
+ log.error("persist failed", { taskId: updated.id, error: persisted.error.message });
18320
+ return Result.error(persisted.error);
18321
+ }
18322
+ log.info("recorded quarantine pointer on blocked task", {
18323
+ taskId: updated.id,
18324
+ sprintId: props.sprintId,
18325
+ stashMessage: props.stashMessage
18326
+ });
18327
+ return Result.ok(updated);
18328
+ };
18329
+
18330
+ // src/application/flows/implement/leaves/quarantine-blocked-diff.ts
18331
+ var quarantineStashMessage = (sprintId, taskId) => `ralphctl/${String(sprintId)}/${String(taskId)}/blocked-diff`;
18332
+ var quarantineBlockedDiffLeaf = (deps, opts, taskId) => leaf(`quarantine-blocked-diff-${String(taskId)}`, {
18333
+ useCase: {
18334
+ execute: async (input) => {
18335
+ const log = deps.logger.named("task.quarantine-blocked-diff");
18336
+ const message = quarantineStashMessage(input.sprintId, taskId);
18337
+ const stashed = await gitStashPush(deps.gitRunner, opts.cwd, message);
18338
+ if (!stashed.ok) {
18339
+ log.warn("quarantine stash failed \u2014 proceeding without cleaning the tree", {
18340
+ taskId: String(taskId),
18341
+ cwd: String(opts.cwd),
18342
+ error: stashed.error.message
18343
+ });
18344
+ return Result.ok(void 0);
18345
+ }
18346
+ if (!stashed.value.stashed) return Result.ok(void 0);
18347
+ const recorded = await recordQuarantineUseCase({
18348
+ task: input.task,
18349
+ sprintId: input.sprintId,
18350
+ stashMessage: message,
18351
+ taskRepo: deps.taskRepo,
18352
+ logger: deps.logger
18353
+ });
18354
+ if (!recorded.ok) {
18355
+ log.warn("quarantine stash succeeded but recording the pointer failed", {
18356
+ taskId: String(taskId),
18357
+ stashMessage: message,
18358
+ error: recorded.error.message
18359
+ });
18360
+ return Result.ok(void 0);
18361
+ }
18362
+ const journalLine = `
18363
+ _Task ${recorded.value.name}: rejected diff quarantined to git stash \u2014 recover via \`git stash list\` (message: \`${message}\`)._
18364
+ `;
18365
+ const appended = await deps.appendFile(opts.progressFile, journalLine);
18366
+ if (!appended.ok) {
18367
+ log.warn("quarantine pointer journal append failed", {
18368
+ taskId: String(taskId),
18369
+ error: appended.error.message
18370
+ });
18371
+ }
18372
+ return Result.ok(recorded.value);
18373
+ }
18374
+ },
18375
+ input: (ctx) => {
18376
+ const task = ctx.tasks?.find((t) => t.id === taskId);
18377
+ if (task === void 0 || task.status !== "blocked") {
18378
+ throw new InvalidStateError({
18379
+ entity: "chain",
18380
+ currentState: task === void 0 ? "missing" : task.status,
18381
+ attemptedAction: `quarantine-blocked-diff-${String(taskId)}`,
18382
+ message: `quarantine-blocked-diff-${String(taskId)}: expected a settled blocked task on ctx.tasks`
18383
+ });
18384
+ }
18385
+ return { task, sprintId: ctx.sprintId };
18386
+ },
18387
+ output: (ctx, out) => {
18388
+ if (out === void 0) return ctx;
18389
+ return {
18390
+ ...ctx,
18391
+ tasks: (ctx.tasks ?? []).map((t) => t.id === out.id ? out : t)
18392
+ };
18393
+ }
18394
+ });
18395
+ var isSettledBlocked = (ctx, taskId) => ctx.tasks?.find((t) => t.id === taskId)?.status === "blocked" && ctx.genEvalTurn !== void 0;
18396
+
18397
+ // src/application/flows/implement/leaves/quarantine-retry-diff.ts
18398
+ var retryStashMessage = (sprintId, taskId, attemptN) => `ralphctl/${String(sprintId)}/${String(taskId)}/attempt-${String(attemptN)}-rejected-diff`;
18399
+ var quarantineRetryDiffLeaf = (deps, opts, taskId) => leaf(`quarantine-retry-diff-${String(taskId)}`, {
18400
+ useCase: {
18401
+ execute: async (input) => {
18402
+ const log = deps.logger.named("task.quarantine-retry-diff");
18403
+ const message = retryStashMessage(input.sprintId, taskId, input.attemptN);
18404
+ const stashed = await gitStashPush(deps.gitRunner, opts.cwd, message);
18405
+ if (!stashed.ok) {
18406
+ log.warn("retry-diff stash failed \u2014 the retried attempt will see the red tree", {
18407
+ taskId: String(taskId),
18408
+ cwd: String(opts.cwd),
18409
+ error: stashed.error.message
18410
+ });
18411
+ return Result.ok(void 0);
18412
+ }
18413
+ if (stashed.value.stashed) {
18414
+ log.info("rejected red diff quarantined before retry", {
18415
+ taskId: String(taskId),
18416
+ stashMessage: message
18417
+ });
18418
+ }
18419
+ return Result.ok(void 0);
18420
+ }
18421
+ },
18422
+ input: (ctx) => ({
18423
+ sprintId: ctx.sprintId,
18424
+ attemptN: ctx.currentTask?.attempts.length ?? 0
18425
+ }),
18426
+ output: (ctx) => ctx
18427
+ });
18428
+ var isRedVerifyRetry = (ctx) => ctx.lastShouldFailAttempt === true && ctx.lastBlockReason !== void 0;
18429
+
17630
18430
  // src/application/flows/implement/leaves/progress-journal.ts
17631
18431
  var dedupeTexts = (texts) => {
17632
18432
  if (texts === void 0 || texts.length === 0) return [];
@@ -17646,14 +18446,43 @@ var renderOutcomeParagraph = (task, attempt) => {
17646
18446
  return task.blockedReason.trim().length > 0 ? `Blocked: ${task.blockedReason.trim()}` : "Blocked \u2014 no reason recorded.";
17647
18447
  }
17648
18448
  if (task.status === "done") {
18449
+ if (attempt?.evaluation?.status === "passed") {
18450
+ return attempt.warning !== void 0 ? "Task settled as done with a warning attached." : "Task completed successfully.";
18451
+ }
17649
18452
  const critique = attempt?.critique?.trim();
17650
18453
  if (critique !== void 0 && critique.length > 0) {
17651
18454
  return critique;
17652
18455
  }
17653
- return "Task completed successfully.";
18456
+ return attempt?.warning !== void 0 ? "Task settled as done with a warning attached." : "Task completed successfully.";
18457
+ }
18458
+ if (task.status === "in_progress") {
18459
+ const critique = attempt?.critique?.trim();
18460
+ return critique !== void 0 && critique.length > 0 ? `Attempt did not pass; the harness is retrying. ${critique}` : "Attempt did not pass; the harness is retrying.";
17654
18461
  }
17655
18462
  return `Settled with task status \`${task.status}\`.`;
17656
18463
  };
18464
+ var deriveVerdict = (task, attempt) => {
18465
+ if (task.status === "blocked") return "blocked";
18466
+ if (task.status === "in_progress") return "escalated";
18467
+ if (task.status === "done" && attempt?.warning !== void 0) return "pass-with-warning";
18468
+ return "pass";
18469
+ };
18470
+ var toJournalWarning = (warning) => {
18471
+ switch (warning.kind) {
18472
+ case "budget-exhausted":
18473
+ return { kind: "budget-exhausted", turnsUsed: warning.turnsUsed, turnBudget: warning.turnBudget };
18474
+ case "plateau":
18475
+ return { kind: "plateau", dimensions: warning.dimensions };
18476
+ case "malformed":
18477
+ return { kind: "malformed", detail: warning.detail };
18478
+ case "verify-failed": {
18479
+ const exit = warning.exitCode !== null ? `exit ${String(warning.exitCode)}` : "no exit code";
18480
+ const detail = warning.stderr.trim().length > 0 ? `${exit} \u2014 ${warning.stderr.trim()}` : exit;
18481
+ return { kind: "verify-failed", detail };
18482
+ }
18483
+ }
18484
+ };
18485
+ var toJournalEscalation = (task) => task.escalatedFromModel !== void 0 && task.escalatedToModel !== void 0 ? { from: task.escalatedFromModel, to: task.escalatedToModel } : void 0;
17657
18486
  var latestAttempt = (task) => task.attempts[task.attempts.length - 1];
17658
18487
  var attemptDurationMs = (attempt) => {
17659
18488
  if (attempt === void 0 || attempt.status === "running") return void 0;
@@ -17663,7 +18492,9 @@ var progressJournalLeaf = (deps, opts, taskId) => leaf(`progress-journal-${Strin
17663
18492
  useCase: {
17664
18493
  execute: async (input) => {
17665
18494
  const attempt = latestAttempt(input.task);
17666
- const verdict = input.task.status === "blocked" ? "blocked" : "pass";
18495
+ const verdict = deriveVerdict(input.task, attempt);
18496
+ const warning = attempt?.warning !== void 0 ? toJournalWarning(attempt.warning) : void 0;
18497
+ const escalation = (verdict === "escalated" || verdict === "pass-with-warning") && attempt?.warning?.kind !== "malformed" ? toJournalEscalation(input.task) : void 0;
17667
18498
  const text = renderJournalEntry({
17668
18499
  taskName: input.task.name,
17669
18500
  attemptN: input.task.attempts.length,
@@ -17672,6 +18503,8 @@ var progressJournalLeaf = (deps, opts, taskId) => leaf(`progress-journal-${Strin
17672
18503
  roundN: input.roundN,
17673
18504
  totalRounds: opts.totalRounds,
17674
18505
  ...attemptDurationMs(attempt) !== void 0 ? { durationMs: attemptDurationMs(attempt) } : {},
18506
+ ...warning !== void 0 ? { warning } : {},
18507
+ ...escalation !== void 0 ? { escalation } : {},
17675
18508
  changes: input.changes,
17676
18509
  decisions: input.decisions,
17677
18510
  learnings: input.learnings,
@@ -17732,6 +18565,9 @@ var settleTask = (props, now) => {
17732
18565
  if (!stamped.ok) return Result.error(stamped.error);
17733
18566
  task = stamped.value;
17734
18567
  }
18568
+ if (props.shouldFailAttempt === true) {
18569
+ return failCurrentAttempt(task, now, props.verdict === "malformed" ? "malformed" : "failed");
18570
+ }
17735
18571
  if (props.blockedReason !== void 0) {
17736
18572
  const aborted = failCurrentAttempt(task, now, "aborted");
17737
18573
  if (!aborted.ok) return Result.error(aborted.error);
@@ -17740,9 +18576,6 @@ var settleTask = (props, now) => {
17740
18576
  }
17741
18577
  return markTaskBlocked(aborted.value, props.blockedReason, "own");
17742
18578
  }
17743
- if (props.shouldFailAttempt === true) {
17744
- return failCurrentAttempt(task, now, "failed");
17745
- }
17746
18579
  return markTaskDone(task, now);
17747
18580
  };
17748
18581
  var settleAttemptUseCase = async (props) => {
@@ -18262,6 +19095,7 @@ var createPerTaskSubchain = (deps, opts, task, repo, readConfig) => {
18262
19095
  templateLoader: deps.templateLoader,
18263
19096
  signals: deps.signals,
18264
19097
  writeFile: deps.writeFile,
19098
+ gitRunner: deps.gitRunner,
18265
19099
  clock: deps.clock,
18266
19100
  logger: deps.logger,
18267
19101
  eventBus: deps.eventBus,
@@ -18325,6 +19159,17 @@ var createPerTaskSubchain = (deps, opts, task, repo, readConfig) => {
18325
19159
  taskId
18326
19160
  )
18327
19161
  ),
19162
+ // Composed-case cleanup: a granted retry (escalate / nudge / malformed) whose work ALSO
19163
+ // failed post-verify red. The commit guard above skipped the red work; this stashes it
19164
+ // so the RETRIED attempt's pre-verify starts from the last good commit instead of
19165
+ // hard-blocking on its own predecessor's rejected diff. Must run BEFORE settle-attempt —
19166
+ // settle's output projection clears both flags the guard reads. Green-verify retries
19167
+ // commit normally and the stash no-ops on their clean tree.
19168
+ guard(
19169
+ `quarantine-retry-diff-guard-${String(taskId)}`,
19170
+ isRedVerifyRetry,
19171
+ quarantineRetryDiffLeaf({ gitRunner: deps.gitRunner, logger: deps.logger }, { cwd: repo.path }, taskId)
19172
+ ),
18328
19173
  settleAttemptLeaf(
18329
19174
  { taskRepo: deps.taskRepo, clock: deps.clock, logger: deps.logger, gitRunner: deps.gitRunner },
18330
19175
  { cwd: repo.path },
@@ -18349,15 +19194,46 @@ var createPerTaskSubchain = (deps, opts, task, repo, readConfig) => {
18349
19194
  )
18350
19195
  ]),
18351
19196
  {
18352
- // The attempt count is bounded by the task's own `maxAttempts` (validated 1–10). When
18353
- // unset, the `loop` primitive's 1000 ceiling is the backstop and `shouldStop` (terminal
18354
- // status) is the real bound. The domain's `failCurrentAttempt` still transitions the
18355
- // task to `blocked` once attempts hit the cap, so a budget-exhausted task is never
18356
- // silently dropped — `shouldStop` just recognises that terminal status and exits.
18357
- ...task.maxAttempts !== void 0 ? { maxIterations: task.maxAttempts } : {},
19197
+ // The attempt count is bounded by the task's own `maxAttempts` (validated 1–10), or the
19198
+ // configured `settings.harness.maxAttempts` fallback for legacy tasks planned before the
19199
+ // field existed (mirrors the budget fallback in `finalize-gen-eval`/`decideEscalation`,
19200
+ // so a legacy task's loop cap and its escalation budget agree). The domain's
19201
+ // `failCurrentAttempt` still transitions the task to `blocked` once attempts hit the
19202
+ // cap, so a budget-exhausted task is never silently dropped — `shouldStop` just
19203
+ // recognises that terminal status and exits.
19204
+ maxIterations: task.maxAttempts ?? deps.config.harness.maxAttempts,
18358
19205
  shouldStop: (ctx) => terminalTaskStatus(ctx, taskId)
18359
19206
  }
18360
19207
  ),
19208
+ // SERIAL-PATH ONLY. A task that settled `blocked` (self-block / budget-exhausted) leaves its
19209
+ // rejected diff in the SHARED worktree — `settle-attempt`'s dirty-tree guardrail exempts the
19210
+ // block path so the operator can inspect it. On the serial path that contaminates the next
19211
+ // task (its `git add -A` sweeps the leftovers into its commit; the dirt flips its pre-verify
19212
+ // red and the red post-verify is mis-attributed `baseline-broken`, landing a corrupt commit).
19213
+ // This guarded leaf stashes the rejected diff so the tree is clean again before the next task
19214
+ // runs, restoring the invariant the prologue's one-shot preflight assumes. The guard is
19215
+ // synchronous (status === 'blocked' read from the settled `ctx.tasks` copy — `settle-attempt`
19216
+ // cleared `ctx.currentTask`); the splice itself is gated on the serial-path proxy
19217
+ // `includeBranchPreflight` so the parallel launcher (per-task worktrees, already isolated)
19218
+ // never includes it. Stays INSIDE the body guard + AFTER the loop so it runs once per task,
19219
+ // and BEFORE `uninstall-skills` so that leaf remains the subchain's terminal element (the
19220
+ // TUI's task-completion detector keys on it).
19221
+ ...includeBranchPreflight ? [
19222
+ guard(
19223
+ `quarantine-blocked-diff-guard-${String(taskId)}`,
19224
+ (ctx) => isSettledBlocked(ctx, taskId),
19225
+ quarantineBlockedDiffLeaf(
19226
+ {
19227
+ gitRunner: deps.gitRunner,
19228
+ taskRepo: deps.taskRepo,
19229
+ appendFile: deps.appendFile,
19230
+ logger: deps.logger
19231
+ },
19232
+ { cwd: repo.path, progressFile: opts.progressFile },
19233
+ taskId
19234
+ )
19235
+ )
19236
+ ] : [],
18361
19237
  uninstallSkillsLeaf(
18362
19238
  { skillsAdapter: deps.skillsAdapter },
18363
19239
  { name: `${opts.terminalLeafName}-${String(taskId)}`, cwdPicker: repoCwdPicker(repo.path) }
@@ -19060,10 +19936,10 @@ var transitionSprintToReviewLeaf = (deps) => leaf("transition-sprint-to-review",
19060
19936
  var combineAbortSignals = (host, secondary) => host === void 0 ? secondary : AbortSignal.any([host, secondary]);
19061
19937
 
19062
19938
  // src/integration/io/lock-paths.ts
19063
- import { createHash as createHash2 } from "crypto";
19939
+ import { createHash as createHash3 } from "crypto";
19064
19940
  import { join as join37 } from "path";
19065
19941
  var repoLockFile = (locksRoot, worktreePath) => {
19066
- const hash = createHash2("sha1").update(String(worktreePath)).digest("hex").slice(0, 16);
19942
+ const hash = createHash3("sha1").update(String(worktreePath)).digest("hex").slice(0, 16);
19067
19943
  return AbsolutePath.parse(join37(String(locksRoot), `repo-${hash}.lock`));
19068
19944
  };
19069
19945
 
@@ -19218,7 +20094,8 @@ var createImplementFlow = (deps, opts) => {
19218
20094
  const readConfig = () => Promise.resolve({
19219
20095
  maxTurns: deps.config.harness.maxTurns,
19220
20096
  escalateOnPlateau: deps.config.harness.escalateOnPlateau,
19221
- escalationMap: deps.config.harness.escalationMap
20097
+ escalationMap: deps.config.harness.escalationMap,
20098
+ maxAttempts: deps.config.harness.maxAttempts
19222
20099
  });
19223
20100
  const perTaskChains = opts.todoTasks.map(
19224
20101
  (task) => createPerTaskSubchain(
@@ -19372,7 +20249,11 @@ var runWorktreeSetupScript = async (deps, worktreePath, setupScript, taskId, ctx
19372
20249
  const name = `worktree-setup-script-${String(taskId)}`;
19373
20250
  if (signal?.aborted) return abortedStep(name, 0, onTrace);
19374
20251
  const start = performance.now();
19375
- const ran = await deps.implement.shellScriptRunner.run(worktreePath, setupScript);
20252
+ const ran = await deps.implement.shellScriptRunner.run(
20253
+ worktreePath,
20254
+ setupScript,
20255
+ signal !== void 0 ? { signal } : {}
20256
+ );
19376
20257
  const durationMs = performance.now() - start;
19377
20258
  if (ran.ok && ran.value.passed) {
19378
20259
  onTrace?.({ elementName: name, status: "completed", durationMs });
@@ -19811,7 +20692,8 @@ var buildParallelElement = (implementDeps, implementOpts, appSignals, maxParalle
19811
20692
  const readConfig = () => Promise.resolve({
19812
20693
  maxTurns: implementDeps.config.harness.maxTurns,
19813
20694
  escalateOnPlateau: implementDeps.config.harness.escalateOnPlateau,
19814
- escalationMap: implementDeps.config.harness.escalationMap
20695
+ escalationMap: implementDeps.config.harness.escalationMap,
20696
+ maxAttempts: implementDeps.config.harness.maxAttempts
19815
20697
  });
19816
20698
  const parallelDeps = { ...implementDeps, appendFile: serializeAppendFile(implementDeps.appendFile) };
19817
20699
  const branchDeps = {
@@ -20179,7 +21061,7 @@ var runReviewRoundUseCase = async (props) => {
20179
21061
  };
20180
21062
 
20181
21063
  // src/integration/ai/prompts/apply-feedback/definition.ts
20182
- var requireNonEmpty3 = (field, message) => (v) => v.trim().length === 0 ? Result.error(new ValidationError({ field, value: v, message })) : Result.ok(v);
21064
+ var requireNonEmpty5 = (field, message) => (v) => v.trim().length === 0 ? Result.error(new ValidationError({ field, value: v, message })) : Result.ok(v);
20183
21065
  var applyFeedbackPromptDef = {
20184
21066
  templateName: "apply-feedback",
20185
21067
  description: "Apply one round of human feedback to an already-implemented sprint. Review-time work, not initial implementation.",
@@ -20187,12 +21069,12 @@ var applyFeedbackPromptDef = {
20187
21069
  repositories: {
20188
21070
  placeholder: "REPOSITORIES",
20189
21071
  description: "Markdown list of every sprint-affected repository (absolute path + display name). The AI picks which to touch based on the latest round.",
20190
- validate: requireNonEmpty3("repositories", "repositories block must not be empty")
21072
+ validate: requireNonEmpty5("repositories", "repositories block must not be empty")
20191
21073
  },
20192
21074
  sprintContext: {
20193
21075
  placeholder: "SPRINT_CONTEXT",
20194
21076
  description: "Sprint metadata block (slug, name, ticket count).",
20195
- validate: requireNonEmpty3("sprintContext", "sprint context must not be empty")
21077
+ validate: requireNonEmpty5("sprintContext", "sprint context must not be empty")
20196
21078
  },
20197
21079
  feedbackLog: {
20198
21080
  placeholder: "FEEDBACK_LOG",
@@ -20201,7 +21083,7 @@ var applyFeedbackPromptDef = {
20201
21083
  latestRound: {
20202
21084
  placeholder: "LATEST_ROUND",
20203
21085
  description: "The round body the AI must act on now.",
20204
- validate: requireNonEmpty3("latestRound", "latest round body must not be empty")
21086
+ validate: requireNonEmpty5("latestRound", "latest round body must not be empty")
20205
21087
  },
20206
21088
  progress: {
20207
21089
  placeholder: "PROGRESS",
@@ -20210,7 +21092,7 @@ var applyFeedbackPromptDef = {
20210
21092
  outputContractSection: {
20211
21093
  placeholder: "OUTPUT_CONTRACT_SECTION",
20212
21094
  description: "Audit-[09] output contract block rendered from the review-round contract \u2014 instructs the AI to write `signals.json` directly with exactly one terminal signal.",
20213
- validate: requireNonEmpty3("outputContractSection", "output-contract section must not be empty")
21095
+ validate: requireNonEmpty5("outputContractSection", "output-contract section must not be empty")
20214
21096
  }
20215
21097
  },
20216
21098
  partials: {
@@ -20623,7 +21505,7 @@ import { promises as fs28 } from "fs";
20623
21505
  import { join as join42 } from "path";
20624
21506
 
20625
21507
  // src/integration/ai/prompts/distill-learnings/definition.ts
20626
- var requireNonEmpty4 = (field, message) => (v) => v.trim().length === 0 ? Result.error(new ValidationError({ field, value: v, message })) : Result.ok(v);
21508
+ var requireNonEmpty6 = (field, message) => (v) => v.trim().length === 0 ? Result.error(new ValidationError({ field, value: v, message })) : Result.ok(v);
20627
21509
  var distillLearningsPromptDef = {
20628
21510
  templateName: "distill-learnings",
20629
21511
  description: "One-shot documentation edit that folds curated learnings into an existing project context file\u2019s own idempotent `## Learnings (ralphctl)` section.",
@@ -20635,12 +21517,12 @@ var distillLearningsPromptDef = {
20635
21517
  candidateLearnings: {
20636
21518
  placeholder: "CANDIDATE_LEARNINGS",
20637
21519
  description: "Markdown list of the curated learnings to fold into the context file.",
20638
- validate: requireNonEmpty4("candidateLearnings", "candidate learnings must not be empty")
21520
+ validate: requireNonEmpty6("candidateLearnings", "candidate learnings must not be empty")
20639
21521
  },
20640
21522
  targetFilename: {
20641
21523
  placeholder: "TARGET_FILENAME",
20642
21524
  description: "Native context-file name for the target provider (CLAUDE.md / .github/copilot-instructions.md / AGENTS.md).",
20643
- validate: requireNonEmpty4("targetFilename", "target filename must not be empty")
21525
+ validate: requireNonEmpty6("targetFilename", "target filename must not be empty")
20644
21526
  },
20645
21527
  projectTooling: {
20646
21528
  placeholder: "PROJECT_TOOLING",
@@ -22554,7 +23436,7 @@ var launchReadiness = async (ctx) => {
22554
23436
  import { join as join48 } from "path";
22555
23437
 
22556
23438
  // src/integration/ai/prompts/detect-skills/definition.ts
22557
- var requireNonEmpty5 = (field, message) => (v) => v.trim().length === 0 ? Result.error(new ValidationError({ field, value: v, message })) : Result.ok(v);
23439
+ var requireNonEmpty7 = (field, message) => (v) => v.trim().length === 0 ? Result.error(new ValidationError({ field, value: v, message })) : Result.ok(v);
22558
23440
  var detectSkillsPromptDef = {
22559
23441
  templateName: "detect-skills",
22560
23442
  description: "Read-only repo inventory that authors two short coding-agent skills: setup (sprint-start prep) and verify (post-task gate interpretation).",
@@ -22562,17 +23444,17 @@ var detectSkillsPromptDef = {
22562
23444
  repositoryPath: {
22563
23445
  placeholder: "REPOSITORY_PATH",
22564
23446
  description: "Absolute path to the repository the AI is inventorying.",
22565
- validate: requireNonEmpty5("repositoryPath", "repository path must not be empty")
23447
+ validate: requireNonEmpty7("repositoryPath", "repository path must not be empty")
22566
23448
  },
22567
23449
  skillsConvention: {
22568
23450
  placeholder: "SKILLS_CONVENTION",
22569
23451
  description: "Provider-specific guidance on where to find existing skills in this repository.",
22570
- validate: requireNonEmpty5("skillsConvention", "skills convention snippet must not be empty")
23452
+ validate: requireNonEmpty7("skillsConvention", "skills convention snippet must not be empty")
22571
23453
  },
22572
23454
  outputContractSection: {
22573
23455
  placeholder: "OUTPUT_CONTRACT_SECTION",
22574
23456
  description: "Audit-[09] output contract block rendered from the detect-skills contract \u2014 instructs the AI to write `signals.json` directly.",
22575
- validate: requireNonEmpty5("outputContractSection", "output-contract section must not be empty")
23457
+ validate: requireNonEmpty7("outputContractSection", "output-contract section must not be empty")
22576
23458
  }
22577
23459
  },
22578
23460
  partials: {
@@ -23007,7 +23889,7 @@ var launchDetectSkills = (ctx) => {
23007
23889
  import { join as join49 } from "path";
23008
23890
 
23009
23891
  // src/integration/ai/prompts/detect-scripts/definition.ts
23010
- var requireNonEmpty6 = (field, message) => (v) => v.trim().length === 0 ? Result.error(new ValidationError({ field, value: v, message })) : Result.ok(v);
23892
+ var requireNonEmpty8 = (field, message) => (v) => v.trim().length === 0 ? Result.error(new ValidationError({ field, value: v, message })) : Result.ok(v);
23011
23893
  var detectScriptsPromptDef = {
23012
23894
  templateName: "detect-scripts",
23013
23895
  description: "Read-only repo inventory that proposes a single-line setup script and a single-line verify script.",
@@ -23015,12 +23897,12 @@ var detectScriptsPromptDef = {
23015
23897
  repositoryPath: {
23016
23898
  placeholder: "REPOSITORY_PATH",
23017
23899
  description: "Absolute path to the repository the AI is inventorying.",
23018
- validate: requireNonEmpty6("repositoryPath", "repository path must not be empty")
23900
+ validate: requireNonEmpty8("repositoryPath", "repository path must not be empty")
23019
23901
  },
23020
23902
  outputContractSection: {
23021
23903
  placeholder: "OUTPUT_CONTRACT_SECTION",
23022
23904
  description: "Audit-[09] output contract block rendered from the detect-scripts contract \u2014 instructs the AI to write `signals.json` directly.",
23023
- validate: requireNonEmpty6("outputContractSection", "output-contract section must not be empty")
23905
+ validate: requireNonEmpty8("outputContractSection", "output-contract section must not be empty")
23024
23906
  }
23025
23907
  },
23026
23908
  partials: {
@@ -23652,7 +24534,8 @@ var parseIdeateOutput = (raw, ctx) => {
23652
24534
  const tasks = parseTaskList(parsed.data.tasks, {
23653
24535
  project: ctx.project,
23654
24536
  mode: { kind: "fixed", ticketId: ctx.ticketId, ...ctx.ticket !== void 0 ? { ticket: ctx.ticket } : {} },
23655
- ...ctx.logger !== void 0 ? { logger: ctx.logger } : {}
24537
+ ...ctx.logger !== void 0 ? { logger: ctx.logger } : {},
24538
+ ...ctx.defaultMaxAttempts !== void 0 ? { defaultMaxAttempts: ctx.defaultMaxAttempts } : {}
23656
24539
  });
23657
24540
  if (!tasks.ok) return Result.error(tasks.error);
23658
24541
  void ctx.sprintId;
@@ -23707,7 +24590,8 @@ var ideateAndPlanLeaf = (deps) => leaf("ideate-and-plan", {
23707
24590
  // Pass the freshly minted ticket so any `externalRef` on it (currently unused by the
23708
24591
  // ideate input surface, but ready when added) propagates to every generated task.
23709
24592
  ticket: pending.value,
23710
- logger: deps.logger
24593
+ logger: deps.logger,
24594
+ defaultMaxAttempts: deps.maxAttempts
23711
24595
  });
23712
24596
  if (!parsed.ok) return Result.error(parsed.error);
23713
24597
  const added = addApprovedTicketUseCase({
@@ -23874,6 +24758,7 @@ var createIdeateFlow = (deps, opts) => {
23874
24758
  writeFile: deps.writeFile,
23875
24759
  eventBus: deps.eventBus,
23876
24760
  model: opts.model,
24761
+ maxAttempts: opts.maxAttempts,
23877
24762
  ...opts.effort !== void 0 ? { effort: opts.effort } : {}
23878
24763
  }),
23879
24764
  uninstallSkillsLeaf({ skillsAdapter: deps.skillsAdapter }, { cwdPicker: () => opts.cwd }),
@@ -23922,6 +24807,7 @@ var launchIdeate = async (ctx) => {
23922
24807
  cwd,
23923
24808
  providerId: settings.ai.ideate.provider,
23924
24809
  model: settings.ai.ideate.model,
24810
+ maxAttempts: settings.harness.maxAttempts,
23925
24811
  ...effort !== void 0 ? { effort } : {},
23926
24812
  ideateRoot: ideateRoot.value
23927
24813
  }
@@ -24811,18 +25697,9 @@ var settingsManifest = {
24811
25697
  triggers: {}
24812
25698
  };
24813
25699
 
24814
- // src/application/flows/ticket-add/manifest.ts
24815
- var ticketAddManifest = {
24816
- id: "ticket-add",
24817
- title: "Add ticket",
24818
- description: "Append a pending ticket to a draft sprint.",
24819
- canBackground: false,
24820
- triggers: { currentSprintStatus: ["draft"] }
24821
- };
24822
-
24823
- // src/application/flows/ticket-remove/manifest.ts
25700
+ // src/application/flows/remove-ticket/manifest.ts
24824
25701
  var ticketRemoveManifest = {
24825
- id: "ticket-remove",
25702
+ id: "remove-ticket",
24826
25703
  title: "Remove ticket",
24827
25704
  description: "Drop a ticket from a draft sprint.",
24828
25705
  canBackground: false,
@@ -24847,7 +25724,6 @@ var flowRegistry = [
24847
25724
  { manifest: createPrManifest },
24848
25725
  { manifest: doctorManifest },
24849
25726
  { manifest: settingsManifest },
24850
- { manifest: ticketAddManifest },
24851
25727
  { manifest: ticketRemoveManifest }
24852
25728
  ];
24853
25729
 
@@ -25085,8 +25961,7 @@ var SPRINT_SCOPED_FLOW_IDS = [
25085
25961
  "review",
25086
25962
  "close-sprint",
25087
25963
  "create-pr",
25088
- "ticket-add",
25089
- "ticket-remove"
25964
+ "remove-ticket"
25090
25965
  ];
25091
25966
  var PROJECT_SCOPED_FLOW_IDS = [
25092
25967
  "create-sprint",
@@ -25101,9 +25976,9 @@ var SPRINT_SCOPED_SET = new Set(SPRINT_SCOPED_FLOW_IDS);
25101
25976
  var PROJECT_SCOPED_SET = new Set(PROJECT_SCOPED_FLOW_IDS);
25102
25977
  var HIDDEN_SET = new Set(HIDDEN_BY_DEFAULT_FLOW_IDS);
25103
25978
  var ALLOWED_BY_STATUS = {
25104
- draft: /* @__PURE__ */ new Set(["refine", "add-tickets", "plan", "ticket-add", "ticket-remove"]),
25105
- planned: /* @__PURE__ */ new Set(["implement", "ticket-add", "ticket-remove"]),
25106
- active: /* @__PURE__ */ new Set(["implement", "ticket-add"]),
25979
+ draft: /* @__PURE__ */ new Set(["refine", "add-tickets", "plan", "remove-ticket"]),
25980
+ planned: /* @__PURE__ */ new Set(["implement", "remove-ticket"]),
25981
+ active: /* @__PURE__ */ new Set(["implement"]),
25107
25982
  review: /* @__PURE__ */ new Set(["review", "close-sprint"]),
25108
25983
  done: /* @__PURE__ */ new Set(["create-pr"])
25109
25984
  };
@@ -25143,8 +26018,7 @@ var viewRouteFor = (flowId, snapshot) => {
25143
26018
  return { id: "doctor" };
25144
26019
  case "settings":
25145
26020
  return { id: "settings" };
25146
- case "ticket-add":
25147
- case "ticket-remove":
26021
+ case "remove-ticket":
25148
26022
  return snapshot.sprint ? { id: "sprint-detail", props: { sprintId: snapshot.sprint.id } } : void 0;
25149
26023
  case "export-context":
25150
26024
  return { id: "export-context" };
@@ -26380,8 +27254,8 @@ var SprintsView = () => {
26380
27254
  import { useEffect as useEffect22, useMemo as useMemo14, useState as useState31 } from "react";
26381
27255
  import { Box as Box44, Text as Text45 } from "ink";
26382
27256
 
26383
- // src/application/flows/ticket-remove/flow.ts
26384
- var createTicketRemoveFlow = (deps) => leaf("ticket-remove", {
27257
+ // src/application/flows/remove-ticket/flow.ts
27258
+ var createTicketRemoveFlow = (deps) => leaf("remove-ticket", {
26385
27259
  useCase: {
26386
27260
  async execute(input) {
26387
27261
  const sprint = await deps.sprintRepo.findById(input.sprintId);
@@ -26943,8 +27817,8 @@ var TaskCard = ({
26943
27817
  expanded,
26944
27818
  index
26945
27819
  }) => {
26946
- const lastAttempt2 = task.attempts[task.attempts.length - 1];
26947
- const lastAttemptElapsed = lastAttempt2 !== void 0 ? attemptElapsedMs(lastAttempt2) : void 0;
27820
+ const lastAttempt3 = task.attempts[task.attempts.length - 1];
27821
+ const lastAttemptElapsed = lastAttempt3 !== void 0 ? attemptElapsedMs(lastAttempt3) : void 0;
26948
27822
  const { atLeast } = useBreakpoint();
26949
27823
  const singleLineMetadata = atLeast("md");
26950
27824
  const metadataParts = buildTaskMetadataParts({
@@ -28717,6 +29591,18 @@ var ExecuteBody = ({
28717
29591
  // src/application/ui/tui/views/execute-view-internals/tasks-panel-host.tsx
28718
29592
  import { useMemo as useMemo17 } from "react";
28719
29593
  import { jsx as jsx71 } from "react/jsx-runtime";
29594
+ var warningSummaryFor = (w) => {
29595
+ switch (w.kind) {
29596
+ case "budget-exhausted":
29597
+ return `done with warning: turn budget exhausted (${String(w.turnsUsed)}/${String(w.turnBudget)} turns)`;
29598
+ case "plateau":
29599
+ return w.dimensions.length > 0 ? `done with warning: evaluator plateaued on ${w.dimensions.join(", ")}` : "done with warning: evaluator plateaued";
29600
+ case "malformed":
29601
+ return "done with warning: evaluator output malformed";
29602
+ case "verify-failed":
29603
+ return `done with warning: post-task verify red (${w.exitCode !== null ? `exit ${String(w.exitCode)}` : "no exit code"})`;
29604
+ }
29605
+ };
28720
29606
  var TasksPanelHost = ({
28721
29607
  bucketed,
28722
29608
  descriptor,
@@ -28746,6 +29632,16 @@ var TasksPanelHost = ({
28746
29632
  }
28747
29633
  return m.size > 0 ? m : void 0;
28748
29634
  }, [taskState]);
29635
+ const warningSummaryById = useMemo17(() => {
29636
+ if (taskState === void 0) return void 0;
29637
+ const m = /* @__PURE__ */ new Map();
29638
+ for (const t of taskState) {
29639
+ if (t.status !== "done") continue;
29640
+ const warning = t.attempts[t.attempts.length - 1]?.warning;
29641
+ if (warning !== void 0) m.set(String(t.id), warningSummaryFor(warning));
29642
+ }
29643
+ return m.size > 0 ? m : void 0;
29644
+ }, [taskState]);
28749
29645
  if (bucketed === void 0) return null;
28750
29646
  return /* @__PURE__ */ jsx71(
28751
29647
  TasksPanel,
@@ -28759,7 +29655,8 @@ var TasksPanelHost = ({
28759
29655
  ...descriptor.taskNames !== void 0 ? { nameById: descriptor.taskNames } : {},
28760
29656
  ...descriptor.taskRecovering !== void 0 ? { recoveringByTaskId: descriptor.taskRecovering } : {},
28761
29657
  ...taskCriteriaById !== void 0 ? { taskCriteriaById } : {},
28762
- ...blockedReasonById !== void 0 ? { blockedReasonById } : {}
29658
+ ...blockedReasonById !== void 0 ? { blockedReasonById } : {},
29659
+ ...warningSummaryById !== void 0 ? { warningSummaryById } : {}
28763
29660
  }
28764
29661
  );
28765
29662
  };
@@ -29184,9 +30081,9 @@ var flowIdToTitle = (flowId) => {
29184
30081
  return "Close Sprint";
29185
30082
  case "add-tickets":
29186
30083
  return "Add Tickets";
29187
- case "ticket-add":
30084
+ case "add-ticket":
29188
30085
  return "Add Ticket";
29189
- case "ticket-remove":
30086
+ case "remove-ticket":
29190
30087
  return "Remove Ticket";
29191
30088
  case "export-context":
29192
30089
  return "Export Context";
@@ -29496,7 +30393,16 @@ var createSettingsShowFlow = (deps) => leaf("settings-show", {
29496
30393
  import "react";
29497
30394
 
29498
30395
  // src/business/settings/presets.ts
29499
- var PRESET_NAMES = ["mixed", "claude-only", "copilot-only", "codex-only"];
30396
+ var PRESET_NAMES = [
30397
+ "mixed",
30398
+ "claude-only",
30399
+ "copilot-only",
30400
+ "codex-only",
30401
+ "mixed-economic",
30402
+ "claude-economic",
30403
+ "copilot-economic",
30404
+ "codex-economic"
30405
+ ];
29500
30406
  var isPresetName = (raw) => PRESET_NAMES.includes(raw);
29501
30407
  var MIXED = {
29502
30408
  effort: "high",
@@ -29527,13 +30433,13 @@ var CLAUDE_ONLY = {
29527
30433
  var COPILOT_ONLY = {
29528
30434
  effort: "high",
29529
30435
  refine: { provider: "github-copilot", model: "claude-sonnet-4.6" },
29530
- plan: { provider: "github-copilot", model: "claude-opus-4.6", effort: "xhigh" },
30436
+ plan: { provider: "github-copilot", model: "claude-opus-4.8", effort: "xhigh" },
29531
30437
  implement: {
29532
- generator: { provider: "github-copilot", model: "claude-opus-4.6", effort: "xhigh" },
29533
- evaluator: { provider: "github-copilot", model: "claude-opus-4.6", effort: "xhigh" }
30438
+ generator: { provider: "github-copilot", model: "claude-opus-4.8", effort: "xhigh" },
30439
+ evaluator: { provider: "github-copilot", model: "claude-opus-4.8", effort: "xhigh" }
29534
30440
  },
29535
30441
  readiness: { provider: "github-copilot", model: "gpt-5-mini", effort: "medium" },
29536
- ideate: { provider: "github-copilot", model: "claude-opus-4.6" },
30442
+ ideate: { provider: "github-copilot", model: "claude-opus-4.8" },
29537
30443
  createPr: { provider: "github-copilot", model: "gpt-5-mini" }
29538
30444
  };
29539
30445
  var CODEX_ONLY = {
@@ -29541,8 +30447,58 @@ var CODEX_ONLY = {
29541
30447
  refine: { provider: "openai-codex", model: "gpt-5.4" },
29542
30448
  plan: { provider: "openai-codex", model: "gpt-5.5", effort: "high" },
29543
30449
  implement: {
29544
- generator: { provider: "openai-codex", model: "gpt-5.3-codex", effort: "high" },
29545
- evaluator: { provider: "openai-codex", model: "gpt-5.3-codex", effort: "high" }
30450
+ // gpt-5.3-codex is deprecated for ChatGPT sign-in — implement now rides the frontier
30451
+ // default so the everyday autonomous loop keeps working under ChatGPT auth.
30452
+ generator: { provider: "openai-codex", model: "gpt-5.5", effort: "high" },
30453
+ evaluator: { provider: "openai-codex", model: "gpt-5.5", effort: "high" }
30454
+ },
30455
+ readiness: { provider: "openai-codex", model: "gpt-5.4-mini", effort: "medium" },
30456
+ ideate: { provider: "openai-codex", model: "gpt-5.5" },
30457
+ createPr: { provider: "openai-codex", model: "gpt-5.4-mini" }
30458
+ };
30459
+ var MIXED_ECONOMIC = {
30460
+ effort: "high",
30461
+ refine: { provider: "openai-codex", model: "gpt-5.4-mini" },
30462
+ plan: { provider: "github-copilot", model: "claude-sonnet-4.6", effort: "high" },
30463
+ implement: {
30464
+ generator: { provider: "claude-code", model: "claude-sonnet-4-6", effort: "high" },
30465
+ evaluator: { provider: "claude-code", model: "claude-sonnet-4-6", effort: "high" }
30466
+ },
30467
+ readiness: { provider: "github-copilot", model: "gpt-5-mini", effort: "medium" },
30468
+ ideate: { provider: "claude-code", model: "claude-sonnet-4-6" },
30469
+ createPr: { provider: "openai-codex", model: "gpt-5.4-mini" }
30470
+ };
30471
+ var CLAUDE_ECONOMIC = {
30472
+ effort: "high",
30473
+ refine: { provider: "claude-code", model: "claude-haiku-4-5" },
30474
+ plan: { provider: "claude-code", model: "claude-sonnet-4-6", effort: "high" },
30475
+ implement: {
30476
+ generator: { provider: "claude-code", model: "claude-sonnet-4-6", effort: "high" },
30477
+ evaluator: { provider: "claude-code", model: "claude-sonnet-4-6", effort: "high" }
30478
+ },
30479
+ readiness: { provider: "claude-code", model: "claude-haiku-4-5", effort: "medium" },
30480
+ ideate: { provider: "claude-code", model: "claude-sonnet-4-6" },
30481
+ createPr: { provider: "claude-code", model: "claude-haiku-4-5" }
30482
+ };
30483
+ var COPILOT_ECONOMIC = {
30484
+ effort: "high",
30485
+ refine: { provider: "github-copilot", model: "gpt-5.4-mini" },
30486
+ plan: { provider: "github-copilot", model: "claude-sonnet-4.6", effort: "high" },
30487
+ implement: {
30488
+ generator: { provider: "github-copilot", model: "claude-sonnet-4.6", effort: "high" },
30489
+ evaluator: { provider: "github-copilot", model: "claude-sonnet-4.6", effort: "high" }
30490
+ },
30491
+ readiness: { provider: "github-copilot", model: "gpt-5-mini", effort: "medium" },
30492
+ ideate: { provider: "github-copilot", model: "claude-sonnet-4.6" },
30493
+ createPr: { provider: "github-copilot", model: "gpt-5-mini" }
30494
+ };
30495
+ var CODEX_ECONOMIC = {
30496
+ effort: "high",
30497
+ refine: { provider: "openai-codex", model: "gpt-5.4-mini" },
30498
+ plan: { provider: "openai-codex", model: "gpt-5.4", effort: "high" },
30499
+ implement: {
30500
+ generator: { provider: "openai-codex", model: "gpt-5.4", effort: "high" },
30501
+ evaluator: { provider: "openai-codex", model: "gpt-5.4", effort: "high" }
29546
30502
  },
29547
30503
  readiness: { provider: "openai-codex", model: "gpt-5.4-mini", effort: "medium" },
29548
30504
  ideate: { provider: "openai-codex", model: "gpt-5.5" },
@@ -29552,7 +30508,11 @@ var PRESETS = {
29552
30508
  mixed: MIXED,
29553
30509
  "claude-only": CLAUDE_ONLY,
29554
30510
  "copilot-only": COPILOT_ONLY,
29555
- "codex-only": CODEX_ONLY
30511
+ "codex-only": CODEX_ONLY,
30512
+ "mixed-economic": MIXED_ECONOMIC,
30513
+ "claude-economic": CLAUDE_ECONOMIC,
30514
+ "copilot-economic": COPILOT_ECONOMIC,
30515
+ "codex-economic": CODEX_ECONOMIC
29556
30516
  };
29557
30517
  var applyPreset = (name, current) => ({
29558
30518
  ...current,
@@ -29565,7 +30525,11 @@ var PRESET_LABEL = {
29565
30525
  mixed: "Apply: Mixed",
29566
30526
  "claude-only": "Apply: Claude only",
29567
30527
  "copilot-only": "Apply: Copilot only",
29568
- "codex-only": "Apply: Codex only"
30528
+ "codex-only": "Apply: Codex only",
30529
+ "mixed-economic": "Apply: Mixed (economic)",
30530
+ "claude-economic": "Apply: Claude (economic)",
30531
+ "copilot-economic": "Apply: Copilot (economic)",
30532
+ "codex-economic": "Apply: Codex (economic)"
29569
30533
  };
29570
30534
  var LOG_LEVELS = ["silent", "debug", "info", "warn", "error"];
29571
30535
  var DEFAULT_TOKEN = "Default";
@@ -30846,6 +31810,28 @@ var createPushBranchLeaf = (deps) => leaf(
30846
31810
  );
30847
31811
 
30848
31812
  // src/business/sprint/views/pr-content.ts
31813
+ var warningDetail = (w) => {
31814
+ switch (w.kind) {
31815
+ case "budget-exhausted":
31816
+ return `turn budget exhausted (${String(w.turnsUsed)}/${String(w.turnBudget)} turns)`;
31817
+ case "plateau":
31818
+ return w.dimensions.length > 0 ? `evaluator plateaued on: ${w.dimensions.join(", ")}` : "evaluator plateaued";
31819
+ case "malformed":
31820
+ return w.detail.trim().length > 0 ? `evaluator output malformed (${w.detail.trim()})` : "evaluator output malformed";
31821
+ case "verify-failed": {
31822
+ const exit = w.exitCode !== null ? `exit ${String(w.exitCode)}` : "no exit code";
31823
+ return w.stderr.trim().length > 0 ? `post-task verify red (${exit}: ${w.stderr.trim()})` : `post-task verify red (${exit})`;
31824
+ }
31825
+ }
31826
+ };
31827
+ var lastAttempt2 = (task) => task.attempts[task.attempts.length - 1];
31828
+ var renderWarningsSection = (tasks) => {
31829
+ const flagged = tasks.map((t) => ({ name: t.name, warning: t.status === "done" ? lastAttempt2(t)?.warning : void 0 })).filter((e) => e.warning !== void 0);
31830
+ if (flagged.length === 0) return "";
31831
+ const lines = flagged.map((e) => `- ${e.name} \u2014 ${warningDetail(e.warning)}`).join("\n");
31832
+ return `## Completed with warnings
31833
+ ${lines}`;
31834
+ };
30849
31835
  var derivePrContent = (sprint, tasks) => {
30850
31836
  const sections = [`# ${sprint.name}`];
30851
31837
  if (sprint.tickets.length > 0) {
@@ -30859,6 +31845,8 @@ ${ticketLines}`);
30859
31845
  sections.push(`## Tasks
30860
31846
  ${taskLines}`);
30861
31847
  }
31848
+ const warnings = renderWarningsSection(tasks);
31849
+ if (warnings.length > 0) sections.push(warnings);
30862
31850
  const orderedRefs = normalizeRefs([
30863
31851
  ...sprint.tickets.map((t) => t.externalRef ?? ""),
30864
31852
  ...tasks.flatMap((t) => t.externalRefs ?? [])
@@ -30909,7 +31897,12 @@ var createCreatePrLeaf = (deps) => leaf("create-pr", {
30909
31897
  }
30910
31898
  const derived = derivePrContent(sprint.value, tasks);
30911
31899
  const title = input.title ?? input.aiContent?.title ?? derived.title;
30912
- const body = input.body ?? input.aiContent?.body ?? derived.body;
31900
+ const resolvedBody = input.body ?? input.aiContent?.body ?? derived.body;
31901
+ const usingDerivedBody = input.body === void 0 && input.aiContent?.body === void 0;
31902
+ const warningsSection = usingDerivedBody ? "" : renderWarningsSection(tasks);
31903
+ const body = warningsSection.length > 0 ? `${resolvedBody}
31904
+
31905
+ ${warningsSection}` : resolvedBody;
30913
31906
  deps.eventBus.publish({
30914
31907
  type: "log",
30915
31908
  level: "info",
@@ -31463,7 +32456,11 @@ var PRESET_LABEL2 = {
31463
32456
  mixed: "mixed",
31464
32457
  "claude-only": "claude-only",
31465
32458
  "copilot-only": "copilot-only",
31466
- "codex-only": "codex-only"
32459
+ "codex-only": "codex-only",
32460
+ "mixed-economic": "mixed-economic",
32461
+ "claude-economic": "claude-economic",
32462
+ "copilot-economic": "copilot-economic",
32463
+ "codex-economic": "codex-economic"
31467
32464
  };
31468
32465
  var pickPresetForDetected = (installed) => {
31469
32466
  if (installed.size === 1) {
@@ -32085,8 +33082,8 @@ var StepView2 = ({ step, onChange, onCancel, onSubmit }) => {
32085
33082
  import { useEffect as useEffect40, useState as useState46 } from "react";
32086
33083
  import { Box as Box75 } from "ink";
32087
33084
 
32088
- // src/application/flows/ticket-add/flow.ts
32089
- var createTicketAddFlow = (deps) => leaf("ticket-add", {
33085
+ // src/application/flows/add-ticket/flow.ts
33086
+ var createTicketAddFlow = (deps) => leaf("add-ticket", {
32090
33087
  useCase: {
32091
33088
  async execute(input) {
32092
33089
  const sprint = await deps.sprintRepo.findById(input.sprintId);