zelari-code 1.28.0 → 1.29.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.
@@ -25074,14 +25074,6 @@ function canRunParallel(a, b) {
25074
25074
  }
25075
25075
  return false;
25076
25076
  }
25077
- function selectParallelWave(candidates) {
25078
- const wave = [];
25079
- for (const node of candidates) {
25080
- if (wave.every((w) => canRunParallel(w, node)))
25081
- wave.push(node);
25082
- }
25083
- return wave;
25084
- }
25085
25077
  var READ_ONLY_KINDS, WRITER_KINDS;
25086
25078
  var init_conflict = __esm({
25087
25079
  "packages/core/dist/kraken/conflict.js"() {
@@ -25091,12 +25083,49 @@ var init_conflict = __esm({
25091
25083
  }
25092
25084
  });
25093
25085
 
25086
+ // packages/core/dist/kraken/verdict.js
25087
+ function parseVerifyVerdict(text) {
25088
+ const source = typeof text === "string" ? text : "";
25089
+ if (source.trim() === "")
25090
+ return { verdict: "unknown", findings: "" };
25091
+ VERDICT_LINE.lastIndex = 0;
25092
+ let match;
25093
+ let last = null;
25094
+ while ((match = VERDICT_LINE.exec(source)) !== null) {
25095
+ last = match;
25096
+ if (match.index === VERDICT_LINE.lastIndex)
25097
+ VERDICT_LINE.lastIndex += 1;
25098
+ }
25099
+ if (!last) {
25100
+ return { verdict: "unknown", findings: capFindings(source) };
25101
+ }
25102
+ const verdict = last[1].toUpperCase() === "FAIL" ? "fail" : "pass";
25103
+ const findings = capFindings(source.slice(0, last.index));
25104
+ return { verdict, findings };
25105
+ }
25106
+ function capFindings(raw) {
25107
+ const trimmed = raw.trim();
25108
+ if (trimmed.length <= MAX_FINDINGS_CHARS)
25109
+ return trimmed;
25110
+ return `${trimmed.slice(0, MAX_FINDINGS_CHARS)}
25111
+ \u2026 [truncated]`;
25112
+ }
25113
+ var MAX_FINDINGS_CHARS, VERDICT_LINE;
25114
+ var init_verdict = __esm({
25115
+ "packages/core/dist/kraken/verdict.js"() {
25116
+ "use strict";
25117
+ MAX_FINDINGS_CHARS = 2800;
25118
+ VERDICT_LINE = /^[\s>*_-]*VERDICT[\s*_]*:[\s*_]*(PASS|FAIL)\b/gim;
25119
+ }
25120
+ });
25121
+
25094
25122
  // packages/core/dist/kraken/index.js
25095
25123
  var init_kraken = __esm({
25096
25124
  "packages/core/dist/kraken/index.js"() {
25097
25125
  "use strict";
25098
25126
  init_graph();
25099
25127
  init_conflict();
25128
+ init_verdict();
25100
25129
  }
25101
25130
  });
25102
25131
 
@@ -25118,8 +25147,11 @@ var init_dist = __esm({
25118
25147
  // src/cli/kraken/graphStatus.ts
25119
25148
  var graphStatus_exports = {};
25120
25149
  __export(graphStatus_exports, {
25150
+ DEFAULT_DIGEST_RESULT_CHARS: () => DEFAULT_DIGEST_RESULT_CHARS,
25121
25151
  endKrakenGraphLive: () => endKrakenGraphLive,
25152
+ formatDuration: () => formatDuration2,
25122
25153
  formatKrakenGraphAscii: () => formatKrakenGraphAscii,
25154
+ formatKrakenGraphDigest: () => formatKrakenGraphDigest,
25123
25155
  formatKrakenGraphSummary: () => formatKrakenGraphSummary,
25124
25156
  getKrakenGraphLive: () => getKrakenGraphLive,
25125
25157
  resetKrakenGraphLive: () => resetKrakenGraphLive,
@@ -25147,6 +25179,46 @@ function formatKrakenGraphAscii(graph) {
25147
25179
  if (lines.length === 0) return summary;
25148
25180
  return [summary, ...lines].join("\n");
25149
25181
  }
25182
+ function formatDuration2(ms) {
25183
+ if (!Number.isFinite(ms) || ms < 0) return "?";
25184
+ if (ms < 1e3) return `${Math.round(ms)}ms`;
25185
+ const totalSeconds = Math.round(ms / 1e3);
25186
+ if (totalSeconds < 60) return `${totalSeconds}s`;
25187
+ const m = Math.floor(totalSeconds / 60);
25188
+ const s = totalSeconds % 60;
25189
+ return s === 0 ? `${m}m` : `${m}m${s}s`;
25190
+ }
25191
+ function formatKrakenGraphDigest(graph, opts = {}) {
25192
+ const maxChars = opts.maxResultChars ?? DEFAULT_DIGEST_RESULT_CHARS;
25193
+ const durations = opts.durationsMs ?? {};
25194
+ const ordered = topoLevels(graph).flat();
25195
+ for (const id of graph.nodes.keys()) {
25196
+ if (!ordered.includes(id)) ordered.push(id);
25197
+ }
25198
+ const lines = [];
25199
+ for (const id of ordered) {
25200
+ const n = graph.nodes.get(id);
25201
+ if (!n) continue;
25202
+ const took = durations[id] !== void 0 ? `, ${formatDuration2(durations[id])}` : "";
25203
+ const detail = n.status === "error" ? n.error : n.result;
25204
+ const firstLine2 = (detail ?? "").trim().split("\n")[0] ?? "";
25205
+ const body = firstLine2.length > maxChars ? `${firstLine2.slice(0, maxChars)}\u2026` : firstLine2;
25206
+ lines.push(
25207
+ `[${STATUS_ICON[n.status]}] ${n.id} (${n.kind}${took})${body ? ` \u2014 ${body}` : ""}`
25208
+ );
25209
+ }
25210
+ const unresolved = opts.unresolvedFindings ?? [];
25211
+ if (unresolved.length > 0) {
25212
+ lines.push("", "unresolved verify findings:");
25213
+ for (const u of unresolved) {
25214
+ const why = u.reason === "fail" ? "rejected, rework budget spent" : "no parseable verdict";
25215
+ const detail = u.findings.trim().split("\n")[0]?.trim() ?? "";
25216
+ const body = detail.length > maxChars ? `${detail.slice(0, maxChars)}\u2026` : detail;
25217
+ lines.push(` ${u.nodeId} (${why})${body ? ` \u2014 ${body}` : ""}`);
25218
+ }
25219
+ }
25220
+ return lines.join("\n");
25221
+ }
25150
25222
  function state() {
25151
25223
  return globalThis;
25152
25224
  }
@@ -25195,7 +25267,7 @@ function formatKrakenGraphSummary() {
25195
25267
  if (live.error) parts.push(`${live.error}\u2717`);
25196
25268
  return `graph ${parts.join(" \xB7 ")}`;
25197
25269
  }
25198
- var STATUS_ICON;
25270
+ var STATUS_ICON, DEFAULT_DIGEST_RESULT_CHARS;
25199
25271
  var init_graphStatus = __esm({
25200
25272
  "src/cli/kraken/graphStatus.ts"() {
25201
25273
  "use strict";
@@ -25207,6 +25279,7 @@ var init_graphStatus = __esm({
25207
25279
  error: "\u2717",
25208
25280
  skipped: "\xBB"
25209
25281
  };
25282
+ DEFAULT_DIGEST_RESULT_CHARS = 200;
25210
25283
  }
25211
25284
  });
25212
25285
 
@@ -26621,7 +26694,7 @@ async function runTentacle(opts) {
26621
26694
  const started = Date.now();
26622
26695
  const g = globalThis;
26623
26696
  let worktree = null;
26624
- let effectiveCwd = parentCwd;
26697
+ let effectiveCwd = opts.cwdOverride || parentCwd;
26625
26698
  const wantWt = agent === "general" && deps.allowWorktree !== false && isKrakenWorktreeEnabled();
26626
26699
  if (wantWt) {
26627
26700
  try {
@@ -35303,10 +35376,17 @@ __export(planner_exports, {
35303
35376
  KRAKEN_PLANNER_SYSTEM_PROMPT: () => KRAKEN_PLANNER_SYSTEM_PROMPT,
35304
35377
  PlannerTransportError: () => PlannerTransportError,
35305
35378
  buildGraphFromPlan: () => buildGraphFromPlan,
35379
+ buildPlannerUserPrompt: () => buildPlannerUserPrompt,
35306
35380
  extractJsonObject: () => extractJsonObject,
35307
35381
  planTaskGraph: () => planTaskGraph,
35308
35382
  stripReasoningBlocks: () => stripReasoningBlocks
35309
35383
  });
35384
+ function resolvePlannerWorkspaceChars(env = process.env) {
35385
+ const raw = env.ZELARI_KRAKEN_PLANNER_WORKSPACE_CHARS;
35386
+ if (raw === void 0 || raw === "") return DEFAULT_PLANNER_WORKSPACE_CHARS;
35387
+ const n = Number.parseInt(raw, 10);
35388
+ return Number.isFinite(n) && n >= 0 ? n : DEFAULT_PLANNER_WORKSPACE_CHARS;
35389
+ }
35310
35390
  function resolvePlannerTimeoutMs(env = process.env) {
35311
35391
  const raw = env.ZELARI_KRAKEN_PLANNER_TIMEOUT_MS;
35312
35392
  if (raw === void 0 || raw === "") return DEFAULT_LLM_TIMEOUT_MS;
@@ -35615,14 +35695,32 @@ async function createDefaultLlmClient(opts) {
35615
35695
  }
35616
35696
  };
35617
35697
  }
35618
- function buildPlannerUserPrompt(prompt, previousAttempt) {
35619
- const prev2 = previousAttempt?.trim() ? `
35620
- ${previousAttempt.trim()}
35621
- ` : "";
35622
- return `Goal:
35623
- ${prompt.trim()}
35624
- ${prev2}
35625
- Return ONLY the JSON object described in the system prompt.`;
35698
+ function resolveWorkspaceListing(opts) {
35699
+ if (opts.workspace !== void 0) return opts.workspace || void 0;
35700
+ if (!opts.cwd) return void 0;
35701
+ const maxChars = resolvePlannerWorkspaceChars();
35702
+ if (maxChars <= 0) return void 0;
35703
+ try {
35704
+ return buildWorkspaceSummary(opts.cwd, { maxEntries: 24, maxChars }) || void 0;
35705
+ } catch {
35706
+ return void 0;
35707
+ }
35708
+ }
35709
+ function buildPlannerUserPrompt(prompt, opts = {}) {
35710
+ const parts = [`Goal:
35711
+ ${prompt.trim()}`];
35712
+ if (opts.workspace?.trim()) {
35713
+ parts.push(
35714
+ "",
35715
+ "## The project this goal is about (real files on disk)",
35716
+ opts.workspace.trim()
35717
+ );
35718
+ }
35719
+ if (opts.previousAttempt?.trim()) {
35720
+ parts.push("", opts.previousAttempt.trim());
35721
+ }
35722
+ parts.push("", "Return ONLY the JSON object described in the system prompt.");
35723
+ return parts.join("\n");
35626
35724
  }
35627
35725
  function uniqueId(base, existing) {
35628
35726
  if (!existing.has(base)) return base;
@@ -35631,11 +35729,40 @@ function uniqueId(base, existing) {
35631
35729
  return `${base}-${i}`;
35632
35730
  }
35633
35731
  function buildAutoVerifyPrompt(general) {
35634
- const acc = general.acceptance && general.acceptance.length > 0 ? `
35635
-
35636
- Acceptance criteria to check:
35637
- ${general.acceptance.map((a) => `- ${a}`).join("\n")}` : "";
35638
- return `Verify the following work was completed correctly on disk: ${general.label}.${acc}`;
35732
+ const taskPrompt = general.prompt.length > MAX_VERIFY_TASK_PROMPT_CHARS ? `${general.prompt.slice(0, MAX_VERIFY_TASK_PROMPT_CHARS)}
35733
+ \u2026 [truncated]` : general.prompt;
35734
+ const parts = [
35735
+ `Verify on disk that this work was actually completed correctly: ${general.label}.`,
35736
+ "",
35737
+ "## The task that was carried out",
35738
+ taskPrompt
35739
+ ];
35740
+ if (general.scope && general.scope.length > 0) {
35741
+ parts.push("", "## Paths the work was scoped to", ...general.scope.map((s) => `- ${s}`));
35742
+ }
35743
+ if (general.acceptance && general.acceptance.length > 0) {
35744
+ parts.push(
35745
+ "",
35746
+ "## Acceptance criteria to check explicitly",
35747
+ ...general.acceptance.map((a) => `- ${a}`)
35748
+ );
35749
+ }
35750
+ parts.push(
35751
+ "",
35752
+ "Read the files involved rather than trusting any summary. Report the commands you ran and every gap you found.",
35753
+ "",
35754
+ "## How to report your verdict",
35755
+ "End your final message with a line of exactly this form, as the LAST line:",
35756
+ "",
35757
+ "VERDICT: PASS",
35758
+ "",
35759
+ "or",
35760
+ "",
35761
+ "VERDICT: FAIL",
35762
+ "",
35763
+ "This line is parsed. FAIL sends the work back to the tentacle that wrote it, together with everything you write above the line \u2014 so state each gap concretely enough to be acted on (file, what is wrong, what it should be). Only report FAIL for a real defect against the task or its acceptance criteria: a rework round is expensive and there is only a small number of them. Stylistic preferences are not a FAIL."
35764
+ );
35765
+ return parts.join("\n");
35639
35766
  }
35640
35767
  function buildGraphFromPlan(graphId, planned) {
35641
35768
  const nodes = planned.map((p3) => ({
@@ -35688,7 +35815,11 @@ async function planTaskGraph(opts) {
35688
35815
  const client = opts.llmClient ?? await createDefaultLlmClient({ provider: opts.provider, model: opts.model });
35689
35816
  const maxNodes = opts.maxNodes ?? DEFAULT_MAX_NODES;
35690
35817
  const graphId = opts.graphId ?? `kraken-${Date.now().toString(36)}`;
35691
- const userBase = buildPlannerUserPrompt(opts.prompt, opts.previousAttempt);
35818
+ const workspace = resolveWorkspaceListing(opts);
35819
+ const userBase = buildPlannerUserPrompt(opts.prompt, {
35820
+ ...opts.previousAttempt ? { previousAttempt: opts.previousAttempt } : {},
35821
+ ...workspace ? { workspace } : {}
35822
+ });
35692
35823
  let lastError;
35693
35824
  let userMessage = userBase;
35694
35825
  for (let attempt = 1; attempt <= MAX_PLAN_ATTEMPTS; attempt++) {
@@ -35719,7 +35850,7 @@ Your previous response was invalid (${lastError}). Return ONLY corrected JSON ma
35719
35850
  `kraken planner: failed to produce a valid task graph after ${MAX_PLAN_ATTEMPTS} attempts \u2014 ${lastError}`
35720
35851
  );
35721
35852
  }
35722
- var MAX_PLAN_ATTEMPTS, DEFAULT_LLM_TIMEOUT_MS, PlannerTransportError, DEFAULT_LLM_MAX_TOKENS, DEFAULT_MAX_RETRIES, KRAKEN_PLANNER_SYSTEM_PROMPT, PlannedNodeSchema, PlannedGraphSchema;
35853
+ var MAX_PLAN_ATTEMPTS, DEFAULT_PLANNER_WORKSPACE_CHARS, DEFAULT_LLM_TIMEOUT_MS, PlannerTransportError, DEFAULT_LLM_MAX_TOKENS, DEFAULT_MAX_RETRIES, KRAKEN_PLANNER_SYSTEM_PROMPT, PlannedNodeSchema, PlannedGraphSchema, MAX_VERIFY_TASK_PROMPT_CHARS;
35723
35854
  var init_planner = __esm({
35724
35855
  "src/cli/kraken/planner.ts"() {
35725
35856
  "use strict";
@@ -35728,7 +35859,9 @@ var init_planner = __esm({
35728
35859
  init_providerConfig();
35729
35860
  init_keyStore();
35730
35861
  init_openai_compatible();
35862
+ init_workspaceSummary();
35731
35863
  MAX_PLAN_ATTEMPTS = 2;
35864
+ DEFAULT_PLANNER_WORKSPACE_CHARS = 3e3;
35732
35865
  DEFAULT_LLM_TIMEOUT_MS = 3e5;
35733
35866
  PlannerTransportError = class extends Error {
35734
35867
  constructor(message) {
@@ -35756,12 +35889,14 @@ var init_planner = __esm({
35756
35889
  '- kind "general": can edit files for one bounded, self-contained unit of work.',
35757
35890
  '- Do NOT emit "verify", "fix", or "merge" nodes \u2014 the executor adds those automatically.',
35758
35891
  '- "id" must be short, unique, kebab-case (e.g. "e1", "g-auth", "g-ui").',
35759
- '- "prompt" must be fully self-contained: the sub-agent sees ONLY this prompt, not this conversation.',
35892
+ '- "prompt" must be self-contained: the sub-agent sees ONLY this prompt, not this conversation.',
35760
35893
  '- "deps" lists ids of nodes that must finish first (topological order); [] if none.',
35894
+ '- A node DOES receive what its "deps" reported when they finished \u2014 the executor injects their conclusions into its prompt. So write a dependent node as "using the findings above, \u2026" rather than duplicating the research its dependency will do.',
35761
35895
  '- When two "general" nodes touch disjoint parts of the codebase, give each a "scope" (path/glob allowlist) so they can run in parallel safely. If scopes might overlap, either omit scope (forces sequential execution) or add a dep between them.',
35896
+ '- The user message includes a listing of the real project on disk. Build every "scope" from paths that appear there (or new paths that clearly belong beside them) \u2014 an invented path makes the parallelism decision meaningless, since scopes are exactly what the executor uses to decide which writers may run at the same time.',
35762
35897
  '- Prefer one "explore" node feeding several parallel "general" nodes over one giant node.',
35763
35898
  "- Keep the graph small: most goals need 3-8 nodes total.",
35764
- '- "acceptance" (optional) lists concrete, checkable criteria for a "general" node.'
35899
+ '- "acceptance" (optional) lists concrete, checkable criteria for a "general" node. These are ENFORCED: the executor adds a verify tentacle that checks them on disk and can send the work back for a rework round when they are not met. Write criteria a reader can settle by opening a file or running a command ("exports slugify(input: string): string", "npm test passes"), never subjective ones ("the code is elegant") \u2014 a criterion nobody can check just burns a rework round.'
35765
35900
  ].join("\n");
35766
35901
  PlannedNodeSchema = external_exports.object({
35767
35902
  id: external_exports.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/, "id must be alphanumeric/dash/underscore"),
@@ -35775,6 +35910,7 @@ var init_planner = __esm({
35775
35910
  PlannedGraphSchema = external_exports.object({
35776
35911
  nodes: external_exports.array(PlannedNodeSchema).min(1).max(DEFAULT_MAX_NODES)
35777
35912
  });
35913
+ MAX_VERIFY_TASK_PROMPT_CHARS = 1200;
35778
35914
  }
35779
35915
  });
35780
35916
 
@@ -35792,11 +35928,16 @@ function snapshotPath(cwd) {
35792
35928
  return path34.join(cwd, SNAPSHOT_DIR, SNAPSHOT_FILE);
35793
35929
  }
35794
35930
  function toGraphSnapshot(graph, opts) {
35931
+ const unresolved = (opts.unresolvedFindings ?? []).map((u) => ({
35932
+ ...u,
35933
+ findings: u.findings.slice(0, MAX_SNAPSHOT_FINDINGS_CHARS)
35934
+ }));
35795
35935
  return {
35796
35936
  graphId: graph.id,
35797
35937
  goal: opts.goal,
35798
35938
  finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
35799
35939
  converged: opts.converged,
35940
+ ...unresolved.length > 0 ? { unresolvedFindings: unresolved } : {},
35800
35941
  nodes: [...graph.nodes.values()].map((n) => ({
35801
35942
  id: n.id,
35802
35943
  kind: n.kind,
@@ -35831,19 +35972,37 @@ function formatSnapshotForPlanner(snapshot) {
35831
35972
  const done = snapshot.nodes.filter((n) => n.status === "done");
35832
35973
  const failed = snapshot.nodes.filter((n) => n.status === "error");
35833
35974
  const skipped = snapshot.nodes.filter((n) => n.status === "skipped");
35834
- if (failed.length === 0 && skipped.length === 0) return "";
35975
+ const rejected = snapshot.unresolvedFindings ?? [];
35976
+ if (failed.length === 0 && skipped.length === 0 && rejected.length === 0) return "";
35835
35977
  const line = (n) => `- ${n.label}${n.scope ? ` [${n.scope.join(", ")}]` : ""}${n.error ? ` \u2014 ${n.error}` : ""}`;
35978
+ const rejectedIds = new Set(rejected.map((r) => r.nodeId));
35979
+ const outcome = [
35980
+ `${done.length} done`,
35981
+ `${failed.length} failed`,
35982
+ `${skipped.length} never ran`,
35983
+ ...rejected.length > 0 ? [`${rejected.length} rejected by review`] : []
35984
+ ].join(", ");
35836
35985
  const parts = [
35837
35986
  "",
35838
35987
  "## Previous unfinished task graph in this project",
35839
35988
  `Goal it was working on: "${snapshot.goal}"`,
35840
- `It did NOT finish (${done.length} done, ${failed.length} failed, ${skipped.length} never ran).`,
35989
+ `It did NOT finish cleanly (${outcome}).`,
35841
35990
  "",
35842
35991
  "If that goal is unrelated to the one above, ignore this section entirely.",
35843
35992
  ""
35844
35993
  ];
35845
- if (done.length > 0) {
35846
- parts.push("Already completed \u2014 do NOT redo this work:", ...done.map(line), "");
35994
+ const cleanlyDone = done.filter((n) => !rejectedIds.has(n.id));
35995
+ if (cleanlyDone.length > 0) {
35996
+ parts.push("Already completed \u2014 do NOT redo this work:", ...cleanlyDone.map(line), "");
35997
+ }
35998
+ if (rejected.length > 0) {
35999
+ parts.push(
36000
+ "Completed but REJECTED by review \u2014 the code exists, the defects do not fix themselves:",
36001
+ ...rejected.map(
36002
+ (r) => `- ${r.label} \u2014 ${r.findings.trim().split("\n")[0] ?? "no detail"}`
36003
+ ),
36004
+ ""
36005
+ );
35847
36006
  }
35848
36007
  if (failed.length > 0) {
35849
36008
  parts.push("Failed \u2014 needs to be finished or repaired:", ...failed.map(line), "");
@@ -35856,12 +36015,13 @@ function formatSnapshotForPlanner(snapshot) {
35856
36015
  );
35857
36016
  return parts.join("\n");
35858
36017
  }
35859
- var SNAPSHOT_DIR, SNAPSHOT_FILE;
36018
+ var SNAPSHOT_DIR, SNAPSHOT_FILE, MAX_SNAPSHOT_FINDINGS_CHARS;
35860
36019
  var init_graphMemory = __esm({
35861
36020
  "src/cli/kraken/graphMemory.ts"() {
35862
36021
  "use strict";
35863
36022
  SNAPSHOT_DIR = path34.join(".zelari", "kraken");
35864
36023
  SNAPSHOT_FILE = "last-graph.json";
36024
+ MAX_SNAPSHOT_FINDINGS_CHARS = 400;
35865
36025
  }
35866
36026
  });
35867
36027
 
@@ -35878,16 +36038,24 @@ var executor_exports = {};
35878
36038
  __export(executor_exports, {
35879
36039
  DEFAULT_CANCEL_GRACE_MS: () => DEFAULT_CANCEL_GRACE_MS,
35880
36040
  DEFAULT_FIX_BUDGET: () => DEFAULT_FIX_BUDGET,
36041
+ DEFAULT_GRAPH_TIMEOUT_MS: () => DEFAULT_GRAPH_TIMEOUT_MS,
35881
36042
  DEFAULT_MAX_PARALLEL: () => DEFAULT_MAX_PARALLEL,
36043
+ DEFAULT_MAX_REVIEW_ROUNDS: () => DEFAULT_MAX_REVIEW_ROUNDS,
35882
36044
  DEFAULT_NODE_TIMEOUT_MS: () => DEFAULT_NODE_TIMEOUT_MS,
35883
36045
  DEFAULT_WRITER_NODE_TIMEOUT_MS: () => DEFAULT_WRITER_NODE_TIMEOUT_MS,
35884
36046
  KrakenGraphExecutor: () => KrakenGraphExecutor,
36047
+ MAX_UPSTREAM_CHARS_PER_DEP: () => MAX_UPSTREAM_CHARS_PER_DEP,
36048
+ MAX_UPSTREAM_CHARS_TOTAL: () => MAX_UPSTREAM_CHARS_TOTAL,
36049
+ buildUpstreamContext: () => buildUpstreamContext,
35885
36050
  isKrakenGraphEnabled: () => isKrakenGraphEnabled,
35886
36051
  isWorldModelGateEnabled: () => isWorldModelGateEnabled,
35887
36052
  resolveCancelGraceMs: () => resolveCancelGraceMs,
35888
36053
  resolveFixBudget: () => resolveFixBudget,
36054
+ resolveGraphTimeoutMs: () => resolveGraphTimeoutMs,
35889
36055
  resolveMaxParallel: () => resolveMaxParallel,
35890
- resolveNodeTimeoutMs: () => resolveNodeTimeoutMs
36056
+ resolveMaxReviewRounds: () => resolveMaxReviewRounds,
36057
+ resolveNodeTimeoutMs: () => resolveNodeTimeoutMs,
36058
+ thoroughnessForKind: () => thoroughnessForKind
35891
36059
  });
35892
36060
  import { existsSync as existsSync34 } from "node:fs";
35893
36061
  import path35 from "node:path";
@@ -35897,11 +36065,25 @@ function resolveMaxParallel(env = process.env) {
35897
36065
  const n = Number.parseInt(raw, 10);
35898
36066
  return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_PARALLEL;
35899
36067
  }
35900
- function resolveFixBudget(env = process.env) {
36068
+ function resolveFixBudget(env = process.env, nodeCount = 0) {
35901
36069
  const raw = env.ZELARI_KRAKEN_FIX_BUDGET;
35902
- if (raw === void 0 || raw === "") return DEFAULT_FIX_BUDGET;
36070
+ if (raw === void 0 || raw === "") {
36071
+ return Math.max(DEFAULT_FIX_BUDGET, Math.ceil(nodeCount / 2));
36072
+ }
35903
36073
  const n = Number.parseInt(raw, 10);
35904
- return Number.isFinite(n) && n >= 0 ? n : DEFAULT_FIX_BUDGET;
36074
+ return Number.isFinite(n) && n >= 0 ? n : Math.max(DEFAULT_FIX_BUDGET, Math.ceil(nodeCount / 2));
36075
+ }
36076
+ function resolveMaxReviewRounds(env = process.env) {
36077
+ const raw = env.ZELARI_KRAKEN_MAX_REVIEW_ROUNDS;
36078
+ if (raw === void 0 || raw === "") return DEFAULT_MAX_REVIEW_ROUNDS;
36079
+ const n = Number.parseInt(raw, 10);
36080
+ return Number.isFinite(n) && n >= 0 ? n : DEFAULT_MAX_REVIEW_ROUNDS;
36081
+ }
36082
+ function resolveGraphTimeoutMs(env = process.env) {
36083
+ const raw = env.ZELARI_KRAKEN_GRAPH_TIMEOUT_MS;
36084
+ if (raw === void 0 || raw === "") return DEFAULT_GRAPH_TIMEOUT_MS;
36085
+ const n = Number.parseInt(raw, 10);
36086
+ return Number.isFinite(n) && n >= 0 ? n : DEFAULT_GRAPH_TIMEOUT_MS;
35905
36087
  }
35906
36088
  function resolveCancelGraceMs(env = process.env) {
35907
36089
  const raw = env.ZELARI_KRAKEN_CANCEL_GRACE_MS;
@@ -35939,7 +36121,51 @@ function defaultChecksExists(cwd) {
35939
36121
  return false;
35940
36122
  }
35941
36123
  }
35942
- var DEFAULT_MAX_PARALLEL, DEFAULT_FIX_BUDGET, DEFAULT_NODE_TIMEOUT_MS, DEFAULT_WRITER_NODE_TIMEOUT_MS, DEFAULT_CANCEL_GRACE_MS, KrakenGraphExecutor;
36124
+ function buildUpstreamContext(graph, node) {
36125
+ const parts = [];
36126
+ const omitted = [];
36127
+ let budget = MAX_UPSTREAM_CHARS_TOTAL;
36128
+ for (const depId of node.deps) {
36129
+ const dep = graph.nodes.get(depId);
36130
+ if (!dep || dep.status !== "done") continue;
36131
+ const raw = (dep.result ?? "").trim();
36132
+ if (!raw) continue;
36133
+ const cap2 = Math.min(MAX_UPSTREAM_CHARS_PER_DEP, budget);
36134
+ if (cap2 <= 0) {
36135
+ omitted.push(dep.label);
36136
+ continue;
36137
+ }
36138
+ const body = raw.length > cap2 ? `${raw.slice(0, cap2)}
36139
+ \u2026 [truncated ${raw.length}\u2192${cap2} chars]` : raw;
36140
+ budget -= Math.min(raw.length, cap2);
36141
+ const scope = dep.scope && dep.scope.length > 0 ? `, scope: ${dep.scope.join(", ")}` : "";
36142
+ parts.push(`### ${dep.label} (${dep.kind}${scope})
36143
+ ${body}`);
36144
+ }
36145
+ if (parts.length === 0) return "";
36146
+ const lines = [
36147
+ "",
36148
+ "## Context from completed upstream tasks",
36149
+ "Results reported by the tasks this one depends on. Treat them as hypotheses \u2014 prefer the actual files on disk where they conflict.",
36150
+ "",
36151
+ ...parts
36152
+ ];
36153
+ if (omitted.length > 0) {
36154
+ lines.push("", `(omitted for context budget: ${omitted.join(", ")})`);
36155
+ }
36156
+ return lines.join("\n");
36157
+ }
36158
+ function thoroughnessForKind(kind) {
36159
+ return kind === "general" || kind === "fix" ? "deep" : "medium";
36160
+ }
36161
+ function firstLine(text, maxChars = 160) {
36162
+ const line = text.trim().split("\n").find((l) => l.trim() !== "")?.trim() ?? "";
36163
+ return line.length > maxChars ? `${line.slice(0, maxChars)}\u2026` : line;
36164
+ }
36165
+ function agentForNode(node) {
36166
+ return node.kind === "explore" || node.kind === "verify" ? node.kind : "general";
36167
+ }
36168
+ var DEFAULT_MAX_PARALLEL, DEFAULT_FIX_BUDGET, DEFAULT_MAX_REVIEW_ROUNDS, DEFAULT_GRAPH_TIMEOUT_MS, DEFAULT_NODE_TIMEOUT_MS, DEFAULT_WRITER_NODE_TIMEOUT_MS, DEFAULT_CANCEL_GRACE_MS, MAX_UPSTREAM_CHARS_PER_DEP, MAX_UPSTREAM_CHARS_TOTAL, KrakenGraphExecutor;
35943
36169
  var init_executor = __esm({
35944
36170
  "src/cli/kraken/executor.ts"() {
35945
36171
  "use strict";
@@ -35952,9 +36178,13 @@ var init_executor = __esm({
35952
36178
  init_graphMemory();
35953
36179
  DEFAULT_MAX_PARALLEL = 12;
35954
36180
  DEFAULT_FIX_BUDGET = 3;
36181
+ DEFAULT_MAX_REVIEW_ROUNDS = 1;
36182
+ DEFAULT_GRAPH_TIMEOUT_MS = 0;
35955
36183
  DEFAULT_NODE_TIMEOUT_MS = 3e5;
35956
36184
  DEFAULT_WRITER_NODE_TIMEOUT_MS = 9e5;
35957
36185
  DEFAULT_CANCEL_GRACE_MS = 3e4;
36186
+ MAX_UPSTREAM_CHARS_PER_DEP = 2800;
36187
+ MAX_UPSTREAM_CHARS_TOTAL = 8e3;
35958
36188
  KrakenGraphExecutor = class {
35959
36189
  deps;
35960
36190
  parentCwd;
@@ -35964,12 +36194,44 @@ var init_executor = __esm({
35964
36194
  /** Explicit all-kinds override; when undefined the budget is per-kind. */
35965
36195
  nodeTimeoutMs;
35966
36196
  cancelGraceMs;
36197
+ /** Explicit override; when undefined the budget scales with the graph size. */
36198
+ fixBudgetOption;
35967
36199
  fixBudgetRemaining;
36200
+ maxReviewRounds;
36201
+ graphTimeoutMs;
35968
36202
  worldModelGateOverride;
35969
36203
  runTentacleFn;
35970
36204
  mergeFn;
35971
36205
  backtestFn;
35972
36206
  nodeRunState = /* @__PURE__ */ new Map();
36207
+ /** fix node id → id of the failed node it was spawned to repair. */
36208
+ repairs = /* @__PURE__ */ new Map();
36209
+ /**
36210
+ * rework node id → id of the writer whose work it is redoing. A rework is a
36211
+ * `fix` node, but unlike a repair it must NOT create a worktree of its own:
36212
+ * it edits the writer's existing one (see {@link spawnReworkPair}).
36213
+ */
36214
+ reworks = /* @__PURE__ */ new Map();
36215
+ /** lineage root writer id → rework rounds already spent on that lineage. */
36216
+ reviewRounds = /* @__PURE__ */ new Map();
36217
+ /**
36218
+ * rework node id → the ORIGINAL writer its lineage started from.
36219
+ *
36220
+ * The budget has to be per lineage, not per node: a rework is itself a
36221
+ * writer, so counting rounds against the node being reworked reset the
36222
+ * counter every round and the graph chained rework → verify → rework
36223
+ * forever, terminating only on the scheduler's iteration cap.
36224
+ */
36225
+ reviewLineage = /* @__PURE__ */ new Map();
36226
+ /** Verify verdicts left unresolved when the run ends. */
36227
+ unresolved = [];
36228
+ /** Live cancellation handles for the tentacles currently running. */
36229
+ nodeControllers = /* @__PURE__ */ new Map();
36230
+ /** Wall-clock duration of each node's last run, by node id. */
36231
+ durationsMs = /* @__PURE__ */ new Map();
36232
+ signal;
36233
+ /** Set once the run has been cancelled: stops admission, retries and fixes. */
36234
+ aborted = false;
35973
36235
  fixCounter = 0;
35974
36236
  constructor(opts) {
35975
36237
  this.deps = opts.taskToolDeps;
@@ -35979,16 +36241,50 @@ var init_executor = __esm({
35979
36241
  this.maxParallel = opts.maxParallel ?? resolveMaxParallel();
35980
36242
  this.nodeTimeoutMs = opts.nodeTimeoutMs;
35981
36243
  this.cancelGraceMs = opts.cancelGraceMs;
36244
+ this.fixBudgetOption = opts.fixBudget;
35982
36245
  this.fixBudgetRemaining = opts.fixBudget ?? resolveFixBudget();
36246
+ this.maxReviewRounds = opts.maxReviewRounds ?? resolveMaxReviewRounds();
36247
+ this.graphTimeoutMs = opts.graphTimeoutMs ?? resolveGraphTimeoutMs();
35983
36248
  this.worldModelGateOverride = opts.worldModelGate;
36249
+ this.signal = opts.signal;
35984
36250
  this.runTentacleFn = opts.runTentacleFn ?? runTentacle;
35985
36251
  this.mergeFn = opts.mergeFn ?? mergeKrakenWorktree;
35986
36252
  this.backtestFn = opts.backtestFn ?? runBacktest;
35987
36253
  }
35988
36254
  /** Execute the graph in place (mutates node statuses) until it settles. */
35989
36255
  async execute(graph) {
36256
+ if (this.fixBudgetOption === void 0) {
36257
+ this.fixBudgetRemaining = resolveFixBudget(process.env, graph.nodes.size);
36258
+ }
36259
+ const onAbort = () => this.cancelRun();
36260
+ if (this.signal) {
36261
+ if (this.signal.aborted) this.aborted = true;
36262
+ else this.signal.addEventListener("abort", onAbort, { once: true });
36263
+ }
36264
+ let graphTimer;
36265
+ if (this.graphTimeoutMs > 0) {
36266
+ graphTimer = setTimeout(() => {
36267
+ this.radio("graph_failed", {
36268
+ description: "graph executor",
36269
+ detail: `graph exceeded its ${this.graphTimeoutMs}ms wall-clock budget \u2014 cancelling`,
36270
+ ok: false
36271
+ });
36272
+ this.cancelRun();
36273
+ }, this.graphTimeoutMs);
36274
+ graphTimer.unref?.();
36275
+ }
36276
+ try {
36277
+ return await this.schedule(graph);
36278
+ } finally {
36279
+ if (graphTimer) clearTimeout(graphTimer);
36280
+ this.signal?.removeEventListener("abort", onAbort);
36281
+ }
36282
+ }
36283
+ /** The scheduling loop proper. See {@link execute} for the cancellation wrapper. */
36284
+ async schedule(graph) {
35990
36285
  const maxIterations = Math.max(64, graph.nodes.size * 8);
35991
36286
  let iterations = 0;
36287
+ const inFlight = /* @__PURE__ */ new Map();
35992
36288
  startKrakenGraphLive(graph);
35993
36289
  while (!isSettled(graph)) {
35994
36290
  iterations += 1;
@@ -36000,24 +36296,39 @@ var init_executor = __esm({
36000
36296
  });
36001
36297
  break;
36002
36298
  }
36003
- const ready = getReadyNodes(graph);
36004
- if (ready.length === 0) {
36005
- const skippedAny = this.skipBlockedNodes(graph);
36006
- if (!skippedAny) {
36299
+ if (this.aborted && inFlight.size === 0) break;
36300
+ const admitted = this.aborted ? [] : this.admit(graph, inFlight);
36301
+ if (admitted.length > 0) {
36302
+ for (const node of admitted) node.status = "running";
36303
+ updateKrakenGraphLive(graph);
36304
+ for (const node of admitted) inFlight.set(node.id, this.runNodeSafely(node, graph));
36305
+ }
36306
+ if (inFlight.size === 0) {
36307
+ if (!this.skipBlockedNodes(graph)) {
36007
36308
  break;
36008
36309
  }
36009
36310
  continue;
36010
36311
  }
36011
- const wave = selectParallelWave(ready).slice(0, this.maxParallel);
36012
- for (const node of wave) node.status = "running";
36013
- const results = await Promise.all(wave.map((node) => this.runNode(node, graph)));
36014
- for (let i = 0; i < wave.length; i++) {
36015
- this.applyResult(graph, wave[i], results[i]);
36016
- }
36312
+ const { id, res } = await Promise.race(inFlight.values());
36313
+ inFlight.delete(id);
36314
+ const settledNode = graph.nodes.get(id);
36315
+ if (settledNode) this.applyResult(graph, settledNode, res);
36017
36316
  updateKrakenGraphLive(graph);
36018
36317
  }
36318
+ if (inFlight.size > 0) {
36319
+ for (const { id, res } of await Promise.all(inFlight.values())) {
36320
+ const node = graph.nodes.get(id);
36321
+ if (node) this.applyResult(graph, node, res);
36322
+ }
36323
+ inFlight.clear();
36324
+ }
36325
+ if (this.aborted) {
36326
+ for (const n of graph.nodes.values()) {
36327
+ if (n.status === "pending") n.status = "skipped";
36328
+ }
36329
+ }
36019
36330
  let backtest;
36020
- const converged = isConverged(graph);
36331
+ const converged = !this.aborted && isConverged(graph);
36021
36332
  if (converged) {
36022
36333
  const gateOn = this.worldModelGateOverride ?? isWorldModelGateEnabled(this.parentCwd);
36023
36334
  if (gateOn) {
@@ -36031,47 +36342,133 @@ var init_executor = __esm({
36031
36342
  } else {
36032
36343
  this.radio("graph_failed", {
36033
36344
  description: "graph executor",
36034
- detail: `failed nodes: ${failedNodeIds(graph).join(", ") || "none"}`,
36345
+ detail: this.aborted ? "cancelled by caller" : `failed nodes: ${failedNodeIds(graph).join(", ") || "none"}`,
36035
36346
  ok: false
36036
36347
  });
36037
36348
  }
36038
36349
  endKrakenGraphLive(graph, converged);
36039
36350
  await saveGraphSnapshot(
36040
36351
  this.parentCwd,
36041
- toGraphSnapshot(graph, { goal: this.goal ?? graph.id, converged })
36352
+ toGraphSnapshot(graph, {
36353
+ goal: this.goal ?? graph.id,
36354
+ converged,
36355
+ unresolvedFindings: this.unresolved
36356
+ })
36042
36357
  );
36043
36358
  return {
36044
36359
  graph,
36045
36360
  converged,
36046
36361
  failedNodeIds: failedNodeIds(graph),
36047
36362
  counts: countByStatus(graph),
36363
+ durationsMs: Object.fromEntries(this.durationsMs),
36364
+ cancelled: this.aborted,
36365
+ unresolvedFindings: [...this.unresolved],
36048
36366
  ...backtest ? { backtest } : {}
36049
36367
  };
36050
36368
  }
36369
+ /**
36370
+ * Stop the run: no further admissions, and every tentacle currently running
36371
+ * is told to unwind. Each node's own timeout/grace machinery then resolves
36372
+ * it, so `execute()` settles instead of leaving orphans behind.
36373
+ */
36374
+ cancelRun() {
36375
+ if (this.aborted) return;
36376
+ this.aborted = true;
36377
+ this.radio("graph_failed", {
36378
+ description: "graph executor",
36379
+ detail: `cancelling ${this.nodeControllers.size} running tentacle(s)`,
36380
+ ok: false
36381
+ });
36382
+ for (const controller of this.nodeControllers.values()) controller.abort();
36383
+ }
36384
+ /**
36385
+ * Pick the ready nodes that may start right now: parallel-safe against every
36386
+ * node already running AND against each other, within the concurrency cap.
36387
+ *
36388
+ * Unlike a wave-at-a-time scheduler this is called on every completion, so a
36389
+ * node becomes eligible the moment its blocker settles instead of waiting
36390
+ * for the slowest member of some earlier batch.
36391
+ */
36392
+ admit(graph, inFlight) {
36393
+ const capacity = this.maxParallel - inFlight.size;
36394
+ if (capacity <= 0) return [];
36395
+ const running = [];
36396
+ for (const id of inFlight.keys()) {
36397
+ const n = graph.nodes.get(id);
36398
+ if (n) running.push(n);
36399
+ }
36400
+ const admitted = [];
36401
+ for (const node of getReadyNodes(graph)) {
36402
+ if (admitted.length >= capacity) break;
36403
+ const safe = running.every((r) => canRunParallel(r, node)) && admitted.every((a) => canRunParallel(a, node));
36404
+ if (safe) admitted.push(node);
36405
+ }
36406
+ return admitted;
36407
+ }
36408
+ /**
36409
+ * Run one node, tagging the result with its id and converting an unexpected
36410
+ * throw into a node failure. The scheduler races these promises, so a
36411
+ * rejection would abandon every other in-flight tentacle mid-write; one
36412
+ * failed node that the retry/fix machinery can reason about is strictly
36413
+ * better than an aborted graph.
36414
+ */
36415
+ runNodeSafely(node, graph) {
36416
+ return this.runNode(node, graph).then(
36417
+ (res) => ({ id: node.id, res }),
36418
+ (err) => ({
36419
+ id: node.id,
36420
+ res: {
36421
+ ok: false,
36422
+ agent: agentForNode(node),
36423
+ error: `tentacle threw: ${err instanceof Error ? err.message : String(err)}`,
36424
+ cancelled: true
36425
+ }
36426
+ })
36427
+ );
36428
+ }
36051
36429
  /** Run one node: dispatch to the merge handler for `merge` nodes, else a tentacle. */
36052
36430
  async runNode(node, graph) {
36431
+ const startedAt = Date.now();
36432
+ try {
36433
+ return await this.runNodeInner(node, graph);
36434
+ } finally {
36435
+ this.durationsMs.set(node.id, Date.now() - startedAt);
36436
+ this.nodeControllers.delete(node.id);
36437
+ }
36438
+ }
36439
+ async runNodeInner(node, graph) {
36053
36440
  this.radio("node_start", { description: node.label, agent: node.kind });
36054
36441
  if (node.kind === "merge") {
36055
36442
  return this.runMergeNode(node, graph);
36056
36443
  }
36057
- const usesWorktree = node.kind === "general" || node.kind === "fix";
36444
+ const isRework = this.reworks.has(node.id);
36445
+ const usesWorktree = (node.kind === "general" || node.kind === "fix") && !isRework;
36058
36446
  const agent = node.kind === "fix" ? "general" : node.kind;
36059
36447
  const controller = new AbortController();
36448
+ this.nodeControllers.set(node.id, controller);
36449
+ if (this.aborted) controller.abort();
36450
+ const upstream = buildUpstreamContext(graph, node);
36451
+ const inheritedCwd = node.kind === "verify" || isRework ? this.inheritedWorktreeCwdFor(node, graph) : void 0;
36060
36452
  const res = await this.withNodeTimeout(
36061
36453
  this.runTentacleFn({
36062
- deps: this.deps,
36454
+ // `allowWorktree: false` is what actually stops a rework from opening
36455
+ // its own worktree: creation is driven by the agent kind ('general')
36456
+ // inside runTentacle, not by anything the executor passes per-call.
36457
+ deps: isRework ? { ...this.deps, allowWorktree: false } : this.deps,
36063
36458
  args: {
36064
36459
  description: node.label,
36065
- prompt: node.prompt,
36460
+ prompt: upstream ? `${node.prompt}
36461
+ ${upstream}` : node.prompt,
36066
36462
  scope: node.scope,
36067
36463
  acceptance: node.acceptance
36068
36464
  },
36069
36465
  agent,
36070
- thoroughness: "medium",
36466
+ thoroughness: thoroughnessForKind(node.kind),
36071
36467
  parentCwd: this.parentCwd,
36468
+ ...inheritedCwd ? { cwdOverride: inheritedCwd } : {},
36072
36469
  sessionId: this.sessionId,
36073
36470
  // Defer merge for writers so the executor controls merge ordering
36074
- // (Correction 4); explore/verify never create a worktree.
36471
+ // (Correction 4); explore/verify/rework never create a worktree.
36075
36472
  deferMerge: usesWorktree,
36076
36473
  graphId: graph.id,
36077
36474
  nodeId: node.id,
@@ -36132,20 +36529,71 @@ var init_executor = __esm({
36132
36529
  };
36133
36530
  }
36134
36531
  /**
36135
- * Sequentially merge every dep node's deferred worktree (in dep order) into
36136
- * parent HEAD. Deps without a recorded worktree handle (worktree isolation
36137
- * disabled, or a read-only node) are a no-op. On conflict the branch is
36138
- * kept and the conflict is surfaced in the merge node's error remaining
36139
- * deps still attempt to merge (independent branches shouldn't be blocked
36140
- * by one conflict).
36532
+ * The worktree a node should run in, inherited from the writer behind it.
36533
+ *
36534
+ * For a `verify`: verification happens BEFORE the merge node, so when its
36535
+ * writer worked in an isolated worktree the changes are not in the parent
36536
+ * tree yet a verify tentacle pointed at `parentCwd` was inspecting a tree
36537
+ * that provably did not contain the work it was asked to check, and reported
36538
+ * it missing.
36539
+ *
36540
+ * For a rework: the same tree, for the stronger reason that writing anywhere
36541
+ * else would strand the round on a second branch.
36542
+ *
36543
+ * Returns undefined when there is no single tree: no worktrees (isolation
36544
+ * disabled — the writers edited the parent tree directly), or several
36545
+ * distinct ones, in which case no single cwd is correct and the parent tree
36546
+ * is the honest default.
36547
+ */
36548
+ inheritedWorktreeCwdFor(node, graph) {
36549
+ const paths = new Set(
36550
+ this.collectWorktreeSources(node, graph).map((s) => s.handle.path)
36551
+ );
36552
+ return paths.size === 1 ? [...paths][0] : void 0;
36553
+ }
36554
+ /**
36555
+ * Resolve the deferred worktrees produced behind a node's dependencies, in
36556
+ * ancestors-first order. Used to decide what a `merge` node must merge, and
36557
+ * which tree a `verify` node should actually inspect.
36558
+ *
36559
+ * A merge node's direct deps are NOT the writers: `buildGraphFromPlan`
36560
+ * injects a `verify` node after every `general` node and points the merge at
36561
+ * those verifies, while worktree handles are recorded against the writer
36562
+ * node ids. Looking only at direct deps therefore found nothing to merge and
36563
+ * silently reported success while every tentacle's work stayed stranded on
36564
+ * its branch. Walk up through non-writer deps until the writers are found.
36565
+ *
36566
+ * Post-order so a writer that depends on another writer merges after it (the
36567
+ * later branch was cut from a HEAD that already contained the earlier work).
36568
+ * `merge` nodes terminate the walk: another merge already owns its subtree.
36569
+ */
36570
+ collectWorktreeSources(node, graph) {
36571
+ const out = [];
36572
+ const seen = /* @__PURE__ */ new Set();
36573
+ const visit = (id) => {
36574
+ if (seen.has(id)) return;
36575
+ seen.add(id);
36576
+ const n = graph.nodes.get(id);
36577
+ if (!n || n.kind === "merge") return;
36578
+ for (const dep of n.deps) visit(dep);
36579
+ const handle = this.nodeRunState.get(id)?.worktreeHandle;
36580
+ if (handle) out.push({ id, handle });
36581
+ };
36582
+ for (const depId of node.deps) visit(depId);
36583
+ return out;
36584
+ }
36585
+ /**
36586
+ * Sequentially merge every deferred worktree this node covers (in
36587
+ * ancestors-first order) into parent HEAD. Nodes without a recorded worktree
36588
+ * handle (worktree isolation disabled, or a read-only node) are a no-op. On
36589
+ * conflict the branch is kept and the conflict is surfaced in the merge
36590
+ * node's error — remaining sources still attempt to merge (independent
36591
+ * branches shouldn't be blocked by one conflict).
36141
36592
  */
36142
36593
  async runMergeNode(node, graph) {
36143
36594
  const conflicts = [];
36144
36595
  const merged = [];
36145
- for (const depId of node.deps) {
36146
- const state3 = this.nodeRunState.get(depId);
36147
- const handle = state3?.worktreeHandle;
36148
- if (!handle) continue;
36596
+ for (const { id: depId, handle } of this.collectWorktreeSources(node, graph)) {
36149
36597
  let result;
36150
36598
  try {
36151
36599
  result = await this.mergeFn(handle, {
@@ -36163,6 +36611,7 @@ var init_executor = __esm({
36163
36611
  if (!result.ok) {
36164
36612
  conflicts.push(`${depId}: ${result.message}`);
36165
36613
  } else {
36614
+ this.nodeRunState.set(depId, { worktreeHandle: null });
36166
36615
  merged.push(depId);
36167
36616
  }
36168
36617
  }
@@ -36190,9 +36639,21 @@ var init_executor = __esm({
36190
36639
  node.status = "done";
36191
36640
  node.result = res.result;
36192
36641
  this.radio("node_end", { description: node.label, agent: node.kind, ok: true });
36642
+ this.reconcileRepairedNode(graph, node);
36643
+ if (node.kind === "verify") this.applyVerifyVerdict(graph, node);
36193
36644
  return;
36194
36645
  }
36195
36646
  node.error = res.error;
36647
+ if (this.aborted) {
36648
+ node.status = "error";
36649
+ this.radio("node_end", {
36650
+ description: node.label,
36651
+ agent: node.kind,
36652
+ detail: res.error,
36653
+ ok: false
36654
+ });
36655
+ return;
36656
+ }
36196
36657
  if (res.cancelled === false) {
36197
36658
  node.status = "error";
36198
36659
  this.radio("node_end", {
@@ -36235,6 +36696,180 @@ var init_executor = __esm({
36235
36696
  ok: false
36236
36697
  });
36237
36698
  }
36699
+ /**
36700
+ * A `fix` node just completed the work its failed predecessor could not.
36701
+ * That unit of work IS done — but the predecessor was left terminally
36702
+ * `error`, and since `isConverged` requires every node to be `done`/
36703
+ * `skipped`, a fully repaired graph reported "did not converge" and listed
36704
+ * the repaired node under `failedNodeIds`. The cross-run snapshot then told
36705
+ * the next planner to redo work the fix had already completed.
36706
+ *
36707
+ * Marking it `done` has no scheduling effect (dependents were re-pointed at
36708
+ * the fix when it was spawned) — it is purely how the run is reported. The
36709
+ * original failure stays visible as the separate `fix: …` node and in the
36710
+ * repaired node's result line.
36711
+ */
36712
+ reconcileRepairedNode(graph, fixNode) {
36713
+ const failedId = this.repairs.get(fixNode.id);
36714
+ if (!failedId) return;
36715
+ const failed = graph.nodes.get(failedId);
36716
+ if (!failed || failed.status !== "error") return;
36717
+ const original = failed.error ? ` (original failure: ${failed.error})` : "";
36718
+ failed.status = "done";
36719
+ failed.result = `repaired by "${fixNode.label}"${original}${fixNode.result ? `: ${fixNode.result}` : ""}`;
36720
+ failed.error = void 0;
36721
+ this.radio("node_end", {
36722
+ description: failed.label,
36723
+ agent: failed.kind,
36724
+ detail: `repaired by ${fixNode.id}`,
36725
+ ok: true
36726
+ });
36727
+ }
36728
+ /**
36729
+ * Act on what a completed `verify` node concluded.
36730
+ *
36731
+ * The verify itself stays `done` either way — it did its job, and doing it
36732
+ * well means being free to say "no". A FAIL instead sends the WRITER back
36733
+ * through a bounded rework round.
36734
+ *
36735
+ * Without this the verdict text was never read: a verify that reported the
36736
+ * work as wrong was recorded exactly like one that reported it correct, the
36737
+ * graph converged over the defect, and the only iteration the engine could
36738
+ * do was on execution failure. An `unknown` verdict (no parseable trailer)
36739
+ * is deliberately non-blocking — a prompt drift must not be able to wedge
36740
+ * every graph — but it is recorded, because a gate that has silently stopped
36741
+ * working is worse than no gate.
36742
+ */
36743
+ applyVerifyVerdict(graph, verify) {
36744
+ const { verdict, findings } = parseVerifyVerdict(verify.result);
36745
+ if (verdict === "pass") return;
36746
+ const writer = this.writerBehind(verify, graph);
36747
+ if (!writer) return;
36748
+ if (verdict === "unknown") {
36749
+ this.unresolved.push({
36750
+ nodeId: writer.id,
36751
+ label: writer.label,
36752
+ reason: "unknown",
36753
+ findings: findings || "(verify produced no parseable VERDICT line)"
36754
+ });
36755
+ return;
36756
+ }
36757
+ const root = this.reviewLineage.get(writer.id) ?? writer.id;
36758
+ const spent = this.reviewRounds.get(root) ?? 0;
36759
+ if (this.aborted || spent >= this.maxReviewRounds) {
36760
+ this.unresolved.push({
36761
+ nodeId: writer.id,
36762
+ label: writer.label,
36763
+ reason: "fail",
36764
+ findings
36765
+ });
36766
+ writer.result = `${writer.result ?? ""}
36767
+
36768
+ [accepted with unresolved verify findings from ` + `"${verify.label}"]${findings ? `: ${firstLine(findings)}` : ""}`.trim();
36769
+ this.radio("node_end", {
36770
+ description: writer.label,
36771
+ agent: writer.kind,
36772
+ detail: this.aborted ? "verify FAIL left unresolved (run cancelled)" : `verify FAIL left unresolved (rework budget ${this.maxReviewRounds} spent)`,
36773
+ ok: false
36774
+ });
36775
+ return;
36776
+ }
36777
+ this.reviewRounds.set(root, spent + 1);
36778
+ this.spawnReworkPair(graph, writer, verify, findings, root, spent + 1);
36779
+ }
36780
+ /**
36781
+ * The writer whose work a `verify` node judged.
36782
+ *
36783
+ * Walks up through non-writer deps, the same shape `collectWorktreeSources`
36784
+ * relies on: a verify's dep is normally its writer directly, but after a
36785
+ * rework round the chain is writer → verify → rework → verify, and the
36786
+ * rework (a `fix` node) is itself the writer to send back.
36787
+ */
36788
+ writerBehind(verify, graph) {
36789
+ const seen = /* @__PURE__ */ new Set();
36790
+ const visit = (id) => {
36791
+ if (seen.has(id)) return void 0;
36792
+ seen.add(id);
36793
+ const n = graph.nodes.get(id);
36794
+ if (!n || n.kind === "merge") return void 0;
36795
+ if (n.kind === "general" || n.kind === "fix") return n;
36796
+ for (const dep of n.deps) {
36797
+ const found = visit(dep);
36798
+ if (found) return found;
36799
+ }
36800
+ return void 0;
36801
+ };
36802
+ for (const depId of verify.deps) {
36803
+ const found = visit(depId);
36804
+ if (found) return found;
36805
+ }
36806
+ return void 0;
36807
+ }
36808
+ /**
36809
+ * Send a writer's work back for one more round: a rework node carrying the
36810
+ * verify's findings, plus a fresh verify to judge the result.
36811
+ *
36812
+ * The rework runs INSIDE the writer's worktree instead of creating one of
36813
+ * its own. Two worktrees for one scope means two branches, and the merge
36814
+ * node walks up to the writer — so a rework on its own branch would be
36815
+ * merged never or twice, exactly the stranded-work failure the merge fix
36816
+ * addressed. `allowWorktree: false` on this node's deps suppresses creation,
36817
+ * and `cwdOverride` (resolved via {@link inheritedWorktreeCwdFor}) points it
36818
+ * at the existing tree; the handle stays registered against the writer.
36819
+ *
36820
+ * Acyclicity is preserved by construction: both new nodes point only at
36821
+ * nodes that already exist, and the rewiring moves an existing edge forward
36822
+ * along the chain rather than back into it.
36823
+ */
36824
+ spawnReworkPair(graph, writer, verify, findings, root, round) {
36825
+ const reworkId = `rework-${root}-${round}`;
36826
+ const reworkNode = {
36827
+ id: reworkId,
36828
+ kind: "fix",
36829
+ label: `rework: ${writer.label}`,
36830
+ prompt: `A reviewer inspected this work on disk and REJECTED it. Address every finding below, then leave the work in a state that satisfies the original task.
36831
+
36832
+ ## Original task
36833
+ ${writer.prompt}
36834
+
36835
+ ## Reviewer findings (these are what must change)
36836
+ ${findings || "(the reviewer reported FAIL without detail)"}`,
36837
+ ...writer.scope ? { scope: writer.scope } : {},
36838
+ ...writer.acceptance ? { acceptance: writer.acceptance } : {},
36839
+ // The verify is already `done`, so the rework is immediately ready.
36840
+ deps: [verify.id],
36841
+ status: "pending",
36842
+ retryCount: 0,
36843
+ maxRetries: 0
36844
+ };
36845
+ graph.nodes.set(reworkId, reworkNode);
36846
+ this.reworks.set(reworkId, writer.id);
36847
+ this.reviewLineage.set(reworkId, root);
36848
+ const reVerifyId = `verify-${reworkId}`;
36849
+ const reVerifyNode = {
36850
+ id: reVerifyId,
36851
+ kind: "verify",
36852
+ label: `verify: ${writer.label} (rework ${round})`,
36853
+ prompt: verify.prompt,
36854
+ deps: [reworkId],
36855
+ status: "pending",
36856
+ retryCount: 0,
36857
+ maxRetries: verify.maxRetries
36858
+ };
36859
+ graph.nodes.set(reVerifyId, reVerifyNode);
36860
+ for (const other of graph.nodes.values()) {
36861
+ if (other.id === reworkId || other.id === reVerifyId) continue;
36862
+ if (other.deps.includes(verify.id)) {
36863
+ other.deps = other.deps.map((d) => d === verify.id ? reVerifyId : d);
36864
+ }
36865
+ }
36866
+ this.radio("node_fix", {
36867
+ description: reworkNode.label,
36868
+ agent: "fix",
36869
+ detail: `verify FAIL on "${writer.label}" \u2014 rework round ${round}/${this.maxReviewRounds}`,
36870
+ ok: false
36871
+ });
36872
+ }
36238
36873
  /**
36239
36874
  * Create a `fix` node that attempts to redo the failed node's work, wired
36240
36875
  * so downstream dependents of the failed node also wait on the fix.
@@ -36261,6 +36896,7 @@ ${failed.error ?? "unknown error"}`,
36261
36896
  // no further retries — one fix attempt per failed node in v1
36262
36897
  };
36263
36898
  graph.nodes.set(fixId, fixNode);
36899
+ this.repairs.set(fixId, failed.id);
36264
36900
  for (const other of graph.nodes.values()) {
36265
36901
  if (other.id === fixId) continue;
36266
36902
  if (other.deps.includes(failed.id)) {
@@ -44904,6 +45540,7 @@ async function handleKrakenGraph(ctx, prompt) {
44904
45540
  const graph = await planTaskGraph({
44905
45541
  prompt,
44906
45542
  graphId: `kraken-${Date.now().toString(36)}`,
45543
+ cwd: ctx.cwd,
44907
45544
  ...previousAttempt ? { previousAttempt } : {}
44908
45545
  });
44909
45546
  appendSystem(ctx.setMessages, formatKrakenGraphAscii(graph));
@@ -44914,11 +45551,17 @@ async function handleKrakenGraph(ctx, prompt) {
44914
45551
  goal: prompt
44915
45552
  });
44916
45553
  const summary = await executor.execute(graph);
45554
+ const digest = formatKrakenGraphDigest(summary.graph, {
45555
+ durationsMs: summary.durationsMs,
45556
+ unresolvedFindings: summary.unresolvedFindings
45557
+ });
44917
45558
  appendSystem(
44918
45559
  ctx.setMessages,
44919
45560
  `${formatKrakenGraphAscii(summary.graph)}
44920
45561
 
44921
- ` + (summary.converged ? "[kraken] graph converged." : `[kraken] graph did not converge \u2014 failed: ${summary.failedNodeIds.join(", ") || "none"}`)
45562
+ ${digest}
45563
+
45564
+ ` + (summary.converged ? "[kraken] graph converged." : summary.cancelled ? "[kraken] graph cancelled." : `[kraken] graph did not converge \u2014 failed: ${summary.failedNodeIds.join(", ") || "none"}`)
44922
45565
  );
44923
45566
  } catch (err) {
44924
45567
  appendSystem(
@@ -47780,7 +48423,7 @@ async function runHeadlessKrakenGraph(opts, provider, model) {
47780
48423
  }
47781
48424
  const { planTaskGraph: planTaskGraph2 } = await Promise.resolve().then(() => (init_planner(), planner_exports));
47782
48425
  const { loadGraphSnapshot: loadGraphSnapshot2, formatSnapshotForPlanner: formatSnapshotForPlanner2 } = await Promise.resolve().then(() => (init_graphMemory(), graphMemory_exports));
47783
- const { formatKrakenGraphAscii: formatKrakenGraphAscii2 } = await Promise.resolve().then(() => (init_graphStatus(), graphStatus_exports));
48426
+ const { formatKrakenGraphAscii: formatKrakenGraphAscii2, formatKrakenGraphDigest: formatKrakenGraphDigest2 } = await Promise.resolve().then(() => (init_graphStatus(), graphStatus_exports));
47784
48427
  const { AuditLogger: AuditLogger2 } = await Promise.resolve().then(() => (init_auditLogger(), auditLogger_exports));
47785
48428
  const { createKrakenSubAgentContextFactory: createKrakenSubAgentContextFactory2 } = await Promise.resolve().then(() => (init_toolRegistry(), toolRegistry_exports));
47786
48429
  const cwd = process.cwd();
@@ -47793,6 +48436,12 @@ async function runHeadlessKrakenGraph(opts, provider, model) {
47793
48436
  `);
47794
48437
  }
47795
48438
  };
48439
+ const abort = new AbortController();
48440
+ const onSigint = () => {
48441
+ log("SIGINT \u2014 cancelling the graph; press Ctrl-C again to force quit");
48442
+ abort.abort();
48443
+ };
48444
+ process.once("SIGINT", onSigint);
47796
48445
  try {
47797
48446
  log(`planning kraken graph: ${prompt}`);
47798
48447
  const previous = await loadGraphSnapshot2(cwd);
@@ -47802,6 +48451,7 @@ async function runHeadlessKrakenGraph(opts, provider, model) {
47802
48451
  prompt,
47803
48452
  provider,
47804
48453
  model,
48454
+ cwd,
47805
48455
  ...previousAttempt ? { previousAttempt } : {}
47806
48456
  });
47807
48457
  log(formatKrakenGraphAscii2(graph));
@@ -47823,10 +48473,20 @@ async function runHeadlessKrakenGraph(opts, provider, model) {
47823
48473
  },
47824
48474
  parentCwd: cwd,
47825
48475
  sessionId,
47826
- goal: prompt
48476
+ goal: prompt,
48477
+ signal: abort.signal
47827
48478
  });
47828
48479
  const summary = await executor.execute(graph);
47829
- const finalAscii = formatKrakenGraphAscii2(summary.graph);
48480
+ if (summary.cancelled) log("graph cancelled \u2014 partial results below");
48481
+ const finalAscii = `${formatKrakenGraphAscii2(summary.graph)}
48482
+
48483
+ ${formatKrakenGraphDigest2(
48484
+ summary.graph,
48485
+ {
48486
+ durationsMs: summary.durationsMs,
48487
+ unresolvedFindings: summary.unresolvedFindings
48488
+ }
48489
+ )}`;
47830
48490
  if (opts.output === "json") {
47831
48491
  emitEvent({ type: "message_start" });
47832
48492
  emitEvent({ type: "message_delta", delta: finalAscii });
@@ -47853,6 +48513,8 @@ async function runHeadlessKrakenGraph(opts, provider, model) {
47853
48513
  `);
47854
48514
  }
47855
48515
  return 2;
48516
+ } finally {
48517
+ process.off("SIGINT", onSigint);
47856
48518
  }
47857
48519
  }
47858
48520
  function planModeFromOpts(opts) {