kantban-cli 0.1.47 → 0.1.51

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.
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  runGates
3
- } from "./chunk-O47Q6CNM.js";
3
+ } from "./chunk-GQQUH3TA.js";
4
4
  import {
5
5
  ClaudeProvider,
6
6
  RalphLoop,
@@ -8,11 +8,13 @@ import {
8
8
  cleanupGateProxyConfigs,
9
9
  cleanupMcpConfig,
10
10
  composeStuckDetectionPrompt,
11
+ detectBranchMerged,
11
12
  generateGateProxyMcpConfig,
12
13
  generateMcpConfig,
13
14
  parseJsonFromLlmOutput,
14
- parseStuckDetectionResponse
15
- } from "./chunk-UKWQ6VUQ.js";
15
+ parseStuckDetectionResponse,
16
+ reapOrphanedMcpConfigDirs
17
+ } from "./chunk-X2CJ3ZAI.js";
16
18
  import {
17
19
  LoopCheckpointSchema,
18
20
  VerdictSchema,
@@ -20,7 +22,7 @@ import {
20
22
  parseGateConfig,
21
23
  parseTimeout,
22
24
  resolveGatesForColumn
23
- } from "./chunk-VDCHOIQI.js";
25
+ } from "./chunk-4R27WTCJ.js";
24
26
  import {
25
27
  IS_WINDOWS,
26
28
  crossSpawnOptions,
@@ -30,9 +32,17 @@ import {
30
32
  } from "./chunk-5ZU2OOES.js";
31
33
 
32
34
  // src/commands/pipeline.ts
33
- import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync4, readFileSync as readFileSync3, unlinkSync as unlinkSync3, existsSync as existsSync3, appendFileSync as appendFileSync2 } from "fs";
34
- import { homedir as homedir2 } from "os";
35
- import { join as join3 } from "path";
35
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync4, readFileSync as readFileSync3, unlinkSync as unlinkSync3, existsSync as existsSync3, appendFileSync as appendFileSync2, promises as fsPromises } from "fs";
36
+ import { execFile } from "child_process";
37
+ import { homedir as homedir3 } from "os";
38
+ import { join as join4, dirname as dirname2, resolve, sep } from "path";
39
+ import { promisify } from "util";
40
+
41
+ // src/lib/harness-signal.ts
42
+ var HARNESS_SIGNAL_PREFIX = "@harness:";
43
+ function createHarnessSignalContent(kind, payload) {
44
+ return HARNESS_SIGNAL_PREFIX + JSON.stringify({ ...payload, kind });
45
+ }
36
46
 
37
47
  // src/lib/tool-profiles.ts
38
48
  function resolveToolRestrictions(builtinTools, allowedTools, disallowedTools) {
@@ -50,6 +60,8 @@ function resolveToolRestrictions(builtinTools, allowedTools, disallowedTools) {
50
60
 
51
61
  // src/lib/worktree.ts
52
62
  import { execFile as defaultExecFile, execFileSync } from "child_process";
63
+ import { homedir } from "os";
64
+ import { join } from "path";
53
65
  function generateWorktreeName(ticketNumber, columnName) {
54
66
  const slug = columnSlug(columnName);
55
67
  return `kantban-${ticketNumber}-${slug}`;
@@ -57,10 +69,16 @@ function generateWorktreeName(ticketNumber, columnName) {
57
69
  function columnSlug(columnName) {
58
70
  return columnName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
59
71
  }
60
- function renderWorktreePath(ticketNumber, columnName, pathPattern) {
72
+ function renderWorktreePath(ticketNumber, columnName, pathPattern, options) {
73
+ const slug = columnSlug(columnName);
61
74
  const worktreeName = generateWorktreeName(ticketNumber, columnName);
62
- if (!pathPattern) return worktreeName;
63
- return pathPattern.replace(/\{ticket_number\}/g, String(ticketNumber)).replace(/\{column_slug\}/g, columnSlug(columnName)).replace(/\{worktree_name\}/g, worktreeName);
75
+ if (pathPattern) {
76
+ return pathPattern.replace(/\{ticket_number\}/g, String(ticketNumber)).replace(/\{column_slug\}/g, slug).replace(/\{worktree_name\}/g, worktreeName);
77
+ }
78
+ return join(options.defaultRoot, options.boardId, slug, String(ticketNumber));
79
+ }
80
+ function defaultWorktreeRoot() {
81
+ return join(homedir(), ".kantban", "worktrees");
64
82
  }
65
83
  function isPlausibleRemoteUrl(url) {
66
84
  return url.includes("/") || url.includes("@") || url.includes("://");
@@ -108,22 +126,22 @@ async function cleanupWorktree(worktreeName, exec = defaultExecFile) {
108
126
  } catch {
109
127
  return true;
110
128
  }
111
- return new Promise((resolve) => {
129
+ return new Promise((resolve2) => {
112
130
  exec("git", ["worktree", "remove", "--force", target], (err) => {
113
131
  if (err) {
114
132
  console.error(`[worktree] cleanup failed for ${worktreeName} (${target}): ${err.message}`);
115
- resolve(false);
133
+ resolve2(false);
116
134
  } else {
117
- resolve(true);
135
+ resolve2(true);
118
136
  }
119
137
  });
120
138
  });
121
139
  }
122
140
  function execPromise(exec, cmd, args) {
123
- return new Promise((resolve, reject) => {
141
+ return new Promise((resolve2, reject) => {
124
142
  exec(cmd, args, (err, stdout, stderr) => {
125
143
  if (err) reject(Object.assign(err, { stdout, stderr }));
126
- else resolve({ stdout, stderr });
144
+ else resolve2({ stdout, stderr });
127
145
  });
128
146
  });
129
147
  }
@@ -470,7 +488,9 @@ function parseReplannerResponse(raw) {
470
488
  }
471
489
 
472
490
  // src/lib/orchestrator.ts
473
- var ACTIVE_LOOP_ZOMBIE_TTL_MS = 90 * 60 * 1e3;
491
+ var ACTIVE_LOOP_ZOMBIE_TTL_MS = Number(
492
+ process.env.ACTIVE_LOOP_ZOMBIE_TTL_MS
493
+ ) || 3 * 60 * 60 * 1e3;
474
494
  function classifyTier(input) {
475
495
  if (input.invocationTier === "light") return "light";
476
496
  if (input.invocationTier === "heavy") return "heavy";
@@ -510,6 +530,12 @@ var PipelineOrchestrator = class {
510
530
  blockedColumns = /* @__PURE__ */ new Set();
511
531
  /** Per-ticket advisor invocation count for the current column transit */
512
532
  advisorBudget = /* @__PURE__ */ new Map();
533
+ /** Count of advisor-driven retries per ticket in the current column transit.
534
+ * Cleared on ticket:moved/archived/deleted and in terminalCleanup. */
535
+ gutterResetCount = /* @__PURE__ */ new Map();
536
+ gutterResetExhausted = /* @__PURE__ */ new Set();
537
+ /** Per-ticket deferred-dispatch signal state — used for dedup and cleanup */
538
+ deferredState = /* @__PURE__ */ new Map();
513
539
  /** Per-ticket model override (set by RETRY_DIFFERENT_MODEL, consumed by startTrackedLoop) */
514
540
  ticketModelOverrides = /* @__PURE__ */ new Map();
515
541
  /** Stable session ID for this orchestrator instance (pipeline run) */
@@ -555,6 +581,10 @@ var PipelineOrchestrator = class {
555
581
  get allBoardColumnNames() {
556
582
  return this.cachedBoardScope?.columns.map((c) => c.name) ?? [];
557
583
  }
584
+ /** Returns {name, type} for every column on the board. */
585
+ get allBoardColumns() {
586
+ return this.cachedBoardScope?.columns.map((c) => ({ name: c.name, type: c.type })) ?? [];
587
+ }
558
588
  /** Returns the total number of active loops. */
559
589
  get activeLoopCount() {
560
590
  return this.activeLoops.size;
@@ -633,12 +663,16 @@ var PipelineOrchestrator = class {
633
663
  } : void 0,
634
664
  builtinTools: cfg?.builtin_tools,
635
665
  allowedTools: cfg?.allowed_tools,
636
- disallowedTools: cfg?.disallowed_tools
666
+ disallowedTools: cfg?.disallowed_tools,
667
+ maxGutterResetsPerTransit: cfg?.max_gutter_resets_per_transit,
668
+ repromptOnBranchMerged: cfg?.reprompt_on_branch_merged,
669
+ maxRepromptAttempts: cfg?.max_reprompt_attempts
637
670
  });
638
671
  this.columnScopes.set(col.id, colScope);
639
672
  this.loopQueues.set(col.id, []);
640
673
  })
641
674
  );
675
+ console.error(`[orchestrator] zombie TTL: ${ACTIVE_LOOP_ZOMBIE_TTL_MS}ms`);
642
676
  }
643
677
  /**
644
678
  * Refresh the cached column scope for a single column.
@@ -919,8 +953,8 @@ var PipelineOrchestrator = class {
919
953
  }
920
954
  for (const [ticketId, columnId] of Array.from(this.deferredTickets)) {
921
955
  try {
922
- const stillBlocked = await this.deps.hasUnresolvedBlockers(ticketId);
923
- if (!stillBlocked) {
956
+ const blocker = await this.deps.hasUnresolvedBlockers(ticketId);
957
+ if (!blocker.blocked) {
924
958
  this.deferredTickets.delete(ticketId);
925
959
  await this.spawnOrQueue(ticketId, columnId, true);
926
960
  } else {
@@ -934,23 +968,56 @@ var PipelineOrchestrator = class {
934
968
  }
935
969
  await this.refreshBoardScope();
936
970
  this.blockedColumns.clear();
937
- for (const [columnId] of this.pipelineColumns) {
938
- await this.refreshColumnScope(columnId);
939
- const colScope = this.columnScopes.get(columnId);
940
- if (!colScope) {
941
- console.error(` [scan] Column ${columnId}: no cached scope`);
942
- continue;
943
- }
944
- if (this.isColumnBlocked(columnId)) {
945
- this.blockedColumns.add(columnId);
946
- console.error(` [scan] Column ${columnId} (${colScope.column.name}): BLOCKED by firing constraints \u2014 skipping ${String(colScope.tickets.length)} ticket(s)`);
947
- continue;
948
- }
949
- console.error(` [scan] Column ${columnId} (${colScope.column.name}): ${String(colScope.tickets.length)} ticket(s)`);
950
- for (const ticket of colScope.tickets) {
951
- await this.spawnOrQueue(ticket.id, columnId);
952
- }
953
- }
971
+ await Promise.all(
972
+ Array.from(this.pipelineColumns.keys()).map(async (columnId) => {
973
+ await this.refreshColumnScope(columnId);
974
+ const colScope = this.columnScopes.get(columnId);
975
+ if (!colScope) {
976
+ console.error(` [scan] column=${columnId} reason="no cached scope"`);
977
+ return;
978
+ }
979
+ if (this.isColumnBlocked(columnId)) {
980
+ this.blockedColumns.add(columnId);
981
+ console.error(
982
+ ` [scan] column=${columnId} name="${colScope.column.name}" tickets=${colScope.tickets.length} dispatched=0 queued=0 skipped=${colScope.tickets.length} reason="firing_constraint"`
983
+ );
984
+ for (const ticket of colScope.tickets) {
985
+ await this.emitDispatchDeferred(ticket.id, "firing_constraint", { columnId });
986
+ }
987
+ return;
988
+ }
989
+ let dispatched = 0;
990
+ let queued = 0;
991
+ let skipped = 0;
992
+ const reasons = /* @__PURE__ */ new Set();
993
+ for (const ticket of colScope.tickets) {
994
+ const wasActive = this.activeLoops.has(ticket.id) || this.spawning.has(ticket.id);
995
+ const wasQueued = (this.loopQueues.get(columnId) ?? []).includes(ticket.id);
996
+ const wasDeferred = this.deferredTickets.has(ticket.id);
997
+ await this.spawnOrQueue(ticket.id, columnId);
998
+ const nowActive = this.activeLoops.has(ticket.id) || this.spawning.has(ticket.id);
999
+ const nowQueued = (this.loopQueues.get(columnId) ?? []).includes(ticket.id);
1000
+ const nowDeferred = this.deferredTickets.has(ticket.id);
1001
+ if (!wasActive && nowActive) {
1002
+ dispatched++;
1003
+ reasons.add("dispatched");
1004
+ } else if (!wasQueued && nowQueued) {
1005
+ queued++;
1006
+ reasons.add("capped");
1007
+ } else if (!wasDeferred && nowDeferred) {
1008
+ skipped++;
1009
+ reasons.add("blocked");
1010
+ } else {
1011
+ skipped++;
1012
+ reasons.add("known");
1013
+ }
1014
+ }
1015
+ const reason = reasons.size === 0 ? "none" : reasons.size === 1 ? [...reasons][0] : "mixed";
1016
+ console.error(
1017
+ ` [scan] column=${columnId} name="${colScope.column.name}" tickets=${colScope.tickets.length} dispatched=${dispatched} queued=${queued} skipped=${skipped} reason="${reason}"`
1018
+ );
1019
+ })
1020
+ );
954
1021
  }
955
1022
  /**
956
1023
  * Handle a pipeline event (typically from WebSocket via EventQueue).
@@ -970,6 +1037,7 @@ var PipelineOrchestrator = class {
970
1037
  this.knownTickets.delete(event.ticketId);
971
1038
  this.spawning.delete(event.ticketId);
972
1039
  this.deferredTickets.delete(event.ticketId);
1040
+ void this.clearDispatchDeferred(event.ticketId);
973
1041
  const oldQueue = this.loopQueues.get(event.fromColumnId);
974
1042
  if (oldQueue) {
975
1043
  const idx = oldQueue.indexOf(event.ticketId);
@@ -984,6 +1052,7 @@ var PipelineOrchestrator = class {
984
1052
  if (this.isColumnBlocked(event.columnId)) {
985
1053
  console.error(` [event] ${event.type} ${event.ticketId} \u2192 column ${event.columnId}: BLOCKED by firing constraints \u2014 deferred`);
986
1054
  this.deferredTickets.set(event.ticketId, event.columnId);
1055
+ await this.emitDispatchDeferred(event.ticketId, "firing_constraint", { columnId: event.columnId });
987
1056
  } else {
988
1057
  await this.spawnOrQueue(event.ticketId, event.columnId, true);
989
1058
  }
@@ -1033,6 +1102,9 @@ var PipelineOrchestrator = class {
1033
1102
  this.deferredTickets.delete(event.ticketId);
1034
1103
  this.spawning.delete(event.ticketId);
1035
1104
  this.advisorBudget.delete(event.ticketId);
1105
+ this.gutterResetCount.delete(event.ticketId);
1106
+ this.gutterResetExhausted.delete(event.ticketId);
1107
+ void this.clearDispatchDeferred(event.ticketId);
1036
1108
  for (const [, queue] of this.loopQueues) {
1037
1109
  const idx = queue.indexOf(event.ticketId);
1038
1110
  if (idx !== -1) {
@@ -1067,15 +1139,59 @@ var PipelineOrchestrator = class {
1067
1139
  }
1068
1140
  return;
1069
1141
  }
1142
+ const isMergeColumn = colConfig.name.toLowerCase().includes("merge");
1143
+ if (isMergeColumn && colConfig.worktreeEnabled && this.deps.detectBranchMerged && this.deps.moveTicketToColumn) {
1144
+ const ticket = this.columnScopes.get(columnId)?.tickets.find((t) => t.id === ticketId);
1145
+ if (ticket) {
1146
+ const wPath = renderWorktreePath(
1147
+ ticket.ticket_number,
1148
+ colConfig.name,
1149
+ colConfig.worktreePathPattern,
1150
+ { boardId: this.boardId, defaultRoot: defaultWorktreeRoot() }
1151
+ );
1152
+ try {
1153
+ const mergeResult = await this.deps.detectBranchMerged({
1154
+ worktreePath: wPath,
1155
+ iterationStartedAt: /* @__PURE__ */ new Date(0),
1156
+ targetBranch: colConfig.worktreeIntegrationBranch ?? "main"
1157
+ });
1158
+ if (mergeResult.merged) {
1159
+ const nextColumnId = this.findNextColumnId(columnId);
1160
+ if (nextColumnId) {
1161
+ await this.deps.createComment(
1162
+ ticketId,
1163
+ `## Merge Complete (auto-finalized)
1164
+
1165
+ Merge commit \`${mergeResult.mergeCommitSha}\` detected on \`origin/${colConfig.worktreeIntegrationBranch ?? "main"}\` at ${mergeResult.mergeCommitTime.toISOString()}. The previous agent did not record the column move before exiting; the orchestrator is finalizing the move now.`
1166
+ );
1167
+ await this.deps.moveTicketToColumn(ticketId, nextColumnId, {
1168
+ reason: "merge_finalize_recovery",
1169
+ merge_commit_sha: mergeResult.mergeCommitSha
1170
+ });
1171
+ console.error(
1172
+ ` [merge-recovery] ticket=${ticketId} column=${columnId} target=${colConfig.worktreeIntegrationBranch ?? "main"} sha=${mergeResult.mergeCommitSha}`
1173
+ );
1174
+ return;
1175
+ }
1176
+ }
1177
+ } catch {
1178
+ }
1179
+ }
1180
+ }
1070
1181
  this.spawning.add(ticketId);
1071
1182
  this.reserveSlot(columnId);
1072
1183
  try {
1073
- const blocked = await this.deps.hasUnresolvedBlockers(ticketId);
1074
- if (blocked) {
1184
+ const result = await this.deps.hasUnresolvedBlockers(ticketId);
1185
+ if (result.blocked) {
1075
1186
  this.spawning.delete(ticketId);
1076
1187
  this.releaseSlot(columnId);
1077
1188
  this.deferredTickets.set(ticketId, columnId);
1078
- console.error(` [skip] ${ticketId} has unresolved blockers \u2014 deferred`);
1189
+ const blockerSummary = result.blockers.map((b) => `${b.ticket_number}:${b.column}`).join(",");
1190
+ const linkIdSummary = result.linkIds.join(",");
1191
+ console.error(
1192
+ ` [scan] skip ticket=${ticketId} reason=blocks count=${result.blockers.length} link_ids=[${linkIdSummary}] blockers=[${blockerSummary}]`
1193
+ );
1194
+ void this.emitDispatchDeferred(ticketId, "unresolved_blockers", { columnId });
1079
1195
  void this.drainQueue(columnId).catch((err) => {
1080
1196
  const msg = err instanceof Error ? err.message : String(err);
1081
1197
  console.error(` [error] drainQueue failed for column ${columnId}: ${msg}`);
@@ -1088,14 +1204,16 @@ var PipelineOrchestrator = class {
1088
1204
  this.spawning.delete(ticketId);
1089
1205
  this.releaseSlot(columnId);
1090
1206
  this.deferredTickets.set(ticketId, columnId);
1091
- void this.drainQueue(columnId).catch((err2) => {
1092
- const msg2 = err2 instanceof Error ? err2.message : String(err2);
1093
- console.error(` [error] drainQueue failed for column ${columnId}: ${msg2}`);
1207
+ void this.emitDispatchDeferred(ticketId, "unresolved_blockers", { columnId });
1208
+ void this.drainQueue(columnId).catch((dErr) => {
1209
+ const dMsg = dErr instanceof Error ? dErr.message : String(dErr);
1210
+ console.error(` [error] drainQueue failed for column ${columnId}: ${dMsg}`);
1094
1211
  });
1095
1212
  return;
1096
1213
  }
1097
1214
  try {
1098
1215
  await this.deps.claimTicket(ticketId);
1216
+ await this.clearDispatchDeferred(ticketId);
1099
1217
  this.startTrackedLoop(ticketId, columnId, colConfig);
1100
1218
  } catch (err) {
1101
1219
  const msg = err instanceof Error ? err.message : String(err);
@@ -1237,16 +1355,16 @@ var PipelineOrchestrator = class {
1237
1355
  gutterThreshold: config.gutterThreshold,
1238
1356
  ...effectiveModel !== void 0 && { model: effectiveModel },
1239
1357
  ...config.maxBudgetUsd !== void 0 && { maxBudgetUsd: config.maxBudgetUsd },
1240
- // Resolve worktree name (branch) and path from ticket context. When the
1241
- // column's agent_config specifies a path_pattern, the filesystem path is
1242
- // rendered from it — otherwise the name doubles as a relative path.
1243
1358
  ...(() => {
1244
1359
  const colScope = this.columnScopes.get(columnId);
1245
1360
  const ticket = colScope?.tickets.find((t) => t.id === ticketId);
1246
1361
  if (!config.worktreeEnabled || !ticket) return {};
1247
1362
  const wName = generateWorktreeName(ticket.ticket_number, config.name);
1248
- const wPath = renderWorktreePath(ticket.ticket_number, config.name, config.worktreePathPattern);
1249
- return wPath !== wName ? { worktreeName: wName, worktreePath: wPath } : { worktreeName: wName };
1363
+ const wPath = renderWorktreePath(ticket.ticket_number, config.name, config.worktreePathPattern, {
1364
+ boardId: this.boardId,
1365
+ defaultRoot: defaultWorktreeRoot()
1366
+ });
1367
+ return { worktreeName: wName, worktreePath: wPath };
1250
1368
  })(),
1251
1369
  ...config.lookaheadColumnId !== void 0 && { lookaheadColumnId: config.lookaheadColumnId },
1252
1370
  // Resume from checkpoint iteration/gutter if provided
@@ -1254,6 +1372,8 @@ var PipelineOrchestrator = class {
1254
1372
  ...startGutterCount !== void 0 && { startGutterCount },
1255
1373
  ...startFingerprint !== void 0 && { startFingerprint },
1256
1374
  ...config.stuckDetection && { stuckDetection: config.stuckDetection },
1375
+ ...config.repromptOnBranchMerged !== void 0 && { repromptOnBranchMerged: config.repromptOnBranchMerged },
1376
+ ...config.maxRepromptAttempts !== void 0 && { maxRepromptAttempts: config.maxRepromptAttempts },
1257
1377
  ...this.deps.costTracker && { isBudgetExhausted: () => this.deps.costTracker.isExhausted() }
1258
1378
  };
1259
1379
  const toolRestrictions = resolveToolRestrictions(
@@ -1283,6 +1403,33 @@ var PipelineOrchestrator = class {
1283
1403
  })
1284
1404
  );
1285
1405
  }
1406
+ /**
1407
+ * Increment the gutter-reset counter and check the per-column cap.
1408
+ * Returns false if the cap has been exceeded (and emits signal + comment).
1409
+ * Callers should abort the retry on false.
1410
+ */
1411
+ async checkGutterResetCap(ticketId, colConfig) {
1412
+ const cap = colConfig.maxGutterResetsPerTransit ?? 2;
1413
+ const count = (this.gutterResetCount.get(ticketId) ?? 0) + 1;
1414
+ this.gutterResetCount.set(ticketId, count);
1415
+ if (count > cap) {
1416
+ this.gutterResetExhausted.add(ticketId);
1417
+ await this.emitHarnessSignal(ticketId, "agent_stalled_exhausted", {
1418
+ resetCount: count,
1419
+ cap
1420
+ });
1421
+ await this.safeAction(
1422
+ ticketId,
1423
+ "stalled:exhaustedComment",
1424
+ () => this.deps.createComment(
1425
+ ticketId,
1426
+ `Stalled after ${String(count)} gutter reset attempt(s) (cap: ${String(cap)}) \u2014 human review needed. See 'agent_stalled_exhausted' signal for details.`
1427
+ )
1428
+ );
1429
+ return false;
1430
+ }
1431
+ return true;
1432
+ }
1286
1433
  /**
1287
1434
  * Phase 2: Invoke the advisor for failure recovery.
1288
1435
  * Returns true if the ticket should be retried, false if handled.
@@ -1386,6 +1533,10 @@ var PipelineOrchestrator = class {
1386
1533
  }
1387
1534
  switch (response.action) {
1388
1535
  case "RETRY_WITH_FEEDBACK": {
1536
+ if (!await this.checkGutterResetCap(ticketId, colConfig)) {
1537
+ this.completing.delete(ticketId);
1538
+ return false;
1539
+ }
1389
1540
  if (response.feedback) {
1390
1541
  await this.safeAction(
1391
1542
  ticketId,
@@ -1400,6 +1551,10 @@ ${response.feedback}`)
1400
1551
  return true;
1401
1552
  }
1402
1553
  case "RETRY_DIFFERENT_MODEL": {
1554
+ if (!await this.checkGutterResetCap(ticketId, colConfig)) {
1555
+ this.completing.delete(ticketId);
1556
+ return false;
1557
+ }
1403
1558
  const currentModel = result.model ?? colConfig.modelPreference ?? "default";
1404
1559
  const escalation = colConfig.modelRouting?.escalation ?? [];
1405
1560
  let nextModel;
@@ -1558,10 +1713,17 @@ ${response.feedback}`)
1558
1713
  );
1559
1714
  }
1560
1715
  }
1716
+ await this.emitHarnessSignal(ticketId, "advisor_action", {
1717
+ action: "ESCALATE",
1718
+ reason: response.reason
1719
+ });
1561
1720
  await this.safeAction(
1562
1721
  ticketId,
1563
- "advisor:createComment",
1564
- () => this.deps.createComment(ticketId, `ADVISOR: Escalated for human review \u2014 ${response.reason}`)
1722
+ "advisor:escalateComment",
1723
+ () => this.deps.createComment(
1724
+ ticketId,
1725
+ `Needs human review \u2014 see 'advisor_action' signal for details.`
1726
+ )
1565
1727
  );
1566
1728
  return false;
1567
1729
  }
@@ -1721,6 +1883,19 @@ ${findingsText}`)
1721
1883
  const nextBoardCol = bs.columns.filter((c) => c.position > currentBoardCol.position && this.pipelineColumns.has(c.id)).sort((a, b) => a.position - b.position)[0];
1722
1884
  return nextBoardCol ? this.pipelineColumns.get(nextBoardCol.id) ?? null : null;
1723
1885
  }
1886
+ /**
1887
+ * Find the ID of the next board column by position, regardless of pipeline status.
1888
+ * Used by merge-recovery to target the downstream column (e.g. Done) which may not
1889
+ * be a pipeline column.
1890
+ */
1891
+ findNextColumnId(currentColumnId) {
1892
+ const bs = this.cachedBoardScope;
1893
+ if (!bs) return null;
1894
+ const currentBoardCol = bs.columns.find((c) => c.id === currentColumnId);
1895
+ if (!currentBoardCol) return null;
1896
+ const nextBoardCol = bs.columns.filter((c) => c.position > currentBoardCol.position).sort((a, b) => a.position - b.position)[0];
1897
+ return nextBoardCol?.id ?? null;
1898
+ }
1724
1899
  /**
1725
1900
  * Called when a loop finishes. Cleans up tracking and drains the queue.
1726
1901
  */
@@ -1817,6 +1992,8 @@ ${findingsText}`)
1817
1992
  const colConfig = this.pipelineColumns.get(columnId);
1818
1993
  const terminalCleanup = async () => {
1819
1994
  this.advisorBudget.delete(ticketId);
1995
+ this.gutterResetCount.delete(ticketId);
1996
+ this.gutterResetExhausted.delete(ticketId);
1820
1997
  if (colConfig?.checkpointEnabled && this.deps.setFieldValue && result.reason !== "stopped") {
1821
1998
  await this.safeAction(
1822
1999
  ticketId,
@@ -1950,47 +2127,41 @@ ${findingsText}`)
1950
2127
  }
1951
2128
  break;
1952
2129
  case "max_iterations":
1953
- comment = `Pipeline agent reached iteration limit (${result.iterations}) without advancing. Manual review needed.`;
2130
+ await this.emitHarnessSignal(ticketId, "agent_max_iterations", {
2131
+ iterations: result.iterations
2132
+ });
2133
+ comment = `Needs human review \u2014 see 'agent_max_iterations' signal for details.`;
1954
2134
  break;
1955
2135
  case "stalled":
1956
- comment = `Pipeline agent stalled \u2014 no progress for ${result.gutterCount} consecutive iterations (of ${result.iterations} total). Manual review needed.`;
2136
+ if (!this.gutterResetExhausted.has(ticketId)) {
2137
+ await this.emitHarnessSignal(ticketId, "agent_stalled", {
2138
+ iterations: result.iterations,
2139
+ gutterCount: result.gutterCount
2140
+ });
2141
+ }
1957
2142
  break;
1958
2143
  case "error":
1959
- comment = `Pipeline agent encountered an error after ${result.iterations} iteration(s): ${result.lastError ?? "unknown error"}`;
2144
+ await this.emitHarnessSignal(ticketId, "agent_error", {
2145
+ iterations: result.iterations,
2146
+ lastError: result.lastError ?? "unknown error"
2147
+ });
2148
+ comment = `Needs human review \u2014 see 'agent_error' signal for details.`;
1960
2149
  break;
1961
2150
  case "stopped":
1962
2151
  comment = `Pipeline agent was stopped externally after ${result.iterations} iteration(s).`;
1963
2152
  break;
1964
2153
  case "deleted":
1965
- comment = `Pipeline agent stopped \u2014 ticket was deleted or archived during iteration ${result.iterations}.`;
1966
2154
  break;
1967
2155
  case "budget":
1968
2156
  comment = `Pipeline budget exhausted after ${result.iterations} iteration(s). Increase token budget in pipeline.gates.yaml settings.budget to continue.`;
1969
2157
  break;
1970
2158
  }
1971
- if (result.reason !== "deleted") {
2159
+ if (comment !== void 0) {
1972
2160
  await this.deps.createComment(ticketId, comment).catch((err) => {
1973
2161
  const msg = err instanceof Error ? err.message : String(err);
1974
2162
  console.error(` [warn] Failed to write completion comment for ${ticketId}: ${msg}`);
1975
2163
  });
1976
2164
  }
1977
- if (result.reason === "stalled") {
1978
- await this.deps.createSignal(
1979
- ticketId,
1980
- `Previous pipeline run stalled after ${result.iterations} iterations with no progress. Review comments for details before retrying.`
1981
- ).catch((err) => {
1982
- const msg = err instanceof Error ? err.message : String(err);
1983
- console.error(` [warn] Failed to write signal for ${ticketId}: ${msg}`);
1984
- });
1985
- } else if (result.reason === "error") {
1986
- await this.deps.createSignal(
1987
- ticketId,
1988
- `Previous pipeline run failed after ${result.iterations} iteration(s): ${result.lastError ?? "unknown error"}. Review comments for details before retrying.`
1989
- ).catch((err) => {
1990
- const msg = err instanceof Error ? err.message : String(err);
1991
- console.error(` [warn] Failed to write signal for ${ticketId}: ${msg}`);
1992
- });
1993
- }
1994
2165
  await terminalCleanup();
1995
2166
  } finally {
1996
2167
  this.completing.delete(ticketId);
@@ -2112,11 +2283,12 @@ ${findingsText}`)
2112
2283
  this.spawning.add(nextTicketId);
2113
2284
  this.reserveSlot(columnId);
2114
2285
  try {
2115
- const blocked = await this.deps.hasUnresolvedBlockers(nextTicketId);
2116
- if (blocked) {
2286
+ const blockerResult = await this.deps.hasUnresolvedBlockers(nextTicketId);
2287
+ if (blockerResult.blocked) {
2117
2288
  this.deferredTickets.set(nextTicketId, columnId);
2118
2289
  this.spawning.delete(nextTicketId);
2119
2290
  this.releaseSlot(columnId);
2291
+ void this.emitDispatchDeferred(nextTicketId, "unresolved_blockers", { columnId });
2120
2292
  continue;
2121
2293
  }
2122
2294
  } catch (err) {
@@ -2125,10 +2297,12 @@ ${findingsText}`)
2125
2297
  this.deferredTickets.set(nextTicketId, columnId);
2126
2298
  this.spawning.delete(nextTicketId);
2127
2299
  this.releaseSlot(columnId);
2300
+ void this.emitDispatchDeferred(nextTicketId, "unresolved_blockers", { columnId });
2128
2301
  continue;
2129
2302
  }
2130
2303
  try {
2131
2304
  await this.deps.claimTicket(nextTicketId);
2305
+ await this.clearDispatchDeferred(nextTicketId);
2132
2306
  this.startTrackedLoop(nextTicketId, columnId, colConfig);
2133
2307
  } catch (err) {
2134
2308
  const msg = err instanceof Error ? err.message : String(err);
@@ -2157,6 +2331,63 @@ ${findingsText}`)
2157
2331
  const current = this.columnReservations.get(columnId) ?? 0;
2158
2332
  if (current > 0) this.columnReservations.set(columnId, current - 1);
2159
2333
  }
2334
+ /**
2335
+ * Emit a dispatch_deferred harness signal for a ticket.
2336
+ * State-change dedup: same reason → no-op; different reason → delete old + create new.
2337
+ * Best-effort: signal creation failures are logged but never thrown.
2338
+ */
2339
+ /**
2340
+ * Emit a structured harness signal on a ticket. Uses createSignal (plain, non-tracked).
2341
+ * Best-effort: failures are logged but never thrown.
2342
+ */
2343
+ async emitHarnessSignal(ticketId, kind, payload) {
2344
+ const content = createHarnessSignalContent(kind, payload);
2345
+ try {
2346
+ await this.deps.createSignal(ticketId, content);
2347
+ } catch (err) {
2348
+ const msg = err instanceof Error ? err.message : String(err);
2349
+ console.error(`[harness-signal] failed to create ${kind} signal for ${ticketId}: ${msg}`);
2350
+ }
2351
+ }
2352
+ async emitDispatchDeferred(ticketId, reason, extras) {
2353
+ if (!this.deps.createHarnessSignal) return;
2354
+ const existing = this.deferredState.get(ticketId);
2355
+ if (existing?.reason === reason) return;
2356
+ if (existing && this.deps.deleteSignal) {
2357
+ try {
2358
+ await this.deps.deleteSignal(existing.signalId);
2359
+ } catch {
2360
+ }
2361
+ }
2362
+ const firstDeferredAt = existing?.firstDeferredAt ?? (/* @__PURE__ */ new Date()).toISOString();
2363
+ const content = createHarnessSignalContent("dispatch_deferred", {
2364
+ reason,
2365
+ firstDeferredAt,
2366
+ ...extras
2367
+ });
2368
+ try {
2369
+ const signal = await this.deps.createHarnessSignal(ticketId, content);
2370
+ this.deferredState.set(ticketId, { reason, signalId: signal.id, firstDeferredAt });
2371
+ } catch (err) {
2372
+ const msg = err instanceof Error ? err.message : String(err);
2373
+ console.error(` [dispatch-deferred] failed to create signal for ticket ${ticketId}: ${msg}`);
2374
+ }
2375
+ }
2376
+ /**
2377
+ * Clear the dispatch_deferred harness signal for a ticket (called on dispatch success or ticket:moved).
2378
+ * Best-effort: deletion failures are swallowed.
2379
+ */
2380
+ async clearDispatchDeferred(ticketId) {
2381
+ const existing = this.deferredState.get(ticketId);
2382
+ if (!existing) return;
2383
+ this.deferredState.delete(ticketId);
2384
+ if (this.deps.deleteSignal) {
2385
+ try {
2386
+ await this.deps.deleteSignal(existing.signalId);
2387
+ } catch {
2388
+ }
2389
+ }
2390
+ }
2160
2391
  };
2161
2392
 
2162
2393
  // src/lib/run-memory.ts
@@ -2408,7 +2639,7 @@ var PipelineWsClient = class _PipelineWsClient {
2408
2639
  this.cleanupTimers();
2409
2640
  const { ticket } = await this.client.post("/ws-ticket");
2410
2641
  const wsUrl = this.client.baseUrl.replace(/^http/, "ws") + `/ws?ticket=${ticket}&clientType=cli`;
2411
- return new Promise((resolve, reject) => {
2642
+ return new Promise((resolve2, reject) => {
2412
2643
  let resolved = false;
2413
2644
  this.ws = new WebSocket(wsUrl);
2414
2645
  let subscribed = false;
@@ -2437,7 +2668,7 @@ var PipelineWsClient = class _PipelineWsClient {
2437
2668
  this.options.onConnect();
2438
2669
  if (!resolved) {
2439
2670
  resolved = true;
2440
- resolve();
2671
+ resolve2();
2441
2672
  }
2442
2673
  }
2443
2674
  this.options.onEvent(event);
@@ -2549,7 +2780,7 @@ var PipelineWsClient = class _PipelineWsClient {
2549
2780
 
2550
2781
  // src/lib/logger.ts
2551
2782
  import { mkdirSync, writeFileSync, appendFileSync, readdirSync, rmSync, statSync, renameSync } from "fs";
2552
- import { join } from "path";
2783
+ import { join as join2 } from "path";
2553
2784
  var MAX_LOG_BYTES = 5 * 1024 * 1024;
2554
2785
  var MAX_ROTATED_FILES = 3;
2555
2786
  var ROTATION_CHECK_INTERVAL = 100;
@@ -2557,11 +2788,11 @@ var PipelineLogger = class {
2557
2788
  boardDir;
2558
2789
  writeCount = 0;
2559
2790
  constructor(baseDir, boardId) {
2560
- this.boardDir = join(baseDir, boardId);
2791
+ this.boardDir = join2(baseDir, boardId);
2561
2792
  mkdirSync(this.boardDir, { recursive: true });
2562
2793
  }
2563
2794
  orchestrator(message) {
2564
- const logPath = join(this.boardDir, "orchestrator.log");
2795
+ const logPath = join2(this.boardDir, "orchestrator.log");
2565
2796
  const entry = `[${(/* @__PURE__ */ new Date()).toISOString()}] ${message}
2566
2797
  `;
2567
2798
  appendFileSync(logPath, entry);
@@ -2593,10 +2824,10 @@ var PipelineLogger = class {
2593
2824
  }
2594
2825
  }
2595
2826
  iteration(ticketNumber, iterationNum, data) {
2596
- const ticketDir = join(this.boardDir, ticketNumber);
2827
+ const ticketDir = join2(this.boardDir, ticketNumber);
2597
2828
  mkdirSync(ticketDir, { recursive: true });
2598
2829
  const padded = String(iterationNum).padStart(3, "0");
2599
- const logPath = join(ticketDir, `iteration-${padded}.log`);
2830
+ const logPath = join2(ticketDir, `iteration-${padded}.log`);
2600
2831
  const entry = {
2601
2832
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2602
2833
  iteration: iterationNum,
@@ -2615,7 +2846,7 @@ var PipelineLogger = class {
2615
2846
  try {
2616
2847
  const entries = readdirSync(this.boardDir, { withFileTypes: true });
2617
2848
  for (const entry of entries) {
2618
- const fullPath = join(this.boardDir, entry.name);
2849
+ const fullPath = join2(this.boardDir, entry.name);
2619
2850
  if (entry.isDirectory() && entry.name !== ".") {
2620
2851
  try {
2621
2852
  const stat = statSync(fullPath);
@@ -3401,8 +3632,9 @@ var CodexProvider = class {
3401
3632
  execFileSync2("git", ["worktree", "add", request.workingDirectory, branch], {
3402
3633
  stdio: "pipe"
3403
3634
  });
3404
- } catch {
3405
- degraded.push("worktreeCreation");
3635
+ } catch (err) {
3636
+ const msg = err instanceof Error ? err.message : String(err);
3637
+ throw new Error(`worktree_creation_failed: ${msg}`);
3406
3638
  }
3407
3639
  }
3408
3640
  } else {
@@ -3421,17 +3653,18 @@ var CodexProvider = class {
3421
3653
  execFileSync2("git", ["worktree", "add", request.workingDirectory, branch], {
3422
3654
  stdio: "pipe"
3423
3655
  });
3424
- } catch {
3425
- degraded.push("worktreeCreation");
3656
+ } catch (err) {
3657
+ const msg = err instanceof Error ? err.message : String(err);
3658
+ throw new Error(`worktree_creation_failed: ${msg}`);
3426
3659
  }
3427
3660
  }
3428
3661
  }
3429
3662
  }
3430
- if (!degraded.includes("worktreeCreation") && existsSync(request.workingDirectory)) {
3663
+ if (existsSync(request.workingDirectory)) {
3431
3664
  ensureWorktreeRemote(request.workingDirectory);
3432
3665
  }
3433
3666
  }
3434
- return new Promise((resolve) => {
3667
+ return new Promise((resolve2) => {
3435
3668
  const [cmd, prefixArgs] = resolveCommand("codex");
3436
3669
  const resolvedArgs = [...prefixArgs, ...args];
3437
3670
  const child = spawn2(cmd, resolvedArgs, {
@@ -3482,7 +3715,7 @@ var CodexProvider = class {
3482
3715
  durationMs: Date.now() - startTime
3483
3716
  };
3484
3717
  if (degraded.length > 0) result.degradedCapabilities = degraded;
3485
- resolve(result);
3718
+ resolve2(result);
3486
3719
  };
3487
3720
  child.on("close", (code) => finish(code));
3488
3721
  child.on("error", (err) => finish(1, err.message));
@@ -3522,22 +3755,22 @@ var CodexProvider = class {
3522
3755
  }
3523
3756
  }
3524
3757
  }
3758
+ const WRITING_TOOLS = /* @__PURE__ */ new Set(["Write", "Edit", "MultiEdit", "Bash", "NotebookEdit"]);
3525
3759
  if (request.toolRestrictions) {
3526
3760
  const tr = request.toolRestrictions;
3527
- const writingTools = ["Write", "Edit", "Bash", "NotebookEdit", "shell", "file_write", "file_edit"];
3528
3761
  if (tr.tools === "") {
3529
3762
  args.push("--sandbox", "read-only");
3530
3763
  degraded.push("builtinToolStripping");
3531
- } else if (tr.disallowedTools?.some((t) => writingTools.includes(t))) {
3532
- args.push("--sandbox", "read-only");
3533
- degraded.push("toolDenylist");
3764
+ } else {
3765
+ const hasWritingDeny = (tr.disallowedTools ?? []).some((t) => WRITING_TOOLS.has(t));
3766
+ if (hasWritingDeny) {
3767
+ args.push("--sandbox", "read-only");
3768
+ degraded.push("toolDenylistAdvisory");
3769
+ }
3534
3770
  }
3535
3771
  if (tr.allowedTools?.length) {
3536
3772
  degraded.push("toolAllowlist");
3537
3773
  }
3538
- if (tr.disallowedTools?.length && !degraded.includes("toolDenylist")) {
3539
- degraded.push("toolDenylist");
3540
- }
3541
3774
  }
3542
3775
  if (request.maxTurns) {
3543
3776
  degraded.push("maxTurns");
@@ -3555,8 +3788,8 @@ var CodexProvider = class {
3555
3788
  // src/providers/gemini-provider.ts
3556
3789
  import { spawn as spawn3, execFileSync as execFileSync3 } from "child_process";
3557
3790
  import { existsSync as existsSync2, writeFileSync as writeFileSync3, mkdirSync as mkdirSync2, rmSync as rmSync3 } from "fs";
3558
- import { join as join2, dirname } from "path";
3559
- import { homedir } from "os";
3791
+ import { join as join3, dirname } from "path";
3792
+ import { homedir as homedir2 } from "os";
3560
3793
  import { fileURLToPath } from "url";
3561
3794
  import { readFileSync as readFileSync2 } from "fs";
3562
3795
 
@@ -3719,13 +3952,13 @@ function resolveHookScriptPath() {
3719
3952
  try {
3720
3953
  const thisDir = dirname(fileURLToPath(import.meta.url));
3721
3954
  const candidates = [
3722
- join2(thisDir, "lib", "gemini-hooks.mjs"),
3955
+ join3(thisDir, "lib", "gemini-hooks.mjs"),
3723
3956
  // dist/lib/ (flat bundle)
3724
- join2(thisDir, "..", "lib", "gemini-hooks.mjs"),
3957
+ join3(thisDir, "..", "lib", "gemini-hooks.mjs"),
3725
3958
  // dist/../lib/ (nested)
3726
- join2(thisDir, "..", "src", "lib", "gemini-hooks.mjs"),
3959
+ join3(thisDir, "..", "src", "lib", "gemini-hooks.mjs"),
3727
3960
  // source from dist/
3728
- join2(thisDir, "..", "..", "src", "lib", "gemini-hooks.mjs")
3961
+ join3(thisDir, "..", "..", "src", "lib", "gemini-hooks.mjs")
3729
3962
  // source from dist/providers/
3730
3963
  ];
3731
3964
  for (const p of candidates) {
@@ -3808,7 +4041,7 @@ var GeminiProvider = class _GeminiProvider {
3808
4041
  if (request.workingDirectory && settingsDir) {
3809
4042
  this.copySettingsToDir(settingsDir, request.workingDirectory);
3810
4043
  }
3811
- return new Promise((resolve) => {
4044
+ return new Promise((resolve2) => {
3812
4045
  const [cmd, prefixArgs] = resolveCommand("gemini");
3813
4046
  const resolvedArgs = [...prefixArgs, ...args];
3814
4047
  const child = spawn3(cmd, resolvedArgs, {
@@ -3881,7 +4114,7 @@ var GeminiProvider = class _GeminiProvider {
3881
4114
  durationMs: Date.now() - startTime
3882
4115
  };
3883
4116
  if (degraded.length > 0) result.degradedCapabilities = degraded;
3884
- resolve(result);
4117
+ resolve2(result);
3885
4118
  };
3886
4119
  child.on("close", (code) => finish(code));
3887
4120
  child.on("error", (err) => finish(1, err.message));
@@ -3919,9 +4152,9 @@ var GeminiProvider = class _GeminiProvider {
3919
4152
  }
3920
4153
  writeGeminiSettings(request, degraded) {
3921
4154
  try {
3922
- const baseDir = join2(homedir(), ".kantban", "pipelines", "gemini-tmp");
3923
- const dir = join2(baseDir, `session-${Date.now()}`);
3924
- const geminiDir = join2(dir, ".gemini");
4155
+ const baseDir = join3(homedir2(), ".kantban", "pipelines", "gemini-tmp");
4156
+ const dir = join3(baseDir, `session-${Date.now()}`);
4157
+ const geminiDir = join3(dir, ".gemini");
3925
4158
  mkdirSync2(geminiDir, { recursive: true, mode: 448 });
3926
4159
  const settings = {};
3927
4160
  if (request.mcpConfig && Object.keys(request.mcpConfig.servers).length > 0) {
@@ -3945,20 +4178,20 @@ var GeminiProvider = class _GeminiProvider {
3945
4178
  hookConfig.disallowedTools = tr.disallowedTools ? translateToolNames(tr.disallowedTools) : null;
3946
4179
  if (tr.tools !== void 0) hookConfig.builtinToolsMode = tr.tools;
3947
4180
  const ext = IS_WINDOWS ? ".cmd" : ".sh";
3948
- const wrapperPath = join2(dir, `.kantban-hook-before-tool${ext}`);
3949
- writeFileSync3(wrapperPath, this.generateHookWrapper(hookPath, "BeforeToolSelection", join2(dir, ".kantban-hook-config.json")), { mode: 493 });
4181
+ const wrapperPath = join3(dir, `.kantban-hook-before-tool${ext}`);
4182
+ writeFileSync3(wrapperPath, this.generateHookWrapper(hookPath, "BeforeToolSelection", join3(dir, ".kantban-hook-config.json")), { mode: 493 });
3950
4183
  hooks.BeforeToolSelection = [{ matcher: "*", hooks: [{ type: "command", command: wrapperPath, timeout: 3e4 }] }];
3951
4184
  }
3952
4185
  if (request.maxTurns && request.mcpConfig) {
3953
4186
  hookConfig.maxTurns = request.maxTurns;
3954
- hookConfig.turnFile = join2(dir, ".kantban-turn-counter");
4187
+ hookConfig.turnFile = join3(dir, ".kantban-turn-counter");
3955
4188
  const ext = IS_WINDOWS ? ".cmd" : ".sh";
3956
- const wrapperPath = join2(dir, `.kantban-hook-after-agent${ext}`);
3957
- writeFileSync3(wrapperPath, this.generateHookWrapper(hookPath, "AfterAgent", join2(dir, ".kantban-hook-config.json")), { mode: 493 });
4189
+ const wrapperPath = join3(dir, `.kantban-hook-after-agent${ext}`);
4190
+ writeFileSync3(wrapperPath, this.generateHookWrapper(hookPath, "AfterAgent", join3(dir, ".kantban-hook-config.json")), { mode: 493 });
3958
4191
  hooks.AfterAgent = [{ matcher: "*", hooks: [{ type: "command", command: wrapperPath, timeout: 3e4 }] }];
3959
4192
  }
3960
4193
  if (Object.keys(hookConfig).length > 0) {
3961
- writeFileSync3(join2(dir, ".kantban-hook-config.json"), JSON.stringify(hookConfig), { mode: 384 });
4194
+ writeFileSync3(join3(dir, ".kantban-hook-config.json"), JSON.stringify(hookConfig), { mode: 384 });
3962
4195
  }
3963
4196
  if (Object.keys(hooks).length > 0) {
3964
4197
  settings.hooks = hooks;
@@ -3971,7 +4204,7 @@ var GeminiProvider = class _GeminiProvider {
3971
4204
  process.stderr.write(`[gemini] Hook script not found \u2014 tool scoping and turn limits unavailable
3972
4205
  `);
3973
4206
  }
3974
- writeFileSync3(join2(geminiDir, "settings.json"), JSON.stringify(settings, null, 2), { mode: 384 });
4207
+ writeFileSync3(join3(geminiDir, "settings.json"), JSON.stringify(settings, null, 2), { mode: 384 });
3975
4208
  return dir;
3976
4209
  } catch (err) {
3977
4210
  process.stderr.write(`[gemini] Failed to write settings.json: ${err}
@@ -4037,17 +4270,29 @@ node "${hookScript}" "${event}" "${configFile}" < "$STDIN_FILE"
4037
4270
  }
4038
4271
  copySettingsToDir(settingsDir, targetDir) {
4039
4272
  try {
4040
- const sourceFile = join2(settingsDir, ".gemini", "settings.json");
4041
- const targetGeminiDir = join2(targetDir, ".gemini");
4273
+ const sourceFile = join3(settingsDir, ".gemini", "settings.json");
4274
+ const targetGeminiDir = join3(targetDir, ".gemini");
4042
4275
  mkdirSync2(targetGeminiDir, { recursive: true, mode: 448 });
4043
4276
  const content = readFileSync2(sourceFile, "utf-8");
4044
- writeFileSync3(join2(targetGeminiDir, "settings.json"), content, { mode: 384 });
4277
+ writeFileSync3(join3(targetGeminiDir, "settings.json"), content, { mode: 384 });
4045
4278
  } catch {
4046
4279
  }
4047
4280
  }
4048
4281
  };
4049
4282
 
4050
4283
  // src/commands/pipeline.ts
4284
+ var execFileAsync = promisify(execFile);
4285
+ var _cachedPrimaryRepoRoot = null;
4286
+ async function getPrimaryRepoRoot() {
4287
+ if (_cachedPrimaryRepoRoot) return _cachedPrimaryRepoRoot;
4288
+ try {
4289
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"]);
4290
+ _cachedPrimaryRepoRoot = resolve(stdout.trim());
4291
+ } catch {
4292
+ _cachedPrimaryRepoRoot = resolve(process.cwd());
4293
+ }
4294
+ return _cachedPrimaryRepoRoot;
4295
+ }
4051
4296
  function createProviderRegistry() {
4052
4297
  const registry = new ProviderRegistry();
4053
4298
  registry.register(new ClaudeProvider());
@@ -4055,9 +4300,25 @@ function createProviderRegistry() {
4055
4300
  registry.register(new GeminiProvider());
4056
4301
  return registry;
4057
4302
  }
4058
- function readMcpConfigAsProviderConfig(filePath) {
4059
- const raw = JSON.parse(readFileSync3(filePath, "utf-8"));
4060
- return { servers: raw.mcpServers };
4303
+ function readMcpConfigAsProviderConfig(filePath, regenerate) {
4304
+ function parse(path) {
4305
+ const raw = JSON.parse(readFileSync3(path, "utf-8"));
4306
+ return { servers: raw.mcpServers };
4307
+ }
4308
+ try {
4309
+ return parse(filePath);
4310
+ } catch (err) {
4311
+ const code = err?.code;
4312
+ if (code === "ENOENT") {
4313
+ process.stderr.write(
4314
+ `[mcp-config] regenerating missing file at ${filePath} (pid=${process.pid})
4315
+ `
4316
+ );
4317
+ const newPath = regenerate();
4318
+ return parse(newPath);
4319
+ }
4320
+ throw err;
4321
+ }
4061
4322
  }
4062
4323
  function parseArgs(args) {
4063
4324
  const positional = [];
@@ -4153,10 +4414,10 @@ Flags:
4153
4414
  return { boardId, once, dryRun, columnFilter, maxIterations, maxBudget, model, provider, concurrency, logRetention, yes };
4154
4415
  }
4155
4416
  function pidDir(boardId) {
4156
- return join3(homedir2(), ".kantban", "pipelines", boardId);
4417
+ return join4(homedir3(), ".kantban", "pipelines", boardId);
4157
4418
  }
4158
4419
  function pidFilePath(boardId) {
4159
- return join3(pidDir(boardId), "orchestrator.pid");
4420
+ return join4(pidDir(boardId), "orchestrator.pid");
4160
4421
  }
4161
4422
  function writePidFile(boardId) {
4162
4423
  const dir = pidDir(boardId);
@@ -4170,7 +4431,7 @@ function removePidFile(boardId) {
4170
4431
  }
4171
4432
  }
4172
4433
  function childManifestPath(boardId) {
4173
- return join3(pidDir(boardId), "children.pid");
4434
+ return join4(pidDir(boardId), "children.pid");
4174
4435
  }
4175
4436
  function readChildManifest(boardId) {
4176
4437
  try {
@@ -4215,7 +4476,7 @@ function cleanupOrphanedProcesses(boardId) {
4215
4476
  console.log(`Killed ${String(manifestPids.length)} orphaned child process(es) from manifest`);
4216
4477
  removeChildManifest(boardId);
4217
4478
  }
4218
- const staleReaperPath = join3(pidDir(boardId), "reaper.pid");
4479
+ const staleReaperPath = join4(pidDir(boardId), "reaper.pid");
4219
4480
  try {
4220
4481
  if (existsSync3(staleReaperPath)) {
4221
4482
  const reaperPid = parseInt(readFileSync3(staleReaperPath, "utf-8").trim(), 10);
@@ -4248,14 +4509,14 @@ Press Ctrl+C to cancel, or re-run with --yes to skip this warning.
4248
4509
  `);
4249
4510
  }
4250
4511
  function waitForConfirmation() {
4251
- return new Promise((resolve) => {
4512
+ return new Promise((resolve2) => {
4252
4513
  process.stdout.write("Continue? [y/N] ");
4253
4514
  process.stdin.setEncoding("utf8");
4254
4515
  process.stdin.once("data", (data) => {
4255
4516
  const answer = data.trim().toLowerCase();
4256
- resolve(answer === "y" || answer === "yes");
4517
+ resolve2(answer === "y" || answer === "yes");
4257
4518
  });
4258
- process.stdin.once("end", () => resolve(false));
4519
+ process.stdin.once("end", () => resolve2(false));
4259
4520
  });
4260
4521
  }
4261
4522
  async function runPipeline(client, args) {
@@ -4274,7 +4535,7 @@ async function runPipeline(client, args) {
4274
4535
  return;
4275
4536
  }
4276
4537
  }
4277
- const gateFilePath = join3(process.cwd(), "pipeline.gates.yaml");
4538
+ const gateFilePath = join4(process.cwd(), "pipeline.gates.yaml");
4278
4539
  let gateConfig;
4279
4540
  if (!existsSync3(gateFilePath)) {
4280
4541
  console.error(`Error: pipeline.gates.yaml not found in ${process.cwd()}`);
@@ -4304,13 +4565,14 @@ async function runPipeline(client, args) {
4304
4565
  process.exit(1);
4305
4566
  }
4306
4567
  }
4568
+ reapOrphanedMcpConfigDirs(pidDir(opts.boardId));
4307
4569
  const mcpConfigPath = generateMcpConfig(client.baseUrl, client.token, opts.boardId);
4308
4570
  const boardProviderConfig = {
4309
4571
  ...opts.provider ? { default_provider: opts.provider } : {},
4310
4572
  ...opts.provider ? { intelligence_provider: opts.provider } : {}
4311
4573
  };
4312
4574
  const intelligenceProvider = registry.resolveForIntelligence(boardProviderConfig);
4313
- const logBaseDir = join3(homedir2(), ".kantban", "pipelines");
4575
+ const logBaseDir = join4(homedir3(), ".kantban", "pipelines");
4314
4576
  const logger = new PipelineLogger(logBaseDir, opts.boardId);
4315
4577
  logger.pruneOldLogs(opts.logRetention);
4316
4578
  let runMemory = null;
@@ -4332,6 +4594,32 @@ async function runPipeline(client, args) {
4332
4594
  ...opts.maxBudget !== null && { maxBudgetUsd: opts.maxBudget },
4333
4595
  ...opts.model !== null && { model: opts.model }
4334
4596
  };
4597
+ if (effectiveConfig.worktreePath) {
4598
+ const primaryRepoRoot = await getPrimaryRepoRoot();
4599
+ const resolvedCwd = resolve(effectiveConfig.worktreePath);
4600
+ const inside = resolvedCwd === primaryRepoRoot || resolvedCwd.startsWith(primaryRepoRoot + sep) || primaryRepoRoot.startsWith(resolvedCwd + sep);
4601
+ if (inside) {
4602
+ const errMsg = `[fatal] dispatch refused: resolved cwd "${resolvedCwd}" is inside, equal to, or a parent of the primary repo "${primaryRepoRoot}". Configure agent_config.worktree.path_pattern to a path outside the primary repo.`;
4603
+ logger.orchestrator(errMsg);
4604
+ await deps.createComment(ticketId, `[dispatch] failed: cwd would be inside the primary repo. Update agent_config.worktree.path_pattern to a path outside this repo.`);
4605
+ return { reason: "error", iterations: 0, gutterCount: 0, lastError: errMsg };
4606
+ }
4607
+ }
4608
+ if (effectiveConfig.worktreePath) {
4609
+ const parent = dirname2(effectiveConfig.worktreePath);
4610
+ try {
4611
+ await fsPromises.mkdir(parent, { recursive: true });
4612
+ } catch (err) {
4613
+ const msg = err instanceof Error ? err.message : String(err);
4614
+ const errMsg = `[dispatch] failed ticket=${ticketId} reason=worktree_unrenderable path="${effectiveConfig.worktreePath}" error="${msg}"`;
4615
+ logger.orchestrator(errMsg);
4616
+ await deps.createComment(
4617
+ ticketId,
4618
+ `[dispatch] failed: cannot create worktree parent directory at "${parent}": ${msg}. Fix the path_pattern or its parent directory; the orchestrator will retry on the next scan.`
4619
+ );
4620
+ return { reason: "error", iterations: 0, gutterCount: 0, lastError: errMsg };
4621
+ }
4622
+ }
4335
4623
  const mem = runMemory;
4336
4624
  const colScopeForName = await client.get(
4337
4625
  `/projects/${projectId}/pipeline-context`,
@@ -4347,8 +4635,8 @@ async function runPipeline(client, args) {
4347
4635
  if (effectiveConfig.model) {
4348
4636
  effectiveConfig.model = registry.resolveModel(columnProvider, effectiveConfig.model);
4349
4637
  }
4350
- const columnGates = resolveGatesForColumn(gateConfig, resolvedColumnName);
4351
- const gateCwd = effectiveConfig.worktreePath ?? (effectiveConfig.worktreeName ? join3(process.cwd(), effectiveConfig.worktreeName) : process.cwd());
4638
+ const { gates: columnGates } = resolveGatesForColumn(gateConfig, resolvedColumnName);
4639
+ const gateCwd = effectiveConfig.worktreePath ?? (effectiveConfig.worktreeName ? join4(process.cwd(), effectiveConfig.worktreeName) : process.cwd());
4352
4640
  const effectiveMcpConfigPath = columnGates.length > 0 ? generateGateProxyMcpConfig(
4353
4641
  client.baseUrl,
4354
4642
  client.token,
@@ -4360,7 +4648,20 @@ async function runPipeline(client, args) {
4360
4648
  gateCwd,
4361
4649
  ticketId
4362
4650
  ) : mcpConfigPath;
4363
- const columnMcpConfig = readMcpConfigAsProviderConfig(effectiveMcpConfigPath);
4651
+ const columnMcpConfig = readMcpConfigAsProviderConfig(
4652
+ effectiveMcpConfigPath,
4653
+ () => columnGates.length > 0 ? generateGateProxyMcpConfig(
4654
+ client.baseUrl,
4655
+ client.token,
4656
+ opts.boardId,
4657
+ gateFilePath,
4658
+ columnId,
4659
+ resolvedColumnName,
4660
+ projectId,
4661
+ gateCwd,
4662
+ ticketId
4663
+ ) : generateMcpConfig(client.baseUrl, client.token, opts.boardId)
4664
+ );
4364
4665
  const loopDeps = {
4365
4666
  fetchTicketContext: (tid) => client.get(`/projects/${projectId}/pipeline-context`, { ticketId: tid }),
4366
4667
  fetchColumnContext: (cid) => client.get(`/projects/${projectId}/pipeline-context`, { columnId: cid }),
@@ -4468,7 +4769,7 @@ async function runPipeline(client, args) {
4468
4769
  };
4469
4770
  }
4470
4771
  effectiveConfig.onPostIterationGates = async (tid, iteration) => {
4471
- const gates = resolveGatesForColumn(gateConfig, resolvedColumnName);
4772
+ const { gates } = resolveGatesForColumn(gateConfig, resolvedColumnName);
4472
4773
  if (gates.length === 0) {
4473
4774
  return gateSnapshotStore.record(tid, iteration, []);
4474
4775
  }
@@ -4504,6 +4805,12 @@ async function runPipeline(client, args) {
4504
4805
  contentPrefix,
4505
4806
  content: body
4506
4807
  }),
4808
+ createHarnessSignal: (ticketId, content) => client.post(`/projects/${projectId}/signals`, {
4809
+ scopeType: "ticket",
4810
+ scopeId: ticketId,
4811
+ content
4812
+ }),
4813
+ deleteSignal: (signalId) => client.delete(`/projects/${projectId}/signals/${signalId}`),
4507
4814
  claimTicket: (ticketId) => client.claimTicket(projectId, ticketId),
4508
4815
  fetchBlockedTickets: (ticketId) => client.get(
4509
4816
  `/projects/${projectId}/tickets/${ticketId}/blocked-tickets`
@@ -4512,7 +4819,27 @@ async function runPipeline(client, args) {
4512
4819
  const blockers = await client.get(
4513
4820
  `/projects/${projectId}/tickets/${ticketId}/unresolved-blockers`
4514
4821
  );
4515
- return blockers.length > 0;
4822
+ if (!blockers || blockers.length === 0) {
4823
+ return { blocked: false };
4824
+ }
4825
+ let linkIds = [];
4826
+ try {
4827
+ const links = await client.get(
4828
+ `/projects/${projectId}/ticket-links?ticketId=${ticketId}&linkType=blocks`
4829
+ );
4830
+ linkIds = (links ?? []).filter((l) => l.target_id === ticketId && l.link_type === "blocks").map((l) => l.id);
4831
+ } catch {
4832
+ linkIds = [];
4833
+ }
4834
+ return {
4835
+ blocked: true,
4836
+ blockers: blockers.map((b) => ({
4837
+ id: b.id,
4838
+ ticket_number: b.ticket_number,
4839
+ column: b.column
4840
+ })),
4841
+ linkIds
4842
+ };
4516
4843
  },
4517
4844
  dispatchLightCall: async (ticketId, columnId) => {
4518
4845
  const [ticketCtx, colScope, boardScope] = await Promise.all([
@@ -4626,6 +4953,7 @@ async function runPipeline(client, args) {
4626
4953
  appendRunMemory: (section, content) => runMemory ? runMemory.append(section, content) : Promise.resolve(),
4627
4954
  cleanupWorktree: (name) => cleanupWorktree(name),
4628
4955
  mergeWorktree: (name, integrationBranch) => mergeWorktreeBranch(name, integrationBranch),
4956
+ detectBranchMerged: async (opts2) => detectBranchMerged(opts2),
4629
4957
  // Pipeline event emission — wsClient captured by reference (set later before any loops run)
4630
4958
  emitPipelineEvent: (event) => {
4631
4959
  wsClient?.send(event);
@@ -4664,7 +4992,7 @@ async function runPipeline(client, args) {
4664
4992
  console.error(`Error: Failed to initialize pipeline: ${message}`);
4665
4993
  console.error("Check that the board exists, has pipeline columns configured, and the API is reachable.");
4666
4994
  cleanupMcpConfig(mcpConfigPath);
4667
- cleanupGateProxyConfigs(pidDir(opts.boardId));
4995
+ cleanupGateProxyConfigs(dirname2(mcpConfigPath));
4668
4996
  process.exit(1);
4669
4997
  }
4670
4998
  const columnIds = orchestrator.pipelineColumnIds;
@@ -4720,7 +5048,7 @@ async function runPipeline(client, args) {
4720
5048
  if (columnIds.length === 0) {
4721
5049
  console.log('No pipeline columns found (columns need has_prompt=true and type !== "done").');
4722
5050
  cleanupMcpConfig(mcpConfigPath);
4723
- cleanupGateProxyConfigs(pidDir(opts.boardId));
5051
+ cleanupGateProxyConfigs(dirname2(mcpConfigPath));
4724
5052
  return;
4725
5053
  }
4726
5054
  console.log(`Discovered ${String(columnIds.length)} pipeline column(s).`);
@@ -4734,6 +5062,15 @@ async function runPipeline(client, args) {
4734
5062
  ` No board column name matched (after canonicalization). These overrides will never fire.`
4735
5063
  );
4736
5064
  }
5065
+ for (const col of orchestrator.allBoardColumns) {
5066
+ if (col.type !== "done") continue;
5067
+ const { source } = resolveGatesForColumn(gateConfig, col.name);
5068
+ if (source === "override") {
5069
+ console.warn(
5070
+ `[gate-config] column "${col.name}" is done-type AND has an explicit override \u2014 gates will run on moves INTO this column (bypass disabled by your pipeline.gates.yaml).`
5071
+ );
5072
+ }
5073
+ }
4737
5074
  if (opts.dryRun) {
4738
5075
  console.log("\n--- Dry Run Configuration ---");
4739
5076
  console.log(`Board ID: ${opts.boardId}`);
@@ -4749,7 +5086,7 @@ async function runPipeline(client, args) {
4749
5086
  console.log(`MCP config: ${mcpConfigPath}`);
4750
5087
  console.log("\n[Dry run -- no agents started]");
4751
5088
  cleanupMcpConfig(mcpConfigPath);
4752
- cleanupGateProxyConfigs(pidDir(opts.boardId));
5089
+ cleanupGateProxyConfigs(dirname2(mcpConfigPath));
4753
5090
  return;
4754
5091
  }
4755
5092
  let shutdownInProgress = false;
@@ -4777,7 +5114,7 @@ Received ${signal}. Shutting down gracefully...`);
4777
5114
  console.log(costTracker.generateReport(gateConfig.settings?.pricing));
4778
5115
  }
4779
5116
  cleanupMcpConfig(mcpConfigPath);
4780
- cleanupGateProxyConfigs(pidDir(opts.boardId));
5117
+ cleanupGateProxyConfigs(dirname2(mcpConfigPath));
4781
5118
  killReaper(reaperPidPath);
4782
5119
  removePidFile(opts.boardId);
4783
5120
  removeChildManifest(opts.boardId);
@@ -4808,14 +5145,14 @@ Received ${signal}. Shutting down gracefully...`);
4808
5145
  cleanupOrphanedProcesses(opts.boardId);
4809
5146
  writePidFile(opts.boardId);
4810
5147
  logger.orchestrator(`PID file written: ${String(process.pid)}`);
4811
- const reaperPidPath = join3(pidDir(opts.boardId), "reaper.pid");
5148
+ const reaperPidPath = join4(pidDir(opts.boardId), "reaper.pid");
4812
5149
  const reaperProcess = spawnReaper({
4813
5150
  orchestratorPid: process.pid,
4814
5151
  manifestPath: childManifestPath(opts.boardId),
4815
5152
  pidFilePath: pidFilePath(opts.boardId),
4816
5153
  reaperPidPath,
4817
5154
  mcpConfigPath,
4818
- pipelineDir: pidDir(opts.boardId)
5155
+ pipelineDir: dirname2(mcpConfigPath)
4819
5156
  });
4820
5157
  logger.orchestrator(`Watchdog reaper spawned (PID ${String(reaperProcess.pid ?? "unknown")})`);
4821
5158
  let eventQueue = null;
@@ -4900,7 +5237,7 @@ Received ${signal}. Shutting down gracefully...`);
4900
5237
  wsClient.stop();
4901
5238
  eventQueue.stop();
4902
5239
  cleanupMcpConfig(mcpConfigPath);
4903
- cleanupGateProxyConfigs(pidDir(opts.boardId));
5240
+ cleanupGateProxyConfigs(dirname2(mcpConfigPath));
4904
5241
  killReaper(reaperPidPath);
4905
5242
  removePidFile(opts.boardId);
4906
5243
  removeChildManifest(opts.boardId);
@@ -4948,7 +5285,7 @@ async function waitForAllLoops(orchestrator, timeoutMs = 4 * 60 * 60 * 1e3) {
4948
5285
  );
4949
5286
  return;
4950
5287
  }
4951
- await new Promise((resolve) => setTimeout(resolve, 1e3));
5288
+ await new Promise((resolve2) => setTimeout(resolve2, 1e3));
4952
5289
  if (activeRalphLoops.size === 0 && orchestrator.activeLoopCount === 0 && !orchestrator.hasActiveQueuedWork && !orchestrator.hasCompletingWork) {
4953
5290
  consecutiveIdle++;
4954
5291
  } else {
@@ -4998,4 +5335,4 @@ export {
4998
5335
  runPipeline,
4999
5336
  stopPipeline
5000
5337
  };
5001
- //# sourceMappingURL=pipeline-EUOOLKHN.js.map
5338
+ //# sourceMappingURL=pipeline-6J64Z7VH.js.map