dahrk-node 0.1.21 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/main.js +611 -451
  2. package/package.json +1 -1
package/dist/main.js CHANGED
@@ -74,6 +74,13 @@ import {
74
74
  query
75
75
  } from "@anthropic-ai/claude-agent-sdk";
76
76
 
77
+ // ../../packages/executor-worktree/src/response-rule.ts
78
+ function decideResponse(bufferedText, endedOnTool, status) {
79
+ const text = bufferedText.trim();
80
+ if (status !== "ok" || !text || endedOnTool) return {};
81
+ return { responseText: text, event: { type: "response", text } };
82
+ }
83
+
77
84
  // ../../packages/executor-worktree/src/claude-mappers.ts
78
85
  function mapClaudeMessage(msg) {
79
86
  switch (msg.type) {
@@ -168,27 +175,21 @@ function consumeClaudeMessage(msg, state, suppressStageExit) {
168
175
  }
169
176
  if (msg.type === "result") {
170
177
  const status = r.resultStatus === "fail" ? "fail" : "ok";
171
- let responseText;
172
- if (status === "ok" && state.bufferedText && !state.turnEndedOnTool) {
173
- responseText = state.bufferedText;
174
- events.push({ type: "response", text: state.bufferedText });
175
- }
178
+ const decision = decideResponse(state.bufferedText ?? "", state.turnEndedOnTool, status);
179
+ if (decision.event) events.push(decision.event);
176
180
  for (const e of r.events) {
177
181
  if (suppressStageExit && e.type === "state") continue;
178
182
  events.push(e);
179
183
  }
180
184
  state.bufferedText = null;
181
185
  state.turnEndedOnTool = false;
182
- return { events, isResult: true, status, responseText };
186
+ return { events, isResult: true, status, responseText: decision.responseText };
183
187
  }
184
188
  events.push(...r.events);
185
189
  return { events, isResult: false };
186
190
  }
187
191
 
188
- // ../../packages/executor-worktree/src/runner-shared.ts
189
- import { readFileSync } from "fs";
190
- import { join } from "path";
191
- import { attachedDocBasename } from "@dahrk/contracts";
192
+ // ../../packages/executor-worktree/src/runtime-session.ts
192
193
  var SUMMARISE_PROMPT = "This stage is ending. In ONE concise sentence for a human reviewer reading the Linear ticket, state concretely what you DID or FOUND in this stage and the result (for example which functions or files changed, whether the tests pass, or what a review flagged). Be specific and lead with the outcome. Do not mention internal scratch files or a next-stage entry point. Reply with only that sentence.";
193
194
  function makeEmit(runtime, onTrace, now = () => (/* @__PURE__ */ new Date()).toISOString()) {
194
195
  return (event, rawRef) => {
@@ -197,6 +198,11 @@ function makeEmit(runtime, onTrace, now = () => (/* @__PURE__ */ new Date()).toI
197
198
  onTrace(full);
198
199
  };
199
200
  }
201
+
202
+ // ../../packages/executor-worktree/src/prompt-assembly.ts
203
+ import { readFileSync } from "fs";
204
+ import { join } from "path";
205
+ import { attachedDocBasename } from "@dahrk/contracts";
200
206
  function stripFrontmatter(text) {
201
207
  const lines = text.split("\n");
202
208
  if (lines[0]?.trim() !== "---") return text;
@@ -319,6 +325,8 @@ var OPENING_KICKOFF = "Begin now. Using the ticket context and your instructions
319
325
  function interactiveSeedText(ctx, instructionInSystemPrompt) {
320
326
  return instructionInSystemPrompt ? OPENING_KICKOFF : resolveStagePrompt(ctx);
321
327
  }
328
+
329
+ // ../../packages/executor-worktree/src/mailbox.ts
322
330
  var ManagedMailbox = class {
323
331
  q = [];
324
332
  waiters = [];
@@ -344,36 +352,19 @@ var ManagedMailbox = class {
344
352
  };
345
353
  }
346
354
  };
347
- function interactiveIdleWindows(ctx) {
348
- const idleMs = ctx.config.idleMs ?? Number(process.env.DAHRK_INTERACTIVE_IDLE_MS ?? process.env.SKAKEL_INTERACTIVE_IDLE_MS ?? 12e4);
349
- const firstReplyMs = ctx.config.firstReplyMs ?? Number(process.env.DAHRK_INTERACTIVE_FIRST_REPLY_MS ?? process.env.SKAKEL_INTERACTIVE_FIRST_REPLY_MS ?? 6e5);
350
- return { firstReplyMs: Math.max(firstReplyMs, idleMs), idleMs };
351
- }
352
- function raceNextTurn(pending, idleMs, signal) {
353
- return new Promise((resolve3) => {
354
- let settled = false;
355
- let timer;
356
- function finish(r) {
357
- if (settled) return;
358
- settled = true;
359
- if (timer) clearTimeout(timer);
360
- signal.removeEventListener("abort", onAbort);
361
- resolve3(r);
362
- }
363
- function onAbort() {
364
- finish({ kind: "cancelled" });
365
- }
366
- if (signal.aborted) {
367
- finish({ kind: "cancelled" });
368
- return;
369
- }
370
- signal.addEventListener("abort", onAbort);
371
- timer = setTimeout(() => finish({ kind: "idle-timeout" }), idleMs);
372
- pending.then(
373
- (res) => finish(res.done ? { kind: "turns-exhausted" } : { kind: "turn", value: res.value }),
374
- () => finish({ kind: "turns-exhausted" })
375
- );
376
- });
355
+
356
+ // ../../packages/executor-worktree/src/elicit-router.ts
357
+ function elicitOutcomeReply(outcome) {
358
+ switch (outcome.kind) {
359
+ case "reply":
360
+ return `The user selected: ${outcome.text}`;
361
+ case "busy":
362
+ return "Only one question can be asked at a time; wait for the current one to be answered, then ask again.";
363
+ case "noreply":
364
+ return "No response from the user; proceed with your best judgement.";
365
+ case "cancel":
366
+ return "The question was cancelled.";
367
+ }
377
368
  }
378
369
  function createElicitTurnRouter(turns, opts) {
379
370
  const conversation = new ManagedMailbox();
@@ -417,6 +408,147 @@ function createElicitTurnRouter(turns, opts) {
417
408
  return { conversation, ask };
418
409
  }
419
410
 
411
+ // ../../packages/executor-worktree/src/turn-loop.ts
412
+ function interactiveIdleWindows(ctx) {
413
+ const idleMs = ctx.config.idleMs ?? Number(process.env.DAHRK_INTERACTIVE_IDLE_MS ?? process.env.SKAKEL_INTERACTIVE_IDLE_MS ?? 12e4);
414
+ const firstReplyMs = ctx.config.firstReplyMs ?? Number(process.env.DAHRK_INTERACTIVE_FIRST_REPLY_MS ?? process.env.SKAKEL_INTERACTIVE_FIRST_REPLY_MS ?? 6e5);
415
+ return { firstReplyMs: Math.max(firstReplyMs, idleMs), idleMs };
416
+ }
417
+ function raceNextTurn(pending, idleMs, signal) {
418
+ return new Promise((resolve3) => {
419
+ let settled = false;
420
+ let timer;
421
+ function finish(r) {
422
+ if (settled) return;
423
+ settled = true;
424
+ if (timer) clearTimeout(timer);
425
+ signal.removeEventListener("abort", onAbort);
426
+ resolve3(r);
427
+ }
428
+ function onAbort() {
429
+ finish({ kind: "cancelled" });
430
+ }
431
+ if (signal.aborted) {
432
+ finish({ kind: "cancelled" });
433
+ return;
434
+ }
435
+ signal.addEventListener("abort", onAbort);
436
+ timer = setTimeout(() => finish({ kind: "idle-timeout" }), idleMs);
437
+ pending.then(
438
+ (res) => finish(res.done ? { kind: "turns-exhausted" } : { kind: "turn", value: res.value }),
439
+ () => finish({ kind: "turns-exhausted" })
440
+ );
441
+ });
442
+ }
443
+ var COALESCE_MS = Number(process.env.DAHRK_COALESCE_MS ?? process.env.SKAKEL_COALESCE_MS ?? 40);
444
+ async function runInteractiveLoop(ctx, turns, emit, makeSession, opts) {
445
+ const { signal, cancelled, cancel, instructionInSystemPrompt } = opts;
446
+ const exit = ctx.config.exit ?? "either";
447
+ const wantsTool = exit === "tool" || exit === "either";
448
+ const { firstReplyMs, idleMs } = interactiveIdleWindows(ctx);
449
+ const router = createElicitTurnRouter(turns, { signal, firstReplyMs, idleMs });
450
+ const humanIter = router.conversation[Symbol.asyncIterator]();
451
+ let awaitingFirstReply = true;
452
+ const ask = async (question) => {
453
+ const outcome = await router.ask(awaitingFirstReply, () => {
454
+ emit({ type: "elicitation", prompt: question.prompt, signal: "select", options: question.options });
455
+ ctx.emitElicit?.(question);
456
+ });
457
+ return elicitOutcomeReply(outcome);
458
+ };
459
+ const session = makeSession({ emit, ask });
460
+ let toolSummary;
461
+ let artifact;
462
+ let exited = "gate";
463
+ let pending = humanIter.next();
464
+ try {
465
+ const seed = await session.sendTurn(interactiveSeedText(ctx, instructionInSystemPrompt));
466
+ if (seed.stageComplete && wantsTool) {
467
+ exited = "tool";
468
+ toolSummary = seed.summary;
469
+ artifact = seed.artifact;
470
+ }
471
+ for (; ; ) {
472
+ if (exited === "tool") break;
473
+ const race = await raceNextTurn(pending, awaitingFirstReply ? firstReplyMs : idleMs, signal);
474
+ awaitingFirstReply = false;
475
+ if (race.kind === "cancelled") {
476
+ exited = "cancelled";
477
+ break;
478
+ }
479
+ if (race.kind === "idle-timeout") {
480
+ exited = "timeout";
481
+ break;
482
+ }
483
+ if (race.kind === "turns-exhausted") {
484
+ exited = "gate";
485
+ break;
486
+ }
487
+ const texts = [race.value.text];
488
+ pending = humanIter.next();
489
+ for (; ; ) {
490
+ const more = await raceNextTurn(pending, COALESCE_MS, signal);
491
+ if (more.kind === "turn") {
492
+ texts.push(more.value.text);
493
+ pending = humanIter.next();
494
+ continue;
495
+ }
496
+ if (more.kind === "cancelled") exited = "cancelled";
497
+ break;
498
+ }
499
+ if (exited === "cancelled") break;
500
+ const tr = await session.sendTurn(texts.join("\n"));
501
+ if (tr.stageComplete && wantsTool) {
502
+ exited = "tool";
503
+ toolSummary = tr.summary;
504
+ artifact = tr.artifact;
505
+ break;
506
+ }
507
+ }
508
+ } catch (e) {
509
+ if (!cancelled()) emit({ type: "error", kind: "runtime_error", message: e.message });
510
+ exited = cancelled() ? "cancelled" : "gate";
511
+ }
512
+ let status = "ok";
513
+ let summary = "";
514
+ if (exited === "tool") {
515
+ summary = toolSummary ?? "(stage marked complete)";
516
+ } else if (exited === "gate") {
517
+ summary = await session.summariseTurn();
518
+ } else if (exited === "timeout") {
519
+ status = "timeout";
520
+ summary = "(stage timed out awaiting input)";
521
+ await cancel();
522
+ } else {
523
+ status = "fail";
524
+ summary = "(stage cancelled)";
525
+ }
526
+ const costUsd = session.cost();
527
+ const sessionId = session.sessionId;
528
+ const outArtifact = status === "ok" ? artifact : void 0;
529
+ return {
530
+ status,
531
+ summary,
532
+ ...sessionId ? { sessionId } : {},
533
+ ...costUsd !== void 0 ? { costUsd } : {},
534
+ ...outArtifact ? { artifact: outArtifact } : {}
535
+ };
536
+ }
537
+ async function runBatchLoop(session, ctx, hooks, opts) {
538
+ let status = "ok";
539
+ try {
540
+ const tr = await session.sendTurn(resolveStagePrompt(ctx));
541
+ if (tr.status) status = tr.status;
542
+ } catch (e) {
543
+ if (!opts.cancelled()) hooks.emit({ type: "error", kind: "runtime_error", message: e.message });
544
+ status = "fail";
545
+ }
546
+ if (opts.cancelled()) status = "fail";
547
+ const costUsd = session.cost();
548
+ const sessionId = session.sessionId;
549
+ return { status, ...sessionId ? { sessionId } : {}, ...costUsd !== void 0 ? { costUsd } : {} };
550
+ }
551
+
420
552
  // ../../packages/executor-worktree/src/stage-complete-tool.ts
421
553
  import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
422
554
  import { z } from "zod";
@@ -424,6 +556,10 @@ var STAGE_COMPLETE_TOOL_NAME = "mcp__dahrk__dahrk_stage_complete";
424
556
  function createStageCompleteTool() {
425
557
  let captured = null;
426
558
  let capturedDoc = null;
559
+ const capture = (args) => {
560
+ captured = args.summary;
561
+ if (args.document !== void 0) capturedDoc = args.document;
562
+ };
427
563
  const completeTool = tool(
428
564
  "dahrk_stage_complete",
429
565
  "End the current stage and hand off a one-sentence summary of what was accomplished. When the stage's deliverable is a document (e.g. a specification or report) to be published, pass its full markdown body as `document`; this is the only way an interactive stage can emit a document, since it cannot write files.",
@@ -432,8 +568,7 @@ function createStageCompleteTool() {
432
568
  document: z.string().optional().describe("The full markdown body of the stage's deliverable document, if any.")
433
569
  },
434
570
  async (args) => {
435
- captured = args.summary;
436
- if (args.document !== void 0) capturedDoc = args.document;
571
+ capture(args);
437
572
  return { content: [{ type: "text", text: "Stage marked complete." }] };
438
573
  }
439
574
  );
@@ -442,7 +577,8 @@ function createStageCompleteTool() {
442
577
  allowedToolName: STAGE_COMPLETE_TOOL_NAME,
443
578
  fired: () => captured !== null,
444
579
  summary: () => captured,
445
- document: () => capturedDoc
580
+ document: () => capturedDoc,
581
+ capture
446
582
  };
447
583
  }
448
584
 
@@ -496,7 +632,6 @@ function createAskUserQuestionTool(deps) {
496
632
  }
497
633
 
498
634
  // ../../packages/executor-worktree/src/claude-adapter.ts
499
- var COALESCE_MS = Number(process.env.DAHRK_COALESCE_MS ?? process.env.SKAKEL_COALESCE_MS ?? 40);
500
635
  var MAX_TURNS = Number(process.env.DAHRK_MAX_TURNS ?? process.env.SKAKEL_MAX_TURNS ?? 64);
501
636
  var HANDED_BACK_ARTIFACT_PATH = ".dahrk/scratch/output/document.md";
502
637
  var CLAUDE_CODE_SYSTEM_PROMPT = { type: "preset", preset: "claude_code" };
@@ -505,6 +640,7 @@ var userMsg = (text) => ({
505
640
  parent_tool_use_id: null,
506
641
  message: { role: "user", content: text }
507
642
  });
643
+ var defaultAsk = async () => elicitOutcomeReply({ kind: "noreply" });
508
644
  var sessionIdOf = (msg) => "session_id" in msg && typeof msg.session_id === "string" ? msg.session_id : void 0;
509
645
  var policyCanUseTool = async (ctx, toolName, input) => {
510
646
  const verdict2 = ctx.authorizeToolUse?.(toolName, input);
@@ -544,7 +680,25 @@ function runtimeEnvOptions(ctx) {
544
680
  if (!ctx.runtimeEnv) return {};
545
681
  return { env: { ...process.env, ...ctx.runtimeEnv } };
546
682
  }
547
- function createClaudeRunner() {
683
+ var defaultCreateClaudeSession = ({ prompt, options }) => {
684
+ const mailbox = prompt === void 0 ? new ManagedMailbox() : void 0;
685
+ const q = query({ prompt: mailbox ?? prompt, options });
686
+ const it = q[Symbol.asyncIterator]();
687
+ return {
688
+ [Symbol.asyncIterator]: () => it,
689
+ push: (m) => mailbox?.push(m),
690
+ end: () => mailbox?.end(),
691
+ close: () => {
692
+ try {
693
+ q.close();
694
+ } catch {
695
+ }
696
+ },
697
+ interrupt: () => q.interrupt()
698
+ };
699
+ };
700
+ function createClaudeRunner(deps = {}) {
701
+ const createSession = deps.createSession ?? defaultCreateClaudeSession;
548
702
  const abortController = new AbortController();
549
703
  let cancelled = false;
550
704
  let active;
@@ -609,11 +763,86 @@ function createClaudeRunner() {
609
763
  }
610
764
  active = void 0;
611
765
  };
766
+ const makeClaudeRuntimeSession = (ctx, hooks, mode) => {
767
+ const state = newBufferState();
768
+ const it = mode.interactive ? mode.session[Symbol.asyncIterator]() : void 0;
769
+ const consumeInteractiveTurn = async () => {
770
+ for (; ; ) {
771
+ const { value: msg, done } = await it.next();
772
+ if (done) return {};
773
+ const res = handleMessage(msg, hooks.emit, ctx, state, true);
774
+ if (res.isResult) return { status: res.status, responseText: res.responseText };
775
+ }
776
+ };
777
+ return {
778
+ get sessionId() {
779
+ return sessionId;
780
+ },
781
+ async sendTurn(text) {
782
+ if (!mode.interactive) {
783
+ const session = createSession({ prompt: text, options: mode.options });
784
+ active = session;
785
+ let status2;
786
+ try {
787
+ for await (const msg of session) {
788
+ const res = handleMessage(msg, hooks.emit, ctx, state, false);
789
+ if (res.isResult && res.status) status2 = res.status;
790
+ }
791
+ } finally {
792
+ closeActive();
793
+ }
794
+ return { stageComplete: false, ...status2 !== void 0 ? { status: status2 } : {} };
795
+ }
796
+ mode.session.push(userMsg(text));
797
+ const { status, responseText } = await consumeInteractiveTurn();
798
+ const stageComplete = mode.stageTool.fired();
799
+ const doc = stageComplete ? mode.stageTool.document() : null;
800
+ return {
801
+ stageComplete,
802
+ ...stageComplete ? { summary: mode.stageTool.summary() ?? void 0 } : {},
803
+ ...responseText !== void 0 ? { responseText } : {},
804
+ ...status !== void 0 ? { status } : {},
805
+ ...doc !== null ? { artifact: { path: ctx.config.emitArtifact ?? HANDED_BACK_ARTIFACT_PATH, content: doc } } : {}
806
+ };
807
+ },
808
+ async summariseTurn() {
809
+ if (!mode.interactive) return "(no summary produced)";
810
+ mode.summarising.value = true;
811
+ try {
812
+ mode.session.push(userMsg(SUMMARISE_PROMPT));
813
+ const { responseText } = await consumeInteractiveTurn();
814
+ return (responseText ?? "").trim() || "(no summary produced)";
815
+ } catch {
816
+ return "(no summary produced)";
817
+ } finally {
818
+ mode.summarising.value = false;
819
+ }
820
+ },
821
+ cost() {
822
+ return costUsd;
823
+ },
824
+ dispose() {
825
+ if (!mode.interactive) return;
826
+ const iter = it;
827
+ mode.session.end();
828
+ void (async () => {
829
+ try {
830
+ for (; ; ) {
831
+ const { done } = await iter.next();
832
+ if (done) break;
833
+ }
834
+ } catch {
835
+ } finally {
836
+ closeActive();
837
+ }
838
+ })();
839
+ }
840
+ };
841
+ };
612
842
  return {
613
843
  runtime: "claude-code",
614
844
  async runBatch(ctx, onTrace) {
615
- const emit = makeEmit("claude-code", onTrace);
616
- const prompt = resolveStagePrompt(ctx);
845
+ const hooks = { emit: makeEmit("claude-code", onTrace), ask: defaultAsk };
617
846
  const mcpServers = buildBrokeredMcpServers(ctx);
618
847
  const options = {
619
848
  ...baseOptions(ctx),
@@ -624,171 +853,50 @@ function createClaudeRunner() {
624
853
  // Headless: allow tools to run without an interactive permission prompt (M6 wires policy).
625
854
  canUseTool: async (toolName, input) => policyCanUseTool(ctx, toolName, input)
626
855
  };
627
- const state = newBufferState();
628
- let status = "ok";
629
- try {
630
- const q = query({ prompt, options });
631
- active = q;
632
- for await (const msg of q) {
633
- const res = handleMessage(msg, emit, ctx, state, false);
634
- if (res.isResult && res.status) status = res.status;
635
- }
636
- } catch (e) {
637
- if (!cancelled) emit({ type: "error", kind: "runtime_error", message: e.message });
638
- status = "fail";
639
- } finally {
640
- closeActive();
641
- }
642
- if (cancelled) status = "fail";
643
- return { status, ...sessionId ? { sessionId } : {}, ...costUsd !== void 0 ? { costUsd } : {} };
856
+ const rt = makeClaudeRuntimeSession(ctx, hooks, { interactive: false, options });
857
+ return runBatchLoop(rt, ctx, hooks, { cancelled: () => cancelled });
644
858
  },
645
859
  async runInteractive(ctx, turns, onTrace) {
646
860
  const emit = makeEmit("claude-code", onTrace);
647
861
  const stageTool = createStageCompleteTool();
648
- const { firstReplyMs, idleMs } = interactiveIdleWindows(ctx);
649
- let awaitingFirstReply = true;
650
- const router = createElicitTurnRouter(turns, { signal: abortController.signal, firstReplyMs, idleMs });
651
- const askTool = createAskUserQuestionTool({
652
- ask: async (question) => {
653
- const outcome = await router.ask(awaitingFirstReply, () => {
654
- emit({ type: "elicitation", prompt: question.prompt, signal: "select", options: question.options });
655
- ctx.emitElicit?.(question);
656
- });
657
- switch (outcome.kind) {
658
- case "reply":
659
- return `The user selected: ${outcome.text}`;
660
- case "busy":
661
- return "Only one question can be asked at a time; wait for the current one to be answered, then ask again.";
662
- case "noreply":
663
- return "No response from the user; proceed with your best judgement.";
664
- case "cancel":
665
- return "The question was cancelled.";
666
- }
667
- }
668
- });
669
- const brokered = buildBrokeredMcpServers(ctx);
670
- let summarising = false;
671
- const options = {
672
- ...baseOptions(ctx),
673
- // Keep the cwd-anchoring preset; fold the stage instruction in via `append` so the
674
- // interactive persona still gets it without losing the working-directory context.
675
- systemPrompt: hasSystemPrompt(ctx) ? { type: "preset", preset: "claude_code", append: resolveStagePrompt(ctx) } : CLAUDE_CODE_SYSTEM_PROMPT,
676
- // Inject the stage-complete exit tool and the AskUserQuestion shadow alongside any brokered
677
- // MCP servers (parity with batch).
678
- mcpServers: { dahrk: stageTool.server, ask: askTool.server, ...brokered ?? {} },
679
- // Redirect the built-in AskUserQuestion to the shadow tool so a structured question surfaces
680
- // as a Linear elicitation. The redirect is name-only and single-hop; the tool still runs, so
681
- // this is mapping, not gating (DHK-344 / DHK-223).
682
- toolAliases: { [ASK_USER_QUESTION_ALIAS]: askTool.allowedToolName },
683
- // Auto-approve the injected tools; `allowedTools` is an auto-approve list, not a whitelist, so
684
- // it does not restrict the other tools canUseTool allows.
685
- allowedTools: [stageTool.allowedToolName, askTool.allowedToolName],
686
- canUseTool: async (toolName, input) => interactiveCanUseTool(summarising, stageTool.allowedToolName, ctx, toolName, input),
687
- maxTurns: MAX_TURNS,
688
- includePartialMessages: false
689
- };
690
- const exit = ctx.config.exit ?? "either";
691
- const wantsTool = exit === "tool" || exit === "either";
692
- const mailbox = new ManagedMailbox();
693
- const q = query({ prompt: mailbox, options });
694
- active = q;
695
- const it = q[Symbol.asyncIterator]();
696
- const humanIter = router.conversation[Symbol.asyncIterator]();
697
- const state = newBufferState();
698
- const consumeTurn = async () => {
699
- for (; ; ) {
700
- const { value: msg, done } = await it.next();
701
- if (done) return void 0;
702
- const res = handleMessage(msg, emit, ctx, state, true);
703
- if (res.isResult) return res.responseText;
704
- }
705
- };
706
- let exited = null;
707
- let pending = humanIter.next();
708
- try {
709
- mailbox.push(userMsg(interactiveSeedText(ctx, hasSystemPrompt(ctx))));
710
- await consumeTurn();
711
- if (stageTool.fired() && wantsTool) exited = "tool";
712
- while (exited === null) {
713
- const race = await raceNextTurn(pending, awaitingFirstReply ? firstReplyMs : idleMs, abortController.signal);
714
- awaitingFirstReply = false;
715
- if (race.kind === "cancelled") {
716
- exited = "cancelled";
717
- break;
718
- }
719
- if (race.kind === "idle-timeout") {
720
- exited = "timeout";
721
- break;
722
- }
723
- if (race.kind === "turns-exhausted") {
724
- exited = "gate";
725
- break;
726
- }
727
- const texts = [race.value.text];
728
- pending = humanIter.next();
729
- for (; ; ) {
730
- const more = await raceNextTurn(pending, COALESCE_MS, abortController.signal);
731
- if (more.kind === "turn") {
732
- texts.push(more.value.text);
733
- pending = humanIter.next();
734
- continue;
735
- }
736
- if (more.kind === "cancelled") exited = "cancelled";
737
- break;
738
- }
739
- if (exited === "cancelled") break;
740
- mailbox.push(userMsg(texts.join("\n")));
741
- await consumeTurn();
742
- if (stageTool.fired() && wantsTool) {
743
- exited = "tool";
744
- break;
745
- }
746
- }
747
- } catch (e) {
748
- if (!cancelled && !exited) emit({ type: "error", kind: "runtime_error", message: e.message });
749
- exited = exited ?? (cancelled ? "cancelled" : "gate");
750
- }
751
- let status = "ok";
752
- let summary = "";
753
- if (exited === "tool") {
754
- summary = stageTool.summary() ?? "(stage marked complete)";
755
- } else if (exited === "gate") {
756
- summarising = true;
757
- try {
758
- mailbox.push(userMsg(SUMMARISE_PROMPT));
759
- const reply = await consumeTurn();
760
- summary = (reply ?? "").trim() || "(no summary produced)";
761
- } catch {
762
- summary = "(no summary produced)";
763
- } finally {
764
- summarising = false;
765
- }
766
- } else if (exited === "timeout") {
767
- status = "timeout";
768
- summary = "(stage timed out awaiting input)";
769
- await this.cancel();
770
- } else {
771
- status = "fail";
772
- summary = "(stage cancelled)";
773
- }
774
- mailbox.end();
775
- try {
776
- for (; ; ) {
777
- const { done } = await it.next();
778
- if (done) break;
779
- }
780
- } catch {
781
- }
782
- closeActive();
783
- const handedBackDoc = status === "ok" ? stageTool.document() : null;
784
- const artifact = handedBackDoc !== null ? { path: ctx.config.emitArtifact ?? HANDED_BACK_ARTIFACT_PATH, content: handedBackDoc } : void 0;
785
- return {
786
- status,
787
- summary,
788
- ...sessionId ? { sessionId } : {},
789
- ...costUsd !== void 0 ? { costUsd } : {},
790
- ...artifact ? { artifact } : {}
862
+ const summarising = { value: false };
863
+ let rt;
864
+ const makeSession = (hooks) => {
865
+ const askTool = createAskUserQuestionTool({ ask: (question) => hooks.ask(question) });
866
+ const brokered = buildBrokeredMcpServers(ctx);
867
+ const options = {
868
+ ...baseOptions(ctx),
869
+ // Keep the cwd-anchoring preset; fold the stage instruction in via `append` so the
870
+ // interactive persona still gets it without losing the working-directory context.
871
+ systemPrompt: hasSystemPrompt(ctx) ? { type: "preset", preset: "claude_code", append: resolveStagePrompt(ctx) } : CLAUDE_CODE_SYSTEM_PROMPT,
872
+ // Inject the stage-complete exit tool and the AskUserQuestion shadow alongside any brokered
873
+ // MCP servers (parity with batch).
874
+ mcpServers: { dahrk: stageTool.server, ask: askTool.server, ...brokered ?? {} },
875
+ // Redirect the built-in AskUserQuestion to the shadow tool so a structured question surfaces
876
+ // as a Linear elicitation. The redirect is name-only and single-hop; the tool still runs, so
877
+ // this is mapping, not gating (DHK-344 / DHK-223).
878
+ toolAliases: { [ASK_USER_QUESTION_ALIAS]: askTool.allowedToolName },
879
+ // Auto-approve the injected tools; `allowedTools` is an auto-approve list, not a whitelist, so
880
+ // it does not restrict the other tools canUseTool allows.
881
+ allowedTools: [stageTool.allowedToolName, askTool.allowedToolName],
882
+ canUseTool: async (toolName, input) => interactiveCanUseTool(summarising.value, stageTool.allowedToolName, ctx, toolName, input),
883
+ maxTurns: MAX_TURNS,
884
+ includePartialMessages: false
885
+ };
886
+ const session = createSession({ options, stageTool });
887
+ active = session;
888
+ rt = makeClaudeRuntimeSession(ctx, hooks, { interactive: true, session, stageTool, summarising });
889
+ return rt;
791
890
  };
891
+ const result = await runInteractiveLoop(ctx, turns, emit, makeSession, {
892
+ signal: abortController.signal,
893
+ cancelled: () => cancelled,
894
+ cancel: () => this.cancel(),
895
+ // Claude carries the stage instruction in its system prompt, so the seed can be a short kickoff.
896
+ instructionInSystemPrompt: hasSystemPrompt(ctx)
897
+ });
898
+ rt?.dispose();
899
+ return result;
792
900
  },
793
901
  async summarise(ctx) {
794
902
  if (!sessionId) return "(no summary: session not established)";
@@ -803,9 +911,9 @@ function createClaudeRunner() {
803
911
  };
804
912
  try {
805
913
  let out2 = "";
806
- const q = query({ prompt: SUMMARISE_PROMPT, options });
807
- active = q;
808
- for await (const msg of q) {
914
+ const session = createSession({ prompt: SUMMARISE_PROMPT, options });
915
+ active = session;
916
+ for await (const msg of session) {
809
917
  const found = sessionIdOf(msg);
810
918
  if (found) sessionId = found;
811
919
  if (msg.type === "result" && msg.subtype === "success") out2 += msg.result;
@@ -833,6 +941,16 @@ function createClaudeRunner() {
833
941
  import { join as join4 } from "path";
834
942
 
835
943
  // ../../packages/executor-worktree/src/pi-mappers.ts
944
+ function parsePiEvent(msg) {
945
+ if (typeof msg !== "object" || msg === null) return null;
946
+ const type = msg.type;
947
+ if (typeof type !== "string") return null;
948
+ if (type === "message_update") {
949
+ const ame = msg.assistantMessageEvent;
950
+ if (typeof ame !== "object" || ame === null) return null;
951
+ }
952
+ return msg;
953
+ }
836
954
  function mapUsage2(u) {
837
955
  return {
838
956
  input: u?.input ?? 0,
@@ -929,12 +1047,8 @@ function consumePiEvent(ev, state, suppressStageExit) {
929
1047
  if (ev.type === "turn_end" || ev.type === "agent_end") {
930
1048
  const r = mapPiEvent(ev);
931
1049
  const status = settleStatus(settleMessage(ev));
932
- let responseText;
933
- const text = state.bufferedText.trim();
934
- if (status === "ok" && text && !state.turnEndedOnTool) {
935
- responseText = text;
936
- events.push({ type: "response", text });
937
- }
1050
+ const decision = decideResponse(state.bufferedText, state.turnEndedOnTool, status);
1051
+ if (decision.event) events.push(decision.event);
938
1052
  if (state.pendingThought) {
939
1053
  events.push({ type: "thought", subtype: "reasoning_text", text: state.pendingThought });
940
1054
  }
@@ -945,7 +1059,7 @@ function consumePiEvent(ev, state, suppressStageExit) {
945
1059
  state.bufferedText = "";
946
1060
  state.pendingThought = "";
947
1061
  state.turnEndedOnTool = false;
948
- return { events, isResult: true, status, responseText };
1062
+ return { events, isResult: true, status, responseText: decision.responseText };
949
1063
  }
950
1064
  return { events, isResult: false };
951
1065
  }
@@ -1007,7 +1121,6 @@ function writeStageCustomProviders(dir, hint2) {
1007
1121
  }
1008
1122
 
1009
1123
  // ../../packages/executor-worktree/src/pi-adapter.ts
1010
- var COALESCE_MS2 = Number(process.env.DAHRK_COALESCE_MS ?? process.env.SKAKEL_COALESCE_MS ?? 40);
1011
1124
  var PI_STAGE_COMPLETE_TOOL = "dahrk_stage_complete";
1012
1125
  var PI_ASK_USER_QUESTION_TOOL = "ask_user_question";
1013
1126
  function piToolCallDecision(ctx, toolName, input) {
@@ -1061,20 +1174,82 @@ function createBrokeredMcpExtension(servers) {
1061
1174
  }
1062
1175
  };
1063
1176
  }
1177
+ var defaultAsk2 = async () => elicitOutcomeReply({ kind: "noreply" });
1178
+ function makePiRuntimeSession(s, ctx, hooks, interactive) {
1179
+ return {
1180
+ get sessionId() {
1181
+ return s.sessionId;
1182
+ },
1183
+ async sendTurn(text) {
1184
+ const state = newPiBufferState();
1185
+ let stageComplete = false;
1186
+ let summary;
1187
+ let stageCompleteCallId;
1188
+ let responseText;
1189
+ let status;
1190
+ const unsub = s.subscribe((ev) => {
1191
+ const rawRef = ctx.writeRaw?.(ev);
1192
+ if (ev.type === "tool_execution_start" && ev.toolName === PI_STAGE_COMPLETE_TOOL) {
1193
+ stageComplete = true;
1194
+ stageCompleteCallId = ev.toolCallId;
1195
+ const args = ev.args;
1196
+ if (args?.summary) summary = args.summary;
1197
+ return;
1198
+ }
1199
+ if (ev.type === "tool_execution_end" && ev.toolCallId === stageCompleteCallId) return;
1200
+ const r = consumePiEvent(ev, state, interactive);
1201
+ for (const e of r.events) hooks.emit(e, rawRef);
1202
+ if (r.responseText) responseText = r.responseText;
1203
+ if (r.isResult && r.status) status = r.status;
1204
+ });
1205
+ try {
1206
+ await s.prompt(text);
1207
+ } finally {
1208
+ unsub();
1209
+ }
1210
+ return {
1211
+ stageComplete,
1212
+ ...summary !== void 0 ? { summary } : {},
1213
+ ...responseText !== void 0 ? { responseText } : {},
1214
+ ...status !== void 0 ? { status } : {}
1215
+ };
1216
+ },
1217
+ async summariseTurn() {
1218
+ if (s.agent) s.agent.state.tools = [];
1219
+ const state = newPiBufferState();
1220
+ let out2;
1221
+ const unsub = s.subscribe((ev) => {
1222
+ const r = consumePiEvent(ev, state, true);
1223
+ if (r.responseText) out2 = r.responseText;
1224
+ });
1225
+ try {
1226
+ await s.prompt(SUMMARISE_PROMPT);
1227
+ return (out2 ?? "").trim() || "(no summary produced)";
1228
+ } catch (e) {
1229
+ return `(summary unavailable: ${e.message})`;
1230
+ } finally {
1231
+ unsub();
1232
+ }
1233
+ },
1234
+ cost() {
1235
+ const cost = s.getSessionStats?.()?.cost;
1236
+ return typeof cost === "number" ? cost : void 0;
1237
+ },
1238
+ dispose() {
1239
+ s.dispose();
1240
+ }
1241
+ };
1242
+ }
1064
1243
  function createPiRunner(deps = {}) {
1065
1244
  const createSession = deps.createSession ?? defaultCreatePiSession;
1066
1245
  const abortController = new AbortController();
1067
1246
  const signal = abortController.signal;
1068
1247
  let cancelled = false;
1069
1248
  let session;
1070
- let sessionId;
1071
1249
  const openSession = async (ctx) => {
1072
1250
  if (!session) session = await createSession(ctx);
1073
1251
  return session;
1074
1252
  };
1075
- const captureSessionId = (s) => {
1076
- if (s.sessionId) sessionId = s.sessionId;
1077
- };
1078
1253
  let sessionDisposed = false;
1079
1254
  const disposeSession = () => {
1080
1255
  if (sessionDisposed || !session) return;
@@ -1084,178 +1259,41 @@ function createPiRunner(deps = {}) {
1084
1259
  } catch {
1085
1260
  }
1086
1261
  };
1087
- const readSessionCost = (s) => {
1088
- const cost = s.getSessionStats?.()?.cost;
1089
- return typeof cost === "number" ? cost : void 0;
1090
- };
1091
1262
  return {
1092
1263
  runtime: "pi",
1093
1264
  async runBatch(ctx, onTrace) {
1094
- const emit = makeEmit("pi", onTrace);
1265
+ const hooks = { emit: makeEmit("pi", onTrace), ask: defaultAsk2 };
1095
1266
  const s = await openSession(ctx);
1096
1267
  registerToolCallGate(s, ctx);
1097
- const state = newPiBufferState();
1098
- let status = "ok";
1099
- const unsub = s.subscribe((ev) => {
1100
- const rawRef = ctx.writeRaw?.(ev);
1101
- const r = consumePiEvent(ev, state, false);
1102
- for (const e of r.events) emit(e, rawRef);
1103
- if (r.isResult && r.status) status = r.status;
1104
- });
1105
- try {
1106
- await s.prompt(resolveStagePrompt(ctx));
1107
- } catch (e) {
1108
- if (!cancelled) emit({ type: "error", kind: "runtime_error", message: e.message });
1109
- status = "fail";
1110
- } finally {
1111
- unsub();
1112
- }
1113
- captureSessionId(s);
1114
- if (cancelled) status = "fail";
1115
- const costUsd = readSessionCost(s);
1116
- if (status !== "ok") disposeSession();
1117
- return { status, ...sessionId ? { sessionId } : {}, ...costUsd !== void 0 ? { costUsd } : {} };
1268
+ const rt = makePiRuntimeSession(s, ctx, hooks, false);
1269
+ const result = await runBatchLoop(rt, ctx, hooks, { cancelled: () => cancelled });
1270
+ if (result.status !== "ok") disposeSession();
1271
+ return result;
1118
1272
  },
1119
1273
  async runInteractive(ctx, turns, onTrace) {
1120
1274
  const emit = makeEmit("pi", onTrace);
1121
1275
  const s = await openSession(ctx);
1122
1276
  registerToolCallGate(s, ctx);
1123
- const state = newPiBufferState();
1124
- const exit = ctx.config.exit ?? "either";
1125
- const wantsTool = exit === "tool" || exit === "either";
1126
- let toolFired = false;
1127
- let toolSummary = null;
1128
- let stageCompleteCallId;
1129
- let lastResponseText;
1130
- const unsub = s.subscribe((ev) => {
1131
- const rawRef = ctx.writeRaw?.(ev);
1132
- if (ev.type === "tool_execution_start" && ev.toolName === PI_STAGE_COMPLETE_TOOL) {
1133
- toolFired = true;
1134
- stageCompleteCallId = ev.toolCallId;
1135
- const args = ev.args;
1136
- if (args?.summary) toolSummary = args.summary;
1137
- return;
1138
- }
1139
- if (ev.type === "tool_execution_end" && ev.toolCallId === stageCompleteCallId) return;
1140
- const r = consumePiEvent(ev, state, true);
1141
- for (const e of r.events) emit(e, rawRef);
1142
- if (r.responseText) lastResponseText = r.responseText;
1143
- });
1144
- const { firstReplyMs, idleMs } = interactiveIdleWindows(ctx);
1145
- const router = createElicitTurnRouter(turns, { signal, firstReplyMs, idleMs });
1146
- const humanIter = router.conversation[Symbol.asyncIterator]();
1147
- let awaitingFirstReply = true;
1148
- const elicitCtx = ctx;
1149
- const ask = async (question) => {
1150
- const outcome = await router.ask(awaitingFirstReply, () => {
1151
- emit({ type: "elicitation", prompt: question.prompt, signal: "select", options: question.options });
1152
- elicitCtx.emitElicit?.(question);
1153
- });
1154
- switch (outcome.kind) {
1155
- case "reply":
1156
- return `The user selected: ${outcome.text}`;
1157
- case "busy":
1158
- return "Only one question can be asked at a time; wait for the current one to be answered, then ask again.";
1159
- case "noreply":
1160
- return "No response from the user; proceed with your best judgement.";
1161
- case "cancel":
1162
- return "The question was cancelled.";
1163
- }
1277
+ const makeSession = (hooks) => {
1278
+ s.setAskUserQuestionHandler?.((questions) => askQuestionsSequentially(questions, (q) => hooks.ask(q)));
1279
+ return makePiRuntimeSession(s, ctx, hooks, true);
1164
1280
  };
1165
- s.setAskUserQuestionHandler?.((questions) => askQuestionsSequentially(questions, ask));
1166
- let exited = "gate";
1167
- let pending = humanIter.next();
1168
- try {
1169
- await s.prompt(interactiveSeedText(ctx, false));
1170
- if (toolFired && wantsTool) exited = "tool";
1171
- for (; ; ) {
1172
- if (exited === "tool") break;
1173
- const race = await raceNextTurn(pending, awaitingFirstReply ? firstReplyMs : idleMs, signal);
1174
- awaitingFirstReply = false;
1175
- if (race.kind === "cancelled") {
1176
- exited = "cancelled";
1177
- break;
1178
- }
1179
- if (race.kind === "idle-timeout") {
1180
- exited = "timeout";
1181
- break;
1182
- }
1183
- if (race.kind === "turns-exhausted") {
1184
- exited = "gate";
1185
- break;
1186
- }
1187
- const texts = [race.value.text];
1188
- pending = humanIter.next();
1189
- for (; ; ) {
1190
- const more = await raceNextTurn(pending, COALESCE_MS2, signal);
1191
- if (more.kind === "turn") {
1192
- texts.push(more.value.text);
1193
- pending = humanIter.next();
1194
- continue;
1195
- }
1196
- if (more.kind === "cancelled") exited = "cancelled";
1197
- break;
1198
- }
1199
- if (exited === "cancelled") break;
1200
- await s.prompt(texts.join("\n"));
1201
- if (toolFired && wantsTool) {
1202
- exited = "tool";
1203
- break;
1204
- }
1205
- }
1206
- } catch (e) {
1207
- if (!cancelled) emit({ type: "error", kind: "runtime_error", message: e.message });
1208
- exited = cancelled ? "cancelled" : "gate";
1209
- }
1210
- let status = "ok";
1211
- let summary = "";
1212
- if (exited === "tool") {
1213
- summary = toolSummary ?? "(stage marked complete)";
1214
- } else if (exited === "gate") {
1215
- lastResponseText = void 0;
1216
- try {
1217
- await s.prompt(SUMMARISE_PROMPT);
1218
- summary = (lastResponseText ?? "").trim() || "(no summary produced)";
1219
- } catch {
1220
- summary = "(no summary produced)";
1221
- }
1222
- } else if (exited === "timeout") {
1223
- status = "timeout";
1224
- summary = "(stage timed out awaiting input)";
1225
- await this.cancel();
1226
- } else {
1227
- status = "fail";
1228
- summary = "(stage cancelled)";
1229
- }
1230
- unsub();
1231
- captureSessionId(s);
1232
- const costUsd = readSessionCost(s);
1281
+ const result = await runInteractiveLoop(ctx, turns, emit, makeSession, {
1282
+ signal,
1283
+ cancelled: () => cancelled,
1284
+ cancel: () => this.cancel(),
1285
+ instructionInSystemPrompt: false
1286
+ });
1233
1287
  disposeSession();
1234
- return {
1235
- status,
1236
- summary,
1237
- ...sessionId ? { sessionId } : {},
1238
- ...costUsd !== void 0 ? { costUsd } : {}
1239
- };
1288
+ return result;
1240
1289
  },
1241
1290
  async summarise(ctx) {
1242
1291
  if (!session) return "(no summary: session not established)";
1243
- const s = session;
1244
- if (s.agent) s.agent.state.tools = [];
1245
- const state = newPiBufferState();
1246
- let out2;
1247
- const unsub = s.subscribe((ev) => {
1248
- const r = consumePiEvent(ev, state, true);
1249
- if (r.responseText) out2 = r.responseText;
1250
- });
1292
+ const rt = makePiRuntimeSession(session, ctx, { emit: () => {
1293
+ }, ask: defaultAsk2 }, true);
1251
1294
  try {
1252
- await s.prompt(SUMMARISE_PROMPT);
1253
- captureSessionId(s);
1254
- return (out2 ?? "").trim() || "(no summary produced)";
1255
- } catch (e) {
1256
- return `(summary unavailable: ${e.message})`;
1295
+ return await rt.summariseTurn();
1257
1296
  } finally {
1258
- unsub();
1259
1297
  disposeSession();
1260
1298
  }
1261
1299
  },
@@ -1338,7 +1376,7 @@ async function defaultCreatePiSession(ctx) {
1338
1376
  },
1339
1377
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1340
1378
  execute: async (_toolCallId, params) => {
1341
- const text = askHandler ? await askHandler(params.questions) : "No response from the user; proceed with your best judgement.";
1379
+ const text = askHandler ? await askHandler(params.questions) : elicitOutcomeReply({ kind: "noreply" });
1342
1380
  return { content: [{ type: "text", text }], details: {} };
1343
1381
  }
1344
1382
  });
@@ -1557,7 +1595,8 @@ var PiRpcSession = class {
1557
1595
  }
1558
1596
  return;
1559
1597
  }
1560
- const ev = msg;
1598
+ const ev = parsePiEvent(msg);
1599
+ if (!ev) return;
1561
1600
  for (const l of [...this.#listeners]) l(ev);
1562
1601
  if (ev.type === "agent_end" && this.#pendingAgentEnd) {
1563
1602
  const d = this.#pendingAgentEnd;
@@ -1620,10 +1659,67 @@ import { execFileSync } from "child_process";
1620
1659
  import { existsSync, mkdirSync, mkdtempSync as mkdtempSync2, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
1621
1660
  import { homedir as homedir2, tmpdir as tmpdir3 } from "os";
1622
1661
  import { basename, dirname, isAbsolute, join as join5 } from "path";
1662
+
1663
+ // ../../packages/executor-worktree/src/footprint.ts
1664
+ function resolveRenamePath(raw) {
1665
+ const brace = /\{(.*?) => (.*?)\}/;
1666
+ if (brace.test(raw)) {
1667
+ return raw.replace(brace, (_m, _old, next) => next).replace(/\/{2,}/g, "/");
1668
+ }
1669
+ const arrow2 = raw.indexOf(" => ");
1670
+ if (arrow2 !== -1) return raw.slice(arrow2 + " => ".length);
1671
+ return raw;
1672
+ }
1673
+ function parseNumstat(raw) {
1674
+ const entries = [];
1675
+ for (const line of raw.split("\n")) {
1676
+ if (line.trim() === "") continue;
1677
+ const firstTab = line.indexOf(" ");
1678
+ const secondTab = line.indexOf(" ", firstTab + 1);
1679
+ if (firstTab === -1 || secondTab === -1) continue;
1680
+ const addedRaw = line.slice(0, firstTab);
1681
+ const removedRaw = line.slice(firstTab + 1, secondTab);
1682
+ const pathRaw = line.slice(secondTab + 1);
1683
+ const binary = addedRaw === "-" && removedRaw === "-";
1684
+ entries.push({
1685
+ path: resolveRenamePath(pathRaw),
1686
+ added: binary ? 0 : Number.parseInt(addedRaw, 10) || 0,
1687
+ removed: binary ? 0 : Number.parseInt(removedRaw, 10) || 0,
1688
+ binary
1689
+ });
1690
+ }
1691
+ return entries;
1692
+ }
1693
+ function topLevel(path) {
1694
+ const slash = path.indexOf("/");
1695
+ return slash === -1 ? path : path.slice(0, slash);
1696
+ }
1697
+ function deriveFootprint(entries, opts) {
1698
+ let added = 0;
1699
+ let removed = 0;
1700
+ const scopeSet = /* @__PURE__ */ new Set();
1701
+ const allPaths = [];
1702
+ for (const e of entries) {
1703
+ added += e.added;
1704
+ removed += e.removed;
1705
+ scopeSet.add(topLevel(e.path));
1706
+ allPaths.push(e.path);
1707
+ }
1708
+ const changedPathsTruncated = allPaths.length > opts.cap;
1709
+ return {
1710
+ numstat: { files: entries.length, added, removed },
1711
+ scope: [...scopeSet].sort(),
1712
+ changedPaths: changedPathsTruncated ? allPaths.slice(0, opts.cap) : allPaths,
1713
+ changedPathsTruncated
1714
+ };
1715
+ }
1716
+
1717
+ // ../../packages/executor-worktree/src/git-service.ts
1623
1718
  var noopLogger = { info: () => {
1624
1719
  }, warn: () => {
1625
1720
  } };
1626
1721
  var SCRATCH_DIR = ".dahrk/scratch";
1722
+ var CHANGED_PATHS_CAP = 100;
1627
1723
  var DEFAULT_MERGE_RESOLVE_RULES = [
1628
1724
  { path: "pnpm-lock.yaml", strategy: "theirs" },
1629
1725
  { path: "package-lock.json", strategy: "theirs" },
@@ -1956,6 +2052,7 @@ function createGitService(opts = {}) {
1956
2052
  const { remote, authEnv, cleanup } = resolveRemoteAuth(ref.gitUrl, opts2.credentialToken);
1957
2053
  let pushed = false;
1958
2054
  let integration;
2055
+ let footprint;
1959
2056
  try {
1960
2057
  let fetched = false;
1961
2058
  if (opts2.base) {
@@ -1975,6 +2072,12 @@ function createGitService(opts = {}) {
1975
2072
  if (!delta.some((p) => !isScratchPath(p))) {
1976
2073
  return { headSha, pushed: false, nothingToCommit: true, commitsAhead, integration: "noop" };
1977
2074
  }
2075
+ const numstatEntries = parseNumstat(git(worktreePath, ["diff", "--numstat", "FETCH_HEAD...HEAD"])).filter(
2076
+ (e) => !isScratchPath(e.path)
2077
+ );
2078
+ if (numstatEntries.length > 0) {
2079
+ footprint = deriveFootprint(numstatEntries, { cap: CHANGED_PATHS_CAP });
2080
+ }
1978
2081
  try {
1979
2082
  git(worktreePath, [
1980
2083
  "-c",
@@ -2009,7 +2112,7 @@ function createGitService(opts = {}) {
2009
2112
  headSha = git(worktreePath, ["rev-parse", "HEAD"]).trim();
2010
2113
  } else {
2011
2114
  git(worktreePath, ["merge", "--abort"]);
2012
- return { headSha, pushed: false, nothingToCommit: !dirty, commitsAhead, integration: "conflict", conflictFiles };
2115
+ return { headSha, pushed: false, nothingToCommit: !dirty, commitsAhead, integration: "conflict", conflictFiles, ...footprint ? { footprint } : {} };
2013
2116
  }
2014
2117
  } else {
2015
2118
  const msg = mergeErr.message;
@@ -2025,7 +2128,7 @@ function createGitService(opts = {}) {
2025
2128
  } finally {
2026
2129
  cleanup();
2027
2130
  }
2028
- return { headSha, pushed, nothingToCommit: !dirty, commitsAhead, ...integration ? { integration } : {} };
2131
+ return { headSha, pushed, nothingToCommit: !dirty, commitsAhead, ...integration ? { integration } : {}, ...footprint ? { footprint } : {} };
2029
2132
  },
2030
2133
  async backupPush(ref, opts2) {
2031
2134
  const { worktreePath } = ref;
@@ -2120,12 +2223,16 @@ import { existsSync as existsSync2, readdirSync, realpathSync, rmSync as rmSync3
2120
2223
  import { join as join6 } from "path";
2121
2224
  var MINUTE = 6e4;
2122
2225
  var HOUR = 60 * MINUTE;
2226
+ var DAY = 24 * HOUR;
2123
2227
  var DEFAULTS = {
2124
2228
  // Deliberately non-optional defaults. "No policy configured" used to mean "never collect anything",
2125
2229
  // which is precisely how the disk reached 65 GB. Absent config must mean sane collection, not none.
2126
2230
  maxRuns: 20,
2127
2231
  maxIdleMs: 6 * HOUR,
2128
- activityGraceMs: 30 * MINUTE
2232
+ activityGraceMs: 30 * MINUTE,
2233
+ // 14 days: long enough that an operator who missed the park log can still recover unpushed work, but
2234
+ // bounded so parked tips cannot pile up for ever. Cited verbatim in docs/logging.md - keep in step.
2235
+ salvageTtlMs: 14 * DAY
2129
2236
  };
2130
2237
  var noop = { info: () => {
2131
2238
  }, warn: () => {
@@ -2166,6 +2273,38 @@ function registeredWorktrees(mirror) {
2166
2273
  }
2167
2274
  return out2.split("\n").filter((l) => l.startsWith("worktree ")).map((l) => canonical(l.slice(9).trim())).filter((p) => p && p !== canonical(mirror));
2168
2275
  }
2276
+ function salvageParkedMs(mirror, ref) {
2277
+ try {
2278
+ return statSync(join6(mirror, ref)).mtimeMs;
2279
+ } catch {
2280
+ }
2281
+ try {
2282
+ const ts = gitOut(mirror, ["log", "-g", "--format=%ct", "-1", ref]).trim();
2283
+ if (ts) return Number(ts) * 1e3;
2284
+ } catch {
2285
+ }
2286
+ return void 0;
2287
+ }
2288
+ function sweepSalvageRefs(mirror, ttlMs, dryRun, now, log) {
2289
+ let refs;
2290
+ try {
2291
+ refs = gitOut(mirror, ["for-each-ref", "--format=%(refname)", "refs/dahrk/salvage/"]).split("\n").map((s) => s.trim()).filter(Boolean);
2292
+ } catch {
2293
+ return 0;
2294
+ }
2295
+ let parked = 0;
2296
+ for (const ref of refs) {
2297
+ const at = salvageParkedMs(mirror, ref);
2298
+ const expired = at !== void 0 && now - at > ttlMs;
2299
+ if (expired && !dryRun) {
2300
+ if (!gitOk(mirror, ["update-ref", "-d", ref])) log.warn(`reaper: could not expire salvage ref ${ref}`);
2301
+ continue;
2302
+ }
2303
+ if (expired) log.info(`reaper (dry-run): would expire salvage ref ${ref}`);
2304
+ parked++;
2305
+ }
2306
+ return parked;
2307
+ }
2169
2308
  function createWorktreeReaper(opts) {
2170
2309
  const log = opts.logger ?? noop;
2171
2310
  const mirrors = () => {
@@ -2184,8 +2323,9 @@ function createWorktreeReaper(opts) {
2184
2323
  const maxRuns = policy.maxRuns ?? DEFAULTS.maxRuns;
2185
2324
  const maxIdleMs = policy.maxIdleMs ?? DEFAULTS.maxIdleMs;
2186
2325
  const graceMs = policy.activityGraceMs ?? DEFAULTS.activityGraceMs;
2326
+ const salvageTtlMs = policy.salvageTtlMs ?? DEFAULTS.salvageTtlMs;
2187
2327
  const dryRun = policy.dryRun ?? false;
2188
- const report = { scanned: 0, reaped: [], skipped: 0, errors: [] };
2328
+ const report = { scanned: 0, reaped: [], skipped: 0, salvagedRefs: 0, errors: [] };
2189
2329
  const now = Date.now();
2190
2330
  const registered = /* @__PURE__ */ new Map();
2191
2331
  for (const m of mirrors()) {
@@ -2247,6 +2387,14 @@ function createWorktreeReaper(opts) {
2247
2387
  if (report.reaped.length) {
2248
2388
  log.info(`reaper: reaped ${report.reaped.length} worktree(s), skipped ${report.skipped}`);
2249
2389
  }
2390
+ for (const m of registered.keys()) {
2391
+ report.salvagedRefs += sweepSalvageRefs(m, salvageTtlMs, dryRun, now, log);
2392
+ }
2393
+ if (report.salvagedRefs) {
2394
+ log.info(
2395
+ `reaper: ${report.salvagedRefs} salvage ref(s) parked, expire ${Math.round(salvageTtlMs / DAY)}d after parking`
2396
+ );
2397
+ }
2250
2398
  return report;
2251
2399
  }
2252
2400
  };
@@ -3520,6 +3668,77 @@ async function startMcpGateway(opts) {
3520
3668
  };
3521
3669
  }
3522
3670
 
3671
+ // ../../packages/edge/src/push-outcome.ts
3672
+ function resolveDeliverOutcome(r, job, pr) {
3673
+ const { jobId, branch, base } = job;
3674
+ if (r.integration === "noop") {
3675
+ return {
3676
+ jobId,
3677
+ status: "ok",
3678
+ branch,
3679
+ headSha: r.headSha,
3680
+ pushed: false,
3681
+ nothingToCommit: true,
3682
+ commitsAhead: r.commitsAhead,
3683
+ summary: `no changes to deliver on ${branch} - work already present on ${base}`
3684
+ };
3685
+ }
3686
+ if (r.integration === "conflict") {
3687
+ return {
3688
+ jobId,
3689
+ status: "ok",
3690
+ branch,
3691
+ headSha: r.headSha,
3692
+ pushed: false,
3693
+ nothingToCommit: r.nothingToCommit,
3694
+ commitsAhead: r.commitsAhead,
3695
+ integration: "conflict",
3696
+ ...r.conflictFiles ? { conflictFiles: r.conflictFiles } : {},
3697
+ ...r.footprint ?? {},
3698
+ summary: `base advanced; merge conflict on ${branch} (manual merge needed)`
3699
+ };
3700
+ }
3701
+ if (r.integration === "diverged") {
3702
+ return {
3703
+ jobId,
3704
+ status: "fail",
3705
+ branch,
3706
+ headSha: r.headSha,
3707
+ pushed: false,
3708
+ nothingToCommit: r.nothingToCommit,
3709
+ commitsAhead: r.commitsAhead,
3710
+ summary: `branch history diverged from ${base}; cannot auto-integrate on ${branch} (the branch likely needs rebuilding from ${base})`
3711
+ };
3712
+ }
3713
+ return {
3714
+ jobId,
3715
+ status: "ok",
3716
+ branch,
3717
+ headSha: r.headSha,
3718
+ pushed: r.pushed,
3719
+ nothingToCommit: r.nothingToCommit,
3720
+ commitsAhead: r.commitsAhead,
3721
+ ...r.integration ? { integration: r.integration } : {},
3722
+ ...pr?.prUrl ? { prUrl: pr.prUrl } : {},
3723
+ ...pr?.prNumber !== void 0 ? { prNumber: pr.prNumber } : {},
3724
+ ...pr?.prError ? { prError: pr.prError } : {},
3725
+ ...r.footprint ?? {},
3726
+ summary: r.nothingToCommit ? `no changes to commit; ${r.pushed ? "branch pushed" : "nothing pushed"}` : `committed ${r.headSha.slice(0, 7)} and pushed ${branch}`
3727
+ };
3728
+ }
3729
+ function resolveBackupOutcome(r, job) {
3730
+ return {
3731
+ jobId: job.jobId,
3732
+ status: "ok",
3733
+ branch: job.branch,
3734
+ headSha: r.headSha,
3735
+ pushed: r.pushed,
3736
+ nothingToCommit: r.nothingToCommit,
3737
+ wipRef: r.wipRef,
3738
+ summary: `backup: preserved ${r.headSha.slice(0, 7)} on ${r.wipRef} (no base merge, no PR)`
3739
+ };
3740
+ }
3741
+
3523
3742
  // ../../packages/edge/src/stage-runner.ts
3524
3743
  var nowIso2 = () => (/* @__PURE__ */ new Date()).toISOString();
3525
3744
  var PREVIEW = 500;
@@ -4135,17 +4354,7 @@ function createStageRunner(deps) {
4135
4354
  branch: job.branch,
4136
4355
  ...job.workspaceRef.credentialToken ? { credentialToken: job.workspaceRef.credentialToken } : {}
4137
4356
  });
4138
- const result = {
4139
- jobId,
4140
- status: "ok",
4141
- branch: job.branch,
4142
- headSha: r.headSha,
4143
- pushed: r.pushed,
4144
- nothingToCommit: r.nothingToCommit,
4145
- wipRef: r.wipRef,
4146
- summary: `backup: preserved ${r.headSha.slice(0, 7)} on ${r.wipRef} (no base merge, no PR)`
4147
- };
4148
- return result;
4357
+ return resolveBackupOutcome(r, { jobId, branch: job.branch });
4149
4358
  } catch (e) {
4150
4359
  return { jobId, status: "fail", branch: job.branch, summary: `backup push failed: ${e.message}` };
4151
4360
  }
@@ -4170,64 +4379,13 @@ function createStageRunner(deps) {
4170
4379
  base: job.base,
4171
4380
  ...job.workspaceRef.credentialToken ? { credentialToken: job.workspaceRef.credentialToken } : {}
4172
4381
  });
4173
- if (r.integration === "noop") {
4174
- return {
4175
- jobId,
4176
- status: "ok",
4177
- branch: job.branch,
4178
- headSha: r.headSha,
4179
- pushed: false,
4180
- nothingToCommit: true,
4181
- commitsAhead: r.commitsAhead,
4182
- summary: `no changes to deliver on ${job.branch} - work already present on ${job.base}`
4183
- };
4184
- }
4185
- if (r.integration === "conflict") {
4186
- return {
4187
- jobId,
4188
- status: "ok",
4189
- branch: job.branch,
4190
- headSha: r.headSha,
4191
- pushed: false,
4192
- nothingToCommit: r.nothingToCommit,
4193
- commitsAhead: r.commitsAhead,
4194
- integration: "conflict",
4195
- ...r.conflictFiles ? { conflictFiles: r.conflictFiles } : {},
4196
- summary: `base advanced; merge conflict on ${job.branch} (manual merge needed)`
4197
- };
4198
- }
4199
- if (r.integration === "diverged") {
4200
- return {
4201
- jobId,
4202
- status: "fail",
4203
- branch: job.branch,
4204
- headSha: r.headSha,
4205
- pushed: false,
4206
- nothingToCommit: r.nothingToCommit,
4207
- commitsAhead: r.commitsAhead,
4208
- summary: `branch history diverged from ${job.base}; cannot auto-integrate on ${job.branch} (the branch likely needs rebuilding from ${job.base})`
4209
- };
4210
- }
4211
4382
  const pr = job.openPr && r.pushed ? await deps.gitService.openPrAmbient(ref, {
4212
4383
  branch: job.branch,
4213
4384
  base: job.base,
4214
4385
  title: job.openPr.title,
4215
4386
  body: job.openPr.body
4216
4387
  }) : void 0;
4217
- return {
4218
- jobId,
4219
- status: "ok",
4220
- branch: job.branch,
4221
- headSha: r.headSha,
4222
- pushed: r.pushed,
4223
- nothingToCommit: r.nothingToCommit,
4224
- commitsAhead: r.commitsAhead,
4225
- ...r.integration ? { integration: r.integration } : {},
4226
- ...pr?.prUrl ? { prUrl: pr.prUrl } : {},
4227
- ...pr?.prNumber !== void 0 ? { prNumber: pr.prNumber } : {},
4228
- ...pr?.prError ? { prError: pr.prError } : {},
4229
- summary: r.nothingToCommit ? `no changes to commit; ${r.pushed ? "branch pushed" : "nothing pushed"}` : `committed ${r.headSha.slice(0, 7)} and pushed ${job.branch}`
4230
- };
4388
+ return resolveDeliverOutcome(r, { jobId, branch: job.branch, base: job.base }, pr);
4231
4389
  } catch (e) {
4232
4390
  return { jobId, status: "fail", summary: `push failed: ${e.message}` };
4233
4391
  }
@@ -4414,10 +4572,10 @@ async function startEdgeNode(opts) {
4414
4572
  await reconcileInterruptedJobs();
4415
4573
  void stageRunner.reapWorktrees().then((r) => {
4416
4574
  counters.worktreeCount = Math.max(r.scanned - r.reaped.length, 0);
4417
- if (r.reaped.length) {
4575
+ if (r.reaped.length || r.salvagedRefs) {
4418
4576
  log.info(
4419
- { reaped: r.reaped, scanned: r.scanned, skipped: r.skipped },
4420
- `EDGE_REAPED:${r.reaped.length} worktrees (scanned ${r.scanned}, skipped ${r.skipped})`
4577
+ { reaped: r.reaped, scanned: r.scanned, skipped: r.skipped, salvagedRefs: r.salvagedRefs },
4578
+ `EDGE_REAPED:${r.reaped.length} worktrees (scanned ${r.scanned}, skipped ${r.skipped}, salvaged ${r.salvagedRefs})`
4421
4579
  );
4422
4580
  }
4423
4581
  for (const e of r.errors) log.warn({ reapError: e }, `EDGE_REAP_ERROR:${e}`);
@@ -5781,7 +5939,8 @@ function driftOf(stored, repo) {
5781
5939
  const drift = {};
5782
5940
  if (typeof stored.defaultBranch === "string" && stored.defaultBranch !== repo.defaultBranch) drift.branch = stored.defaultBranch;
5783
5941
  if (typeof stored.name === "string" && stored.name !== repo.name) drift.name = stored.name;
5784
- return drift.branch || drift.name ? drift : void 0;
5942
+ if (typeof stored.gitUrl === "string" && stored.gitUrl !== repo.gitUrl) drift.gitUrl = stored.gitUrl;
5943
+ return drift.branch || drift.name || drift.gitUrl ? drift : void 0;
5785
5944
  }
5786
5945
  async function registerRepo(deps, args) {
5787
5946
  const url = `${args.base}/config/api/repositories`;
@@ -7229,7 +7388,7 @@ async function runStatus(inputs, deps) {
7229
7388
  }
7230
7389
 
7231
7390
  // src/main.ts
7232
- var CLIENT_VERSION = "0.1.21";
7391
+ var CLIENT_VERSION = "0.1.22";
7233
7392
  var DEFAULT_HUB_URL = "wss://api.dahrk.ai";
7234
7393
  var list = (v) => (v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
7235
7394
  var RUNTIMES2 = ["claude-code", "codex", "pi"];
@@ -7622,6 +7781,7 @@ async function runRepoAdd(flags) {
7622
7781
  const bits = [];
7623
7782
  if (result.drift.branch) bits.push(`branch ${result.drift.branch} (this repo is on ${repo.defaultBranch})`);
7624
7783
  if (result.drift.name) bits.push(`name ${result.drift.name}`);
7784
+ if (result.drift.gitUrl) bits.push(`URL ${result.drift.gitUrl} (this repo registers as ${repo.gitUrl})`);
7625
7785
  out(hint(`The hub's stored record differs - ${bits.join(", ")} - and was left unchanged.`));
7626
7786
  }
7627
7787
  return 0;