@ngockhoale/ukit 3.4.29 → 3.4.30

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 (34) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/manifests/documentation.yaml +88 -0
  3. package/package.json +1 -1
  4. package/src/core/permissionPolicy.js +1 -1
  5. package/src/index/buildIndex.js +244 -4
  6. package/src/index/impactContext.js +91 -11
  7. package/src/index/languageTools.js +126 -0
  8. package/src/index/paths.js +5 -1
  9. package/src/index/playbookRegistry.js +108 -9
  10. package/src/index/queryIndex.js +415 -1
  11. package/src/index/routeCatalog.js +1 -1
  12. package/src/index/taskRouting.js +25 -3
  13. package/template_project/.claude/commands/ukit/handoff-create.md +5 -0
  14. package/template_project/.claude/commands/ukit/handoff-fullstack.md +43 -4
  15. package/template_project/.claude/hooks/block-dangerous.mjs +7 -1
  16. package/template_project/.claude/hooks/handoff-model-guard.sh +235 -21
  17. package/template_project/.claude/hooks/record-execution.mjs +23 -0
  18. package/template_project/.claude/hooks/skill-router.sh +105 -7
  19. package/template_project/.claude/settings.json +21 -1
  20. package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
  21. package/template_project/.claude/ukit/index/impact-context.mjs +70 -15
  22. package/template_project/.claude/ukit/index/land-worktree.mjs +2 -2
  23. package/template_project/.claude/ukit/index/lib/index-core.mjs +883 -14
  24. package/template_project/.claude/ukit/index/playbook-registry.mjs +108 -9
  25. package/template_project/.claude/ukit/index/query-index.mjs +100 -7
  26. package/template_project/.claude/ukit/index/route-catalog.mjs +1 -1
  27. package/template_project/.claude/ukit/index/route-task.mjs +208 -31
  28. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +453 -24
  29. package/template_project/.claude/ukit/runtime/handoff-intent.mjs +122 -6
  30. package/template_project/.claude/ukit/runtime/resumable-run.mjs +417 -0
  31. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +635 -28
  32. package/template_project/.omp/hooks/pre/ukit-bridge.js +32 -4
  33. package/template_user/playbooks/feature-implementation.md +9 -0
  34. package/template_user/playbooks/migration.md +4 -0
@@ -74,6 +74,7 @@
74
74
 
75
75
  import fs from 'node:fs/promises';
76
76
  import fsSync from 'node:fs';
77
+ import crypto from 'node:crypto';
77
78
  import path from 'node:path';
78
79
  import { spawn, spawnSync } from 'node:child_process';
79
80
 
@@ -207,6 +208,10 @@ export const HANDOFF_CURSOR_DEFAULTS = Object.freeze({
207
208
  // RUN.md — e.g. one opened just to plan something else — is never hijacked.
208
209
  // 'project': legacy behaviour, any session in the repo is bounced.
209
210
  scope: 'session',
211
+ // Same failing task across consecutive stops → release after this many
212
+ // completion-gate failures (overridable via handoff.fullstack.maxAttemptsPerFailure
213
+ // or orchestration.maxAttemptsPerFailure).
214
+ maxAttemptsPerFailure: 2,
210
215
  });
211
216
 
212
217
  // ─── Redaction ────────────────────────────────────────────────────────────
@@ -698,6 +703,11 @@ async function loadHandoffGateConfig(projectRoot) {
698
703
  await fs.readFile(path.join(projectRoot, '.ukit', 'storage', 'config.json'), 'utf8'),
699
704
  );
700
705
  const gate = parsed?.handoff?.fullstack;
706
+ const configuredAttempts = Number.isFinite(gate?.maxAttemptsPerFailure)
707
+ ? Math.max(1, gate.maxAttemptsPerFailure)
708
+ : Number.isFinite(parsed?.orchestration?.maxAttemptsPerFailure)
709
+ ? Math.max(1, parsed.orchestration.maxAttemptsPerFailure)
710
+ : HANDOFF_CURSOR_DEFAULTS.maxAttemptsPerFailure;
701
711
  return {
702
712
  enabled: typeof gate?.stopGateEnabled === 'boolean' ? gate.stopGateEnabled : HANDOFF_CURSOR_DEFAULTS.enabled,
703
713
  maxStalledBlocks: Number.isFinite(gate?.stopGateMaxStalledBlocks)
@@ -706,6 +716,10 @@ async function loadHandoffGateConfig(projectRoot) {
706
716
  scope: gate?.stopGateScope === 'project' || gate?.stopGateScope === 'session'
707
717
  ? gate.stopGateScope
708
718
  : HANDOFF_CURSOR_DEFAULTS.scope,
719
+ // Same failing task across consecutive stops → release after this many
720
+ // completion-gate failures (orchestration.maxAttemptsPerFailure when set,
721
+ // else the documented default of 2).
722
+ maxAttemptsPerFailure: configuredAttempts,
709
723
  };
710
724
  } catch {
711
725
  return { ...HANDOFF_CURSOR_DEFAULTS };
@@ -721,9 +735,38 @@ function parseRunCursor(text) {
721
735
  cursor: field('Cursor'),
722
736
  next: field('Next'),
723
737
  exitPredicate: field('ExitPredicate'),
738
+ goalScope: field('GoalScope').toLowerCase(),
724
739
  };
725
740
  }
726
741
 
742
+ // ─── Goal backlog beyond the active cycle ──────────────────────────────────
743
+ // A finalized cycle resets INDEX.md to a header-only table, so "empty INDEX" proves nothing
744
+ // about the GOAL. For a run-all goal (`GoalScope: run-all` in RUN.md) the authoritative
745
+ // remaining backlog is the set of queued blueprints that a cycle can still consume:
746
+ // docs/AI_HANDOFF/queued/<slug>/ — excluding `QueueIntent: deliberate-future` ones (separate
747
+ // backlog by contract) and blueprints already moved to archive/. Unreadable → null (unknown is
748
+ // never "nothing left").
749
+ export async function pendingQueuedBlueprints(runRoot) {
750
+ const dir = path.join(runRoot, 'docs', 'AI_HANDOFF', 'queued');
751
+ let entries;
752
+ try {
753
+ entries = await fs.readdir(dir, { withFileTypes: true });
754
+ } catch (error) {
755
+ return error?.code === 'ENOENT' ? [] : null;
756
+ }
757
+ const pending = [];
758
+ for (const entry of entries) {
759
+ if (!entry.isDirectory()) continue;
760
+ let header = '';
761
+ try {
762
+ header = await fs.readFile(path.join(dir, entry.name, 'HANDOFF.md'), 'utf8');
763
+ } catch { /* a blueprint without HANDOFF.md uses the legacy fold-in behaviour */ }
764
+ if (/^QueueIntent:\s*deliberate-future\b/im.test(header)) continue;
765
+ pending.push(entry.name);
766
+ }
767
+ return pending.sort();
768
+ }
769
+
727
770
  // ─── Exit predicate (mechanic #7 — exit-predicate-first loop) ─────────────
728
771
  //
729
772
  // The planner writes one `ExitPredicate:` line into RUN.md at P2 and the gate
@@ -747,7 +790,7 @@ export function parseExitPredicate(expr) {
747
790
  .map((t) => t.trim())
748
791
  .filter(Boolean);
749
792
  if (terms.length === 0) return null;
750
- const TERM_RE = /^(index:all-done|git:clean|file-exists:[^\s,]+)$/;
793
+ const TERM_RE = /^(index:all-done|git:clean|file-exists:[^\s,]+|agents:none-running|verify:pass)$/;
751
794
  return terms.every((t) => TERM_RE.test(t)) ? terms : null;
752
795
  }
753
796
 
@@ -756,7 +799,7 @@ export function parseExitPredicate(expr) {
756
799
  * terminal-success status (`done` or `cancelled_superseded`). An unreadable or
757
800
  * taskless INDEX cannot prove all-done, so the term fails.
758
801
  */
759
- async function evalIndexAllDone(projectRoot) {
802
+ async function evalIndexAllDone(projectRoot, { goalScope = '' } = {}) {
760
803
  let text;
761
804
  try {
762
805
  text = await fs.readFile(path.join(projectRoot, 'docs', 'AI_HANDOFF', 'INDEX.md'), 'utf8');
@@ -767,7 +810,7 @@ async function evalIndexAllDone(projectRoot) {
767
810
  .split('\n')
768
811
  .map((line) => line.trim())
769
812
  .filter((line) => /^\|\s*TASK-/i.test(line));
770
- if (rows.length === 0) return { pass: false, detail: 'INDEX.md has no task rows' };
813
+ if (rows.length === 0) return evalFinalizedCycles(projectRoot, goalScope);
771
814
  const failing = [];
772
815
  for (const row of rows) {
773
816
  const cells = row.split('|').map((c) => c.trim()).filter(Boolean);
@@ -781,6 +824,72 @@ async function evalIndexAllDone(projectRoot) {
781
824
  : { pass: false, detail: `not done: ${failing.join(', ')}` };
782
825
  }
783
826
 
827
+ /** Task id → lower-cased status from the rows of an INDEX.md (or archive INDEX snapshot). */
828
+ function parseIndexStatuses(text) {
829
+ const map = new Map();
830
+ for (const line of String(text).split('\n')) {
831
+ const row = line.trim();
832
+ if (!/^\|\s*TASK-/i.test(row)) continue;
833
+ const cells = row.split('|').slice(1, -1).map((c) => c.trim()); // keep empty cells: columns must not shift
834
+ map.set(String(cells[0]).toUpperCase(), String(cells[4] || '').toLowerCase());
835
+ }
836
+ return map;
837
+ }
838
+
839
+ /**
840
+ * A finalized cycle (F3) resets INDEX.md to a header-only table and moves its task files to
841
+ * archive/cycle-NN/. Zero index rows therefore proves nothing by itself, but it is not "no work
842
+ * happened" either: the archived task files ARE the auditable record. All-done holds when at
843
+ * least one archived task exists, every archived task is terminal-success, and — for a run-all
844
+ * goal — no consumable queued blueprint remains. No archive evidence → still unprovable (fail).
845
+ */
846
+ async function evalFinalizedCycles(projectRoot, goalScope) {
847
+ const base = path.join(projectRoot, 'docs', 'AI_HANDOFF', 'archive');
848
+ let cycles;
849
+ try {
850
+ cycles = (await fs.readdir(base, { withFileTypes: true })).filter((e) => e.isDirectory() && /^cycle-\d+$/.test(e.name));
851
+ } catch {
852
+ return { pass: false, detail: 'INDEX.md has no task rows' };
853
+ }
854
+ let total = 0;
855
+ const failing = [];
856
+ for (const cycle of cycles) {
857
+ let files;
858
+ try { files = await fs.readdir(path.join(base, cycle.name)); } catch { continue; }
859
+ // Authoritative status = the INDEX snapshot taken at archive time (resumable-run.mjs
860
+ // archive-cycle). A task file's own `- Status:` header is executor-written and goes stale
861
+ // (`ready`) once INDEX.md owns the lifecycle; it is only the fallback for legacy archives
862
+ // that carry no snapshot. A snapshot row that is NOT terminal always wins (fail closed).
863
+ let snapshot = null;
864
+ if (files.includes('INDEX.md')) {
865
+ try { snapshot = parseIndexStatuses(await fs.readFile(path.join(base, cycle.name, 'INDEX.md'), 'utf8')); } catch { /* unreadable → header fallback */ }
866
+ }
867
+ for (const file of files.filter((f) => /^TASK-[\w.-]+\.md$/i.test(f))) {
868
+ total += 1;
869
+ const id = file.replace(/\.md$/i, '').toUpperCase();
870
+ // A snapshot that exists but has no row for this task is unproven → fail closed; only an
871
+ // archive with NO snapshot (legacy) falls back to the task header.
872
+ let status = snapshot ? (snapshot.get(id) || '') : '';
873
+ if (!snapshot) {
874
+ let body = '';
875
+ try { body = await fs.readFile(path.join(base, cycle.name, file), 'utf8'); } catch { /* unreadable → not proven */ }
876
+ status = (body.match(/^-\s*Status:\s*`?([\w-]+)`?/im)?.[1] || '').toLowerCase();
877
+ }
878
+ if (status !== 'done' && status !== 'cancelled_superseded') {
879
+ failing.push(`${file.replace(/\.md$/i, '')}=${status || '?'}${snapshot ? ' (not in the archive INDEX snapshot)' : ' (no INDEX snapshot — run `node .claude/ukit/runtime/resumable-run.mjs archive-cycle <root> <NN>`)'}`);
880
+ }
881
+ }
882
+ }
883
+ if (total === 0) return { pass: false, detail: 'INDEX.md has no task rows' };
884
+ if (failing.length > 0) return { pass: false, detail: `archived task not done: ${failing.join(', ')}` };
885
+ if (String(goalScope).toLowerCase() === 'run-all') {
886
+ const remaining = await pendingQueuedBlueprints(projectRoot);
887
+ if (!Array.isArray(remaining)) return { pass: false, detail: 'queued backlog unreadable' };
888
+ if (remaining.length > 0) return { pass: false, detail: `queued blueprint(s) remain: ${remaining.join(', ')}` };
889
+ }
890
+ return { pass: true, detail: `${total} archived task(s) done, index finalized` };
891
+ }
892
+
784
893
  /**
785
894
  * `git:clean` — `git status --porcelain` is empty. A non-git directory (or a
786
895
  * missing git binary) cannot prove clean, so the term fails without throwing.
@@ -824,13 +933,92 @@ async function evalFileExists(projectRoot, rel) {
824
933
  }
825
934
  }
826
935
 
936
+ // ─── Goal-level predicate terms (agents:none-running, verify:pass) ─────────
937
+ // Both read EXISTING state only — the task-claim book (resumable-run.mjs) and
938
+ // the exec-ledger receipts — and both treat unknown/unreadable state as UNMET
939
+ // with a clear detail, never as a crash and never as a pass.
940
+
941
+ let resumableRunModulePromise = null;
942
+ function loadResumableRunModule() {
943
+ if (!resumableRunModulePromise) {
944
+ resumableRunModulePromise = import(
945
+ new URL('./resumable-run.mjs', import.meta.url).href
946
+ ).catch(() => null);
947
+ }
948
+ return resumableRunModulePromise;
949
+ }
950
+
951
+ /**
952
+ * `agents:none-running` — the per-task claim book holds no ACTIVE claim
953
+ * (owner + heartbeat within its TTL). No registry at all proves none running;
954
+ * an unreadable registry proves nothing → unmet.
955
+ */
956
+ async function evalAgentsNoneRunning(projectRoot) {
957
+ const mod = await loadResumableRunModule();
958
+ if (!mod || typeof mod.countActiveTaskClaims !== 'function') {
959
+ return { pass: false, detail: 'claims registry unavailable (runtime module missing)' };
960
+ }
961
+ try {
962
+ const count = await mod.countActiveTaskClaims(projectRoot);
963
+ if (count === null) return { pass: false, detail: 'claims registry unreadable' };
964
+ return count === 0
965
+ ? { pass: true, detail: 'no active claims' }
966
+ : { pass: false, detail: `${count} active task claim(s)` };
967
+ } catch {
968
+ return { pass: false, detail: 'claims registry unreadable' };
969
+ }
970
+ }
971
+
972
+ /**
973
+ * `verify:pass` — the NEWEST verification receipt in the newest exec-ledger
974
+ * file passed. No ledger, no verification receipt, or a failed one → unmet.
975
+ * Details stay fixed strings (no paths, no ledger content) so the predicate
976
+ * state is safe to echo into block reasons.
977
+ */
978
+ async function evalVerifyPass(projectRoot) {
979
+ const dir = path.join(projectRoot, '.ukit', 'storage', 'cache', 'exec-ledger');
980
+ let entries;
981
+ try {
982
+ entries = (await fs.readdir(dir))
983
+ .filter((f) => f.endsWith('.json') && f !== 'gate-crash-counter.json');
984
+ } catch {
985
+ return { pass: false, detail: 'no exec ledger recorded' };
986
+ }
987
+ if (entries.length === 0) return { pass: false, detail: 'no exec ledger recorded' };
988
+ let newest = null;
989
+ let newestMs = -1;
990
+ for (const name of entries.slice(0, 64)) {
991
+ try {
992
+ const st = await fs.stat(path.join(dir, name));
993
+ if (st.mtimeMs > newestMs) { newestMs = st.mtimeMs; newest = name; }
994
+ } catch { /* vanished mid-scan — skip */ }
995
+ }
996
+ if (!newest) return { pass: false, detail: 'exec ledger unreadable' };
997
+ let ledger;
998
+ try {
999
+ ledger = JSON.parse(await fs.readFile(path.join(dir, newest), 'utf8'));
1000
+ } catch {
1001
+ return { pass: false, detail: 'exec ledger unreadable' };
1002
+ }
1003
+ const receipts = Array.isArray(ledger?.receipts) ? ledger.receipts : [];
1004
+ for (let i = receipts.length - 1; i >= 0; i -= 1) {
1005
+ const r = receipts[i];
1006
+ if (!r || typeof r !== 'object' || r.kind !== 'verification') continue;
1007
+ if (r.success === true && r.verdict?.kind !== 'failed') {
1008
+ return { pass: true, detail: 'last verification passed' };
1009
+ }
1010
+ return { pass: false, detail: 'last verification failed' };
1011
+ }
1012
+ return { pass: false, detail: 'no verification receipt recorded' };
1013
+ }
1014
+
827
1015
  /**
828
1016
  * Evaluate the RUN.md `ExitPredicate:` expression. Returns
829
1017
  * `{ ok, terms: [{term, pass, detail}] }`; `ok` is false when the expression is
830
1018
  * malformed (unsatisfiable). Advisory like the rest of the lane — a thrown
831
1019
  * error becomes a failed term, never an exception.
832
1020
  */
833
- async function evaluateExitPredicate(projectRoot, expr) {
1021
+ async function evaluateExitPredicate(projectRoot, expr, { goalScope = '' } = {}) {
834
1022
  const terms = parseExitPredicate(expr);
835
1023
  if (!terms) {
836
1024
  return { ok: false, terms: [{ term: String(expr || '').trim() || '(empty)', pass: false, detail: 'malformed predicate' }] };
@@ -839,8 +1027,10 @@ async function evaluateExitPredicate(projectRoot, expr) {
839
1027
  for (const term of terms) {
840
1028
  let r;
841
1029
  try {
842
- if (term === 'index:all-done') r = await evalIndexAllDone(projectRoot);
1030
+ if (term === 'index:all-done') r = await evalIndexAllDone(projectRoot, { goalScope });
843
1031
  else if (term === 'git:clean') r = evalGitClean(projectRoot);
1032
+ else if (term === 'agents:none-running') r = await evalAgentsNoneRunning(projectRoot);
1033
+ else if (term === 'verify:pass') r = await evalVerifyPass(projectRoot);
844
1034
  else r = await evalFileExists(projectRoot, term.slice('file-exists:'.length));
845
1035
  } catch (err) {
846
1036
  r = { pass: false, detail: `evaluator error: ${err?.message || err}` };
@@ -855,6 +1045,172 @@ function formatPredicateState(result) {
855
1045
  return result.terms.map((t) => `${t.term}=${t.pass ? 'pass' : `FAIL (${t.detail})`}`).join(', ');
856
1046
  }
857
1047
 
1048
+ // ─── Goal status derivation (pure, deterministic) ──────────────────────────
1049
+ // The GOAL is the whole run-all objective; a CYCLE is one pass of the pipeline
1050
+ // (cursor P0..F). Four mechanical states, derived — never inferred from model
1051
+ // text:
1052
+ // RUNNING — work remains (or the run is mid-cycle / evidence unproven)
1053
+ // PAUSED — the user or a quota pause held the run (never force-continued)
1054
+ // BLOCKED — remaining work exists but none of it is executable
1055
+ // COMPLETED — cursor done AND every in-scope task terminal AND no active
1056
+ // agents AND dependency-consistent AND verification evidence
1057
+ // passes AND the final snapshot is consistent
1058
+ // COMPLETED is only ever PROVEN from the caller-supplied inputs; absent
1059
+ // evidence is unmet, so a bare `Phase: done` can never derive COMPLETED.
1060
+
1061
+ export const GOAL_STATUSES = Object.freeze(['RUNNING', 'PAUSED', 'BLOCKED', 'COMPLETED']);
1062
+
1063
+ // Terminal-SUCCESS task statuses (INDEX `Status:` vocabulary). `blocked` is
1064
+ // pending-but-not-executable; everything else executable is pending work.
1065
+ const GOAL_DONE_TASK_STATUSES = new Set(['done', 'cancelled', 'cancelled_superseded']);
1066
+
1067
+ function goalTaskId(id) {
1068
+ return String(id ?? '').trim().toLowerCase();
1069
+ }
1070
+
1071
+ function goalVerificationVerdict(ledger) {
1072
+ // Evidence travels in the caller's own verdict/ledger fields (the completion
1073
+ // gate's ledger or a receipt verdict) — this function never re-derives it.
1074
+ if (!ledger || typeof ledger !== 'object') return { pass: false, detail: 'verification evidence absent' };
1075
+ const verdict = typeof ledger.verdict === 'string' ? ledger.verdict.trim().toLowerCase() : null;
1076
+ if (verdict === 'pass' || verdict === 'passed' || verdict === 'verified') return { pass: true, detail: 'verification verdict: pass' };
1077
+ if (verdict === 'fail' || verdict === 'failed') return { pass: false, detail: 'verification verdict: fail' };
1078
+ if (ledger.verified === true) return { pass: true, detail: 'verification: verified' };
1079
+ if (ledger.verified === false) return { pass: false, detail: 'verification: not verified' };
1080
+ if (typeof ledger.verify === 'string' && ledger.verify.trim().toLowerCase() === 'pass') return { pass: true, detail: 'verify: pass' };
1081
+ return { pass: false, detail: 'verification evidence unmet' };
1082
+ }
1083
+
1084
+ /**
1085
+ * Derive the goal status from structured inputs. Pure: no fs, no clock, no
1086
+ * transcript — callers hand in the cursor fields, the CURRENT task snapshot
1087
+ * (INDEX rows incl. review-created repair tasks and newly added tasks), the
1088
+ * ledger verdict fields, and the agent/cycle state.
1089
+ *
1090
+ * @param {object} [input]
1091
+ * @param {{ phase?: string, paused?: boolean } | null} [input.runCursor]
1092
+ * @param {Array<{id: string, status: string, dependsOn?: string[]}> | null} [input.tasks]
1093
+ * @param {{ verdict?: string, verified?: boolean, verify?: string } | null} [input.ledger]
1094
+ * @param {boolean|number} [input.agentsRunning]
1095
+ * @param {boolean} [input.snapshotConsistent]
1096
+ * @returns {{ status: 'RUNNING'|'PAUSED'|'BLOCKED'|'COMPLETED', nextCycle: boolean, reasons: string[] }}
1097
+ */
1098
+ export function deriveGoalStatus({ runCursor = null, tasks = null, ledger = null, agentsRunning = false, snapshotConsistent = true } = {}) {
1099
+ const reasons = [];
1100
+ const phase = String(runCursor?.phase ?? '').trim();
1101
+ if (runCursor?.paused === true || /^paused\b/i.test(phase)) {
1102
+ return { status: 'PAUSED', nextCycle: false, reasons: ['run cursor is paused'] };
1103
+ }
1104
+
1105
+ const list = Array.isArray(tasks)
1106
+ ? tasks
1107
+ .filter((t) => t && typeof t === 'object')
1108
+ .map((t) => ({
1109
+ id: String(t.id ?? '').trim(),
1110
+ key: goalTaskId(t.id),
1111
+ status: String(t.status ?? '').trim().toLowerCase(),
1112
+ dependsOn: Array.isArray(t.dependsOn) ? t.dependsOn.map(goalTaskId) : [],
1113
+ }))
1114
+ : [];
1115
+ const doneIds = new Set(list.filter((t) => GOAL_DONE_TASK_STATUSES.has(t.status)).map((t) => t.key));
1116
+ const pending = list.filter((t) => !GOAL_DONE_TASK_STATUSES.has(t.status));
1117
+ // Dependency consistency: no task may be `done` while a dependency is not.
1118
+ const depInconsistent = list.filter((t) => t.status === 'done' && t.dependsOn.some((d) => !doneIds.has(d)));
1119
+ if (depInconsistent.length > 0) {
1120
+ reasons.push(`dependency-inconsistent done task(s): ${depInconsistent.map((t) => t.id || '?').join(', ')}`);
1121
+ }
1122
+ const executable = pending.filter((t) => t.status !== 'blocked' && t.dependsOn.every((d) => doneIds.has(d)));
1123
+ const blockedTasks = pending.filter((t) => t.status === 'blocked');
1124
+ const agentsActive = agentsRunning === true || (Number.isFinite(agentsRunning) && agentsRunning > 0);
1125
+
1126
+ // Blocked work with nothing executable anywhere is BLOCKED — whatever the
1127
+ // cursor says; the goal cannot advance.
1128
+ if (pending.length > 0 && executable.length === 0 && blockedTasks.length > 0) {
1129
+ return {
1130
+ status: 'BLOCKED',
1131
+ nextCycle: false,
1132
+ reasons: [...reasons, `no executable work; blocked task(s): ${blockedTasks.slice(0, 5).map((t) => t.id || '?').join(', ')}`],
1133
+ };
1134
+ }
1135
+
1136
+ if (/^done\b/i.test(phase)) {
1137
+ // Cycle end: executable backlog → the goal simply continues in a new cycle.
1138
+ if (executable.length > 0) {
1139
+ return {
1140
+ status: 'RUNNING',
1141
+ nextCycle: true,
1142
+ reasons: [...reasons, `${executable.length} executable task(s) remain (e.g. ${executable.slice(0, 3).map((t) => t.id || '?').join(', ')})`],
1143
+ };
1144
+ }
1145
+ if (pending.length > 0) {
1146
+ // Tasks remain but none is executable and none is blocked (waiting on a
1147
+ // dependency that is itself still pending, or a dep cycle) — not BLOCKED
1148
+ // (no dead end), not COMPLETED (work remains).
1149
+ return { status: 'RUNNING', nextCycle: false, reasons: [...reasons, 'tasks remain but none is currently executable'] };
1150
+ }
1151
+ // Everything terminal: completion must be PROVEN, each gate in turn.
1152
+ if (depInconsistent.length > 0) return { status: 'RUNNING', nextCycle: false, reasons };
1153
+ const verdict = goalVerificationVerdict(ledger);
1154
+ if (!verdict.pass) return { status: 'RUNNING', nextCycle: false, reasons: [...reasons, verdict.detail] };
1155
+ if (agentsActive) return { status: 'RUNNING', nextCycle: false, reasons: [...reasons, 'agents/cycle still running'] };
1156
+ if (snapshotConsistent === false) return { status: 'RUNNING', nextCycle: false, reasons: [...reasons, 'final snapshot inconsistent'] };
1157
+ return { status: 'COMPLETED', nextCycle: false, reasons: ['all in-scope tasks terminal, verification passed, no agents running, snapshot consistent'] };
1158
+ }
1159
+
1160
+ // Nonterminal cycle: the goal tracks the live run.
1161
+ reasons.push(`run phase "${phase || '(none)'}" is not terminal`);
1162
+ return { status: 'RUNNING', nextCycle: false, reasons };
1163
+ }
1164
+
1165
+ // ─── INDEX snapshot (task ids + statuses) ──────────────────────────────────
1166
+ // The CURRENT INDEX rows feed both the goal derivation and the no-progress
1167
+ // breaker: the ids+statuses hash changes whenever a task moves or a new one is
1168
+ // reconciled in, so the stall signature tracks goal progress, not just cursor
1169
+ // wording.
1170
+
1171
+ function indexTaskRows(text) {
1172
+ return String(text)
1173
+ .split('\n')
1174
+ .map((line) => line.trim())
1175
+ .filter((line) => /^\|\s*TASK-/i.test(line));
1176
+ }
1177
+
1178
+ function indexTaskCells(row) {
1179
+ return row.split('|').map((c) => c.trim()).filter(Boolean);
1180
+ }
1181
+
1182
+ function indexTasks(indexText) {
1183
+ return indexTaskRows(indexText).map((row) => {
1184
+ const cells = indexTaskCells(row);
1185
+ return { id: cells[0] || '', status: (cells[4] || '').toLowerCase() };
1186
+ });
1187
+ }
1188
+
1189
+ function indexTaskSnapshotHash(indexText) {
1190
+ const rows = indexTaskRows(indexText).map((row) => {
1191
+ const cells = indexTaskCells(row);
1192
+ return `${(cells[0] || '').toLowerCase()}=${(cells[4] || '').toLowerCase()}`;
1193
+ });
1194
+ return crypto.createHash('sha256').update(rows.join('|')).digest('hex').slice(0, 16);
1195
+ }
1196
+
1197
+ function firstOpenIndexTaskId(indexText) {
1198
+ for (const row of indexTaskRows(indexText)) {
1199
+ const cells = indexTaskCells(row);
1200
+ const status = (cells[4] || '').toLowerCase();
1201
+ if (status !== 'done' && status !== 'cancelled_superseded') return cells[0] || null;
1202
+ }
1203
+ return null;
1204
+ }
1205
+
1206
+ async function readIndexSnapshot(runRoot) {
1207
+ try {
1208
+ return await fs.readFile(path.join(runRoot, 'docs', 'AI_HANDOFF', 'INDEX.md'), 'utf8');
1209
+ } catch {
1210
+ return '';
1211
+ }
1212
+ }
1213
+
858
1214
  // ─── Session intent (does THIS session CURRENTLY drive the run?) ──────────
859
1215
  // Structured-evidence rules live in the shared handoff-intent module so the
860
1216
  // SessionStart resume hook and this Stop lane classify a transcript identically.
@@ -894,7 +1250,13 @@ export async function transcriptShowsHandoffRun(transcriptPath, opts = {}) {
894
1250
  /**
895
1251
  * Mutate `.ukit/storage/cache/stop-coordinator/state.json`'s `handoff` slot:
896
1252
  * { signature, count }. Same signature as last time → count+1; a moved cursor
897
- * resets the streak. Returns the committed streak count.
1253
+ * or task snapshot resets the streak (the signature carries the INDEX
1254
+ * ids+statuses hash, so reconciling a new task or moving a task status counts
1255
+ * as progress). Also maintains the sibling `handoffTask` slot:
1256
+ * { snapshotHash, firstOpenTaskId, failingCount } — `failingCount` accrues only
1257
+ * while the completion gate keeps blocking on the SAME first-open task and
1258
+ * snapshot, and resets to 0 on any progress or a non-failing stop. Returns
1259
+ * `{ count, failingCount }` (both null only when the state file is unwritable).
898
1260
  *
899
1261
  * RC-4: a lock-busy acquisition used to return null, and the release branch
900
1262
  * required `streak !== null` — so a leaked state lock froze the
@@ -904,7 +1266,7 @@ export async function transcriptShowsHandoffRun(transcriptPath, opts = {}) {
904
1266
  * under a racing writer only delays the cap by one stop; a frozen breaker loops
905
1267
  * forever. Only a genuinely unwritable state file still returns null.
906
1268
  */
907
- async function bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs }) {
1269
+ async function bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs, snapshotHash = '', firstOpenTaskId = null, failing = false }) {
908
1270
  const statePath = path.join(projectRoot, '.ukit', 'storage', 'cache', 'stop-coordinator', 'state.json');
909
1271
  const bump = async () => {
910
1272
  let current = {};
@@ -913,13 +1275,19 @@ async function bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs }) {
913
1275
  } catch {}
914
1276
  const prev = current.handoff || {};
915
1277
  const count = prev.signature === signature ? (Number(prev.count) || 0) + 1 : 1;
1278
+ const prevTask = current.handoffTask || {};
1279
+ const sameFailingTask = prevTask.snapshotHash === snapshotHash
1280
+ && (prevTask.firstOpenTaskId || null) === (firstOpenTaskId || null);
1281
+ const failingCount = failing
1282
+ ? (sameFailingTask ? (Number(prevTask.failingCount) || 0) + 1 : 1)
1283
+ : 0;
916
1284
  await fs.mkdir(path.dirname(statePath), { recursive: true });
917
1285
  await fs.writeFile(
918
1286
  statePath,
919
- `${JSON.stringify({ ...current, handoff: { signature, count } }, null, 1)}\n`,
1287
+ `${JSON.stringify({ ...current, handoff: { signature, count }, handoffTask: { snapshotHash, firstOpenTaskId, failingCount } }, null, 1)}\n`,
920
1288
  'utf8',
921
1289
  );
922
- return count;
1290
+ return { count, failingCount };
923
1291
  };
924
1292
  try {
925
1293
  const outcome = await withAsyncLock(statePath, { deadlineMs: lockBudgetMs }, bump);
@@ -932,7 +1300,7 @@ async function bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs }) {
932
1300
  // keeps advancing behind a wedged or leaked lock.
933
1301
  return await bump();
934
1302
  } catch {
935
- return null;
1303
+ return { count: null, failingCount: null };
936
1304
  }
937
1305
  }
938
1306
 
@@ -944,19 +1312,28 @@ async function bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs }) {
944
1312
  * premature "done" reply gets bounced straight back into the pipeline. Releases
945
1313
  * when the cursor says `done` (or `blocked` — a genuinely stuck run is a
946
1314
  * legitimate final report), when RUN.md is absent/unreadable, or when the same
947
- * un-advancing cursor has already bounced `maxStalledBlocks` stops in a row
948
- * (liveness breaker — the gate must never be an unstoppable loop).
1315
+ * un-advancing cursor AND task snapshot have already bounced `maxStalledBlocks`
1316
+ * stops in a row (liveness breaker — the gate must never be an unstoppable
1317
+ * loop).
949
1318
  *
950
1319
  * Exit predicate (mechanic #7): when RUN.md carries an `ExitPredicate:` line,
951
1320
  * `Phase: done` is only accepted if the predicate evaluates true — a `done`
952
- * claim with failing terms is bounced back with the per-term state. A missing
953
- * predicate keeps the legacy phase-only release; `Phase: blocked` stays
1321
+ * claim with failing terms is bounced back with the per-term state. A `done`
1322
+ * claim WITHOUT a predicate evaluates the default `all(index:all-done)` (the
1323
+ * goal-completion floor: a run-all goal cannot claim done over an unfinished
1324
+ * backlog); a project with NO INDEX.md at all keeps the legacy phase-only
1325
+ * release because there is nothing in scope to prove. `Phase: blocked` stays
954
1326
  * terminal either way. The predicate is never relaxed: the stalled-cursor
955
1327
  * advisory reports its state so a plateau is visible, not silent.
956
1328
  *
1329
+ * Same failing task (task #5): when the completion gate keeps blocking on the
1330
+ * same first-open task across consecutive stops, the lane releases after
1331
+ * `maxAttemptsPerFailure` failures — retrying the same edit is a loop, not
1332
+ * progress.
1333
+ *
957
1334
  * Advisory lane: EVERY failure path returns { kind: 'none' }, never throws.
958
1335
  */
959
- export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot, now = Date.now(), lockBudgetMs = LOCK_BUDGET_MS, transcriptPath = null, stopHookActive = false } = {}) {
1336
+ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot, now = Date.now(), lockBudgetMs = LOCK_BUDGET_MS, transcriptPath = null, stopHookActive = false, completionKind = null } = {}) {
960
1337
  // `runRoot` is the checkout whose RUN.md cursor this session may be held to: the
961
1338
  // git-verified linked worktree when the session runs in one, else `projectRoot`.
962
1339
  // Policy (gate config) stays with `projectRoot`; the cursor, its exit predicate
@@ -970,14 +1347,38 @@ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot
970
1347
  } catch {
971
1348
  return { kind: 'none' };
972
1349
  }
973
- const { command, goal, phase, cursor, next, exitPredicate } = parseRunCursor(text);
1350
+ const { command, goal, phase, cursor, next, exitPredicate, goalScope } = parseRunCursor(text);
974
1351
  // Shared terminal predicate — tests/handoff/cycle21 greps this exact shape across
975
1352
  // all four RUN.md consumers; keep `^(done|blocked)` as one expression.
976
1353
  const terminal = String(phase).match(/^(done|blocked)\b/i)?.[1]?.toLowerCase() || '';
977
1354
  if (!phase || terminal === 'blocked') {
978
1355
  return { kind: 'none' };
979
1356
  }
980
- if (terminal === 'done' && !exitPredicate) return { kind: 'none' }; // legacy phase-only release
1357
+ // Default exit predicate (goal completion): a `done` cursor with no explicit
1358
+ // ExitPredicate is no longer released on phase alone when the project HAS a
1359
+ // task index — the default `all(index:all-done)` applies so a run-all goal
1360
+ // cannot claim done over an unfinished backlog. No INDEX.md at all → the
1361
+ // legacy phase-only release (nothing in scope to prove).
1362
+ let effectivePredicate = exitPredicate;
1363
+ let predicateIsDefault = false;
1364
+ let openNextCycle = null;
1365
+ if (terminal === 'done' && !effectivePredicate) {
1366
+ const doneIndex = await readIndexSnapshot(runRoot);
1367
+ if (!doneIndex.trim()) return { kind: 'none' }; // legacy release: no index, nothing in scope
1368
+ // A finalized cycle resets INDEX.md to a header-only table before `Phase: done`:
1369
+ // zero task rows = nothing in scope, so release rather than fail all-done.
1370
+ if (!/^\s*\|\s*TASK-/im.test(doneIndex)) {
1371
+ // Header-only INDEX = this CYCLE is finalized, not necessarily the GOAL: a run-all goal
1372
+ // continues while consumable queued blueprints remain (or the backlog is unreadable).
1373
+ if (goalScope !== 'run-all') return { kind: 'none' };
1374
+ const remaining = await pendingQueuedBlueprints(runRoot);
1375
+ if (Array.isArray(remaining) && remaining.length === 0) return { kind: 'none' };
1376
+ openNextCycle = { remaining };
1377
+ } else {
1378
+ effectivePredicate = 'all(index:all-done)';
1379
+ predicateIsDefault = true;
1380
+ }
1381
+ }
981
1382
 
982
1383
  // Session scope: a run owned by another session (or abandoned) must not
983
1384
  // hijack an unrelated session, and a session whose CURRENT intent is planning
@@ -989,22 +1390,56 @@ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot
989
1390
  return { kind: 'none' };
990
1391
  }
991
1392
 
1393
+ if (terminal === 'done' && openNextCycle) {
1394
+ const names = openNextCycle.remaining;
1395
+ return {
1396
+ kind: 'block',
1397
+ reason: [
1398
+ 'UKit run-all goal: this cycle is finalized but the GOAL is not complete — ',
1399
+ names === null
1400
+ ? 'the queued backlog (docs/AI_HANDOFF/queued/) could not be read, so remaining work is unknown, not zero.'
1401
+ : `${names.length} queued blueprint(s) remain: ${names.slice(0, 5).join(', ')}.`,
1402
+ '',
1403
+ 'CONTINUE IMMEDIATELY: open the next cycle — fold the next queued blueprint into',
1404
+ 'INDEX.md / tasks/ (P2), set `Phase:` to a live phase, and execute it. Archive each',
1405
+ 'consumed blueprint to archive/blueprints/. Set `Phase: blocked` only for a real external blocker.',
1406
+ ].join('\n').replace('— \n', '— '),
1407
+ };
1408
+ }
1409
+
992
1410
  if (terminal === 'done') {
993
1411
  let predicate;
994
1412
  try {
995
- predicate = await evaluateExitPredicate(runRoot, exitPredicate);
1413
+ predicate = await evaluateExitPredicate(runRoot, effectivePredicate, { goalScope });
996
1414
  } catch {
997
1415
  return { kind: 'none' }; // advisory lane: evaluator failure never wedges a stop
998
1416
  }
999
1417
  if (predicate.ok) return { kind: 'none' };
1418
+ // Goal derivation over the CURRENT index snapshot — informs the bounce with
1419
+ // the mechanical goal state (release stays predicate-owned: verification
1420
+ // evidence belongs to the completion-gate lane, not the cursor lane).
1421
+ let goalLine = '';
1422
+ try {
1423
+ const doneIndex = await readIndexSnapshot(runRoot);
1424
+ const claimState = await evalAgentsNoneRunning(runRoot);
1425
+ const goal = deriveGoalStatus({
1426
+ runCursor: { phase },
1427
+ tasks: indexTasks(doneIndex),
1428
+ ledger: null,
1429
+ // Unreadable registry proves nothing → treated as running (fail closed).
1430
+ agentsRunning: !claimState.pass,
1431
+ });
1432
+ goalLine = ` Goal status: ${goal.status}${goal.nextCycle ? ' (next cycle — executable work remains)' : ''}`;
1433
+ } catch { /* enrichment only — never changes the decision */ }
1000
1434
  const lines = [
1001
1435
  'UKit handoff run claims `Phase: done` but its ExitPredicate is not satisfied — this',
1002
1436
  'stop is refused. "Done" means the predicate the planner wrote at P2 evaluates true;',
1003
1437
  'a plateau is not a stop and the predicate is never relaxed.',
1004
1438
  '',
1005
1439
  ` Goal: ${goal || '(not recorded)'}`,
1006
- ` ExitPredicate: ${exitPredicate}`,
1440
+ ` ExitPredicate: ${effectivePredicate}${predicateIsDefault ? ' (default — no ExitPredicate line recorded)' : ''}`,
1007
1441
  ` Predicate state: ${formatPredicateState(predicate)}`,
1442
+ ...(goalLine ? [goalLine] : []),
1008
1443
  '',
1009
1444
  'CONTINUE IMMEDIATELY: read docs/AI_HANDOFF/RUN.md and docs/AI_HANDOFF/INDEX.md, finish',
1010
1445
  'the work the failing terms name (or record the real external blocker and set',
@@ -1037,14 +1472,28 @@ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot
1037
1472
  };
1038
1473
  }
1039
1474
 
1040
- const signature = `${phase}|${cursor}|${next}`;
1041
- const streak = await bumpHandoffStallCount({ projectRoot: runRoot, signature, lockBudgetMs });
1475
+ // No-progress breaker inputs: the stall signature now carries the INDEX
1476
+ // ids+statuses hash, so an unchanged task snapshot accrues toward the cap even
1477
+ // when the cursor wording keeps moving — and reconciling a new task or moving
1478
+ // a task status resets it (real goal progress).
1479
+ const indexText = await readIndexSnapshot(runRoot);
1480
+ const snapshotHash = indexTaskSnapshotHash(indexText);
1481
+ const firstOpenTask = firstOpenIndexTaskId(indexText);
1482
+ const signature = `${phase}|${cursor}|${next}|${snapshotHash}`;
1483
+ const streak = await bumpHandoffStallCount({
1484
+ projectRoot: runRoot,
1485
+ signature,
1486
+ lockBudgetMs,
1487
+ snapshotHash,
1488
+ firstOpenTaskId: firstOpenTask,
1489
+ failing: completionKind === 'block',
1490
+ });
1042
1491
  // Reentrancy valve (stop_hook_active): Claude Code sets it when this Stop fires
1043
1492
  // inside the continuation turn WE forced. The same cursor signature seen again
1044
1493
  // (streak > 1) means that continuation made no cursor progress — blocking again
1045
1494
  // is a no-progress loop, so release once instead of waiting for the stalled cap.
1046
1495
  // A moved cursor resets the streak to 1 and keeps blocking (real progress).
1047
- if (stopHookActive === true && streak !== null && streak > 1) {
1496
+ if (stopHookActive === true && streak !== null && streak.count > 1) {
1048
1497
  return {
1049
1498
  kind: 'advisory',
1050
1499
  systemMessage:
@@ -1053,11 +1502,11 @@ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot
1053
1502
  `RUN.md is untouched; resume with /ukit:handoff-fullstack or clear with /ukit:handoff-clear.`,
1054
1503
  };
1055
1504
  }
1056
- if (streak !== null && streak > gate.maxStalledBlocks) {
1505
+ if (streak !== null && streak.count > gate.maxStalledBlocks) {
1057
1506
  let predicateState = 'no ExitPredicate recorded';
1058
- if (exitPredicate) {
1507
+ if (effectivePredicate) {
1059
1508
  try {
1060
- predicateState = formatPredicateState(await evaluateExitPredicate(runRoot, exitPredicate));
1509
+ predicateState = formatPredicateState(await evaluateExitPredicate(runRoot, effectivePredicate, { goalScope }));
1061
1510
  } catch {
1062
1511
  predicateState = 'predicate evaluation failed';
1063
1512
  }
@@ -1066,13 +1515,29 @@ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot
1066
1515
  kind: 'advisory',
1067
1516
  systemMessage:
1068
1517
  `[ukit-stop-coordinator] handoff-cursor released this stop: the run cursor ` +
1069
- `("${signature}") has not advanced across ${streak - 1} consecutive blocked stops ` +
1518
+ `("${signature}") has not advanced across ${streak.count - 1} consecutive blocked stops ` +
1070
1519
  `(cap ${gate.maxStalledBlocks}), so the gate treats the run as stalled rather than ` +
1071
1520
  `looping forever. ExitPredicate state: ${predicateState}. ` +
1072
1521
  `Inspect docs/AI_HANDOFF/RUN.md and docs/AI_HANDOFF/INDEX.md; ` +
1073
1522
  `the run can be resumed with /ukit:handoff-fullstack or cleared with /ukit:handoff-clear.`,
1074
1523
  };
1075
1524
  }
1525
+ // Same failing task (maxAttempts): a completion gate that keeps failing on the
1526
+ // same first-open task and snapshot is a retry loop, not progress — release
1527
+ // after `maxAttemptsPerFailure` consecutive failures so the session reports
1528
+ // honestly instead of being bounced into the same wall.
1529
+ if (streak !== null && streak.failingCount >= gate.maxAttemptsPerFailure && firstOpenTask) {
1530
+ return {
1531
+ kind: 'advisory',
1532
+ systemMessage:
1533
+ `[ukit-stop-coordinator] handoff-cursor released this stop: task ${firstOpenTask} has now ` +
1534
+ `failed the completion gate ${streak.failingCount} consecutive times ` +
1535
+ `(maxAttemptsPerFailure ${gate.maxAttemptsPerFailure}). The task snapshot ` +
1536
+ `("${snapshotHash}") has not changed — record the concrete blocker on the task ` +
1537
+ `(or fix the failure differently) and continue; the counter resets on any ` +
1538
+ `snapshot or first-open-task change.`,
1539
+ };
1540
+ }
1076
1541
 
1077
1542
  const lines = [
1078
1543
  'UKit handoff run in progress — this stop is refused. A handoff-fullstack cycle is still',
@@ -1188,6 +1653,111 @@ export async function detectUserInterruption(transcriptPath, {
1188
1653
  }
1189
1654
  }
1190
1655
 
1656
+ // ─── Provider quota / rate-limit pause (goal-loop liveness) ────────────────
1657
+ // A provider quota exhaustion is a genuine external pause: forcing continuation
1658
+ // would bounce the model against a wall, and reporting completion would be a
1659
+ // lie. The gate RELEASES such a stop with an advisory and marks the run cursor
1660
+ // PAUSED (the `Paused: yes` line — the exact marker runCursorPaused reads —
1661
+ // with `Phase:` untouched), so a later resume is possible. Never COMPLETED.
1662
+ //
1663
+ // 'stream_disconnected' is deliberately NOT a quota signature: a dropped stream
1664
+ // is a retry, not a pause.
1665
+ export const QUOTA_SCAN_MAX_BYTES = 64 * 1024;
1666
+ export const QUOTA_SCAN_BUDGET_MS = 500;
1667
+ export const QUOTA_LIMIT_RE = /\busage limit\b|\brate limit\b|\bquota\b|\b429\b|\bcredit balance\b/i;
1668
+
1669
+ /** First quota/rate-limit signature in the text (lowercased), or null. */
1670
+ export function detectQuotaSignal(text) {
1671
+ const m = QUOTA_LIMIT_RE.exec(String(text || ''));
1672
+ return m ? m[0].toLowerCase() : null;
1673
+ }
1674
+
1675
+ /**
1676
+ * Quota signature in STRUCTURED provider-error records only. Ordinary
1677
+ * assistant/user/tool text that merely mentions "rate limit" or "429" (code,
1678
+ * discussion) must never pause a run, so each JSONL line is parsed and only
1679
+ * error records (`isApiErrorMessage`, error/api_error type or subtype, or an
1680
+ * `error` field) are scanned. A partial first line of the tail fails to parse
1681
+ * and is skipped.
1682
+ */
1683
+ export function detectQuotaSignalInTranscript(text) {
1684
+ for (const line of String(text || '').split('\n')) {
1685
+ const t = line.trim();
1686
+ if (!t.startsWith('{')) continue;
1687
+ let rec;
1688
+ try { rec = JSON.parse(t); } catch { continue; }
1689
+ if (!rec || typeof rec !== 'object') continue;
1690
+ const kind = `${rec.type || ''} ${rec.subtype || ''}`;
1691
+ const structured = rec.isApiErrorMessage === true || /\berror\b|api_error/i.test(kind) || rec.error != null;
1692
+ if (!structured) continue;
1693
+ const hit = detectQuotaSignal(JSON.stringify(rec.error ?? rec.message ?? rec.content ?? ''));
1694
+ if (hit) return hit;
1695
+ }
1696
+ return null;
1697
+ }
1698
+
1699
+ /**
1700
+ * Bounded tail scan of the host transcript (plus any caller-supplied text) for
1701
+ * a quota/rate-limit signature. Unknown/unreadable/over-budget → null, so a
1702
+ * lost read never pauses a healthy run.
1703
+ */
1704
+ export async function detectQuotaPauseSignal({ transcriptPath = null, extraText = '' } = {}) {
1705
+ if (typeof extraText === 'string' && extraText) {
1706
+ const direct = detectQuotaSignal(extraText);
1707
+ if (direct) return direct;
1708
+ }
1709
+ if (typeof transcriptPath !== 'string' || !transcriptPath.trim()) return null;
1710
+ let handle;
1711
+ try {
1712
+ handle = await fs.open(transcriptPath, 'r');
1713
+ const size = Number((await handle.stat()).size) || 0;
1714
+ const start = Math.max(0, size - QUOTA_SCAN_MAX_BYTES);
1715
+ const deadline = Date.now() + QUOTA_SCAN_BUDGET_MS;
1716
+ const buf = Buffer.allocUnsafe(size - start);
1717
+ let got = 0;
1718
+ while (got < buf.length) {
1719
+ if (Date.now() > deadline) return null;
1720
+ const { bytesRead } = await handle.read(buf, got, buf.length - got, start + got);
1721
+ if (!bytesRead) break;
1722
+ got += bytesRead;
1723
+ }
1724
+ return detectQuotaSignalInTranscript(buf.toString('utf8', 0, got));
1725
+ } catch {
1726
+ return null;
1727
+ } finally {
1728
+ try { await handle?.close(); } catch {}
1729
+ }
1730
+ }
1731
+
1732
+ /**
1733
+ * Mark RUN.md paused WITHOUT touching its `Phase:` line: write/replace a
1734
+ * literal `Paused: yes` line — the exact marker the shared `runCursorPaused`
1735
+ * contract reads, so every consumer (resume hook, Stop gate, omp bridge) sees
1736
+ * the same pause. Idempotent; atomic tmp+rename; missing RUN.md → no-op.
1737
+ */
1738
+ export async function markRunCursorPaused(runRoot) {
1739
+ const runMdPath = path.join(runRoot, 'docs', 'AI_HANDOFF', 'RUN.md');
1740
+ let text;
1741
+ try {
1742
+ text = await fs.readFile(runMdPath, 'utf8');
1743
+ } catch {
1744
+ return { ok: false, reason: 'no-run-md' };
1745
+ }
1746
+ if (/^Paused:\s*yes\b/im.test(text)) return { ok: true, already: true };
1747
+ const next = /^Paused:/m.test(text)
1748
+ ? text.replace(/^Paused:.*$/m, 'Paused: yes')
1749
+ : `${text.replace(/\s*$/, '\n')}Paused: yes\n`;
1750
+ const tmp = `${runMdPath}.tmp-pause-${process.pid}-${Date.now()}`;
1751
+ try {
1752
+ await fs.writeFile(tmp, next, 'utf8');
1753
+ await fs.rename(tmp, runMdPath);
1754
+ return { ok: true, already: false };
1755
+ } catch {
1756
+ try { await fs.rm(tmp, { force: true }); } catch {}
1757
+ return { ok: false, reason: 'write-failed' };
1758
+ }
1759
+ }
1760
+
1191
1761
  // ─── Once-per-stop guard ──────────────────────────────────────────────────
1192
1762
  /**
1193
1763
  * Lock-scoped dedupe: persist `{ lastStop: { key, ts } }` and report whether this
@@ -1297,6 +1867,41 @@ export async function runStopCoordinator({
1297
1867
  failures.watchdog = error?.message || String(error);
1298
1868
  process.stderr.write(`[ukit-stop-coordinator] task-watchdog evaluator failed: ${failures.watchdog}\n`);
1299
1869
  }
1870
+
1871
+ // Provider quota / rate limit: a genuine external pause. Release the stop with
1872
+ // an advisory, mark the cursor PAUSED via the shared `Paused: yes` contract
1873
+ // (`Phase:` untouched), and never report completion for this stop.
1874
+ try {
1875
+ const quotaSignal = await detectQuotaPauseSignal({
1876
+ transcriptPath: typeof payload?.transcript_path === 'string' ? payload.transcript_path : null,
1877
+ // Only a structured hook-payload error counts; raw input text is free-form.
1878
+ extraText: payload?.error != null ? JSON.stringify(payload.error) : '',
1879
+ });
1880
+ if (quotaSignal) {
1881
+ const pause = await markRunCursorPaused(await resolveRunRoot(projectRoot, payload?.cwd));
1882
+ return {
1883
+ skip: null,
1884
+ merged: {
1885
+ decision: null,
1886
+ reason: undefined,
1887
+ quotaPause: true,
1888
+ // This early return bypasses the shared terminalOutcome assignment
1889
+ // below, so the paused outcome is stamped here: a quota pause waits,
1890
+ // it is never COMPLETE.
1891
+ terminalOutcome: 'WAIT_FOR_APPROVAL',
1892
+ systemMessage:
1893
+ `[ukit-stop-coordinator] stop released: a provider quota/rate-limit signal ("${quotaSignal}") ` +
1894
+ `was detected. ` + (pause.ok
1895
+ ? 'The run cursor is marked PAUSED; nothing was resumed or completed. '
1896
+ : 'No RUN.md cursor found to pause; nothing was resumed or completed. ')
1897
+ + 'Continue in a NEW message (or /ukit:handoff-fullstack) once the quota resets.',
1898
+ policyOwner: 'quota-pause',
1899
+ policies: ['quota-pause'],
1900
+ },
1901
+ };
1902
+ }
1903
+ } catch { /* advisory lane: detection failure never changes the stop */ }
1904
+
1300
1905
  let handoff = null;
1301
1906
  try {
1302
1907
  handoff = await evaluateHandoffCursor({
@@ -1306,6 +1911,7 @@ export async function runStopCoordinator({
1306
1911
  lockBudgetMs,
1307
1912
  transcriptPath: typeof payload?.transcript_path === 'string' ? payload.transcript_path : null,
1308
1913
  stopHookActive: payload?.stop_hook_active === true,
1914
+ completionKind: completion?.kind ?? null,
1309
1915
  });
1310
1916
  } catch (error) {
1311
1917
  failures.handoff = error?.message || String(error);
@@ -1321,11 +1927,12 @@ export async function runStopCoordinator({
1321
1927
  } catch { /* detector failure keeps the original decision */ }
1322
1928
 
1323
1929
  // Termination contract (label only; no new hook, no LLM): every coordinated stop ends in one
1324
- // outcome. A loop-breaker release is BLOCKED_AND_STOP, never approval; a PAUSED run waits.
1930
+ // outcome. A loop-breaker release is BLOCKED_AND_STOP, never approval; a PAUSED run
1931
+ // (user interruption or quota pause) waits — it is never COMPLETE.
1325
1932
  // Advisory label only (nothing consumes it); a gate block approves nothing.
1326
1933
  merged.terminalOutcome = merged.decision === 'block' ? 'CONTINUE_REQUIRED'
1327
1934
  : merged.loopBreak === true ? 'BLOCKED_AND_STOP'
1328
- : handoff?.waitForApproval === true ? 'WAIT_FOR_APPROVAL' : 'COMPLETE_AND_STOP';
1935
+ : merged.quotaPause === true || handoff?.waitForApproval === true ? 'WAIT_FOR_APPROVAL' : 'COMPLETE_AND_STOP';
1329
1936
 
1330
1937
  // C85 TASK-015 (BL-015): advisory review-policy verdict. The evaluator runs
1331
1938
  // only when a route record exists; its action + namedCheck are recorded on