@ngockhoale/ukit 3.4.23 → 3.4.25

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.
@@ -956,7 +956,7 @@ async function bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs }) {
956
956
  *
957
957
  * Advisory lane: EVERY failure path returns { kind: 'none' }, never throws.
958
958
  */
959
- export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), lockBudgetMs = LOCK_BUDGET_MS, transcriptPath = null } = {}) {
959
+ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), lockBudgetMs = LOCK_BUDGET_MS, transcriptPath = null, stopHookActive = false } = {}) {
960
960
  const gate = await loadHandoffGateConfig(projectRoot);
961
961
  if (!gate.enabled) return { kind: 'none' };
962
962
 
@@ -1024,6 +1024,20 @@ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), loc
1024
1024
 
1025
1025
  const signature = `${phase}|${cursor}|${next}`;
1026
1026
  const streak = await bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs });
1027
+ // Reentrancy valve (stop_hook_active): Claude Code sets it when this Stop fires
1028
+ // inside the continuation turn WE forced. The same cursor signature seen again
1029
+ // (streak > 1) means that continuation made no cursor progress — blocking again
1030
+ // is a no-progress loop, so release once instead of waiting for the stalled cap.
1031
+ // A moved cursor resets the streak to 1 and keeps blocking (real progress).
1032
+ if (stopHookActive === true && streak !== null && streak > 1) {
1033
+ return {
1034
+ kind: 'advisory',
1035
+ systemMessage:
1036
+ `[ukit-stop-coordinator] handoff-cursor released this reentrant stop (stop_hook_active): ` +
1037
+ `the run cursor ("${signature}") did not advance during the forced continuation. ` +
1038
+ `RUN.md is untouched; resume with /ukit:handoff-fullstack or clear with /ukit:handoff-clear.`,
1039
+ };
1040
+ }
1027
1041
  if (streak !== null && streak > gate.maxStalledBlocks) {
1028
1042
  let predicateState = 'no ExitPredicate recorded';
1029
1043
  if (exitPredicate) {
@@ -1064,6 +1078,101 @@ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), loc
1064
1078
  return { kind: 'block', reason: lines.join('\n') };
1065
1079
  }
1066
1080
 
1081
+ // ─── Explicit user interruption (pause / cancel) ──────────────────────────
1082
+ // A Stop must never be forced while the USER has paused or cancelled the run:
1083
+ // every lane (completion, watchdog, handoff-cursor) would otherwise bounce the
1084
+ // agent's "PAUSED" reply back as "CONTINUE IMMEDIATELY". The signal is read ONLY
1085
+ // from genuine user turns in the host-authored transcript — never from the agent's
1086
+ // own words, tool output, sub-agent reports or hook feedback (all skipped). The
1087
+ // latest pause/cancel-vs-resume turn wins; neutral turns (questions, health
1088
+ // checks) keep the earlier state; an unreadable/over-budget scan is "no
1089
+ // interruption" so enforcement is never relaxed by a lost read.
1090
+ export const INTERRUPT_SCAN_BUDGET_MS = 800;
1091
+ export const INTERRUPT_SCAN_MAX_BYTES = 8 * 1024 * 1024;
1092
+ const INTERRUPT_MAX_TEXT = 240;
1093
+ const WORD_END = '(?![\\p{L}\\p{N}])';
1094
+ const CANCEL_RE = new RegExp(`^(?:please\\s+)?(?:cancel|abort)${WORD_END}|^(?:hủy|huỷ|huy)${WORD_END}`, 'iu');
1095
+ const PAUSE_RE = new RegExp(
1096
+ `^(?:please\\s+)?(?:pause|halt|stop|hold\\s+(?:on|off))${WORD_END}`
1097
+ + `|^(?:tạm\\s+dừng|tạm\\s+ngưng|tạm\\s+ngừng|tam\\s+dung|dừng(?:\\s+lại)?|dung\\s+lai|ngừng)${WORD_END}`,
1098
+ 'iu',
1099
+ );
1100
+ const RESUME_RE = new RegExp(
1101
+ `^(?:please\\s+)?(?:continue|resume|proceed|unpause|go\\s+on|carry\\s+on)${WORD_END}`
1102
+ + `|^(?:tiếp\\s+tục|tiep\\s+tuc)${WORD_END}`,
1103
+ 'iu',
1104
+ );
1105
+ const SYNTHETIC_USER_PREFIX = /^(?:<system-reminder|<task-notification|<local-command|<user-prompt-submit-hook|Stop hook feedback|\[ukit|Caveat:|\[Request interrupted)/i;
1106
+ const RESUME_COMMAND_TAG = /^<command-name>\/?(?:ukit:)?handoff-(?:fullstack|implement)(?![\w-])/;
1107
+
1108
+ function genuineUserText(entry) {
1109
+ if (!entry || entry.type !== 'user' || entry.isSidechain === true || entry.isMeta === true) return null;
1110
+ const msg = entry.message;
1111
+ if (!msg || msg.role !== 'user') return null;
1112
+ let text = '';
1113
+ if (typeof msg.content === 'string') {
1114
+ text = msg.content;
1115
+ } else if (Array.isArray(msg.content)) {
1116
+ for (const b of msg.content) {
1117
+ if (!b || typeof b !== 'object') continue;
1118
+ if (b.type === 'tool_result') return null;
1119
+ if (b.type === 'text' && typeof b.text === 'string') text += (text ? '\n' : '') + b.text;
1120
+ }
1121
+ } else {
1122
+ return null;
1123
+ }
1124
+ text = text.trim();
1125
+ if (!text || SYNTHETIC_USER_PREFIX.test(text)) return null;
1126
+ return text;
1127
+ }
1128
+
1129
+ /**
1130
+ * @returns {Promise<{ kind: 'pause'|'cancel'|null }>} Newest decisive genuine user
1131
+ * turn: pause/cancel → interrupted; resume → not interrupted; none → not interrupted.
1132
+ */
1133
+ export async function detectUserInterruption(transcriptPath, {
1134
+ budgetMs = INTERRUPT_SCAN_BUDGET_MS,
1135
+ maxBytes = INTERRUPT_SCAN_MAX_BYTES,
1136
+ } = {}) {
1137
+ if (typeof transcriptPath !== 'string' || !transcriptPath.trim()) return { kind: null };
1138
+ let handle;
1139
+ try {
1140
+ handle = await fs.open(transcriptPath, 'r');
1141
+ const size = Number((await handle.stat()).size) || 0;
1142
+ const start = Math.max(0, size - maxBytes);
1143
+ const deadline = Date.now() + budgetMs;
1144
+ const buf = Buffer.allocUnsafe(size - start);
1145
+ let got = 0;
1146
+ while (got < buf.length) {
1147
+ if (Date.now() > deadline) return { kind: null };
1148
+ const { bytesRead } = await handle.read(buf, got, buf.length - got, start + got);
1149
+ if (!bytesRead) break;
1150
+ got += bytesRead;
1151
+ }
1152
+ let lines = buf.toString('utf8', 0, got).split('\n');
1153
+ if (start > 0) lines = lines.slice(1);
1154
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
1155
+ if (Date.now() > deadline) return { kind: null };
1156
+ const line = lines[i];
1157
+ if (!line || !line.includes('"user"') || line.includes('"tool_result"')) continue;
1158
+ let entry;
1159
+ try { entry = JSON.parse(line); } catch { continue; }
1160
+ const text = genuineUserText(entry);
1161
+ if (text === null) continue;
1162
+ if (RESUME_COMMAND_TAG.test(text)) return { kind: null };
1163
+ if (text.length > INTERRUPT_MAX_TEXT) continue;
1164
+ if (CANCEL_RE.test(text)) return { kind: 'cancel' };
1165
+ if (PAUSE_RE.test(text)) return { kind: 'pause' };
1166
+ if (RESUME_RE.test(text)) return { kind: null };
1167
+ }
1168
+ return { kind: null };
1169
+ } catch {
1170
+ return { kind: null };
1171
+ } finally {
1172
+ try { await handle?.close(); } catch {}
1173
+ }
1174
+ }
1175
+
1067
1176
  // ─── Once-per-stop guard ──────────────────────────────────────────────────
1068
1177
  /**
1069
1178
  * Lock-scoped dedupe: persist `{ lastStop: { key, ts } }` and report whether this
@@ -1124,6 +1233,27 @@ export async function runStopCoordinator({
1124
1233
  return { skip: 'duplicate Stop invocation for this session inside the dedupe window', merged: null };
1125
1234
  }
1126
1235
 
1236
+ // User pause/cancel outranks every lane: release, change nothing (RUN.md, tasks,
1237
+ // branches, worktrees stay as they are so the run can be resumed).
1238
+ const interruption = await detectUserInterruption(
1239
+ typeof payload?.transcript_path === 'string' ? payload.transcript_path : null,
1240
+ );
1241
+ if (interruption.kind) {
1242
+ return {
1243
+ skip: null,
1244
+ merged: {
1245
+ decision: null,
1246
+ reason: undefined,
1247
+ systemMessage:
1248
+ `[ukit-stop-coordinator] stop allowed: the user requested ${interruption.kind}. ` +
1249
+ `No verification or continuation is forced; run state is preserved. ` +
1250
+ `Resume by asking to continue (or /ukit:handoff-fullstack).`,
1251
+ policyOwner: 'user-interruption',
1252
+ policies: ['user-interruption'],
1253
+ },
1254
+ };
1255
+ }
1256
+
1127
1257
  let completion = null;
1128
1258
  const failures = {};
1129
1259
  try {
@@ -1149,6 +1279,7 @@ export async function runStopCoordinator({
1149
1279
  now,
1150
1280
  lockBudgetMs,
1151
1281
  transcriptPath: typeof payload?.transcript_path === 'string' ? payload.transcript_path : null,
1282
+ stopHookActive: payload?.stop_hook_active === true,
1152
1283
  });
1153
1284
  } catch (error) {
1154
1285
  failures.handoff = error?.message || String(error);