@ngockhoale/ukit 3.4.67 → 3.4.68

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 (62) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/manifests/documentation.yaml +33 -0
  3. package/manifests/instructionRules.yaml +10 -0
  4. package/manifests/platform.full.yaml +67 -0
  5. package/package.json +1 -1
  6. package/src/core/applyPlan.js +111 -2
  7. package/src/core/buildPlan.js +3 -1
  8. package/src/core/diffPlan.js +49 -1
  9. package/src/core/executionContracts.js +49 -8
  10. package/src/core/metadata.js +45 -0
  11. package/src/core/runInstallPipeline.js +30 -2
  12. package/src/core/uninstall.js +106 -2
  13. package/src/manifest/validateManifest.js +25 -1
  14. package/template_project/.claude/agents/code-reviewer.md +22 -9
  15. package/template_project/.claude/agents/handoff-planner.md +97 -8
  16. package/template_project/.claude/commands/ukit/handoff-create.md +114 -19
  17. package/template_project/.claude/commands/ukit/handoff-fullstack.md +247 -79
  18. package/template_project/.claude/commands/ukit/handoff-implement.md +75 -40
  19. package/template_project/.claude/commands/ukit/handoff-review.md +14 -10
  20. package/template_project/.claude/hooks/auto-allow-bash.sh +73 -12
  21. package/template_project/.claude/hooks/auto-prune-bash.sh +10 -0
  22. package/template_project/.claude/hooks/delegation-gate.mjs +21 -0
  23. package/template_project/.claude/hooks/handoff-model-guard.sh +22 -6
  24. package/template_project/.claude/hooks/handoff-resume.sh +141 -1
  25. package/template_project/.claude/hooks/record-execution.mjs +140 -9
  26. package/template_project/.claude/hooks/skill-router.sh +173 -8
  27. package/template_project/.claude/settings.json +2 -2
  28. package/template_project/.claude/skills/advisor-plan/REFERENCE.md +10 -0
  29. package/template_project/.claude/skills/advisor-plan/SKILL.md +2 -2
  30. package/template_project/.claude/ukit/index/allocate-task-worktree.mjs +15 -0
  31. package/template_project/.claude/ukit/index/amend-guard.mjs +116 -0
  32. package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
  33. package/template_project/.claude/ukit/index/land-worktree.mjs +243 -21
  34. package/template_project/.claude/ukit/index/plan-count.mjs +115 -0
  35. package/template_project/.claude/ukit/index/ready-gate.mjs +170 -0
  36. package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +293 -4
  37. package/template_project/.claude/ukit/index/route-resolver.mjs +48 -8
  38. package/template_project/.claude/ukit/index/route-task.mjs +180 -11
  39. package/template_project/.claude/ukit/index/source-snapshot.mjs +172 -0
  40. package/template_project/.claude/ukit/index/task-budget-validator.mjs +109 -3
  41. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +20 -3
  42. package/template_project/.claude/ukit/runtime/handoff-task-scope.mjs +205 -0
  43. package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
  44. package/template_project/.claude/ukit/runtime/plan-dispatch.mjs +32 -5
  45. package/template_project/.claude/ukit/runtime/resumable-run.mjs +310 -21
  46. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +405 -105
  47. package/template_project/.claude/ukit/runtime/task-record-reconciliation.mjs +46 -0
  48. package/template_project/.claude/ukit/runtime/task-watchdog.mjs +28 -2
  49. package/template_project/.claude/ukit/runtime/worktree-allocation.mjs +75 -0
  50. package/template_project/.codex/README.md +25 -0
  51. package/template_project/.omp/RULES.md +22 -22
  52. package/template_project/.omp/agents/code-reviewer.md +22 -9
  53. package/template_project/.omp/agents/handoff-planner.md +102 -12
  54. package/template_project/.omp/hooks/pre/ukit-bridge.js +1 -1
  55. package/template_project/AGENTS.md +5 -1
  56. package/template_project/CLAUDE.md +1 -1
  57. package/template_project/docs/AI_HANDOFF/RULES.md +8 -0
  58. package/template_project/docs/AI_HANDOFF/tasks/_TEMPLATE.md +8 -1
  59. package/template_project/instructions/core.md +5 -1
  60. package/template_project/instructions/layout.yaml +2 -0
  61. package/template_project/instructions/overlays/omp-rules.md +22 -22
  62. package/template_project/ukit/storage/config.json +1 -1
@@ -1,75 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * stop-coordinator.mjs — the ONE Stop decision coordinator (TASK-025, PLAN §2 H17).
4
- *
5
- * H17: `.claude/settings.json` used to register BOTH `completion-gate.sh` and
6
- * `task-watchdog.sh` on the Stop event. Each hook evaluated its own policy and each
7
- * could emit `decision: block`, so a single Stop could produce two continuation
8
- * decisions and two state mutations (completion gate continuation counter + watchdog
9
- * hard-block counter). This module replaces that with one coordinator:
10
- *
11
- * - it evaluates the completion policy by spawning execution-ledger.mjs
12
- * `--evaluate-stop` exactly once (the ledger owns its own counting semantics);
13
- * - it evaluates the watchdog policy in-process through task-watchdog.mjs's
14
- * exported `evaluateStopWatchdog` exactly once;
15
- * - it evaluates the handoff-cursor policy in-process: while
16
- * `docs/AI_HANDOFF/RUN.md` carries `Phase:` other than `done`/`blocked`, a
17
- * handoff-fullstack run is still in flight and the stop is bounced back with
18
- * the cursor's `Next:` step — this is what keeps an overnight run moving
19
- * after a recap instead of stalling idle. When RUN.md carries an
20
- * `ExitPredicate:` line (mechanic #7), `Phase: done` is accepted only if the
21
- * predicate evaluates true — `all(index:all-done, git:clean,
22
- * file-exists:<relpath>)` terms checked mechanically, no eval, no shell-out.
23
- * A stalled-cursor breaker releases the stop if the cursor has not advanced
24
- * across `stopGateMaxStalledBlocks` consecutive blocked stops (same liveness
25
- * shape as the ledger's noProgressCount breaker), and its advisory reports
26
- * the predicate state so a plateau is visible;
27
- * - it merges the three evaluator results with a deterministic owner order —
28
- * fail-closed completion failure > completion block > handoff-cursor block >
29
- * watchdog block > merged advisory > silent release — and emits AT MOST ONE
30
- * Stop JSON line;
31
- * - a lock-scoped once-per-stop guard makes a legacy double-registered Stop
32
- * (old settings.json still listing both hooks) inert: the second coordinator
33
- * invocation for the same session inside the dedupe window emits nothing and
34
- * mutates nothing, so counters still move at most once.
35
- *
36
- * Failure posture (security test row):
37
- * - The completion gate is the fail-closed blocker. If it fails to produce a
38
- * verdict (missing script, spawn error, non-zero exit, unparseable output,
39
- * budget overrun) the coordinator BLOCKS fail-closed with a fixed, redacted
40
- * infrastructure reason — raw failure detail goes to stderr only, never into
41
- * the parsed decision (mirrors execution-ledger's own crash counter posture).
42
- * - The watchdog is an advisory lane. Its failure never decides anything; the
43
- * other evaluator's decision stands and the failure is reported verbatim in
44
- * the emitted `systemMessage` (stderr carries it too).
45
- *
46
- * Exports (independently testable — tests import the module directly):
47
- * DEDUPE_WINDOW_MS — same-session Stop invocations closer than this are
48
- * treated as one event (a recovery turn takes >>2s).
49
- * redactInfrastructureReason — the fixed redacted block reason for a failed
50
- * fail-closed blocker; never repeats its input.
51
- * redactDeadlineReason — the fixed redacted block reason for a deadline
52
- * overrun (budget, not crash — same redaction).
53
- * emitDeadlineBlock — the deadline path's single fail-closed emission.
54
- * normalizeEvaluatorStdout — ledger stdout → { kind, reason?, systemMessage? }.
55
- * coordinateStopDecisions — the pure ordered merge; owns ordering + provenance
56
- * only (evaluators stay independently testable).
57
- * evaluateHandoffCursor — in-process RUN.md lane; advisory on any failure.
58
- * evaluateCompletionPolicy — spawns the ledger once, returns the normalized
59
- * completion evaluator result (throws on failure).
60
- * runStopCoordinator — event guard + dedupe + both evaluators + merge.
61
- *
62
- * Like every UKit hook runtime this fails loud and stays inside the hook deadline:
63
- * a self-kill timer honours UKIT_HOOK_DEADLINE_MS (default 3000ms), the ledger child
64
- * gets a bounded budget, and every path exits 0.
65
- *
66
- * Deadline posture (fix round 1 — reviewer critical): the deadline is the LAST line
67
- * of defence for the fail-closed blocker, so it must not be a silent exit. The
68
- * wrapper (`completion-gate.sh`) reads "exit 0 with empty stdout" as a clean release,
69
- * which meant a coordinator that hit its own deadline while the completion evaluator
70
- * hung RELEASED Stop instead of blocking it. The deadline now emits the same fixed
71
- * redacted fail-closed block (synchronously, so process.exit cannot drop a buffered
72
- * pipe write) and reaps any in-flight evaluator child before exiting.
3
+ * One Stop coordinator (TASK-025, PLAN §2 H17): evaluate the ledger child,
4
+ * watchdog and RUN cursor once; emit at most one decision. A lock-scoped dedupe
5
+ * window prevents legacy double hook registration from double-counting.
6
+ * Priority: fail-closed completion failure > completion block > cursor block >
7
+ * watchdog block > merged advisory > silent release.
8
+ * RUN Next carries continuation; done requires its mechanical ExitPredicate.
9
+ * Stalled-cursor releases are advisory, never completion evidence.
10
+ * Completion infrastructure failures block with redacted reasons (details only
11
+ * on stderr); watchdog failures are advisory and reported verbatim.
12
+ * UKIT_HOOK_DEADLINE_MS bounds execution (default 3000ms), including the ledger
13
+ * child. Deadline expiry synchronously emits a fail-closed block and reaps the
14
+ * child before exit0: empty stdout would incorrectly release the wrapper.
15
+ * Exported evaluators, merge, normalization and redaction seams are tested directly.
73
16
  */
74
17
 
75
18
  import fs from 'node:fs/promises';
@@ -79,6 +22,10 @@ import path from 'node:path';
79
22
  import { spawn, spawnSync } from 'node:child_process';
80
23
 
81
24
  import { withAsyncLock } from './async-lock.mjs';
25
+ // Frozen RUN scope membership (TaskScope / ScopeQueue / ScopePlans, cycle-qualified):
26
+ // the SAME selector filters the completion and blocked predicates below. Sandbox
27
+ // runtimes that copy this file by an explicit sibling list must copy this module too.
28
+ import { selectTaskScope } from './handoff-task-scope.mjs';
82
29
  // TASK-005 (FR-005): the outcome seam loads LAZILY — a partial install missing
83
30
  // observability-emit.mjs must degrade to "no outcome record" (telemetry is
84
31
  // advisory), never crash the coordinator at module load.
@@ -884,8 +831,15 @@ export function parseExitPredicate(expr) {
884
831
  * `index:all-done` — every TASK-xxx row in docs/AI_HANDOFF/INDEX.md is in a
885
832
  * terminal-success status (`done` or `cancelled_superseded`). An unreadable or
886
833
  * taskless INDEX cannot prove all-done, so the term fails.
834
+ *
835
+ * Frozen RUN scope: rows are filtered through the SAME TaskScope selector the
836
+ * blocked predicate uses (runText supplies the mappings; the live INDEX resolves
837
+ * against the run's current `Cycle:`). A row explicitly mapped outside the frozen
838
+ * ScopeQueue/ScopePlans is excluded; unknown/ambiguous mappings are retained AND
839
+ * their reconciliation diagnostics fail the term — completion is never declared
840
+ * over an unresolved mapping.
887
841
  */
888
- async function evalIndexAllDone(projectRoot, { goalScope = '', scopeQueue = '', cycleField = '' } = {}) {
842
+ async function evalIndexAllDone(projectRoot, { goalScope = '', scopeQueue = '', cycleField = '', runText = '' } = {}) {
889
843
  let text;
890
844
  try {
891
845
  text = await fs.readFile(path.join(projectRoot, 'docs', 'AI_HANDOFF', 'INDEX.md'), 'utf8');
@@ -893,9 +847,20 @@ async function evalIndexAllDone(projectRoot, { goalScope = '', scopeQueue = '',
893
847
  return { pass: false, detail: 'INDEX.md unreadable' };
894
848
  }
895
849
  const rows = indexTaskRows(text);
896
- if (rows.length === 0) return evalFinalizedCycles(projectRoot, goalScope, scopeQueue, cycleField);
850
+ if (rows.length === 0) return evalFinalizedCycles(projectRoot, goalScope, scopeQueue, cycleField, runText);
851
+ const selectable = rows.map((raw) => ({ id: (indexTaskCells(raw)[0] || ''), raw }));
852
+ const { rows: scoped, reconciliation } = selectTaskScope(runText, selectable, { cycle: parseRunCycle(cycleField) });
853
+ if (reconciliation.length > 0) {
854
+ return { pass: false, detail: `frozen TaskScope reconciliation unresolved: ${reconciliation.slice(0, 3).join(' | ')}` };
855
+ }
897
856
  const failing = [];
898
- for (const row of rows) {
857
+ const liveIds = new Set(scoped.map((r) => goalTaskId(r.id)));
858
+ const liveCls = new Map(scoped.filter((r) => indexTaskRowIsWellFormed(r.raw)).map((r) => {
859
+ const c = indexTaskCells(r.raw);
860
+ return [goalTaskId(c[0]), classifyTaskStatus((c[4] || '').toLowerCase(), c[0], liveIds)];
861
+ }));
862
+ for (const scopedRow of scoped) {
863
+ const row = scopedRow.raw;
899
864
  if (!indexTaskRowIsWellFormed(row)) {
900
865
  const shape = row.startsWith('|') ? 'malformed task row' : 'unsupported task row (missing outer leading pipe)';
901
866
  failing.push(`${shape} (expected ${INDEX_TASK_COLUMNS} columns): ${row.slice(0, 60)}`);
@@ -903,12 +868,14 @@ async function evalIndexAllDone(projectRoot, { goalScope = '', scopeQueue = '',
903
868
  }
904
869
  const cells = indexTaskCells(row);
905
870
  const status = (cells[4] || '').toLowerCase();
906
- if (status !== 'done' && status !== 'cancelled_superseded') {
907
- failing.push(`${cells[0]}=${cells[4] || '?'}`);
871
+ const cls = classifyTaskStatus(status, cells[0], liveIds);
872
+ const terminal = cls === 'delivered' || (cls === 'cancelled-grounded' && groundedCancellationResolved(cells[0], liveCls));
873
+ if (!terminal) {
874
+ failing.push(`${cells[0]}=${cells[4] || '?'}${cls === 'review-obligation' ? ' (review/integration owed; not delivered)' : cls === 'unresolved' ? ' (bare cancelled does not prove the requirement was met)' : ''}`);
908
875
  }
909
876
  }
910
877
  return failing.length === 0
911
- ? { pass: true, detail: `${rows.length} task row(s) done` }
878
+ ? { pass: true, detail: `${scoped.length} in-scope task row(s) done` }
912
879
  : { pass: false, detail: `not done: ${failing.join(', ')}` };
913
880
  }
914
881
 
@@ -934,8 +901,13 @@ function parseIndexStatuses(text) {
934
901
  * happened" either: the archived task files ARE the auditable record. All-done holds when at
935
902
  * least one archived task exists, every archived task is terminal-success, and — for a run-all
936
903
  * goal — no consumable queued blueprint remains. No archive evidence → still unprovable (fail).
904
+ *
905
+ * Frozen RUN scope: each archive cycle's rows resolve through the SAME TaskScope selector
906
+ * against that ARCHIVE cycle's own keys (`runText` supplies the preserved mappings), so a
907
+ * snapshot row mapped outside the frozen scope is excluded and an unknown/ambiguous mapping
908
+ * fails closed — an unresolved mapping can never prove a finalized cycle.
937
909
  */
938
- async function evalFinalizedCycles(projectRoot, goalScope, scopeQueue = '', cycleField = '') {
910
+ async function evalFinalizedCycles(projectRoot, goalScope, scopeQueue = '', cycleField = '', runText = '') {
939
911
  const cycles = [];
940
912
  const seen = new Set();
941
913
  const assignedCycle = parseRunCycle(cycleField);
@@ -992,7 +964,20 @@ async function evalFinalizedCycles(projectRoot, goalScope, scopeQueue = '', cycl
992
964
  snapshot = parseIndexStatuses(raw);
993
965
  if (snapshot === null) return { pass: false, detail: `archive INDEX snapshot malformed: ${cycle.name}` };
994
966
  }
995
- for (const file of files.filter((f) => /^TASK-[\w.-]+\.md$/i.test(f))) {
967
+ // Frozen-scope selection over THIS archive cycle: rows resolve against the archive
968
+ // cycle's own keys, so preserved per-cycle mappings decide and a stale unqualified
969
+ // line that another cycle qualified is ambiguous (included + reconciliation, fail).
970
+ const taskFiles = files.filter((f) => /^TASK-[\w.-]+\.md$/i.test(f));
971
+ const { rows: scopedFiles, reconciliation: scopeDiags } = selectTaskScope(
972
+ runText,
973
+ taskFiles.map((f) => ({ id: f.replace(/\.md$/i, ''), file: f })),
974
+ { cycle: cycle.name },
975
+ );
976
+ if (scopeDiags.length > 0) {
977
+ return { pass: false, detail: `archive ${cycle.name}: frozen TaskScope reconciliation unresolved: ${scopeDiags.slice(0, 3).join(' | ')}` };
978
+ }
979
+ for (const scopedFile of scopedFiles) {
980
+ const file = scopedFile.file;
996
981
  total += 1;
997
982
  const id = file.replace(/\.md$/i, '').toUpperCase();
998
983
  // A snapshot that exists but has no row for this task is unproven → fail closed; only an
@@ -1003,7 +988,11 @@ async function evalFinalizedCycles(projectRoot, goalScope, scopeQueue = '', cycl
1003
988
  try { body = await fs.readFile(path.join(cycle.dir, file), 'utf8'); } catch { /* unreadable → not proven */ }
1004
989
  status = (body.match(/^-\s*Status:\s*`?([\w-]+)`?/im)?.[1] || '').toLowerCase();
1005
990
  }
1006
- if (status !== 'done' && status !== 'cancelled_superseded') {
991
+ const archIds = new Set(snapshot ? [...snapshot.keys()].map(goalTaskId) : []);
992
+ const archCls = classifyTaskStatus(status, id, archIds);
993
+ const archClsByKey = new Map([...(snapshot || new Map()).entries()].map(([k, st]) => [goalTaskId(k), classifyTaskStatus(st, k, archIds)]));
994
+ const archTerminal = archCls === 'delivered' || (archCls === 'cancelled-grounded' && groundedCancellationResolved(id, archClsByKey));
995
+ if (!archTerminal) {
1007
996
  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>`)'}`);
1008
997
  }
1009
998
  }
@@ -1151,7 +1140,7 @@ async function evalVerifyPass(projectRoot) {
1151
1140
  * malformed (unsatisfiable). Advisory like the rest of the lane — a thrown
1152
1141
  * error becomes a failed term, never an exception.
1153
1142
  */
1154
- async function evaluateExitPredicate(projectRoot, expr, { goalScope = '', scopeQueue = '', cycleField = '' } = {}) {
1143
+ async function evaluateExitPredicate(projectRoot, expr, { goalScope = '', scopeQueue = '', cycleField = '', runText = '' } = {}) {
1155
1144
  const terms = parseExitPredicate(expr);
1156
1145
  if (!terms) {
1157
1146
  return { ok: false, terms: [{ term: String(expr || '').trim() || '(empty)', pass: false, detail: 'malformed predicate' }] };
@@ -1160,7 +1149,7 @@ async function evaluateExitPredicate(projectRoot, expr, { goalScope = '', scopeQ
1160
1149
  for (const term of terms) {
1161
1150
  let r;
1162
1151
  try {
1163
- if (term === 'index:all-done') r = await evalIndexAllDone(projectRoot, { goalScope, scopeQueue, cycleField });
1152
+ if (term === 'index:all-done') r = await evalIndexAllDone(projectRoot, { goalScope, scopeQueue, cycleField, runText });
1164
1153
  else if (term === 'git:clean') r = evalGitClean(projectRoot);
1165
1154
  else if (term === 'agents:none-running') r = await evalAgentsNoneRunning(projectRoot);
1166
1155
  else if (term === 'verify:pass') r = await evalVerifyPass(projectRoot);
@@ -1195,7 +1184,70 @@ export const GOAL_STATUSES = Object.freeze(['RUNNING', 'PAUSED', 'BLOCKED', 'COM
1195
1184
 
1196
1185
  // Terminal-SUCCESS task statuses (INDEX `Status:` vocabulary). `blocked` is
1197
1186
  // pending-but-not-executable; everything else executable is pending work.
1198
- const GOAL_DONE_TASK_STATUSES = new Set(['done', 'cancelled', 'cancelled_superseded']);
1187
+ //
1188
+ // The same status vocabulary must NOT answer every question. Four distinct facts:
1189
+ // delivered `done` only — proves delivery.
1190
+ // cancelled-grounded `cancelled_superseded` = the RULES recovery state (record could not resume;
1191
+ // a `<ID>-R<n>` successor row carries the obligation and is judged on its own).
1192
+ // Terminal, but never proof of delivery. A bare `cancelled` carries no such
1193
+ // grounds and does not prove the requirement was met: it stays an unresolved
1194
+ // obligation (reconcile, never silently dropped).
1195
+ // review-obligation `pending_review`/`approved`/`approved_minor`: nothing left to IMPLEMENT, but
1196
+ // review/integration/status-flip/release is still owed — never "delivered".
1197
+ // history `archived`: moved out of the live cycle; no obligation, no delivery claim.
1198
+ // needs-planning `needs_breakdown`: not yet validator+review-approved, so its actor is the
1199
+ // PLANNER/REVIEW gate (/ukit:handoff-create resume-gates), never the executor
1200
+ // (handoff-implement only picks `ready`). It keeps the run open and is never
1201
+ // delivered/done — but it is NOT executor-executable, and ordering the executor
1202
+ // to run it deadlocks the stop until a liveness cap falsely releases the run.
1203
+ // Everything else (ready, in_progress, changes_requested, critical_block, needs_executor_report,
1204
+ // merge_conflict, queued_blueprint, unknown) is dispatchable work. `blocked` is pending but not
1205
+ // dispatchable.
1206
+ const REVIEW_OBLIGATION_STATUSES = new Set(['pending_review', 'approved', 'approved_minor', 'approved_with_minor']);
1207
+
1208
+ // A grounded cancellation (`cancelled_superseded`) is resolved ONLY by a STRICTLY LATER
1209
+ // `<ID>-R<n>` successor — a repair number strictly greater than its own (the root accepts
1210
+ // any R>=1) — that is itself delivered, or that is grounded-cancelled and resolved in turn
1211
+ // (chained recovery). A same-or-lower-numbered sibling delivers an EARLIER obligation, not
1212
+ // this one: `root cancelled_superseded → R1 done → R2 cancelled_superseded` leaves R2 open
1213
+ // even though R1 is done. A missing successor, a pending/review-owed successor (ready,
1214
+ // pending_review, approved*) and a bare `cancelled` successor all leave the obligation open.
1215
+ // `clsByKey`: Map<lower-cased id, classification> over the SAME task set. Each hop appends
1216
+ // `-R<n>`, so chains are finite; `seen` guards any loop. Recovery numbering is per root
1217
+ // (`TASK-xxx-R1`, `-R2`, …), so a successor of any `-R<n>` row is a sibling `<root>-R<m>`
1218
+ // row with m > n; suffixes compare numerically (BigInt, like the claim runtime), so R2 does
1219
+ // not resolve R10. The root is the id with its trailing `-R<n>` stripped.
1220
+ function groundedCancellationResolved(id, clsByKey, seen = new Set()) {
1221
+ const key = goalTaskId(id);
1222
+ if (seen.has(key)) return false;
1223
+ seen.add(key);
1224
+ const root = key.replace(/-r\d+$/, '');
1225
+ const ownSuffix = key.match(/-r(\d+)$/);
1226
+ const ownNum = ownSuffix ? BigInt(ownSuffix[1]) : 0n;
1227
+ const prefix = `${root}-r`;
1228
+ for (const [other, cls] of clsByKey) {
1229
+ if (!other.startsWith(prefix) || !/^\d+$/.test(other.slice(prefix.length))) continue;
1230
+ const num = BigInt(other.slice(prefix.length));
1231
+ if (num <= ownNum) continue;
1232
+ if (cls === 'delivered') return true;
1233
+ if (cls === 'cancelled-grounded' && groundedCancellationResolved(other, clsByKey, seen)) return true;
1234
+ }
1235
+ return false;
1236
+ }
1237
+
1238
+ /** Classify one task. `allIds` = Set of lower-cased ids in the same task set (for successor grounding). */
1239
+ export function classifyTaskStatus(status, id, allIds = new Set()) {
1240
+ const st = String(status ?? '').trim().toLowerCase();
1241
+ if (st === 'done') return 'delivered';
1242
+ if (st === 'cancelled_superseded') return 'cancelled-grounded';
1243
+ if (st === 'cancelled') return 'unresolved';
1244
+ if (st === 'archived') return 'history';
1245
+ if (st === 'blocked') return 'blocked';
1246
+ if (st === 'needs_breakdown') return 'needs-planning';
1247
+ if (st === 'malformed') return 'malformed';
1248
+ if (REVIEW_OBLIGATION_STATUSES.has(st)) return 'review-obligation';
1249
+ return 'dispatchable';
1250
+ }
1199
1251
 
1200
1252
  function goalTaskId(id) {
1201
1253
  return String(id ?? '').trim().toLowerCase();
@@ -1246,24 +1298,45 @@ export function deriveGoalStatus({ runCursor = null, tasks = null, ledger = null
1246
1298
  dependsOn: Array.isArray(t.dependsOn) ? t.dependsOn.map(goalTaskId) : [],
1247
1299
  }))
1248
1300
  : [];
1249
- const doneIds = new Set(list.filter((t) => GOAL_DONE_TASK_STATUSES.has(t.status)).map((t) => t.key));
1250
- const pending = list.filter((t) => !GOAL_DONE_TASK_STATUSES.has(t.status));
1301
+ const allKeys = new Set(list.map((t) => t.key));
1302
+ for (const t of list) t.cls = classifyTaskStatus(t.status, t.id, allKeys);
1303
+ // A dependency is satisfied by delivery, history, or a grounded cancellation whose successor
1304
+ // was delivered; approved/cancelled-without-grounds never satisfy it.
1305
+ const deliveredKeys = new Set(list.filter((t) => t.cls === 'delivered').map((t) => t.key));
1306
+ const doneIds = new Set(list.filter((t) => t.cls === 'delivered' || t.cls === 'history').map((t) => t.key));
1307
+ const clsByKey = new Map(list.map((t) => [t.key, t.cls]));
1308
+ for (const t of list) {
1309
+ if (t.cls === 'cancelled-grounded' && groundedCancellationResolved(t.key, clsByKey)) doneIds.add(t.key);
1310
+ }
1311
+ // Only a RESOLVED grounded cancellation leaves pending; an orphan one is an open obligation.
1312
+ const pending = list.filter((t) => !doneIds.has(t.key));
1251
1313
  // Dependency consistency: no task may be `done` while a dependency is not.
1252
- const depInconsistent = list.filter((t) => t.status === 'done' && t.dependsOn.some((d) => !doneIds.has(d)));
1314
+ const depInconsistent = list.filter((t) => t.cls === 'delivered' && t.dependsOn.some((d) => !doneIds.has(d)));
1253
1315
  if (depInconsistent.length > 0) {
1254
1316
  reasons.push(`dependency-inconsistent done task(s): ${depInconsistent.map((t) => t.id || '?').join(', ')}`);
1255
1317
  }
1256
- const executable = pending.filter((t) => t.status !== 'blocked' && t.dependsOn.every((d) => doneIds.has(d)));
1257
- const blockedTasks = pending.filter((t) => t.status === 'blocked');
1318
+ // Executor-executable: dispatchable/review-obligation/malformed work whose deps are done.
1319
+ // needs-planning rows (needs_breakdown) are NOT executor-executable — their actor is the
1320
+ // planner/review gate — and `blocked` rows are pending but not dispatchable.
1321
+ const executable = pending.filter((t) => t.cls !== 'blocked' && t.cls !== 'needs-planning' && t.dependsOn.every((d) => doneIds.has(d)));
1322
+ const blockedTasks = pending.filter((t) => t.cls === 'blocked');
1323
+ const planningTasks = pending.filter((t) => t.cls === 'needs-planning');
1258
1324
  const agentsActive = agentsRunning === true || (Number.isFinite(agentsRunning) && agentsRunning > 0);
1259
1325
 
1260
- // Blocked work with nothing executable anywhere is BLOCKED — whatever the
1261
- // cursor says; the goal cannot advance.
1262
- if (pending.length > 0 && executable.length === 0 && blockedTasks.length > 0) {
1326
+ // Blocked or planning-gated work with nothing executor-executable anywhere is
1327
+ // BLOCKED — whatever the cursor says; the goal cannot advance.
1328
+ if (pending.length > 0 && executable.length === 0 && (blockedTasks.length > 0 || planningTasks.length > 0)) {
1329
+ const deadEnd = [...reasons];
1330
+ if (blockedTasks.length > 0) {
1331
+ deadEnd.push(`no executable work; blocked task(s): ${blockedTasks.slice(0, 5).map((t) => t.id || '?').join(', ')}`);
1332
+ }
1333
+ if (planningTasks.length > 0) {
1334
+ deadEnd.push(`needs-planning task(s) await the planner/review gate, not the executor (run /ukit:handoff-create resume-gates (validator + plan review)): ${planningTasks.slice(0, 5).map((t) => t.id || '?').join(', ')}`);
1335
+ }
1263
1336
  return {
1264
1337
  status: 'BLOCKED',
1265
1338
  nextCycle: false,
1266
- reasons: [...reasons, `no executable work; blocked task(s): ${blockedTasks.slice(0, 5).map((t) => t.id || '?').join(', ')}`],
1339
+ reasons: deadEnd,
1267
1340
  };
1268
1341
  }
1269
1342
 
@@ -1328,10 +1401,32 @@ function indexTasks(indexText) {
1328
1401
  return indexTaskRows(indexText).map((row) => {
1329
1402
  if (!indexTaskRowIsWellFormed(row)) return { id: '', status: 'malformed' };
1330
1403
  const cells = indexTaskCells(row);
1331
- return { id: cells[0] || '', status: (cells[4] || '').toLowerCase() };
1404
+ const deps = (cells[3] || '').split(',').map((d) => d.trim()).filter((d) => d && d.toLowerCase() !== 'none');
1405
+ return { id: cells[0] || '', status: (cells[4] || '').toLowerCase(), deps };
1332
1406
  });
1333
1407
  }
1334
1408
 
1409
+ // Task-file dependency authority for the blocked-run predicate. Returns normalized TASK ids,
1410
+ // [] for `(none)`, or null when the file/section cannot be read (caller fails closed).
1411
+ async function readTaskFileDependencies(runRoot, taskId) {
1412
+ const id = String(taskId ?? '').trim();
1413
+ if (!/^TASK-[A-Za-z0-9._-]+$/.test(id)) return null;
1414
+ try {
1415
+ const text = await fs.readFile(path.join(runRoot, 'docs', 'AI_HANDOFF', 'tasks', `${id}.md`), 'utf8');
1416
+ const m = text.match(/^##\s+Dependencies\s*$([\s\S]*?)(?=^##\s|(?![\s\S]))/m);
1417
+ if (!m) return null;
1418
+ // Only list items / bare id lines declare dependencies; prose ("Unlike TASK-001 ...") does not.
1419
+ const body = m[1]
1420
+ .replace(/<!--[\s\S]*?-->/g, ' ')
1421
+ .split(/\r?\n/)
1422
+ .filter((l) => /^\s*(?:[-*+]|\d+[.)])\s/.test(l) || /^\s*TASK-[\w.,\s-]*$/i.test(l))
1423
+ .join('\n');
1424
+ return [...new Set((body.match(/TASK-[A-Za-z0-9._-]*[A-Za-z0-9]/g) || []).map(goalTaskId))];
1425
+ } catch {
1426
+ return null;
1427
+ }
1428
+ }
1429
+
1335
1430
  function indexTaskSnapshotHash(indexText) {
1336
1431
  const rows = indexTaskRows(indexText).map((row) => {
1337
1432
  const cells = indexTaskCells(row);
@@ -1341,10 +1436,17 @@ function indexTaskSnapshotHash(indexText) {
1341
1436
  }
1342
1437
 
1343
1438
  function firstOpenIndexTaskId(indexText) {
1439
+ const allIds = new Set(indexTaskRows(indexText).map((r) => goalTaskId(indexTaskCells(r)[0])));
1440
+ const clsByKey = new Map(indexTaskRows(indexText).filter((r) => indexTaskRowIsWellFormed(r)).map((r) => {
1441
+ const c = indexTaskCells(r);
1442
+ return [goalTaskId(c[0]), classifyTaskStatus((c[4] || '').toLowerCase(), c[0], allIds)];
1443
+ }));
1344
1444
  for (const row of indexTaskRows(indexText)) {
1345
1445
  const cells = indexTaskCells(row);
1346
1446
  const status = (cells[4] || '').toLowerCase();
1347
- if (status !== 'done' && status !== 'cancelled_superseded') return cells[0] || null;
1447
+ const cls = classifyTaskStatus(status, cells[0], allIds);
1448
+ const terminal = cls === 'delivered' || (cls === 'cancelled-grounded' && groundedCancellationResolved(cells[0], clsByKey));
1449
+ if (!terminal) return cells[0] || null;
1348
1450
  }
1349
1451
  return null;
1350
1452
  }
@@ -1412,7 +1514,7 @@ export async function transcriptShowsHandoffRun(transcriptPath, opts = {}) {
1412
1514
  * under a racing writer only delays the cap by one stop; a frozen breaker loops
1413
1515
  * forever. Only a genuinely unwritable state file still returns null.
1414
1516
  */
1415
- async function bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs, snapshotHash = '', firstOpenTaskId = null, failing = false }) {
1517
+ async function bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs, snapshotHash = '', firstOpenTaskId = null, failing = false, cycle = null }) {
1416
1518
  const statePath = path.join(projectRoot, '.ukit', 'storage', 'cache', 'stop-coordinator', 'state.json');
1417
1519
  const bump = async () => {
1418
1520
  let current = {};
@@ -1422,15 +1524,17 @@ async function bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs, sna
1422
1524
  const prev = current.handoff || {};
1423
1525
  const count = prev.signature === signature ? (Number(prev.count) || 0) + 1 : 1;
1424
1526
  const prevTask = current.handoffTask || {};
1527
+ // A reused task id under a NEW cycle is a different task: the failing streak restarts.
1425
1528
  const sameFailingTask = prevTask.snapshotHash === snapshotHash
1426
- && (prevTask.firstOpenTaskId || null) === (firstOpenTaskId || null);
1529
+ && (prevTask.firstOpenTaskId || null) === (firstOpenTaskId || null)
1530
+ && (prevTask.cycle || null) === (cycle || null);
1427
1531
  const failingCount = failing
1428
1532
  ? (sameFailingTask ? (Number(prevTask.failingCount) || 0) + 1 : 1)
1429
1533
  : 0;
1430
1534
  await fs.mkdir(path.dirname(statePath), { recursive: true });
1431
1535
  await fs.writeFile(
1432
1536
  statePath,
1433
- `${JSON.stringify({ ...current, handoff: { signature, count }, handoffTask: { snapshotHash, firstOpenTaskId, failingCount } }, null, 1)}\n`,
1537
+ `${JSON.stringify({ ...current, handoff: { signature, count }, handoffTask: { snapshotHash, firstOpenTaskId, cycle: cycle || null, failingCount } }, null, 1)}\n`,
1434
1538
  'utf8',
1435
1539
  );
1436
1540
  return { count, failingCount };
@@ -1497,7 +1601,7 @@ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot
1497
1601
  // Shared terminal predicate — tests/handoff/cycle21 greps this exact shape across
1498
1602
  // all four RUN.md consumers; keep `^(done|blocked)` as one expression.
1499
1603
  const terminal = String(phase).match(/^(done|blocked)\b/i)?.[1]?.toLowerCase() || '';
1500
- if (!phase || terminal === 'blocked') {
1604
+ if (!phase) {
1501
1605
  return { kind: 'none' };
1502
1606
  }
1503
1607
  // Default exit predicate (goal completion): a `done` cursor with no explicit
@@ -1526,6 +1630,108 @@ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot
1526
1630
  };
1527
1631
  }
1528
1632
 
1633
+ // `Phase: blocked` is a true external-blocker release only when NO pending row is executable:
1634
+ // every pending row is blocked or waits on a dependency that is not done. An independent READY
1635
+ // row (deps all done) keeps the run open. Malformed rows fail closed (treated as executable).
1636
+ // Runs AFTER the pause check, so a paused run is never bounced here. Empty INDEX → legacy release.
1637
+ if (terminal === 'blocked') {
1638
+ const blockedIndex = await readIndexSnapshot(runRoot);
1639
+ const allRows = indexTasks(blockedIndex);
1640
+ // Same frozen-scope selector as the completion predicate: a row explicitly mapped outside
1641
+ // the frozen ScopeQueue/ScopePlans does not keep a blocked run open, and rows whose mapping
1642
+ // is unknown/ambiguous are RETAINED (reconcile, never guess). The live INDEX resolves
1643
+ // against the run's current `Cycle:`; repair/replan tasks inherit their original scope.
1644
+ const { rows, reconciliation } = selectTaskScope(text, allRows, { cycle: parseRunCycle(cycleField) });
1645
+ // Blocked gate asks "is any pending row still EXECUTOR-workable?" — NOT "is it delivered?".
1646
+ // Dispatchable, review-obligation (pending_review/approved*: review/flip/integration still
1647
+ // owed), unresolved (bare cancelled / superseded without successor) and malformed rows all
1648
+ // keep the run open when their dependencies are satisfied. needs-planning rows
1649
+ // (needs_breakdown) do NOT: their actor is the planner/review gate, and ordering the executor
1650
+ // to run them deadlocks the stop (FS-04).
1651
+ const allKeys = new Set(rows.map((t) => goalTaskId(t.id)));
1652
+ for (const t of rows) t.cls = t.status === 'malformed' ? 'malformed' : classifyTaskStatus(t.status, t.id, allKeys);
1653
+ const clsOf = new Map(rows.map((t) => [goalTaskId(t.id), t.cls]));
1654
+ const depSatisfied = (d) => {
1655
+ const c = clsOf.get(d);
1656
+ if (c === 'delivered' || c === 'history') return true;
1657
+ if (c === 'cancelled-grounded') {
1658
+ // Satisfied once the recovery successor (if any) is delivered; with no successor row the
1659
+ // grounded cancellation itself is terminal.
1660
+ return groundedCancellationResolved(d, clsOf);
1661
+ }
1662
+ return false;
1663
+ };
1664
+ const pending = rows.filter((t) => t.cls !== 'delivered' && t.cls !== 'history'
1665
+ && !(t.cls === 'cancelled-grounded' && groundedCancellationResolved(t.id, clsOf)));
1666
+ const planning = pending.filter((t) => t.cls === 'needs-planning');
1667
+ // INDEX has no dependency column: the authority is the task file's `## Dependencies`
1668
+ // section. Unreadable file, unknown dependency id => fail closed (row counts as executable).
1669
+ const executable = [];
1670
+ for (const t of pending) {
1671
+ if (t.cls === 'malformed') { executable.push(t); continue; }
1672
+ if (t.cls === 'blocked' || t.cls === 'needs-planning') continue;
1673
+ const deps = await readTaskFileDependencies(runRoot, t.id);
1674
+ if (deps === null) { executable.push(t); continue; }
1675
+ if (deps.every(depSatisfied)) executable.push(t);
1676
+ else if (deps.some((d) => !allKeys.has(d))) executable.push(t);
1677
+ else if (deps.some((d) => clsOf.get(d) === 'unresolved')) executable.push(t);
1678
+ }
1679
+ if (rows.length === 0) return { kind: 'none' };
1680
+ if (executable.length === 0) {
1681
+ if (planning.length === 0) return { kind: 'none' }; // legacy terminal: genuinely stuck / externally blocked
1682
+ // FS-04(d)/FS-05: the ONLY remaining rows are planner-gated. Defined terminal
1683
+ // outcome — release WITHOUT a completion claim and persist the durable
1684
+ // unfinished marker through the existing `Paused: yes` writer (Phase untouched,
1685
+ // never `done`), so a later resume sees the obligation instead of silently
1686
+ // inheriting a "completed" session.
1687
+ const pause = await markRunCursorPaused(runRoot);
1688
+ const namedPlanning = planning.slice(0, 5).map((t) => (t.id ? `${t.id}=${t.status}` : '(malformed INDEX row)')).join(', ');
1689
+ return {
1690
+ kind: 'advisory',
1691
+ waitForApproval: true,
1692
+ systemMessage: [
1693
+ `[ukit-stop-coordinator] handoff run released: Phase: blocked and every remaining in-scope task is needs_breakdown (${namedPlanning}).`,
1694
+ 'These rows are planner/review-gated, not executor-executable — the run ends this stop UNFINISHED, never as completed.',
1695
+ pause?.ok
1696
+ ? 'RUN.md is marked `Paused: yes` (Phase untouched): the open obligation is persisted and no resume is auto-injected.'
1697
+ : 'WARNING: RUN.md could not be marked `Paused: yes` (write failed) — the unfinished obligation is NOT durably marked.',
1698
+ 'CONTINUE LATER: run /ukit:handoff-create resume-gates (validator + plan review) for these tasks, then resume explicitly with /ukit:handoff-fullstack (or clear with /ukit:handoff-clear).',
1699
+ ].join('\n'),
1700
+ };
1701
+ }
1702
+ const named = executable.slice(0, 5).map((t) => (t.id ? `${t.id}=${t.status}` : '(malformed INDEX row)')).join(', ');
1703
+ // Unresolved scope reconciliation is surfaced bounded — it must be resolved against
1704
+ // RUN.md before terminal phases, never guessed away.
1705
+ const reconcileNote = reconciliation.length > 0
1706
+ ? [
1707
+ '',
1708
+ `Frozen TaskScope needs reconciliation (${reconciliation.slice(0, 3).join(' | ')}).`,
1709
+ 'Resolve the mapping lines in docs/AI_HANDOFF/RUN.md before terminal phases.',
1710
+ ]
1711
+ : [];
1712
+ // FS-04: planning-gated rows ride along so the executor is never ordered to run
1713
+ // work only the planner gate can act on.
1714
+ const planningNote = planning.length > 0
1715
+ ? [
1716
+ '',
1717
+ `needs_breakdown row(s) (${planning.slice(0, 5).map((t) => (t.id ? `${t.id}=${t.status}` : '(malformed INDEX row)')).join(', ')}) are NOT executor-executable.`,
1718
+ 'Run /ukit:handoff-create resume-gates (validator + plan review) for these tasks.',
1719
+ ]
1720
+ : [];
1721
+ return {
1722
+ kind: 'block',
1723
+ reason: [
1724
+ `UKit handoff run: Phase: blocked, but independent in-scope work is still executable (${named}).`,
1725
+ 'A blocker on one task does not end the run.',
1726
+ ...planningNote,
1727
+ ...reconcileNote,
1728
+ '',
1729
+ 'CONTINUE IMMEDIATELY: execute the executable row(s) above, then rewrite the run cursor.',
1730
+ 'Set `Phase: blocked` only when no executable row remains.',
1731
+ ].join('\n'),
1732
+ };
1733
+ }
1734
+
1529
1735
  let effectivePredicate = exitPredicate;
1530
1736
  let predicateIsDefault = false;
1531
1737
  let openNextCycle = null;
@@ -1572,7 +1778,7 @@ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot
1572
1778
  if (terminal === 'done') {
1573
1779
  let predicate;
1574
1780
  try {
1575
- predicate = await evaluateExitPredicate(runRoot, effectivePredicate, { goalScope, scopeQueue, cycleField });
1781
+ predicate = await evaluateExitPredicate(runRoot, effectivePredicate, { goalScope, scopeQueue, cycleField, runText: text });
1576
1782
  } catch {
1577
1783
  return { kind: 'none' }; // advisory lane: evaluator failure never wedges a stop
1578
1784
  }
@@ -1630,13 +1836,16 @@ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot
1630
1836
  const indexText = await readIndexSnapshot(runRoot);
1631
1837
  const snapshotHash = indexTaskSnapshotHash(indexText);
1632
1838
  const firstOpenTask = firstOpenIndexTaskId(indexText);
1633
- const signature = `${phase}|${cursor}|${next}|${snapshotHash}`;
1839
+ // The run's cycle is part of the identity: a streak accrued under cycle-01 must not carry into
1840
+ // cycle-02 when task ids restart (same TASK-001, same cursor wording would otherwise match).
1841
+ const signature = `${cycleField || ''}|${phase}|${cursor}|${next}|${snapshotHash}`;
1634
1842
  const streak = await bumpHandoffStallCount({
1635
1843
  projectRoot: runRoot,
1636
1844
  signature,
1637
1845
  lockBudgetMs,
1638
1846
  snapshotHash,
1639
1847
  firstOpenTaskId: firstOpenTask,
1848
+ cycle: cycleField,
1640
1849
  failing: completionKind === 'block',
1641
1850
  });
1642
1851
  // Reentrancy valve (stop_hook_active): Claude Code sets it when this Stop fires
@@ -1657,39 +1866,76 @@ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot
1657
1866
  let predicateState = 'no ExitPredicate recorded';
1658
1867
  if (effectivePredicate) {
1659
1868
  try {
1660
- predicateState = formatPredicateState(await evaluateExitPredicate(runRoot, effectivePredicate, { goalScope, scopeQueue, cycleField }));
1869
+ predicateState = formatPredicateState(await evaluateExitPredicate(runRoot, effectivePredicate, { goalScope, scopeQueue, cycleField, runText: text }));
1661
1870
  } catch {
1662
1871
  predicateState = 'predicate evaluation failed';
1663
1872
  }
1664
1873
  }
1874
+ // FS-05: releasing a nonterminal run is a loop-liveness escape, never a
1875
+ // completion — persist the durable unfinished marker before the advisory.
1876
+ const pause = await markRunCursorPaused(runRoot);
1665
1877
  return {
1666
1878
  kind: 'advisory',
1879
+ waitForApproval: true,
1667
1880
  systemMessage:
1668
1881
  `[ukit-stop-coordinator] handoff-cursor released this stop: the run cursor ` +
1669
1882
  `("${signature}") has not advanced across ${streak.count - 1} consecutive blocked stops ` +
1670
1883
  `(cap ${gate.maxStalledBlocks}), so the gate treats the run as stalled rather than ` +
1671
1884
  `looping forever. ExitPredicate state: ${predicateState}. ` +
1885
+ `${unfinishedMarkerNote(pause)} ` +
1672
1886
  `Inspect docs/AI_HANDOFF/RUN.md and docs/AI_HANDOFF/INDEX.md; ` +
1673
- `the run can be resumed with /ukit:handoff-fullstack or cleared with /ukit:handoff-clear.`,
1887
+ `To resume, send a fresh explicit /ukit:handoff-fullstack in a new message (only that explicit invoke lifts the pause; ordinary prompts do not); ` +
1888
+ `or clear with /ukit:handoff-clear.`,
1674
1889
  };
1675
1890
  }
1676
1891
  // Same failing task (maxAttempts): a completion gate that keeps failing on the
1677
1892
  // same first-open task and snapshot is a retry loop, not progress — release
1678
1893
  // after `maxAttemptsPerFailure` consecutive failures so the session reports
1679
- // honestly instead of being bounced into the same wall.
1894
+ // honestly instead of being bounced into the same wall. FS-05: the release
1895
+ // persists the durable unfinished marker — it is a loop escape, not a done.
1680
1896
  if (streak !== null && streak.failingCount >= gate.maxAttemptsPerFailure && firstOpenTask) {
1897
+ const pause = await markRunCursorPaused(runRoot);
1681
1898
  return {
1682
1899
  kind: 'advisory',
1900
+ waitForApproval: true,
1683
1901
  systemMessage:
1684
1902
  `[ukit-stop-coordinator] handoff-cursor released this stop: task ${firstOpenTask} has now ` +
1685
1903
  `failed the completion gate ${streak.failingCount} consecutive times ` +
1686
1904
  `(maxAttemptsPerFailure ${gate.maxAttemptsPerFailure}). The task snapshot ` +
1687
1905
  `("${snapshotHash}") has not changed — record the concrete blocker on the task ` +
1688
1906
  `(or fix the failure differently) and continue; the counter resets on any ` +
1689
- `snapshot or first-open-task change.`,
1907
+ `snapshot or first-open-task change. ${unfinishedMarkerNote(pause)} ` +
1908
+ `Resume explicitly with /ukit:handoff-fullstack or clear with /ukit:handoff-clear.`,
1690
1909
  };
1691
1910
  }
1692
1911
 
1912
+ // FS-04: a run whose ENTIRE in-scope backlog is needs_breakdown is planner-gated —
1913
+ // handoff-implement only picks `ready`, so the generic "execute the `Next:` step"
1914
+ // order below would loop on work the executor refuses. Route this stop to the
1915
+ // planner/review gate instead. Malformed rows fail closed (treated as executor
1916
+ // work, generic block), and any dispatchable/review/blocked row keeps the generic
1917
+ // continuation. The stall-cap and maxAttempts releases above keep priority: a
1918
+ // wedged planning-only run still trips liveness and releases (FS-05 marker).
1919
+ if (indexText.trim()) {
1920
+ const ntScope = selectTaskScope(text, indexTasks(indexText), { cycle: parseRunCycle(cycleField) });
1921
+ const ntRows = ntScope.rows || [];
1922
+ const isPlanningRow = (t) => t.status !== 'malformed' && classifyTaskStatus(t.status, t.id) === 'needs-planning';
1923
+ const planningRows = ntRows.filter(isPlanningRow);
1924
+ const executorRows = ntRows.filter((t) => !isPlanningRow(t));
1925
+ if (ntRows.length > 0 && executorRows.length === 0 && planningRows.length > 0) {
1926
+ const namedPlanning = planningRows.slice(0, 5).map((t) => (t.id ? `${t.id}=${t.status}` : '(malformed INDEX row)')).join(', ');
1927
+ return {
1928
+ kind: 'block',
1929
+ reason: [
1930
+ `UKit handoff run: every in-scope task is needs_breakdown (${namedPlanning}) — nothing here is executor-executable.`,
1931
+ 'handoff-implement only picks `ready` rows: these tasks are awaiting the planner/review gate, so continuing the current `Next:` step cannot start them.',
1932
+ '',
1933
+ 'CONTINUE IMMEDIATELY: run /ukit:handoff-create resume-gates (validator + plan review) for these tasks, flip them to `ready`, then rewrite the run cursor and keep working.',
1934
+ ].join('\n'),
1935
+ };
1936
+ }
1937
+ }
1938
+
1693
1939
  const lines = [
1694
1940
  'UKit handoff run in progress — this stop is refused. A handoff-fullstack cycle is still',
1695
1941
  'in flight; a recap, wave summary, or "waiting" reply is NOT completion. The only valid',
@@ -1909,6 +2155,18 @@ export async function markRunCursorPaused(runRoot) {
1909
2155
  }
1910
2156
  }
1911
2157
 
2158
+ // FS-05: a liveness release (stalled-cursor cap, maxAttemptsPerFailure, or a
2159
+ // blocked run whose only remaining rows are planner-gated) must be
2160
+ // distinguishable from a completed report. The marker is the run cursor's own
2161
+ // `Paused: yes` line — the exact contract the SessionStart resume hook reads
2162
+ // (never auto-inject a resume; only an explicit instruction lifts it) — written
2163
+ // by the existing markRunCursorPaused writer, which never touches `Phase:`.
2164
+ function unfinishedMarkerNote(pause) {
2165
+ return pause?.ok
2166
+ ? 'The run is unfinished, not completed: RUN.md is marked `Paused: yes` (Phase untouched), so no resume is auto-injected and nothing is reported as done.'
2167
+ : 'WARNING: the run is unfinished, not completed, but RUN.md could not be marked `Paused: yes` (write failed) — the open obligation is not durably marked.';
2168
+ }
2169
+
1912
2170
  // ─── Once-per-stop guard ──────────────────────────────────────────────────
1913
2171
  /**
1914
2172
  * Lock-scoped dedupe: persist `{ lastStop: { key, ts } }` and report whether this
@@ -1952,6 +2210,18 @@ async function resolveRunRoot(projectRoot, cwd) {
1952
2210
  return mod.resolveHandoffRunRoot(projectRoot, cwd);
1953
2211
  }
1954
2212
 
2213
+ // The run's pinned cycle (RUN.md `Cycle:`, normalized by parseRunCycle) — the
2214
+ // watchdog's cycle-scoping input so per-task anchors from a PREVIOUS cycle are
2215
+ // reset instead of false-tripping a reused task id. Unreadable/blank → null.
2216
+ async function readRunCycle(runRoot) {
2217
+ try {
2218
+ const text = await fs.readFile(path.join(runRoot, 'docs', 'AI_HANDOFF', 'RUN.md'), 'utf8');
2219
+ return parseRunCycle((String(text).match(/^Cycle:\s*(.+)$/m) || [])[1] || '');
2220
+ } catch {
2221
+ return null;
2222
+ }
2223
+ }
2224
+
1955
2225
  // ─── Orchestration ────────────────────────────────────────────────────────
1956
2226
  /**
1957
2227
  * One coordinator invocation: event guard → once-per-stop dedupe → completion
@@ -2013,7 +2283,12 @@ export async function runStopCoordinator({
2013
2283
  let watchdog = null;
2014
2284
  try {
2015
2285
  const watchdogRuntime = await import(new URL('./task-watchdog.mjs', import.meta.url).href);
2016
- watchdog = await watchdogRuntime.evaluateStopWatchdog({ projectRoot, now, lockBudgetMs });
2286
+ watchdog = await watchdogRuntime.evaluateStopWatchdog({
2287
+ projectRoot,
2288
+ now,
2289
+ lockBudgetMs,
2290
+ cycle: await readRunCycle(await resolveRunRoot(projectRoot, payload?.cwd)),
2291
+ });
2017
2292
  } catch (error) {
2018
2293
  failures.watchdog = error?.message || String(error);
2019
2294
  process.stderr.write(`[ukit-stop-coordinator] task-watchdog evaluator failed: ${failures.watchdog}\n`);
@@ -2232,6 +2507,15 @@ async function main() {
2232
2507
  // Compare real paths (same shape as execution-ledger.mjs): a project under a
2233
2508
  // symlinked root makes import.meta.url resolve to the real path while argv[1]
2234
2509
  // keeps the symlinked spelling. Importing this module must never run main().
2510
+ // FS-06: a direct run through a RENAMED symlink (`node stop-gate.mjs` where
2511
+ // stop-gate.mjs → stop-coordinator.mjs) used to be rejected by the basename
2512
+ // fast path, so main() never ran and a wrapper spawning the coordinator through
2513
+ // such a link got a silent release instead of the fail-closed block. argv[1] is
2514
+ // realpath'd first: it is the entry node already executed (never a stalled
2515
+ // mount), and when it moved, the decision is made on real paths — an importer's
2516
+ // real path is its own file and can never equal ours, so that comparison is
2517
+ // decisive. Plain importers keep the BUG-C22-01 fast path (basename skip, no
2518
+ // extra fs beyond the one argv realpath).
2235
2519
  function isDirectRun() {
2236
2520
  const argvPath = process.argv[1];
2237
2521
  if (!argvPath) return false;
@@ -2243,9 +2527,25 @@ function isDirectRun() {
2243
2527
  selfUrlPath = null;
2244
2528
  }
2245
2529
  if (selfUrlPath && selfUrlPath === selfPath) return true;
2530
+ if (!selfUrlPath) return false;
2531
+ let realArgPath = null;
2532
+ try {
2533
+ realArgPath = fsSync.realpathSync(selfPath);
2534
+ } catch {
2535
+ realArgPath = null;
2536
+ }
2537
+ if (realArgPath && realArgPath !== selfPath) {
2538
+ let realSelfPath = null;
2539
+ try {
2540
+ realSelfPath = fsSync.realpathSync(selfUrlPath);
2541
+ } catch {
2542
+ realSelfPath = selfUrlPath;
2543
+ }
2544
+ return realArgPath === realSelfPath;
2545
+ }
2246
2546
  // BUG-C22-01: skip fs entirely for non-matching importers — a stalled mount
2247
2547
  // must not park the event loop during `await import` inside an armed deadline.
2248
- if (selfUrlPath && path.basename(selfUrlPath) !== path.basename(selfPath)) return false;
2548
+ if (path.basename(selfUrlPath) !== path.basename(selfPath)) return false;
2249
2549
  try {
2250
2550
  return fsSync.realpathSync(selfUrlPath) === fsSync.realpathSync(selfPath);
2251
2551
  } catch {